mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: finish the web-endpoint plugin, add ctx.desktop (B8)
This commit is contained in:
1 parent
1682861853
commit
535126fd1b
50 files changed
+2018
-898
No files matched your search
@@ -511,6 +511,20 @@ to poll the plugin's state. A plugin can also listen for another plugin's
|
||||
`plugin.<id>.*` events without any capability: automations turns
|
||||
`plugin.tunnels.tunnel_disconnected` into its `tunnel_disconnected` trigger.
|
||||
|
||||
**B8** added a second, narrower use of `ctx.registry`: a core route that
|
||||
touches every plugin's host-scope settings generically (without importing any
|
||||
one of them) consumes `ctx.registry.provide("<pluginId>.hostImportNormalizer",
|
||||
fn)`. `host-bulk-routes.ts`'s Termix-JSON import path is the first caller,
|
||||
through `applyPluginHostImportSettings` in `host-plugin-settings.ts`: for
|
||||
every enabled plugin that declares `contributes.settings.host`, it looks up
|
||||
`"<id>.hostImportNormalizer"` and, if the plugin registered one, calls it with
|
||||
the raw imported row and writes back whatever it returns (or nothing, for
|
||||
`null`). This is not part of the SDK's typed `ctx` surface - it is a plain
|
||||
`ctx.registry` convention, the same mechanism `terminal.sessions` and
|
||||
`remote-desktop.sessions` already use, just with a name core's own code knows
|
||||
to look for. web-endpoint (`src/backend/host-import.ts`) is the first plugin
|
||||
to register one.
|
||||
|
||||
### 10. Lifecycle
|
||||
|
||||
`activate(ctx)` creates all state. Everything registered through `ctx` or `app`
|
||||
@@ -555,10 +569,14 @@ against the real schema's property names works unchanged in a test. Pass its
|
||||
`createMockCtx({ db })`: `define` builds the real table, `client` is Drizzle, and
|
||||
`persisted` counts `persist()` calls. The mock also takes `router` (for example
|
||||
`() => express.Router()`, so routes are served for real), `permissions`
|
||||
(enforced by `ctx.rbac`, answering 403 like core; omit it to pass everything),
|
||||
and returns `setActor(userId)` for a test middleware and `services` for what the
|
||||
plugin provided. `plugins/workspaces/tests/backend/helpers.ts` is the worked
|
||||
example.
|
||||
(enforced by `ctx.rbac`, answering 403 like core; omit it to pass everything)
|
||||
and `hosts` (`PluginHostSummary[]`, what `ctx.hosts.list/get/checkAccess`
|
||||
serve - **B8** added this option, since it existed on `createFakeContext`
|
||||
already but was not forwarded), and returns `setActor(userId)` for a test
|
||||
middleware and `services` for what the plugin provided. **B8** also gave
|
||||
`ctx.registry` on both doubles a real in-memory backing (it was a no-op stub
|
||||
before), so `provide`/`consume`/`revoke` round-trip the way the real registry
|
||||
does. `plugins/workspaces/tests/backend/helpers.ts` is the worked example.
|
||||
|
||||
`renderWithApp` also takes `api` (a stub for `app.api` and `usePluginApi()`),
|
||||
`layout` (what `app.tabs.getLayout()` returns) and `ready` (fire
|
||||
@@ -1063,6 +1081,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
|
||||
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** |
|
||||
| `ctx.notify.*` | `notify:send` | A6 |
|
||||
| `ctx.auth.*` | `auth:provide` | **A8** |
|
||||
| `ctx.desktop.openIsolatedWindow` | `desktop:window` | **B8** |
|
||||
| `ctx.fetch` | `network:outbound` | B |
|
||||
|
||||
`ctx.ssh` was pulled forward from B so plugin transports go through core's
|
||||
@@ -1150,6 +1169,20 @@ existing ones (proxmox's discovery and sync flow is the first caller):
|
||||
token): a plugin that creates a host picks an `authType` and, for
|
||||
`"credential"`, a `credentialId` it does not need to see the contents of.
|
||||
|
||||
**B8** added `ctx.desktop.openIsolatedWindow({ url, partition?, title?,
|
||||
ignoreCert? })`, for a plugin that wants a URL shown outside the main
|
||||
renderer's own session (a tunnelled or direct host web UI, so far). The
|
||||
backend cannot open a `BrowserWindow` itself: it runs as a separate forked
|
||||
process (`electron/main.cjs` forks it with `stdio: [..., "ipc"]`), so the call
|
||||
is relayed over that same fork IPC channel to a small request/response
|
||||
protocol in `src/backend/utils/electron-ipc-bridge.ts`, and handled in
|
||||
`electron/main.cjs` by `createIsolatedWindows` (`electron/isolated-window.cjs`,
|
||||
the module both the renderer's own isolated-window IPC call and this bridge
|
||||
share). Rejects outside the desktop app (`ELECTRON_EMBEDDED` unset, or no
|
||||
`process.send`). Each window gets its own non-persistent session, so it never
|
||||
shares cookies or storage with the main window or with another isolated
|
||||
window.
|
||||
|
||||
### The actor
|
||||
|
||||
Never comes from plugin code. It is held in `AsyncLocalStorage` and set two
|
||||
@@ -1633,8 +1666,8 @@ What the lint fence enforces today, in `eslint.config.mjs`:
|
||||
| Core importing a plugin backend | **Error** | 0 | - |
|
||||
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
|
||||
| The shell importing plugin code | **Error** | 0 | - |
|
||||
| A plugin frontend importing core through `@/` | Warning | 138 files | D1 |
|
||||
| A plugin importing core by relative path | Warning | 68 files | D1 |
|
||||
| A plugin frontend importing core through `@/` | Warning | 135 files | D1 |
|
||||
| A plugin importing core by relative path | Warning | 67 files | D1 |
|
||||
| A plugin importing another plugin's source | Warning | 3 files | B18 |
|
||||
|
||||
A warning does not fail a build, so the counts are held by
|
||||
|
||||
@@ -110,3 +110,37 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
|
||||
remote-origin plugin API client. `subscribeTunnelStatuses` already takes a
|
||||
`fetchRemote` and merges with local statuses winning, so it only needs a
|
||||
client for `/plugin-api/tunnels/status` on the remote server. Owner: D1.
|
||||
- **B8 (web-endpoint):** `enable_web_ui` and `web_ui_config` are still live
|
||||
`ssh_data` columns; only the copy-into-plugin-settings migration shipped
|
||||
(`web-endpoint-settings-migration.ts`). Unlike B6/B7, this step DID convert
|
||||
every core read/write of those two fields (`host.ts`'s create/update and
|
||||
export routes, `host-normalizers.ts`, `host-bulk-routes.ts`,
|
||||
`database.ts`'s encrypt/decrypt round trip is the one exception - it copies
|
||||
whatever is already in the column for the raw DB export/backup tool, which
|
||||
needs no change since nothing reads that copy through `pluginSettings`).
|
||||
Dropping the columns in lockstep (`schema.ts`, `db/index.ts`, a drizzle
|
||||
migration per dialect, `schema:generate`) is the only remaining piece.
|
||||
Owner: a dedicated follow-up step, or D0.
|
||||
- **B8 (web-endpoint):** added the first generic cross-plugin hook for core
|
||||
routes that touch host-scope plugin settings without importing a plugin:
|
||||
`ctx.registry.provide("<id>.hostImportNormalizer", fn)` plus
|
||||
`applyPluginHostImportSettings` in `host-plugin-settings.ts`, called from
|
||||
`host-bulk-routes.ts`'s Termix-JSON import path. Only import validation is
|
||||
covered; B7's TODO above about the Hosts panel feature filter and the bulk
|
||||
enable/disable menu (`hostCapability`-driven, not import-driven) is
|
||||
unrelated and still open. Owner: D1 for extending the same pattern there.
|
||||
- **B8 (web-endpoint):** noticed while sanity-checking with `npm run lint`
|
||||
(not required by this step): `eslint.config.mjs`'s
|
||||
`globalIgnores(["dist", ...])` only matches a top-level `dist/` folder, not
|
||||
nested `packages/*/dist` or `plugins/*/dist`, so `npm run lint` fails on
|
||||
bundled output repo-wide with "Definition for rule X was not found"
|
||||
errors. Pre-existing, not introduced by this or any single plugin step.
|
||||
Owner: whoever next needs a clean `npm run lint`, or D0.
|
||||
- **B8 (web-endpoint):** the isolated-window Electron IPC bridge
|
||||
(`src/backend/utils/electron-ipc-bridge.ts`, `ctx.desktop.openIsolatedWindow`)
|
||||
is a single request/response channel keyed by a random id with one
|
||||
registered backend-request handler (`open-isolated-window`) in
|
||||
`electron/main.cjs`. Fine for today's one caller; if a second plugin needs
|
||||
to ask Electron's main process for something, extend
|
||||
`BACKEND_REQUEST_HANDLERS` there rather than building a parallel channel.
|
||||
No owner needed unless a second caller appears.
|
||||
@@ -964,6 +964,28 @@ export interface PluginAuth {
|
||||
removeEnrollment: (userId: string, factorId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginOpenIsolatedWindowRequest {
|
||||
/** http(s) only. Refused when it points anywhere else. */
|
||||
url: string;
|
||||
/** Isolated Electron session partition; a fresh one when omitted. */
|
||||
partition?: string;
|
||||
title?: string;
|
||||
/** Present an invalid TLS certificate on this window's own origin only. */
|
||||
ignoreCert?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a desktop window outside the main renderer, for a target a plugin
|
||||
* does not want sharing Termix's own session (a tunnelled or direct web UI).
|
||||
* Electron only: rejects when the server is not running embedded in the
|
||||
* desktop app. Needs desktop:window.
|
||||
*/
|
||||
export interface PluginDesktop {
|
||||
openIsolatedWindow: (
|
||||
request: PluginOpenIsolatedWindowRequest,
|
||||
) => Promise<{ success: true }>;
|
||||
}
|
||||
|
||||
export interface PluginContext {
|
||||
readonly pluginId: string;
|
||||
readonly manifest: PluginManifest;
|
||||
@@ -986,6 +1008,8 @@ export interface PluginContext {
|
||||
readonly ssh: PluginSsh;
|
||||
/** Login methods, second factors and SSH auth types. Needs auth:provide. */
|
||||
readonly auth: PluginAuth;
|
||||
/** Opens Electron windows outside the main renderer. Needs desktop:window. */
|
||||
readonly desktop: PluginDesktop;
|
||||
|
||||
/**
|
||||
* Runs `fn` with `userId` as the acting user, for background work that has
|
||||
|
||||
@@ -109,6 +109,13 @@ export interface FakePluginContext {
|
||||
}>;
|
||||
/** Everything registered through ctx.auth. */
|
||||
auth: FakeAuthRegistrations;
|
||||
/** Every ctx.desktop.openIsolatedWindow call, in order. */
|
||||
desktopWindows: Array<{
|
||||
url: string;
|
||||
partition?: string;
|
||||
title?: string;
|
||||
ignoreCert?: boolean;
|
||||
}>;
|
||||
/** Changes the acting user, as core's request middleware would. */
|
||||
setActor: (userId: string | undefined) => void;
|
||||
/** Implementations provided through ctx.services.provide, by service name. */
|
||||
@@ -179,12 +186,14 @@ export function createFakeContext(
|
||||
const settingsListeners = new Map<string, Set<(value: unknown) => void>>();
|
||||
const sshConnections: FakePluginContext["sshConnections"] = [];
|
||||
const hostShares: FakePluginContext["hostShares"] = [];
|
||||
const desktopWindows: FakePluginContext["desktopWindows"] = [];
|
||||
const hostsById = new Map<number, PluginHostSummary>(
|
||||
(options.hosts ?? []).map((h) => [h.id, h]),
|
||||
);
|
||||
const hostRecordsById = new Map<number, PluginHostRecord>();
|
||||
let nextHostId = Math.max(0, ...(options.hosts ?? []).map((h) => h.id)) + 1;
|
||||
const services = new Map<string, object>();
|
||||
const registryProviders = new Map<string, unknown>();
|
||||
const auth: FakeAuthRegistrations = {
|
||||
sshAuthProviders: [],
|
||||
loginMethods: [],
|
||||
@@ -308,9 +317,17 @@ export function createFakeContext(
|
||||
},
|
||||
|
||||
registry: {
|
||||
provide: () => {},
|
||||
consume: () => undefined,
|
||||
revoke: () => false,
|
||||
provide: (key, value) => {
|
||||
registryProviders.set(key, value);
|
||||
},
|
||||
consume: (key) => registryProviders.get(key) as never,
|
||||
revoke: (key, value) => {
|
||||
if (!registryProviders.has(key)) return false;
|
||||
if (value !== undefined && registryProviders.get(key) !== value) {
|
||||
return false;
|
||||
}
|
||||
return registryProviders.delete(key);
|
||||
},
|
||||
},
|
||||
|
||||
services: {
|
||||
@@ -533,6 +550,13 @@ export function createFakeContext(
|
||||
},
|
||||
},
|
||||
|
||||
desktop: {
|
||||
openIsolatedWindow: async (request) => {
|
||||
desktopWindows.push(request);
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
|
||||
asUser: async (userId, fn) => {
|
||||
const previous = actor;
|
||||
actor = userId;
|
||||
@@ -561,6 +585,7 @@ export function createFakeContext(
|
||||
sshConnections,
|
||||
hostShares,
|
||||
auth,
|
||||
desktopWindows,
|
||||
setActor: (userId) => {
|
||||
actor = userId;
|
||||
},
|
||||
@@ -589,6 +614,8 @@ export interface MockContextOptions {
|
||||
router?: () => unknown;
|
||||
/** Role permissions the acting user holds. See FakeContextOptions. */
|
||||
permissions?: string[];
|
||||
/** Hosts ctx.hosts.list/get/checkAccess serve. See FakeContextOptions. */
|
||||
hosts?: PluginHostSummary[];
|
||||
}
|
||||
|
||||
export interface MockPluginContext extends FakePluginContext {
|
||||
@@ -622,6 +649,7 @@ export function createMockCtx(
|
||||
db: options.db,
|
||||
router: options.router,
|
||||
permissions: options.permissions,
|
||||
hosts: options.hosts,
|
||||
manifest: {
|
||||
capabilities: options.capabilities ?? [],
|
||||
...options.manifest,
|
||||
@@ -819,6 +847,13 @@ export function createMockCtx(
|
||||
return ctx.auth.removeEnrollment(userId, factorId);
|
||||
},
|
||||
},
|
||||
|
||||
desktop: {
|
||||
openIsolatedWindow: async (request) => {
|
||||
require("desktop:window");
|
||||
return ctx.desktop.openIsolatedWindow(request);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return { ...base, ctx: gatedCtx, checked };
|
||||
|
||||
Reference in new issue
Block a user