diff --git a/scripts/copy-bundled-plugins.cjs b/scripts/copy-bundled-plugins.cjs index b54532aad..34b239b06 100644 --- a/scripts/copy-bundled-plugins.cjs +++ b/scripts/copy-bundled-plugins.cjs @@ -6,11 +6,12 @@ * tsc only emits .ts, so without a copy step the plugins directory simply * would not exist in dist and the loader would find nothing. * - * docker and host-metrics are the exception: their backends are real - * TypeScript, physically relocated from src/backend/hosts/docker/ and - * src/backend/hosts/metrics/ rather than kept as hand-written JS, because - * each is thousands of lines of typed SSH/session logic that is not worth - * hand-transpiling. tsconfig.plugins.json compiles each plugin backend's + * docker, host-metrics and remote-desktop are the exception: their backends + * are real TypeScript, physically relocated from src/backend/hosts/docker/, + * src/backend/hosts/metrics/ and src/backend/hosts/guacamole/ rather than + * kept as hand-written JS, because each is thousands of lines of typed + * SSH/session (or, for remote-desktop, guacd protocol) logic that is not + * worth hand-transpiling. tsconfig.plugins.json compiles each plugin backend's * TypeScript to a sibling .js next to its source. Their imports into core * (e.g. "../../../src/backend/utils/logger.js") are written relative to the * TypeScript SOURCE tree so tsc can type-check them, since plugins/ is not diff --git a/scripts/plugin-manifest.schema.json b/scripts/plugin-manifest.schema.json index 8bf143827..f9e6a47e1 100644 --- a/scripts/plugin-manifest.schema.json +++ b/scripts/plugin-manifest.schema.json @@ -154,14 +154,32 @@ } }, "hostCapability": { - "type": "object", - "additionalProperties": false, - "required": ["key", "labelKey", "editorTab"], - "properties": { - "key": { "type": "string", "minLength": 1 }, - "labelKey": { "type": "string", "minLength": 1 }, - "editorTab": { "type": "string", "minLength": 1 } - } + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["key", "labelKey", "editorTab"], + "properties": { + "key": { "type": "string", "minLength": 1 }, + "labelKey": { "type": "string", "minLength": 1 }, + "editorTab": { "type": "string", "minLength": 1 } + } + }, + { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["key", "labelKey", "editorTab"], + "properties": { + "key": { "type": "string", "minLength": 1 }, + "labelKey": { "type": "string", "minLength": 1 }, + "editorTab": { "type": "string", "minLength": 1 } + } + } + } + ] }, "permissionGroup": { "type": "object", diff --git a/scripts/validate-plugin-manifest.cjs b/scripts/validate-plugin-manifest.cjs index 0f0d5ab60..f2ea24523 100644 --- a/scripts/validate-plugin-manifest.cjs +++ b/scripts/validate-plugin-manifest.cjs @@ -396,11 +396,26 @@ function validateContributes(contributes) { } if ("hostCapability" in contributes) { - for (const field of ["key", "labelKey", "editorTab"]) { - if (!(field in (contributes.hostCapability || {}))) { - errors.push(`contributes.hostCapability.${field} is required`); + const raw = contributes.hostCapability; + const entries = Array.isArray(raw) ? raw : [raw]; + const seenKeys = new Set(); + entries.forEach((entry, index) => { + const hc = entry || {}; + const prefix = Array.isArray(raw) + ? `contributes.hostCapability[${index}]` + : "contributes.hostCapability"; + for (const field of ["key", "labelKey", "editorTab"]) { + if (!(field in hc)) { + errors.push(`${prefix}.${field} is required`); + } } - } + if (typeof hc.key === "string") { + if (seenKeys.has(hc.key)) { + errors.push(`${prefix}.key is a duplicate: "${hc.key}"`); + } + seenKeys.add(hc.key); + } + }); } if ("permissionGroup" in contributes) { diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index d95d2d4ea..27a4c7c77 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -15,7 +15,7 @@ import workspaceRoutes from "./routes/workspaces.js"; import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js"; import terminalRoutes from "./routes/terminal.js"; import sessionLogRoutes from "./routes/session-log-routes.js"; -import guacamoleRoutes from "../hosts/guacamole/routes.js"; +import guacamoleDispatch from "./routes/guacamole-dispatch.js"; import sessionSharingRoutes from "../hosts/session-sharing/routes.js"; import collabRoutes from "../hosts/collab/routes.js"; import networkTopologyRoutes from "./routes/network-topology.js"; @@ -1758,7 +1758,7 @@ app.use("/workspaces", workspaceRoutes); app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes); app.use("/terminal", terminalRoutes); app.use("/session_logs", sessionLogRoutes); -app.use("/guacamole", guacamoleRoutes); +app.use("/guacamole", guacamoleDispatch); app.use("/session-sharing", sessionSharingRoutes); app.use("/collab", collabRoutes); app.use("/network-topology", networkTopologyRoutes); diff --git a/src/backend/database/routes/user-settings-routes.ts b/src/backend/database/routes/user-settings-routes.ts index c5d1bd9dd..a738786f0 100644 --- a/src/backend/database/routes/user-settings-routes.ts +++ b/src/backend/database/routes/user-settings-routes.ts @@ -1,7 +1,12 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import { getErrorMessage } from "../../utils/error-message.js"; import type { RequestHandler, Router } from "express"; -import { restartGuacServer } from "../../hosts/guacamole/guacamole-server.js"; +// Remote Desktop is a first-party plugin (plugins/remote-desktop); this goes +// through the same in-core bridge its router is mounted behind, rather than +// importing the plugin's TypeScript directly, since plugins/ is compiled +// separately from src/backend/ and core cannot statically import across that +// boundary. See guacamole-dispatch.ts and that plugin's README. +import { restartGuacamoleService as restartGuacServer } from "./guacamole-dispatch.js"; import { authLogger, getGlobalLogLevel, diff --git a/src/backend/hosts/collab/routes.ts b/src/backend/hosts/collab/routes.ts index 5d7b4f3f7..13949b142 100644 --- a/src/backend/hosts/collab/routes.ts +++ b/src/backend/hosts/collab/routes.ts @@ -8,7 +8,14 @@ import { getAuditUsername, getRequestMeta, } from "../../utils/audit-logger.js"; -import { GuacamoleTokenService } from "../guacamole/token-service.js"; +// Remote Desktop is a first-party plugin (plugins/remote-desktop); this goes +// through the same in-core bridge its router is mounted behind, since +// plugins/ is compiled separately from src/backend/ and core cannot +// statically import across that boundary. See +// src/backend/database/routes/guacamole-dispatch.ts and that plugin's +// README -- this is an accepted, temporary coupling until collab becomes a +// plugin of its own. +import { createGuacamoleJoinToken } from "../../database/routes/guacamole-dispatch.js"; import { collabRoomHub } from "./room-hub.js"; import { getStageController, setStageController } from "./stage-control.js"; import { isCollabGuestRateLimited } from "./guest-rate-limit.js"; @@ -41,7 +48,6 @@ import type { CollabRoomRecord } from "../../database/repositories/collab-room-r const router = express.Router(); const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); -const tokenService = GuacamoleTokenService.getInstance(); const STAGE_SHARE_EXPIRY_HOURS = 12; const MAX_INVITE_TARGETS = 200; @@ -657,7 +663,7 @@ router.get( }; if (protocol !== "ssh") { stage.connectParams = { - token: tokenService.createJoinToken(share.sessionId, true), + token: createGuacamoleJoinToken(share.sessionId, true), }; } res.json({ stage }); @@ -1089,7 +1095,7 @@ router.get("/guest/:token", async (req: Request, res: Response) => { } : { connectParams: { - token: tokenService.createJoinToken(share.sessionId, true), + token: createGuacamoleJoinToken(share.sessionId, true), }, }), }; diff --git a/src/backend/hosts/session-sharing/live-sessions.ts b/src/backend/hosts/session-sharing/live-sessions.ts index 8f9b87fe6..7a0eaf643 100644 --- a/src/backend/hosts/session-sharing/live-sessions.ts +++ b/src/backend/hosts/session-sharing/live-sessions.ts @@ -1,5 +1,12 @@ import { sessionManager } from "../terminal/session-manager.js"; -import { getGuacSessionInfo } from "../guacamole/guacamole-server.js"; +// Remote Desktop is a first-party plugin (plugins/remote-desktop); this goes +// through the same in-core bridge its router is mounted behind, since +// plugins/ is compiled separately from src/backend/ and core cannot +// statically import across that boundary. See +// src/backend/database/routes/guacamole-dispatch.ts and that plugin's +// README -- this is an accepted, temporary coupling until collab/ +// session-sharing themselves become plugins. +import { getGuacamoleSessionInfo as getGuacSessionInfo } from "../../database/routes/guacamole-dispatch.js"; import { createCurrentHostResolutionRepository, createCurrentSettingsRepository, diff --git a/src/backend/hosts/session-sharing/routes.ts b/src/backend/hosts/session-sharing/routes.ts index c19023376..fbcd6cfee 100644 --- a/src/backend/hosts/session-sharing/routes.ts +++ b/src/backend/hosts/session-sharing/routes.ts @@ -10,14 +10,20 @@ import { isLiveSessionOwnedBy, isSharingEnabledForHost, } from "./live-sessions.js"; -import { GuacamoleTokenService } from "../guacamole/token-service.js"; +// Remote Desktop is a first-party plugin (plugins/remote-desktop); this goes +// through the same in-core bridge its router is mounted behind, since +// plugins/ is compiled separately from src/backend/ and core cannot +// statically import across that boundary. See +// src/backend/database/routes/guacamole-dispatch.ts and that plugin's +// README -- this is an accepted, temporary coupling until session-sharing +// becomes a plugin of its own. +import { createGuacamoleJoinToken } from "../../database/routes/guacamole-dispatch.js"; import { createCurrentSessionShareRepository } from "../../database/repositories/factory.js"; const router = express.Router(); const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); const permissionManager = PermissionManager.getInstance(); -const tokenService = GuacamoleTokenService.getInstance(); const DEFAULT_EXPIRY_HOURS = 24; const MAX_EXPIRY_HOURS = 24 * 30; @@ -406,7 +412,7 @@ router.get("/resolve/:linkToken", async (req: Request, res: Response) => { }; if (protocol !== "ssh") { - const joinToken = tokenService.createJoinToken( + const joinToken = createGuacamoleJoinToken( share.sessionId, share.permissionLevel === "read-only", ); diff --git a/src/backend/plugins/first-party.ts b/src/backend/plugins/first-party.ts index 4c9685ba1..aff62a689 100644 --- a/src/backend/plugins/first-party.ts +++ b/src/backend/plugins/first-party.ts @@ -62,6 +62,12 @@ * do, verifies host keys via SSHHostKeyVerifier, and streams its discovery * progress as server-sent events. Neither the worker's 13-field read-only * ctx.hosts nor its single-postMessage-reply ctx.http.route can support that. + * + * remote-desktop is here for the same reason ssh-terminal is: it owns a + * WebSocket server (guacamole-lite, port 30008) and its + * /connect-host/:hostId route does multi-step credential/tunnel resolution + * (SSH jump-host tunnels, a macOS VNC compatibility proxy) using raw + * net.Socket plumbing that cannot cross a structured-clone boundary. */ export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet = new Set([ "ssh-terminal", @@ -69,6 +75,7 @@ export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet = new Set([ "host-metrics", "ai", "proxmox", + "remote-desktop", ]); export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner"; diff --git a/src/backend/plugins/manifest.ts b/src/backend/plugins/manifest.ts index 1dd5d7742..006e3d451 100644 --- a/src/backend/plugins/manifest.ts +++ b/src/backend/plugins/manifest.ts @@ -128,7 +128,7 @@ export interface PluginManifest { icon: string; openFrom: string[]; }>; - hostCapability?: { key: string; labelKey: string; editorTab: string }; + hostCapability?: HostCapabilityContribution | HostCapabilityContribution[]; permissionGroup?: { group: string; permissions: string[]; @@ -161,6 +161,19 @@ export interface PluginManifest { sidecars: Array<{ id: string; binary: string }>; } +/** + * A host-level checkbox this plugin contributes to the host editor (e.g. + * "Enable RDP"), backed by an existing boolean column on the host record. + * Most plugins declare one; a plugin covering several independent protocols + * on the same host (rdp/vnc/telnet) declares several -- see + * contributes.hostCapability, which accepts either shape. + */ +export interface HostCapabilityContribution { + key: string; + labelKey: string; + editorTab: string; +} + export interface PluginServiceProvide { service: string; version: string; @@ -540,15 +553,26 @@ function validateContributes(contributes: Record): string[] { } if ("hostCapability" in contributes) { - const hostCapability = (contributes.hostCapability ?? {}) as Record< - string, - unknown - >; - for (const field of ["key", "labelKey", "editorTab"]) { - if (!(field in hostCapability)) { - errors.push(`contributes.hostCapability.${field} is required`); + const raw = contributes.hostCapability; + const entries = Array.isArray(raw) ? raw : [raw]; + const seenKeys = new Set(); + entries.forEach((entry, index) => { + const hc = (entry ?? {}) as Record; + const prefix = Array.isArray(raw) + ? `contributes.hostCapability[${index}]` + : "contributes.hostCapability"; + for (const field of ["key", "labelKey", "editorTab"]) { + if (!(field in hc)) { + errors.push(`${prefix}.${field} is required`); + } } - } + if (typeof hc.key === "string") { + if (seenKeys.has(hc.key)) { + errors.push(`${prefix}.key is a duplicate: "${hc.key}"`); + } + seenKeys.add(hc.key); + } + }); } if ("permissionGroup" in contributes) { diff --git a/src/backend/starter.ts b/src/backend/starter.ts index 956935259..60a955623 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -299,11 +299,11 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { // ssh-terminal, docker and host-metrics plugins start their own servers, // so disabling any of them stops its WS/HTTP server. See // plugins/ssh-terminal, plugins/docker and plugins/host-metrics. - // AI and Proxmox are also absent for the same reason: each plugin - // registers its router with its own dispatcher in database.ts on - // activate (/ai, /proxmox), so disabling either makes its routes 404 - // instead of leaving a dead import here. See plugins/ai and - // plugins/proxmox. + // AI, Proxmox and Remote Desktop are also absent for the same reason: + // each plugin registers its router with its own dispatcher in + // database.ts on activate (/ai, /proxmox, /guacamole), so disabling any + // of them makes its routes 404 instead of leaving a dead import here. + // See plugins/ai, plugins/proxmox and plugins/remote-desktop. await import("./hosts/tunnel/index.js"); await import("./hosts/file-manager/index.js"); await import("./hosts/tmux/index.js"); @@ -322,27 +322,6 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { }); } - // Initialize Guacamole server for RDP/VNC/Telnet support - const guacEnabled = getCurrentSettingValue("guac_enabled") !== "false"; - - if (process.env.ENABLE_GUACAMOLE !== "false" && guacEnabled) { - import("./hosts/guacamole/guacamole-server.js") - .then(() => { - systemLogger.info("Guacamole server initialized", { - operation: "guac_init", - }); - }) - .catch((error) => { - systemLogger.warn( - "Failed to initialize Guacamole server (guacd may not be available)", - { - operation: "guac_init_skip", - error: getErrorMessage(error), - }, - ); - }); - } - // After metrics, which the automation triggers and headless polling hook into. const { startAutomationScheduler } = await import("./automations/scheduler.js"); diff --git a/src/backend/tests/hosts/collab/routes.test.ts b/src/backend/tests/hosts/collab/routes.test.ts index 6bb5c9d5c..b49c866d1 100644 --- a/src/backend/tests/hosts/collab/routes.test.ts +++ b/src/backend/tests/hosts/collab/routes.test.ts @@ -56,13 +56,9 @@ vi.mock("../../../utils/audit-logger.js", () => ({ getAuditUsername: vi.fn(async (id: string) => id.toUpperCase()), getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }), })); -vi.mock("../../../hosts/guacamole/token-service.js", () => ({ - GuacamoleTokenService: { - getInstance: () => ({ - createJoinToken: (id: string, readOnly: boolean) => - `join:${id}:${readOnly}`, - }), - }, +vi.mock("../../../database/routes/guacamole-dispatch.js", () => ({ + createGuacamoleJoinToken: (id: string, readOnly: boolean) => + `join:${id}:${readOnly}`, })); vi.mock("../../../hosts/collab/room-hub.js", () => ({ collabRoomHub: { diff --git a/src/backend/tests/hosts/guacamole/drive-settings.test.ts b/src/backend/tests/hosts/guacamole/drive-settings.test.ts deleted file mode 100644 index 0110f8ed2..000000000 --- a/src/backend/tests/hosts/guacamole/drive-settings.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { withDriveSettings } from "../../../hosts/guacamole/drive-settings.js"; - -describe("withDriveSettings", () => { - it("gives each user a folder under GUACD_DRIVE_PATH and creates it", () => { - expect( - withDriveSettings({ "enable-drive": true }, "user-1", { - GUACD_DRIVE_PATH: "/termix-data/rdp-drive/", - }), - ).toEqual({ - "enable-drive": true, - "drive-path": "/termix-data/rdp-drive/user-1", - "create-drive-path": true, - }); - }); - - it("falls back to /drive when the environment says nothing", () => { - expect( - withDriveSettings({ "enable-drive": true }, "user-1", {}), - ).toMatchObject({ "drive-path": "/drive/user-1" }); - }); - - it("leaves a host-chosen drive-path alone", () => { - const config = { "enable-drive": true, "drive-path": "/mnt/share" }; - expect( - withDriveSettings(config, "user-1", { GUACD_DRIVE_PATH: "/x" }), - ).toBe(config); - }); - - it("does nothing when the drive is not enabled", () => { - const config = { "enable-drive": false }; - expect(withDriveSettings(config, "user-1")).toBe(config); - }); -}); diff --git a/src/backend/tests/hosts/guacamole/jump-tunnel-endpoint.test.ts b/src/backend/tests/hosts/guacamole/jump-tunnel-endpoint.test.ts deleted file mode 100644 index cca9f1c95..000000000 --- a/src/backend/tests/hosts/guacamole/jump-tunnel-endpoint.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveJumpTunnelEndpoint } from "../../../hosts/guacamole/jump-tunnel-endpoint.js"; - -describe("resolveJumpTunnelEndpoint", () => { - it("keeps an in-process guacd tunnel on loopback", () => { - expect(resolveJumpTunnelEndpoint("localhost")).toEqual({ - bindHost: "127.0.0.1", - advertisedHost: "127.0.0.1", - }); - }); - - it("exposes the tunnel to a separate guacd container", () => { - expect(resolveJumpTunnelEndpoint("guacd")).toEqual({ - bindHost: "0.0.0.0", - advertisedHost: "termix", - }); - }); - - it("supports a custom backend hostname for external guacd", () => { - expect( - resolveJumpTunnelEndpoint("guacd.example", "termix-backend"), - ).toEqual({ - bindHost: "0.0.0.0", - advertisedHost: "termix-backend", - }); - }); -}); diff --git a/src/backend/tests/hosts/guacamole/macos-vnc-proxy.test.ts b/src/backend/tests/hosts/guacamole/macos-vnc-proxy.test.ts deleted file mode 100644 index 3d1f574c6..000000000 --- a/src/backend/tests/hosts/guacamole/macos-vnc-proxy.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import net from "net"; -import { afterEach, describe, expect, it } from "vitest"; -import { createMacosVncCompatibilityProxy } from "../../../hosts/guacamole/macos-vnc-proxy.js"; - -const closers: Array<() => void> = []; - -afterEach(() => { - for (const close of closers.splice(0)) close(); -}); - -async function listen(server: net.Server): Promise { - return new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - resolve((server.address() as net.AddressInfo).port); - }); - }); -} - -async function read(socket: net.Socket, length: number): Promise { - return new Promise((resolve, reject) => { - let received = Buffer.alloc(0); - const onData = (chunk: Buffer) => { - received = Buffer.concat([received, chunk]); - if (received.length < length) return; - socket.off("data", onData); - resolve(received); - }; - socket.on("data", onData); - socket.once("error", reject); - }); -} - -describe("createMacosVncCompatibilityProxy", () => { - it("normalizes Apple's private RFB banner and forces classic VNC auth", async () => { - let clientBanner = ""; - let selectedSecurityType = 0; - const target = net.createServer((socket) => { - socket.write("RFB 003."); - socket.write("889\n"); - socket.once("data", (data) => { - clientBanner = data.toString("ascii"); - socket.write(Buffer.from([5, 30, 33])); - socket.write(Buffer.from([36, 2, 35])); - socket.once("data", (selection) => { - selectedSecurityType = selection[0]; - socket.write("desktop-data"); - }); - }); - }); - const targetPort = await listen(target); - closers.push(() => target.close()); - - const proxy = await createMacosVncCompatibilityProxy({ - targetHost: "127.0.0.1", - targetPort, - bindHost: "127.0.0.1", - }); - closers.push(proxy.close); - - const client = net.createConnection(proxy.port, "127.0.0.1"); - closers.push(() => client.destroy()); - expect((await read(client, 12)).subarray(0, 12).toString("ascii")).toBe( - "RFB 003.008\n", - ); - client.write("RFB 003.008\n"); - expect([...(await read(client, 2))]).toEqual([1, 2]); - client.write(Buffer.from([2])); - expect((await read(client, 12)).toString("ascii")).toBe("desktop-data"); - expect(clientBanner).toBe("RFB 003.008\n"); - expect(selectedSecurityType).toBe(2); - }); - - it("passes standard RFB negotiation through unchanged", async () => { - const target = net.createServer((socket) => { - socket.write("RFB 003.008\n"); - socket.once("data", () => socket.write(Buffer.from([2, 30, 2]))); - }); - const targetPort = await listen(target); - closers.push(() => target.close()); - const proxy = await createMacosVncCompatibilityProxy({ - targetHost: "127.0.0.1", - targetPort, - bindHost: "127.0.0.1", - }); - closers.push(proxy.close); - - const client = net.createConnection(proxy.port, "127.0.0.1"); - closers.push(() => client.destroy()); - expect((await read(client, 12)).toString("ascii")).toBe("RFB 003.008\n"); - client.write("RFB 003.008\n"); - expect([...(await read(client, 3))]).toEqual([2, 30, 2]); - }); - - it("passes Apple's security types through when VNC auth is unavailable", async () => { - const target = net.createServer((socket) => { - socket.write("RFB 003.889\n"); - socket.once("data", () => socket.write(Buffer.from([2, 30, 33]))); - }); - const targetPort = await listen(target); - closers.push(() => target.close()); - const proxy = await createMacosVncCompatibilityProxy({ - targetHost: "127.0.0.1", - targetPort, - bindHost: "127.0.0.1", - }); - closers.push(proxy.close); - - const client = net.createConnection(proxy.port, "127.0.0.1"); - closers.push(() => client.destroy()); - expect((await read(client, 12)).toString("ascii")).toBe("RFB 003.008\n"); - client.write("RFB 003.008\n"); - expect([...(await read(client, 3))]).toEqual([2, 30, 33]); - }); -}); diff --git a/src/backend/tests/hosts/guacamole/rdp-settings.test.ts b/src/backend/tests/hosts/guacamole/rdp-settings.test.ts deleted file mode 100644 index d945a8394..000000000 --- a/src/backend/tests/hosts/guacamole/rdp-settings.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildRdpSettings, - resolveRdpAuthTypeForConnect, - resolveRdpDomain, -} from "../../../hosts/guacamole/rdp-settings.js"; - -describe("buildRdpSettings", () => { - it("keeps saved RDP settings authoritative over stale advanced config", () => { - expect( - buildRdpSettings({ - port: 3390, - domain: "EXAMPLE", - security: "nla", - ignoreCert: true, - guacConfig: { - port: 3389, - domain: "OLD", - security: "rdp", - "ignore-cert": false, - "color-depth": 24, - }, - guacdOverrides: { guacdHost: "guacd.internal" }, - }), - ).toEqual({ - port: 3390, - domain: "EXAMPLE", - security: "nla", - "ignore-cert": true, - "color-depth": 24, - guacdHost: "guacd.internal", - }); - }); - - it("preserves an advanced security value when no saved value exists", () => { - expect( - buildRdpSettings({ - port: 3389, - ignoreCert: false, - guacConfig: { security: "tls" }, - guacdOverrides: {}, - }).security, - ).toBe("tls"); - }); - - it("uses the prompted domain for prompt-on-connect authentication", () => { - expect(resolveRdpDomain("none", "EXAMPLE", "OLD")).toBe("EXAMPLE"); - expect(resolveRdpDomain("none", "", "OLD")).toBe(""); - }); - - it("keeps the stored domain for saved authentication", () => { - expect(resolveRdpDomain("direct", "EXAMPLE", "SAVED")).toBe("SAVED"); - expect(resolveRdpDomain("none", undefined, "SAVED")).toBe("SAVED"); - }); -}); - -describe("resolveRdpAuthTypeForConnect", () => { - it("keeps prompt-on-connect authentication for a secretless recipient", () => { - expect( - resolveRdpAuthTypeForConnect({ - storedAuthType: "none", - sharedResolution: { source: "secretless" }, - }), - ).toBe("none"); - }); - - it("uses a recipient override instead of prompting", () => { - expect( - resolveRdpAuthTypeForConnect({ - storedAuthType: "none", - sharedResolution: { source: "personal-override" }, - }), - ).toBe("credential"); - }); - - it("uses authentication shared by the owner", () => { - expect( - resolveRdpAuthTypeForConnect({ - storedAuthType: "credential", - sharedResolution: { source: "owner-shared", authType: "credential" }, - }), - ).toBe("credential"); - }); -}); diff --git a/src/backend/tests/hosts/guacamole/recording-settings.test.ts b/src/backend/tests/hosts/guacamole/recording-settings.test.ts deleted file mode 100644 index 68b19c530..000000000 --- a/src/backend/tests/hosts/guacamole/recording-settings.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { withRecordingSettings } from "../../../hosts/guacamole/recording-settings.js"; - -const PATH = "/app/data/session_recordings/guacamole"; -const NAME = "b7e6c0f2-0000-4000-8000-000000000000.guac"; - -describe("withRecordingSettings", () => { - it("takes ownership of the location and filename", () => { - const merged = withRecordingSettings( - { - "recording-path": "/var/lib/termix/recordings", - "recording-name": "${GUAC_USERNAME}-${GUAC_DATE}", - "create-recording-path": false, - }, - PATH, - NAME, - ); - - expect(merged).toMatchObject({ - "recording-path": PATH, - "recording-name": NAME, - "create-recording-path": true, - }); - }); - - it("defaults the content flags when the host has no opinion", () => { - expect(withRecordingSettings({}, PATH, NAME)).toMatchObject({ - "recording-exclude-output": false, - "recording-include-keys": true, - }); - }); - - it("keeps the host's content flags, including the falsy ones", () => { - const merged = withRecordingSettings( - { - "recording-exclude-output": true, - "recording-include-keys": false, - }, - PATH, - NAME, - ); - - expect(merged).toMatchObject({ - "recording-exclude-output": true, - "recording-include-keys": false, - }); - }); - - it("leaves unrelated settings alone", () => { - const merged = withRecordingSettings( - { "recording-exclude-mouse": true, width: "1920" }, - PATH, - NAME, - ); - - expect(merged).toMatchObject({ - "recording-exclude-mouse": true, - width: "1920", - }); - }); - - it("does not mutate the settings it was given", () => { - const original = { "recording-path": "/tmp/mine" }; - withRecordingSettings(original, PATH, NAME); - - expect(original).toEqual({ "recording-path": "/tmp/mine" }); - }); -}); diff --git a/src/backend/tests/hosts/guacamole/token-delivery.test.ts b/src/backend/tests/hosts/guacamole/token-delivery.test.ts deleted file mode 100644 index bd644e948..000000000 --- a/src/backend/tests/hosts/guacamole/token-delivery.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { readFileSync } from "node:fs"; -import { runInNewContext } from "node:vm"; -import ts from "typescript"; -import { expect, it, vi } from "vitest"; - -it("returns the minted token without waiting for a connection that needs that token", async () => { - const source = readFileSync( - new URL("../../../hosts/guacamole/routes.ts", import.meta.url), - "utf8", - ); - const ast = ts.createSourceFile( - "routes.ts", - source, - ts.ScriptTarget.Latest, - true, - ); - const route = ast.statements.find( - (s) => - ts.isExpressionStatement(s) && - ts.isCallExpression(s.expression) && - s.expression.arguments[0]?.getText(ast) === '"/connect-host/:hostId"', - ) as ts.ExpressionStatement; - const callback = (route.expression as ts.CallExpression) - .arguments[1] as ts.ArrowFunction; - const block = (callback.body as ts.Block).statements[0] as ts.TryStatement; - const index = block.tryBlock.statements - .map((s, i) => (ts.isSwitchStatement(s) ? i : -1)) - .filter((i) => i >= 0) - .at(-1)!; - expect(index).toBeGreaterThan(-1); - const tail = block.tryBlock.statements - .slice(index + 1) - .map((s) => s.getText(ast)) - .join("\n"); - const json = vi.fn(); - const wait = vi.fn(() => { - throw new Error("Client has not received the token yet"); - }); - await runInNewContext( - ts.transpileModule(`(async () => { ${tail} })()`, { - compilerOptions: { target: ts.ScriptTarget.ES2022 }, - }).outputText, - { - waitForGuacdOpen: wait, - token: "minted-token", - termixConnectId: "correlation-id", - req: {}, - getRequestMeta: () => ({}), - logAudit: async () => {}, - getAuditUsername: async () => "owner", - userId: "owner", - connectionType: "rdp", - hostId: 7, - hostname: "host", - port: 3389, - res: { json }, - }, - ); - expect(wait).not.toHaveBeenCalled(); - expect(json).toHaveBeenCalledWith({ - token: "minted-token", - termixConnectId: "correlation-id", - guacamoleConnectionId: null, - }); -}); - -it("only reveals the live session ID to its owner", () => { - const source = readFileSync( - new URL("../../../hosts/guacamole/guacamole-server.ts", import.meta.url), - "utf8", - ); - const ast = ts.createSourceFile( - "server.ts", - source, - ts.ScriptTarget.Latest, - true, - ); - const fn = ast.statements.find( - (s) => - ts.isFunctionDeclaration(s) && - s.name?.text === "getGuacSessionByConnectId", - )!; - const info = { ownerUserId: "owner", guacamoleConnectionId: "guacd-id" }; - const context = { - exports: {} as Record unknown>, - guacSessionByConnectId: new Map([["connect-id", info]]), - }; - runInNewContext( - ts.transpileModule(fn.getText(ast), { - compilerOptions: { module: ts.ModuleKind.CommonJS }, - }).outputText, - context, - ); - const lookup = context.exports.getGuacSessionByConnectId; - expect(lookup("connect-id", "owner")).toEqual(info); - expect(lookup("connect-id", "other")).toBeNull(); - expect(lookup("pending", "owner")).toBeNull(); -}); diff --git a/src/backend/tests/hosts/guacamole/token-service.test.ts b/src/backend/tests/hosts/guacamole/token-service.test.ts deleted file mode 100644 index d6cfc9942..000000000 --- a/src/backend/tests/hosts/guacamole/token-service.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -vi.mock("../../../utils/logger.js", () => ({ - guacLogger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - success: vi.fn(), - }, -})); - -const { GuacamoleTokenService } = - await import("../../../hosts/guacamole/token-service.js"); - -describe("GuacamoleTokenService", () => { - const tokenService = GuacamoleTokenService.getInstance(); - - it("disables RDP pre-authentication when no credentials are configured", () => { - const token = tokenService.createRdpToken("windows.example.test", "", ""); - const decrypted = tokenService.decryptToken(token); - - expect(decrypted?.connection.settings).toMatchObject({ - hostname: "windows.example.test", - port: 3389, - "ignore-cert": true, - "disable-auth": true, - }); - expect(decrypted?.connection.settings.username).toBeUndefined(); - expect(decrypted?.connection.settings.password).toBeUndefined(); - }); - - it("keeps normal RDP credential authentication unchanged", () => { - const token = tokenService.createRdpToken( - "windows.example.test", - "Administrator", - "secret", - ); - const decrypted = tokenService.decryptToken(token); - - expect(decrypted?.connection.settings).toMatchObject({ - hostname: "windows.example.test", - username: "Administrator", - password: "secret", - port: 3389, - }); - expect(decrypted?.connection.settings["disable-auth"]).toBeUndefined(); - }); - - it("preserves recording metadata outside guacd connection settings", () => { - const recording = { - hostId: 42, - userId: "user-1", - protocol: "vnc" as const, - path: "recording.guac", - startedAt: "2026-07-14T00:00:00.000Z", - }; - const token = tokenService.createVncToken( - "vnc.example.test", - "user", - "secret", - {}, - recording, - ); - - expect(tokenService.decryptToken(token)?.recording).toEqual(recording); - }); - - it("preserves termixMeta through the encrypt/decrypt round trip", () => { - const termixMeta = { - termixConnectId: "connect-1", - hostId: 7, - ownerUserId: "user-1", - protocol: "rdp" as const, - }; - const token = tokenService.createRdpToken( - "windows.example.test", - "Administrator", - "secret", - {}, - undefined, - termixMeta, - ); - - expect(tokenService.decryptToken(token)?.termixMeta).toEqual(termixMeta); - }); - - it("createJoinToken sets connection.join, not connection.type", () => { - const token = tokenService.createJoinToken("guacd-conn-123", true); - const decrypted = tokenService.decryptToken(token); - - expect(decrypted?.connection.join).toBe("guacd-conn-123"); - expect(decrypted?.connection.type).toBeUndefined(); - expect(decrypted?.connection.readOnly).toBe(true); - }); - - it("createJoinToken round-trips a read-write join through decryptToken", () => { - const token = tokenService.createJoinToken("guacd-conn-456", false); - const decrypted = tokenService.decryptToken(token); - - expect(decrypted?.connection.join).toBe("guacd-conn-456"); - expect(decrypted?.connection.readOnly).toBe(false); - expect(decrypted?.recording).toBeUndefined(); - }); -}); diff --git a/src/backend/tests/hosts/session-sharing/routes.test.ts b/src/backend/tests/hosts/session-sharing/routes.test.ts index 052d17015..8c9047fdb 100644 --- a/src/backend/tests/hosts/session-sharing/routes.test.ts +++ b/src/backend/tests/hosts/session-sharing/routes.test.ts @@ -63,18 +63,13 @@ vi.mock("../../../hosts/terminal/session-manager.js", () => ({ }, })); -vi.mock("../../../hosts/guacamole/guacamole-server.js", () => ({ - getGuacSessionInfo: (guacamoleConnectionId: string) => +vi.mock("../../../database/routes/guacamole-dispatch.js", () => ({ + getGuacamoleSessionInfo: (guacamoleConnectionId: string) => state.guacSessions.get(guacamoleConnectionId) ?? null, -})); - -vi.mock("../../../hosts/guacamole/token-service.js", () => ({ - GuacamoleTokenService: { - getInstance: () => ({ - createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) => - `join-token:${guacamoleConnectionId}:${readOnly}`, - }), - }, + createGuacamoleJoinToken: ( + guacamoleConnectionId: string, + readOnly: boolean, + ) => `join-token:${guacamoleConnectionId}:${readOnly}`, })); vi.mock("../../../database/repositories/factory.js", () => ({ diff --git a/src/backend/tests/plugins/bundled-plugin.test.ts b/src/backend/tests/plugins/bundled-plugin.test.ts index 2d741e67a..12ca29e61 100644 --- a/src/backend/tests/plugins/bundled-plugin.test.ts +++ b/src/backend/tests/plugins/bundled-plugin.test.ts @@ -186,3 +186,61 @@ describe("bundled host-metrics plugin", () => { expect(source).toMatch(/export async function deactivate/); }); }); + +describe("bundled remote-desktop plugin", () => { + it("ships a directory with a manifest and both entry points", () => { + const dir = path.join(getBundledPluginsDir(), "remote-desktop"); + + expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true); + expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true); + expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true); + }); + + it("has a manifest that passes the real validator", () => { + const { manifest, errors } = parseManifest( + readBundledManifest("remote-desktop"), + ); + + expect(errors).toEqual([]); + expect(manifest?.id).toBe("remote-desktop"); + expect(manifest?.category).toBe("Terminal"); + }); + + it("declares the transport-owner capability and qualifies for the tier", () => { + const { manifest } = parseManifest(readBundledManifest("remote-desktop")); + + expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY); + expect(isFirstParty("remote-desktop")).toBe(true); + expect(runsInProcess("remote-desktop", manifest!.permissions)).toBe(true); + }); + + it("contributes the rdp/vnc/telnet tabs the shell registers", () => { + const { manifest } = parseManifest(readBundledManifest("remote-desktop")); + const tabIds = manifest?.contributes?.tabs?.map((tab) => tab.id); + + expect(tabIds).toEqual(["rdp", "vnc", "telnet"]); + }); + + it("declares three host capabilities as an array", () => { + const { manifest } = parseManifest(readBundledManifest("remote-desktop")); + const hostCapability = manifest?.contributes?.hostCapability; + + expect(Array.isArray(hostCapability)).toBe(true); + expect( + (hostCapability as Array<{ key: string }>).map((entry) => entry.key), + ).toEqual(["enableRdp", "enableVnc", "enableTelnet"]); + }); + + it("exports activate and deactivate from its backend entry", () => { + const entry = path.join( + getBundledPluginsDir(), + "remote-desktop", + "backend", + "index.mjs", + ); + const source = fs.readFileSync(entry, "utf8"); + + expect(source).toMatch(/export async function activate/); + expect(source).toMatch(/export async function deactivate/); + }); +}); diff --git a/src/backend/tests/plugins/first-party.test.ts b/src/backend/tests/plugins/first-party.test.ts index eefe28e5a..306032d73 100644 --- a/src/backend/tests/plugins/first-party.test.ts +++ b/src/backend/tests/plugins/first-party.test.ts @@ -39,22 +39,24 @@ function manifest(overrides: Record = {}) { } describe("first-party allowlist", () => { - it("contains only ssh-terminal, docker, host-metrics, ai and proxmox", () => { + it("contains only ssh-terminal, docker, host-metrics, ai, proxmox and remote-desktop", () => { expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([ "ai", "docker", "host-metrics", "proxmox", + "remote-desktop", "ssh-terminal", ]); }); - it("recognises ssh-terminal, docker, host-metrics, ai and proxmox and nothing else", () => { + it("recognises ssh-terminal, docker, host-metrics, ai, proxmox and remote-desktop and nothing else", () => { expect(isFirstParty("ssh-terminal")).toBe(true); expect(isFirstParty("docker")).toBe(true); expect(isFirstParty("host-metrics")).toBe(true); expect(isFirstParty("ai")).toBe(true); expect(isFirstParty("proxmox")).toBe(true); + expect(isFirstParty("remote-desktop")).toBe(true); expect(isFirstParty("ssh-terminal-pro")).toBe(false); expect(isFirstParty("community-plugin")).toBe(false); expect(isFirstParty("")).toBe(false); @@ -72,6 +74,9 @@ describe("runsInProcess", () => { ); expect(runsInProcess("ai", [TRANSPORT_OWNER_CAPABILITY])).toBe(true); expect(runsInProcess("proxmox", [TRANSPORT_OWNER_CAPABILITY])).toBe(true); + expect(runsInProcess("remote-desktop", [TRANSPORT_OWNER_CAPABILITY])).toBe( + true, + ); // On the list but not asking for it: stays in a worker, so the manifest // remains an honest description of what the plugin does. @@ -80,6 +85,7 @@ describe("runsInProcess", () => { expect(runsInProcess("host-metrics", ["hosts.read"])).toBe(false); expect(runsInProcess("ai", ["hosts.read"])).toBe(false); expect(runsInProcess("proxmox", ["hosts.read"])).toBe(false); + expect(runsInProcess("remote-desktop", ["hosts.read"])).toBe(false); // Asking for it but not on the list. expect( diff --git a/src/main.tsx b/src/main.tsx index 3949bd217..d8271c43a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -59,7 +59,7 @@ const DockerApp = lazy(() => })), ); const GuacamoleApp = lazy(() => - import("@/features/guacamole/GuacamoleApp").then((m) => ({ + import("../plugins/remote-desktop/frontend/GuacamoleApp").then((m) => ({ default: m.default, })), ); diff --git a/src/ui/features/collab/CollabGuestView.tsx b/src/ui/features/collab/CollabGuestView.tsx index 41dfdd3c1..ef69555f9 100644 --- a/src/ui/features/collab/CollabGuestView.tsx +++ b/src/ui/features/collab/CollabGuestView.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { AlertCircle, Eye, Presentation, Users } from "lucide-react"; -import { GuacamoleDisplay } from "@/features/guacamole/GuacamoleDisplay.tsx"; +import { GuacamoleDisplay } from "../../../../plugins/remote-desktop/frontend/GuacamoleDisplay.tsx"; import { GuestTerminalView, type SessionParticipantInfo, diff --git a/src/ui/features/collab/CollabRoomTab.tsx b/src/ui/features/collab/CollabRoomTab.tsx index ab715c069..19a5b9088 100644 --- a/src/ui/features/collab/CollabRoomTab.tsx +++ b/src/ui/features/collab/CollabRoomTab.tsx @@ -33,8 +33,8 @@ import { Input } from "@/components/input"; import { CollabMembersSidebar } from "./CollabMembersSidebar"; import { Terminal } from "@/features/terminal/Terminal"; import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext"; -import { GuacamoleDisplay } from "@/features/guacamole/GuacamoleDisplay.tsx"; -import { getGuacamoleTokenFromHost } from "@/api/guacamole-api"; +import { GuacamoleDisplay } from "../../../../plugins/remote-desktop/frontend/GuacamoleDisplay.tsx"; +import { getGuacamoleTokenFromHost } from "../../../../plugins/remote-desktop/frontend/guacamole-api"; import { getSSHHosts, getUserList, type SSHHostWithStatus } from "@/main-axios"; import { getRoles } from "@/api/rbac-api"; import type { Role } from "@/main-axios"; diff --git a/src/ui/features/session-sharing/SharedSessionView.tsx b/src/ui/features/session-sharing/SharedSessionView.tsx index 374c78b02..8099c2961 100644 --- a/src/ui/features/session-sharing/SharedSessionView.tsx +++ b/src/ui/features/session-sharing/SharedSessionView.tsx @@ -11,7 +11,7 @@ import { import { SimpleLoader } from "@/lib/SimpleLoader.tsx"; import { getBasePath } from "@/lib/base-path"; import { isElectron } from "@/lib/electron"; -import { GuacamoleDisplay } from "@/features/guacamole/GuacamoleDisplay.tsx"; +import { GuacamoleDisplay } from "../../../../plugins/remote-desktop/frontend/GuacamoleDisplay.tsx"; const PING_INTERVAL_MS = 30000; diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index ddc2e3f62..3ff22f778 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -903,7 +903,7 @@ "useSocks5ProxyDesc": "Route connection through a proxy server", "connectionOrigin": "Connection Origin", "connectionOriginDesc": "Where this host's connection originates from. Overrides the desktop app's global default.", - "connectionOriginGuacamoleNote": "RDP, VNC and Telnet use the remote server unless you pick \"This device\", which needs a guacd this machine can reach — set one in the guacd URL setting, or per host under guacd Proxy.", + "connectionOriginRemoteDesktopNote": "RDP, VNC and Telnet use the remote server unless you pick \"This device\", which needs a guacd this machine can reach — set one in the guacd URL setting, or per host under guacd Proxy.", "connectionOriginDefault": "Use default", "connectionOriginLocal": "This device (local network)", "connectionOriginRemote": "Remote server", @@ -2050,7 +2050,7 @@ "userShareBadge": "User: {{username}}", "loadSharesFailed": "Failed to load active shares" }, - "guacamole": { + "remoteDesktop": { "connecting": "Connecting to {{type}} session...", "checkingGuacd": "Checking remote desktop service...", "requestingToken": "Requesting {{type}} connection token...", @@ -3665,9 +3665,9 @@ "metrics": "Metrics", "sec": "sec", "logLevel": "Log Level", - "enableGuacamole": "Enable Guacamole", - "enableGuacamoleDesc": "RDP/VNC remote desktop", - "enableGuacamoleDocsLink": "View docs", + "enableRemoteDesktop": "Enable Remote Desktop", + "enableRemoteDesktopDesc": "RDP/VNC remote desktop", + "enableRemoteDesktopDocsLink": "View docs", "guacdUrl": "guacd URL", "tailscaleApiKey": "Tailscale API Key", "tailscaleApiKeyDescription": "Used for device discovery in the host editor. Supports Tailscale (tskey-) and Headscale (hskey-) keys. Generate a key at tailscale.com/admin/settings/keys or from your Headscale instance.", @@ -3821,9 +3821,9 @@ "metricsHistoryRetention": "Metrics History Retention", "metricsHistoryRetentionRange": "1 to 90 days", "days": "days", - "guacamoleSaved": "Guacamole settings saved", - "guacamoleSaveFailed": "Failed to save Guacamole settings", - "guacamoleUpdateFailed": "Failed to update Guacamole setting", + "remoteDesktopSaved": "Remote Desktop settings saved", + "remoteDesktopSaveFailed": "Failed to save Remote Desktop settings", + "remoteDesktopUpdateFailed": "Failed to update Remote Desktop setting", "tailscaleSettingsSaved": "Tailscale settings saved", "tailscaleSettingsSaveFailed": "Failed to save Tailscale settings", "logLevelUpdateFailed": "Failed to update log level", diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index e2c7afb75..3f203ce27 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -2191,13 +2191,13 @@ export { export type { GuacamoleTokenRequest, GuacamoleTokenResponse, -} from "@/api/guacamole-api"; +} from "../../plugins/remote-desktop/frontend/guacamole-api"; export { getGuacamoleDpi, getGuacamoleToken, getGuacamoleTokenFromHost, getGuacdStatus, -} from "@/api/guacamole-api"; +} from "../../plugins/remote-desktop/frontend/guacamole-api"; // ============================================================================ // RBAC MANAGEMENT diff --git a/src/ui/shell/CommandPalette.tsx b/src/ui/shell/CommandPalette.tsx index 3b86db22e..396757365 100644 --- a/src/ui/shell/CommandPalette.tsx +++ b/src/ui/shell/CommandPalette.tsx @@ -539,11 +539,12 @@ export function CommandPalette({ handleAction(() => { const type = host.enableSsh ? "terminal" - : host.enableRdp + : host.enableRdp && isTabTypeAvailable("rdp") ? "rdp" - : host.enableVnc + : host.enableVnc && isTabTypeAvailable("vnc") ? "vnc" - : host.enableTelnet + : host.enableTelnet && + isTabTypeAvailable("telnet") ? "telnet" : "terminal"; onOpenTab(type, host.name); @@ -599,12 +600,13 @@ export function CommandPalette({ ), )} {host.enableSsh && - (host.enableRdp || - host.enableVnc || - host.enableTelnet) && ( + ((host.enableRdp && isTabTypeAvailable("rdp")) || + (host.enableVnc && isTabTypeAvailable("vnc")) || + (host.enableTelnet && + isTabTypeAvailable("telnet"))) && (
)} - {host.enableRdp && ( + {host.enableRdp && isTabTypeAvailable("rdp") && ( )} - {host.enableVnc && ( + {host.enableVnc && isTabTypeAvailable("vnc") && ( )} - {host.enableTelnet && ( - - )} + {host.enableTelnet && + isTabTypeAvailable("telnet") && ( + + )} {canEditHost(host) && ( <>
diff --git a/src/ui/shell/pluginLoader.ts b/src/ui/shell/pluginLoader.ts index 433be534e..83c92dce2 100644 --- a/src/ui/shell/pluginLoader.ts +++ b/src/ui/shell/pluginLoader.ts @@ -40,6 +40,7 @@ import { */ const BUILT_IN_TABS_BY_PLUGIN: Record = { "ssh-terminal": ["terminal"], + "remote-desktop": ["rdp", "vnc", "telnet"], ai: ["ai"], }; diff --git a/src/ui/shell/tabUtils.tsx b/src/ui/shell/tabUtils.tsx index 753a52b37..ca7313347 100644 --- a/src/ui/shell/tabUtils.tsx +++ b/src/ui/shell/tabUtils.tsx @@ -39,7 +39,7 @@ import type { TerminalHandle, TerminalHostConfig, } from "@/features/terminal/Terminal"; -import type { GuacamoleAppHandle } from "@/features/guacamole/GuacamoleApp"; +import type { GuacamoleAppHandle } from "../../../plugins/remote-desktop/frontend/GuacamoleApp"; import { useIsMobile } from "@/hooks/use-mobile"; import type { Tab, TabType, Host } from "@/types/ui-types"; import { @@ -110,7 +110,7 @@ const loadTmuxMonitor = () => })); const TmuxMonitor = lazy(loadTmuxMonitor); const loadGuacamoleApp = () => - import("@/features/guacamole/GuacamoleApp").then((m) => ({ + import("../../../plugins/remote-desktop/frontend/GuacamoleApp").then((m) => ({ default: m.default, })); const GuacamoleApp = lazy(loadGuacamoleApp); @@ -655,7 +655,10 @@ export function renderTabContent( case "telnet": if (!host) return ( - + ); return withTabSuspense( - {t("admin.enableGuacamoleDesc")}{" "} + {t("admin.enableRemoteDesktopDesc")}{" "} - {t("admin.enableGuacamoleDocsLink")} + {t("admin.enableRemoteDesktopDocsLink")} } diff --git a/src/ui/sidebar/HostEditorGeneralTab.tsx b/src/ui/sidebar/HostEditorGeneralTab.tsx index 2fb64908a..ede2772bb 100644 --- a/src/ui/sidebar/HostEditorGeneralTab.tsx +++ b/src/ui/sidebar/HostEditorGeneralTab.tsx @@ -773,7 +773,7 @@ export function HostEditorGeneralTab({ protocols.enableRdp || protocols.enableVnc || protocols.enableTelnet - ? `${t("hosts.connectionOriginDesc")} ${t("hosts.connectionOriginGuacamoleNote")}` + ? `${t("hosts.connectionOriginDesc")} ${t("hosts.connectionOriginRemoteDesktopNote")}` : t("hosts.connectionOriginDesc") } > diff --git a/src/ui/sidebar/quick-connect-host.ts b/src/ui/sidebar/quick-connect-host.ts index e03eae246..a39fd23ab 100644 --- a/src/ui/sidebar/quick-connect-host.ts +++ b/src/ui/sidebar/quick-connect-host.ts @@ -1,6 +1,6 @@ import type { SSHHostData } from "@/types"; import type { Host } from "@/types/ui-types"; -import type { GuacamoleQuickHost } from "@/features/guacamole/GuacamoleApp"; +import type { GuacamoleQuickHost } from "../../../plugins/remote-desktop/frontend/GuacamoleApp"; export type QuickConnectProtocol = "ssh" | "rdp" | "vnc"; diff --git a/src/ui/sidebar/tree/HostItem/HostItem.tsx b/src/ui/sidebar/tree/HostItem/HostItem.tsx index 8d3d633d3..b4eb119de 100644 --- a/src/ui/sidebar/tree/HostItem/HostItem.tsx +++ b/src/ui/sidebar/tree/HostItem/HostItem.tsx @@ -493,17 +493,19 @@ export function HostItem({ const sshActions = getSshActions(host); const availableActions: TabType[] = [ ...sshActions.map(({ type }) => type), - ...(host.enableRdp ? (["rdp"] as const) : []), - ...(host.enableVnc ? (["vnc"] as const) : []), - ...(host.enableTelnet ? (["telnet"] as const) : []), + ...(host.enableRdp && isTabTypeAvailable("rdp") ? (["rdp"] as const) : []), + ...(host.enableVnc && isTabTypeAvailable("vnc") ? (["vnc"] as const) : []), + ...(host.enableTelnet && isTabTypeAvailable("telnet") + ? (["telnet"] as const) + : []), ]; const defaultAction: TabType = host.enableSsh ? "terminal" - : host.enableRdp + : host.enableRdp && isTabTypeAvailable("rdp") ? "rdp" - : host.enableVnc + : host.enableVnc && isTabTypeAvailable("vnc") ? "vnc" - : host.enableTelnet + : host.enableTelnet && isTabTypeAvailable("telnet") ? "telnet" : "terminal"; const openHostTab = ( @@ -604,11 +606,13 @@ export function HostItem({ ), )} {host.enableSsh && - (host.enableRdp || host.enableVnc || host.enableTelnet) && + ((host.enableRdp && isTabTypeAvailable("rdp")) || + (host.enableVnc && isTabTypeAvailable("vnc")) || + (host.enableTelnet && isTabTypeAvailable("telnet"))) && sshActions.length > 0 && (
)} - {host.enableRdp && ( + {host.enableRdp && isTabTypeAvailable("rdp") && ( )} - {host.enableVnc && ( + {host.enableVnc && isTabTypeAvailable("vnc") && ( )} - {host.enableTelnet && ( + {host.enableTelnet && isTabTypeAvailable("telnet") && (