feat: finish the remote-desktop plugin (B14)

This commit is contained in:
LukeGus committed 2026-09-24 01:02:45 -05:00
1 parent 0af2c94983
commit 14eb558b85
215 files changed
+30029 -3921

No files matched your search

+67 -3
View File
@@ -477,7 +477,6 @@ export interface PluginHostRecord {
folder: string | null;
jumpHosts?: unknown;
enableSsh?: boolean | null;
enableRdp?: boolean | null;
enableTerminal?: boolean | null;
enableFileManager?: boolean | null;
enableTunnel?: boolean | null;
@@ -487,7 +486,11 @@ export interface PluginHostRecord {
[key: string]: unknown;
}
/** Fields a plugin may set when creating a host it will own or manage. */
/**
* Fields a plugin may set when creating a host it will own or manage. Fields
* that belong to another plugin's host settings (enableRdp, rdpPort) are
* handed to that plugin's host import normalizer, as on a bulk import.
*/
export type PluginHostCreateInput = Partial<
Omit<PluginHostRecord, "id" | "userId">
> & {
@@ -1116,10 +1119,69 @@ export interface PluginOpenIsolatedWindowRequest {
* Electron only: rejects when the server is not running embedded in the
* desktop app. Needs desktop:window.
*/
export interface PluginNativeRdpRequest {
host: string;
port?: number;
username?: string;
domain?: string;
}
export interface PluginDesktop {
openIsolatedWindow: (
request: PluginOpenIsolatedWindowRequest,
) => Promise<{ success: true }>;
/**
* Opens the operating system's own RDP client (mstsc on Windows) for a
* host. The password is never passed; the client asks for it. Windows
* desktop app only.
*/
launchNativeRdp: (
request: PluginNativeRdpRequest,
) => Promise<{ success: boolean; error?: string }>;
/** Whether the server runs embedded in the desktop app, so the calls above can work. */
available: () => boolean;
}
/** A host protocol whose credentials core keeps next to the host. */
export type PluginHostProtocol = "rdp" | "vnc" | "telnet";
/**
* What a plugin needs to hand a host's protocol login to something core
* does not run (guacd): the address and the plaintext credentials the acting
* user is allowed to use. A shared recipient gets the owner's shared
* snapshot or their own override, never the owner's raw secret.
*/
export interface PluginProtocolTarget {
host: {
id: number;
name: string | null;
ip: string;
/** The host's SSH port, the old fallback when no protocol port is set. */
port: number;
ownerUserId: string;
jumpHosts: Array<{ hostId: number }>;
};
/** True when the acting user is not the owner. */
shared: boolean;
auth: {
/** "direct", "credential", or "none" when the user is asked at connect time. */
authType: string;
username: string;
password: string;
domain: string;
};
}
/**
* Plaintext host credentials, for a plugin that has to hand them to another
* program. Needs credentials:read, and every call is audited.
*/
export interface PluginCredentials {
/** Null when the host does not exist or the acting user cannot connect to it. */
resolveHostProtocol: (
hostId: number,
protocol: PluginHostProtocol,
) => Promise<PluginProtocolTarget | null>;
}
/**
@@ -1145,7 +1207,7 @@ export interface PluginAudit {
* hardware (a serial port, a USB device) is the first caller. Core cannot
* mediate that access the way it mediates ctx.ssh or ctx.db, so the
* capability here is a declared, reviewable, audited statement of intent
* rather than a technical gate, matching credentials:read and process:spawn.
* rather than a technical gate, matching process:spawn.
*/
export interface PluginCapabilities {
/** Whether this plugin currently holds `capability`. Not audited: for deciding whether to offer something, not for gating an action. */
@@ -1181,6 +1243,8 @@ export interface PluginContext {
readonly auth: PluginAuth;
/** Opens Electron windows outside the main renderer. Needs desktop:window. */
readonly desktop: PluginDesktop;
/** Plaintext host protocol credentials. Needs credentials:read. */
readonly credentials: PluginCredentials;
/** Audit lines under the plugin's own action names. */
readonly audit: PluginAudit;
+36
View File
@@ -317,6 +317,26 @@ export interface HostActionContribution {
}[];
}
/**
* A connection protocol next to SSH, shown as a switch in the host editor's
* General tab and in host filters and Quick Connect. Its switch and port are
* this plugin's own host settings (declare both in contributes.settings.host).
*/
export interface HostProtocolContribution {
id: string;
/** Boolean host setting that turns the protocol on. */
settingKey: string;
/** Number host setting holding the port. */
portKey?: string;
defaultPort: number;
titleKey: string;
descriptionKey?: string;
icon: IconComponent;
order?: number;
/** Offer it in Quick Connect, optionally with a domain field. */
quickConnect?: { showDomain?: boolean };
}
export interface HostBadgeContribution {
id: string;
when: (host: PluginHostRecord) => boolean;
@@ -524,6 +544,7 @@ export interface TermixApp extends TermixAppInfo {
section: HostEditorSectionContribution,
) => Disposer;
registerHostAction: (action: HostActionContribution) => Disposer;
registerHostProtocol: (protocol: HostProtocolContribution) => Disposer;
registerHostBadge: (badge: HostBadgeContribution) => Disposer;
registerHostContextMenuItem: (
item: HostContextMenuItemContribution,
@@ -574,6 +595,11 @@ export interface TermixApp extends TermixAppInfo {
/** HTTP client for this plugin's /plugin-api/<id>/ routes. */
api: PluginApiClient;
/**
* The same client for a resolved connection origin: "remote" reaches the
* desktop app's connected remote server, anything else is `api`.
*/
apiFor: (origin?: unknown) => PluginApiClient;
/** WebSocket URL and auth subprotocols for /plugin-ws/<id>/<path>. */
wsUrl: (
path: string,
@@ -668,6 +694,7 @@ export interface PluginHostBridge {
usePluginComponent: (
id: string,
) => ComponentType<Record<string, unknown>> | undefined;
hostProtocols: (host: PluginHostRecord) => string[];
}
let host: PluginHostBridge | null = null;
@@ -778,6 +805,15 @@ export function useSshAuthTypes(): {
* next to its own. Same permission and `when` filtering as ComponentSlot;
* `context` is what each contribution's `when` sees.
*/
/**
* The connection protocols a host has switched on: "ssh" plus every protocol
* a running plugin registered with registerHostProtocol (RDP, VNC). Lets a
* plugin offer a protocol without knowing which plugin owns its settings.
*/
export function hostProtocols(record: PluginHostRecord): string[] {
return requireHost().hostProtocols(record);
}
export function useSlotContributions(
slotId: string,
context?: Record<string, unknown>,
+10
View File
@@ -224,6 +224,12 @@ export interface PluginSettingsField {
group?: string;
/** Registered component id. Required when type is "custom". */
component?: string;
/**
* Stored and validated like any field, but not drawn by the generic
* settings form, because the plugin edits it in its own UI (a host editor
* section).
*/
hidden?: boolean;
}
export interface PluginHostSettingsContribution {
@@ -382,6 +388,7 @@ const ALLOWED_SETTINGS_FIELD = [
"permission",
"group",
"component",
"hidden",
];
/** Settings keys are stored as-is, so they stay short and index-safe. */
@@ -933,6 +940,9 @@ function validateSettingsFields(
if ("permission" in raw) {
requireString(raw.permission, `${at}.permission`, errors);
}
if ("hidden" in raw && typeof raw.hidden !== "boolean") {
errors.push(`${at}.hidden must be a boolean`);
}
if (raw.type === "select" || raw.type === "multiselect") {
validateSettingsOptions(raw.options, at, errors);
+50
View File
@@ -35,6 +35,10 @@ import type {
} from "./backend.js";
import type { SyncEntityRegistration } from "./backend.js";
import type { PluginDatabase } from "./backend.js";
import type {
PluginNativeRdpRequest,
PluginProtocolTarget,
} from "./backend.js";
export interface FakeContextOptions {
pluginId?: string;
@@ -69,6 +73,13 @@ export interface FakeContextOptions {
shareableRoles?: PluginShareableRole[];
/** Hosts ctx.ssh.resolveHost answers with, secrets included. */
sshHosts?: PluginSshHost[];
/**
* What ctx.credentials.resolveHostProtocol answers, keyed
* "<hostId>:<protocol>". A missing key answers null.
*/
protocolTargets?: Record<string, PluginProtocolTarget>;
/** What ctx.desktop.available() answers. Defaults to false. */
desktopAvailable?: boolean;
}
export interface FakeAuthRegistrations {
@@ -123,6 +134,10 @@ export interface FakePluginContext {
title?: string;
ignoreCert?: boolean;
}>;
/** Every ctx.desktop.launchNativeRdp call, in order. */
nativeRdpLaunches: PluginNativeRdpRequest[];
/** Every ctx.credentials.resolveHostProtocol call, in order. */
credentialReads: Array<{ hostId: number; protocol: string }>;
/** Every ctx.audit.record entry, in order. */
audits: Array<{ action: string; success: boolean; [key: string]: unknown }>;
/** Every ctx.hosts.recordActivity call, in order. */
@@ -206,6 +221,8 @@ export function createFakeContext(
const sshConnections: FakePluginContext["sshConnections"] = [];
const hostShares: FakePluginContext["hostShares"] = [];
const desktopWindows: FakePluginContext["desktopWindows"] = [];
const nativeRdpLaunches: FakePluginContext["nativeRdpLaunches"] = [];
const credentialReads: FakePluginContext["credentialReads"] = [];
const audits: FakePluginContext["audits"] = [];
const activities: FakePluginContext["activities"] = [];
const trackedSessions: number[] = [];
@@ -635,6 +652,18 @@ export function createFakeContext(
desktopWindows.push(request);
return { success: true };
},
launchNativeRdp: async (request) => {
nativeRdpLaunches.push(request);
return { success: true };
},
available: () => options.desktopAvailable ?? false,
},
credentials: {
resolveHostProtocol: async (hostId, protocol) => {
credentialReads.push({ hostId, protocol });
return options.protocolTargets?.[`${hostId}:${protocol}`] ?? null;
},
},
asUser: async (userId, fn) => {
@@ -666,6 +695,8 @@ export function createFakeContext(
hostShares,
auth,
desktopWindows,
nativeRdpLaunches,
credentialReads,
audits,
activities,
trackedSessions,
@@ -704,6 +735,10 @@ export interface MockContextOptions {
services?: Record<string, object>;
/** Hosts ctx.ssh.resolveHost serves. See FakeContextOptions. */
sshHosts?: PluginSshHost[];
/** What ctx.credentials serves. See FakeContextOptions. */
protocolTargets?: Record<string, PluginProtocolTarget>;
/** What ctx.desktop.available() answers. */
desktopAvailable?: boolean;
}
export interface MockPluginContext extends FakePluginContext {
@@ -740,6 +775,8 @@ export function createMockCtx(
hosts: options.hosts,
sshHosts: options.sshHosts,
services: options.services,
protocolTargets: options.protocolTargets,
desktopAvailable: options.desktopAvailable,
manifest: {
capabilities: options.capabilities ?? [],
...options.manifest,
@@ -972,6 +1009,18 @@ export function createMockCtx(
require("desktop:window");
return ctx.desktop.openIsolatedWindow(request);
},
launchNativeRdp: async (request) => {
require("desktop:window");
return ctx.desktop.launchNativeRdp(request);
},
available: () => ctx.desktop.available(),
},
credentials: {
resolveHostProtocol: async (hostId, protocol) => {
require("credentials:read");
return ctx.credentials.resolveHostProtocol(hostId, protocol);
},
},
capabilities: {
@@ -1180,6 +1229,7 @@ export interface RenderedPluginApp {
tabs: () => string[];
panels: () => string[];
hostActions: () => Array<{ id: string; tabType?: string }>;
hostProtocols: () => string[];
hostEditorSections: () => string[];
dashboardCards: () => string[];
settingsComponents: () => string[];