feat: finish the web-endpoint plugin, add ctx.desktop (B8)

This commit is contained in:
LukeGus committed 2026-09-23 18:36:41 -05:00
1 parent 1682861853
commit 535126fd1b
50 files changed
+2018 -898

No files matched your search

+39 -6
View File
@@ -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
+34
View File
@@ -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.
+24
View File
@@ -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
+38 -3
View File
@@ -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 };