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

+42 -32
View File
@@ -13,7 +13,7 @@ import {
createCurrentPluginSettingsRepository,
} from "../repositories/factory.js";
import { validateParentHostId } from "./host-parent-validation.js";
import { serializeWebUiConfig } from "./host-web-endpoints.js";
import { applyPluginHostImportSettings } from "./host-plugin-settings.js";
import {
isNonEmptyString,
isValidPort,
@@ -286,10 +286,6 @@ export function registerHostBulkRoutes(
simpleUpdates.enableFileManager = updates.enableFileManager;
if (typeof updates.enableDocker === "boolean")
simpleUpdates.enableDocker = updates.enableDocker;
if (typeof updates.enableWebUi === "boolean") {
simpleUpdates.enableWebUi = updates.enableWebUi;
if (!updates.enableWebUi) simpleUpdates.webUiConfig = null;
}
if (typeof updates.enableTmuxMonitor === "boolean")
simpleUpdates.enableTmuxMonitor = updates.enableTmuxMonitor;
if (typeof updates.enableTerminalToolbar === "boolean")
@@ -377,6 +373,39 @@ export function registerHostBulkRoutes(
}
}
// Web endpoint enable/disable lives in the plugin's own host-scope
// settings. Disabling clears webUiConfig, matching the old column
// write it replaces.
if (typeof updates.enableWebUi === "boolean") {
const pluginSettingsRepository =
createCurrentPluginSettingsRepository();
for (const host of ownedHosts) {
try {
const scopeId = String(host.id);
await pluginSettingsRepository.set(
"web-endpoint",
"host",
scopeId,
"enableWebUi",
JSON.stringify(updates.enableWebUi),
);
if (!updates.enableWebUi) {
await pluginSettingsRepository.set(
"web-endpoint",
"host",
scopeId,
"webUiConfig",
JSON.stringify(null),
);
}
} catch {
errors.push(
`Failed to ${updates.enableWebUi ? "enable" : "disable"} web endpoints for host ${host.id}`,
);
}
}
}
return res.json({
updated: ownedIds.length,
failed: unauthorizedIds.length,
@@ -737,7 +766,6 @@ export function registerHostBulkRoutes(
enableTunnel: hostData.enableTunnel !== false,
enableFileManager: hostData.enableFileManager !== false,
enableDocker: hostData.enableDocker || false,
enableWebUi: hostData.enableWebUi || false,
enableTmuxMonitor: hostData.enableTmuxMonitor || false,
enableTerminalToolbar: hostData.enableTerminalToolbar !== false,
enableAiAssistant: hostData.enableAiAssistant || false,
@@ -762,9 +790,6 @@ export function registerHostBulkRoutes(
dockerConfig: hostData.dockerConfig
? JSON.stringify(hostData.dockerConfig)
: null,
webUiConfig: hostData.enableWebUi
? serializeWebUiConfig(hostData.webUiConfig)
: null,
terminalConfig: hostData.terminalConfig
? JSON.stringify(hostData.terminalConfig)
: null,
@@ -860,27 +885,14 @@ export function registerHostBulkRoutes(
results.success++;
}
if (hostData.enableProxmox || hostData.proxmoxConfig) {
const pluginSettingsRepository =
createCurrentPluginSettingsRepository();
const scopeId = String(savedHostId);
await pluginSettingsRepository.set(
"proxmox",
"host",
scopeId,
"enableProxmox",
JSON.stringify(hostData.enableProxmox || false),
);
if (hostData.proxmoxConfig) {
await pluginSettingsRepository.set(
"proxmox",
"host",
scopeId,
"proxmoxConfig",
JSON.stringify(hostData.proxmoxConfig),
);
}
}
// Every enabled plugin that declares host-scope settings and
// registered a hostImportNormalizer validates and writes its own
// fields here, so this loop does not need to know which plugins
// exist. See host-plugin-settings.ts.
await applyPluginHostImportSettings(
savedHostId,
hostData as Record<string, unknown>,
);
} catch (error) {
results.failed++;
results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`);
@@ -1038,7 +1050,6 @@ export function registerHostBulkRoutes(
enableTunnel: true,
enableFileManager: true,
enableDocker: false,
enableWebUi: false,
enableTmuxMonitor: false,
enableTerminalToolbar: true,
enableAiAssistant: false,
@@ -1056,7 +1067,6 @@ export function registerHostBulkRoutes(
quickActions: null,
statsConfig: null,
dockerConfig: null,
webUiConfig: null,
terminalConfig: null,
forceKeyboardInteractive: "false",
notes: null,
@@ -1,5 +1,4 @@
import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js";
import { parseWebUiConfig } from "./host-web-endpoints.js";
export function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
@@ -364,8 +363,6 @@ const CONNECT_LEVEL_FIELDS = new Set([
"enableTunnel",
"enableFileManager",
"enableDocker",
"enableWebUi",
"webUiConfig",
"enableTmuxMonitor",
"enableTerminalToolbar",
"enableAiAssistant",
@@ -467,7 +464,6 @@ export function transformHostResponse(
enableTunnel: !!host.enableTunnel,
enableFileManager: host.enableFileManager !== false,
enableDocker: !!host.enableDocker,
enableWebUi: !!host.enableWebUi,
enableTmuxMonitor: !!host.enableTmuxMonitor,
enableTerminalToolbar: host.enableTerminalToolbar !== false,
enableAiAssistant: !!host.enableAiAssistant,
@@ -519,9 +515,6 @@ export function transformHostResponse(
dockerConfig: host.dockerConfig
? JSON.parse(host.dockerConfig as string)
: undefined,
// Guarded, unlike dockerConfig directly above: parseWebUiConfig never
// throws, so a half-written config cannot take out the whole host listing.
webUiConfig: parseWebUiConfig(host.webUiConfig),
forceKeyboardInteractive: host.forceKeyboardInteractive === "true",
useWarpgate: !!host.useWarpgate,
socks5ProxyChain: host.socks5ProxyChain
@@ -15,12 +15,13 @@ import { createCurrentPluginSettingsRepository } from "../repositories/factory.j
import type { PluginSettingsRecord } from "../repositories/plugin-settings-repository.js";
import { declaredFields, resolveFieldValue } from "../../plugins/settings.js";
import { getPluginRuntime } from "../../plugins/index.js";
import { consume } from "../../plugins/registry.js";
import { sshLogger } from "../../utils/logger.js";
export type HostPluginSettings = Record<string, Record<string, unknown>>;
/** Enabled plugins that declare host-scope settings, with their manifests. */
function hostSettingsPlugins(): PluginManifest[] {
export function hostSettingsPlugins(): PluginManifest[] {
try {
const { loader } = getPluginRuntime();
return loader
@@ -147,3 +148,48 @@ export async function writeHostPluginSettings(
await repository.set(pluginId, "host", scopeId, key, JSON.stringify(value));
}
}
/**
* A plugin's own validator for the fields it accepts inline on a bulk host
* import row (host-bulk-routes.ts's Termix-JSON import path). Registered
* through ctx.registry.provide("<pluginId>.hostImportNormalizer", fn) - not
* part of the SDK's typed ctx surface, because only this one bulk-import
* path calls it and a full contract is speculative until a second caller
* needs it. Returns the fields to write (JSON-serializable, matching
* writeHostPluginSettings's input), or null to write nothing for this row.
*/
export type PluginHostImportNormalizer = (
raw: Record<string, unknown>,
) => Record<string, unknown> | null;
/**
* Runs every enabled plugin's registered import normalizer over one imported
* host row and writes whatever each one returns, so a plugin's host-scope
* settings are validated the same way on bulk import as they are anywhere
* else - instead of host-bulk-routes.ts hardcoding one plugin's shape.
*
* Best-effort per plugin: one plugin's normalizer throwing must not fail the
* whole import, so it is logged and skipped rather than propagated.
*/
export async function applyPluginHostImportSettings(
hostId: number,
raw: Record<string, unknown>,
): Promise<void> {
for (const manifest of hostSettingsPlugins()) {
const normalizer = consume<PluginHostImportNormalizer>(
`${manifest.id}.hostImportNormalizer`,
);
if (!normalizer) continue;
try {
const values = normalizer(raw);
if (values) await writeHostPluginSettings(manifest.id, hostId, values);
} catch (error) {
sshLogger.warn("Plugin host import normalizer failed", {
operation: "host_import_plugin_settings",
pluginId: manifest.id,
hostId,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
@@ -1,201 +0,0 @@
import {
MAX_WEB_ENDPOINTS,
MAX_WEB_ENDPOINT_LABEL_LENGTH,
} from "../../../types/index.js";
import type { WebEndpoint, WebUiConfig } from "../../../types/index.js";
export { MAX_WEB_ENDPOINTS, MAX_WEB_ENDPOINT_LABEL_LENGTH };
const SCHEMES = new Set(["http", "https"]);
const ACCESS_VALUES = new Set(["direct", "tunnel"]);
const RENDER_VALUES = new Set(["external", "embedded"]);
const MIN_PORT = 1;
const MAX_PORT = 65535;
function isValidPort(value: unknown): value is number {
return (
typeof value === "number" &&
Number.isInteger(value) &&
value >= MIN_PORT &&
value <= MAX_PORT
);
}
/**
* C0 controls and DEL. Checked by codepoint rather than by regex character
* class so no escaping subtlety can let one through: a path like
* "\t//evil.example" defeats a naive startsWith("//") guard, because the
* browser strips the control character and then follows the authority.
*/
function hasControlCharacter(value: string): boolean {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code <= 0x1f || code === 0x7f) return true;
}
return false;
}
/**
* Coerces a path to begin with "/", or returns null to drop the endpoint.
*
* This is THE enforcement point for a value that reaches an href and an
* iframe src -- the editor's checks are UX. Anything that could redirect the
* frame away from the endpoint is rejected rather than sanitized, because
* sanitizing invites a second, subtly different implementation elsewhere.
*/
function normalizePath(raw: unknown): string | null {
if (raw === undefined || raw === null || raw === "") return "/";
if (typeof raw !== "string") return null;
if (hasControlCharacter(raw)) return null;
const path = raw.startsWith("/") ? raw : `/${raw}`;
if (path.startsWith("//")) return null;
if (/^\/[a-z][a-z0-9+.-]*:/i.test(path)) return null;
if (path.includes("\\")) return null;
return path;
}
/**
* A bare host literal, or null to drop the endpoint, or undefined to leave the
* field unset (the route then falls back to loopback).
*
* The value lands both in a TCP listener and in a URL authority, so nothing
* carrying a scheme, port, path or credentials may survive.
*/
function normalizeBindHost(raw: unknown): string | null | undefined {
if (raw === undefined || raw === null || raw === "") return undefined;
if (typeof raw !== "string") return null;
const value = raw.trim();
if (!value) return undefined;
if (value.length > 253) return null;
if (hasControlCharacter(value)) return null;
const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/;
const HOSTNAME =
/^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
const IPV6_BRACKETED = /^\[[0-9A-Fa-f:.]+\]$/;
if (IPV4.test(value)) {
return value.split(".").every((part) => Number(part) <= 255) ? value : null;
}
if (IPV6_BRACKETED.test(value) || HOSTNAME.test(value)) return value;
return null;
}
function normalizeEndpoint(raw: unknown): WebEndpoint | null {
if (!raw || typeof raw !== "object") return null;
const input = raw as Record<string, unknown>;
const id = typeof input.id === "string" ? input.id.trim() : "";
if (!id) return null;
const label = typeof input.label === "string" ? input.label.trim() : "";
if (!label) return null;
const scheme = input.scheme;
if (typeof scheme !== "string" || !SCHEMES.has(scheme)) return null;
if (!isValidPort(input.port)) return null;
const path = normalizePath(input.path);
if (path === null) return null;
const access = input.access;
if (typeof access !== "string" || !ACCESS_VALUES.has(access)) return null;
const render = input.render;
if (typeof render !== "string" || !RENDER_VALUES.has(render)) return null;
const isTunnel = access === "tunnel";
const endpoint: WebEndpoint = {
id,
label: label.slice(0, MAX_WEB_ENDPOINT_LABEL_LENGTH),
scheme: scheme as WebEndpoint["scheme"],
port: input.port,
path,
access: access as WebEndpoint["access"],
render: render as WebEndpoint["render"],
// Meaningless for a tunnel, whose host component is loopback and already
// exempt from certificate checks. Cleared rather than carried so nothing
// downstream has to re-derive that.
ignoreCert: !isTunnel && input.ignoreCert === true,
};
// Both are tunnel-only. Silently ignored on a direct endpoint rather than
// rejected: they describe a forward that a direct endpoint never creates.
if (isTunnel) {
const bindHost = normalizeBindHost(input.bindHost);
if (bindHost === null) return null;
if (bindHost !== undefined) endpoint.bindHost = bindHost;
if (input.localPort !== undefined && input.localPort !== null) {
if (!isValidPort(input.localPort)) return null;
endpoint.localPort = input.localPort;
}
}
return endpoint;
}
/**
* Drops any endpoint it refuses rather than rejecting the whole host -- one
* bad row must not make a host unsaveable or unlistable. The editor is
* responsible for telling the user before that happens
* (plugins/web-endpoint/src/frontend/web-endpoint-validation.ts).
*/
export function normalizeWebEndpoints(raw: unknown): WebEndpoint[] {
if (!Array.isArray(raw)) return [];
const seen = new Set<string>();
const endpoints: WebEndpoint[] = [];
for (const candidate of raw) {
if (endpoints.length >= MAX_WEB_ENDPOINTS) break;
const endpoint = normalizeEndpoint(candidate);
if (!endpoint) continue;
if (seen.has(endpoint.id)) continue;
seen.add(endpoint.id);
endpoints.push(endpoint);
}
return endpoints;
}
/**
* Never throws. A malformed stored value yields an empty endpoint list, so a
* half-written config cannot take out the whole host listing -- which is what
* dockerConfig's bare JSON.parse does today.
*/
export function parseWebUiConfig(raw: unknown): WebUiConfig {
if (raw === undefined || raw === null) return { endpoints: [] };
let value: unknown = raw;
if (typeof raw === "string") {
try {
value = JSON.parse(raw);
} catch {
return { endpoints: [] };
}
}
if (!value || typeof value !== "object") return { endpoints: [] };
return {
endpoints: normalizeWebEndpoints(
(value as { endpoints?: unknown }).endpoints,
),
};
}
/** Null when nothing survives normalization, so the column is cleared. */
export function serializeWebUiConfig(config: unknown): string | null {
const endpoints = normalizeWebEndpoints(
(config as { endpoints?: unknown } | null | undefined)?.endpoints,
);
if (endpoints.length === 0) return null;
return JSON.stringify({ endpoints });
}
+19 -37
View File
@@ -72,10 +72,6 @@ import type {
HostResolutionHostRecord,
} from "../repositories/host-resolution-repository.js";
import { AUTH_PROTOCOL_METADATA } from "../../../types/auth-protocols.js";
import {
parseWebUiConfig,
serializeWebUiConfig,
} from "./host-web-endpoints.js";
import {
requiresPersonalHostAuthentication,
resolveRecipientSharedHostAuthentication,
@@ -202,7 +198,6 @@ router.post(
enableFileManager,
scpLegacy,
enableDocker,
enableWebUi,
enableProxmox,
enableTmuxMonitor,
enableTerminalToolbar,
@@ -219,7 +214,6 @@ router.post(
quickActions,
statsConfig,
dockerConfig,
webUiConfig,
proxmoxConfig,
enableProxmoxStats,
proxmoxStatsConfig,
@@ -348,7 +342,6 @@ router.post(
enableFileManager: enableFileManager ? 1 : 0,
scpLegacy: scpLegacy ? 1 : 0,
enableDocker: enableDocker ? 1 : 0,
enableWebUi: enableWebUi ? 1 : 0,
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
enableTerminalToolbar: enableTerminalToolbar === false ? 0 : 1,
enableAiAssistant: enableAiAssistant ? 1 : 0,
@@ -369,13 +362,6 @@ router.post(
? dockerConfig
: JSON.stringify(dockerConfig)
: null,
webUiConfig: enableWebUi
? serializeWebUiConfig(
typeof webUiConfig === "string"
? safeParseJson(webUiConfig)
: webUiConfig,
)
: null,
terminalConfig: terminalConfig
? typeof terminalConfig === "string"
? terminalConfig
@@ -930,7 +916,6 @@ router.put(
enableFileManager,
scpLegacy,
enableDocker,
enableWebUi,
enableProxmox,
enableTmuxMonitor,
enableTerminalToolbar,
@@ -947,7 +932,6 @@ router.put(
quickActions,
statsConfig,
dockerConfig,
webUiConfig,
proxmoxConfig,
enableProxmoxStats,
proxmoxStatsConfig,
@@ -1079,7 +1063,6 @@ router.put(
enableFileManager: enableFileManager ? 1 : 0,
scpLegacy: scpLegacy ? 1 : 0,
enableDocker: enableDocker ? 1 : 0,
enableWebUi: enableWebUi ? 1 : 0,
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
enableTerminalToolbar: enableTerminalToolbar === false ? 0 : 1,
enableAiAssistant: enableAiAssistant ? 1 : 0,
@@ -1100,13 +1083,6 @@ router.put(
? dockerConfig
: JSON.stringify(dockerConfig)
: null,
webUiConfig: enableWebUi
? serializeWebUiConfig(
typeof webUiConfig === "string"
? safeParseJson(webUiConfig)
: webUiConfig,
)
: null,
terminalConfig: terminalConfig
? typeof terminalConfig === "string"
? terminalConfig
@@ -2100,9 +2076,10 @@ router.get(
}
const resolvedHost = (await resolveHostCredentials(host, userId)) || host;
const proxmoxSettings = (
const hostPluginSettings = (
await loadHostPluginSettings([Number(hostId)])
).get(Number(hostId))?.proxmox as
).get(Number(hostId));
const proxmoxSettings = hostPluginSettings?.proxmox as
| {
enableProxmox?: boolean;
proxmoxConfig?: unknown;
@@ -2110,6 +2087,8 @@ router.get(
proxmoxStatsConfig?: unknown;
}
| undefined;
const webEndpointSettings = hostPluginSettings?.["web-endpoint"] as
{ enableWebUi?: boolean; webUiConfig?: unknown } | undefined;
const exportedConnectionType =
(resolvedHost.connectionType as string) || "ssh";
@@ -2177,7 +2156,7 @@ router.get(
enableFileManager: resolvedHost.enableFileManager !== false,
scpLegacy: !!resolvedHost.scpLegacy,
enableDocker: !!resolvedHost.enableDocker,
enableWebUi: !!resolvedHost.enableWebUi,
enableWebUi: !!webEndpointSettings?.enableWebUi,
enableProxmox: !!proxmoxSettings?.enableProxmox,
enableProxmoxStats: !!proxmoxSettings?.enableProxmoxStats,
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
@@ -2202,7 +2181,7 @@ router.get(
statsConfig: resolvedHost.statsConfig
? JSON.parse(resolvedHost.statsConfig as string)
: null,
webUiConfig: parseWebUiConfig(resolvedHost.webUiConfig),
webUiConfig: webEndpointSettings?.webUiConfig ?? { endpoints: [] },
dockerConfig: resolvedHost.dockerConfig
? JSON.parse(resolvedHost.dockerConfig as string)
: null,
@@ -2282,7 +2261,7 @@ router.get(
try {
const allHosts =
await createCurrentHostResolutionRepository().findHostsByUserId(userId);
const proxmoxSettingsByHost = await loadHostPluginSettings(
const pluginSettingsByHost = await loadHostPluginSettings(
allHosts.map((h) => h.id as number),
);
@@ -2293,9 +2272,11 @@ router.get(
const resolvedHost = shareMode
? host
: (await resolveHostCredentials(host, userId)) || host;
const proxmoxSettings = proxmoxSettingsByHost.get(host.id as number)
?.proxmox as
const hostPluginSettings = pluginSettingsByHost.get(host.id as number);
const proxmoxSettings = hostPluginSettings?.proxmox as
{ enableProxmox?: boolean; proxmoxConfig?: unknown } | undefined;
const webEndpointSettings = hostPluginSettings?.["web-endpoint"] as
{ enableWebUi?: boolean; webUiConfig?: unknown } | undefined;
const exportedConnectionType =
(resolvedHost.connectionType as string) || "ssh";
@@ -2344,7 +2325,7 @@ router.get(
enableTunnel: !!resolvedHost.enableTunnel,
enableFileManager: resolvedHost.enableFileManager !== false,
enableDocker: !!resolvedHost.enableDocker,
enableWebUi: !!resolvedHost.enableWebUi,
enableWebUi: !!webEndpointSettings?.enableWebUi,
enableProxmox: !!proxmoxSettings?.enableProxmox,
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
enableTerminalToolbar:
@@ -2371,7 +2352,9 @@ router.get(
statsConfig: resolvedHost.statsConfig
? JSON.parse(resolvedHost.statsConfig as string)
: null,
webUiConfig: parseWebUiConfig(resolvedHost.webUiConfig),
webUiConfig: webEndpointSettings?.webUiConfig ?? {
endpoints: [],
},
dockerConfig: resolvedHost.dockerConfig
? JSON.parse(resolvedHost.dockerConfig as string)
: null,
@@ -3010,10 +2993,9 @@ registerHostNetworkRoutes(router, {
export default router;
/**
* A webUiConfig arriving as a JSON string (an import, or a client that
* stringified it) must still reach serializeWebUiConfig as an object.
* Malformed input becomes null, which serializes to a cleared column rather
* than throwing inside a host save.
* A config field arriving as a JSON string (an import, or a client that
* stringified it) must still reach storage as an object. Malformed input
* becomes null rather than throwing inside a host save.
*/
function safeParseJson(raw: string): unknown {
try {
+27
View File
@@ -42,6 +42,7 @@ import {
PluginCapabilityError,
type PluginContext,
type PluginModule,
type PluginOpenIsolatedWindowRequest,
} from "@termix/plugin-sdk/backend";
import type { PluginTableDefinition } from "@termix/plugin-sdk/db";
import * as syncRegistry from "./sync-registry.js";
@@ -299,6 +300,28 @@ export function createPluginContext(
{ action: "db_refs" },
);
const desktopOpenIsolatedWindow = guarded(
manifest,
"desktop:window",
async (request: PluginOpenIsolatedWindowRequest) => {
const { isElectronIpcAvailable, requestFromElectronMain } =
await import("../utils/electron-ipc-bridge.js");
if (!isElectronIpcAvailable()) {
throw new Error(
`Plugin ${pluginId} tried to open an isolated window outside the desktop app`,
);
}
return requestFromElectronMain<{ success: true }>(
"open-isolated-window",
request,
);
},
{
action: "desktop_open_isolated_window",
details: () => "opened an isolated Electron window",
},
);
return {
pluginId,
manifest,
@@ -613,6 +636,10 @@ export function createPluginContext(
ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }),
auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
desktop: {
openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request),
},
/**
* Background work acts as a named user. Always audited, because "this ran
* as someone" is exactly the thing an operator needs to be able to see.
+4
View File
@@ -357,6 +357,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
const { runTunnelsSettingsMigration } =
await import("./utils/crypto-migration/tunnels-settings-migration.js");
await runTunnelsSettingsMigration();
const { runWebEndpointSettingsMigration } =
await import("./utils/crypto-migration/web-endpoint-settings-migration.js");
await runWebEndpointSettingsMigration();
} catch (error) {
systemLogger.warn("Plugin runtime failed to initialize", {
operation: "plugin_init",
@@ -24,6 +24,18 @@ const getAllForScopeIds = vi.fn(async (scope: string, scopeIds: string[]) =>
(row) => row.scope === scope && scopeIds.includes(row.scopeId ?? ""),
),
);
const setCalls: Array<[string, string, string, string, string]> = [];
const set = vi.fn(
async (
pluginId: string,
scope: string,
scopeId: string,
key: string,
value: string,
) => {
setCalls.push([pluginId, scope, scopeId, key, value]);
},
);
vi.mock("../../../utils/logger.js", () => {
const logger = {
@@ -37,7 +49,7 @@ vi.mock("../../../utils/logger.js", () => {
});
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => ({ getAllForScopeIds }),
createCurrentPluginSettingsRepository: () => ({ getAllForScopeIds, set }),
}));
vi.mock("../../../plugins/index.js", () => ({
@@ -50,10 +62,17 @@ vi.mock("../../../utils/system-secret-crypto.js", () => ({
decryptSystemSecret: async (stored: string) => stored,
}));
const registryProviders = new Map<string, unknown>();
vi.mock("../../../plugins/registry.js", () => ({
consume: (key: string) => registryProviders.get(key),
}));
const {
attachHostPluginSettings,
loadHostPluginSettings,
withHostPluginSettings,
writeHostPluginSettings,
applyPluginHostImportSettings,
} = await import("../../../database/routes/host-plugin-settings.js");
function manifest(id: string, host: unknown): PluginManifest {
@@ -80,7 +99,10 @@ const DOCKER = manifest("docker", {
beforeEach(() => {
rows.length = 0;
loaded.length = 0;
setCalls.length = 0;
registryProviders.clear();
getAllForScopeIds.mockClear();
set.mockClear();
});
describe("loadHostPluginSettings", () => {
@@ -242,3 +264,81 @@ describe("withHostPluginSettings", () => {
expect(await withHostPluginSettings(host)).toBe(host);
});
});
describe("writeHostPluginSettings", () => {
it("JSON-stringifies each value under the plugin's own namespace", async () => {
await writeHostPluginSettings("docker", 7, {
enableDocker: true,
socketPath: null,
});
expect(setCalls).toEqual([
["docker", "host", "7", "enableDocker", "true"],
["docker", "host", "7", "socketPath", "null"],
]);
});
it("skips a field whose value is undefined", async () => {
await writeHostPluginSettings("docker", 7, {
enableDocker: true,
socketPath: undefined,
});
expect(setCalls).toEqual([["docker", "host", "7", "enableDocker", "true"]]);
});
});
describe("applyPluginHostImportSettings", () => {
it("runs every enabled plugin's registered normalizer and writes what it returns", async () => {
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
loaded.push({
id: "web-endpoint",
manifest: manifest("web-endpoint", {
enableKey: "enableWebUi",
enableLabelKey: "k",
fields: [{ key: "webUiConfig", type: "json", labelKey: "k" }],
}),
state: "active",
});
registryProviders.set("docker.hostImportNormalizer", () => ({
enableDocker: true,
}));
registryProviders.set("web-endpoint.hostImportNormalizer", () => null);
await applyPluginHostImportSettings(7, { enableDocker: true });
expect(setCalls).toEqual([["docker", "host", "7", "enableDocker", "true"]]);
});
it("skips a plugin with no registered normalizer", async () => {
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
await applyPluginHostImportSettings(7, {});
expect(setCalls).toEqual([]);
});
it("logs and continues past a normalizer that throws", async () => {
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
loaded.push({
id: "web-endpoint",
manifest: manifest("web-endpoint", {
enableKey: "enableWebUi",
enableLabelKey: "k",
fields: [{ key: "webUiConfig", type: "json", labelKey: "k" }],
}),
state: "active",
});
registryProviders.set("docker.hostImportNormalizer", () => {
throw new Error("boom");
});
registryProviders.set("web-endpoint.hostImportNormalizer", () => ({
enableWebUi: true,
}));
await expect(applyPluginHostImportSettings(7, {})).resolves.toBeUndefined();
expect(setCalls).toEqual([
["web-endpoint", "host", "7", "enableWebUi", "true"],
]);
});
});
@@ -0,0 +1,168 @@
import { createRequire } from "node:module";
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { createIsolatedWindows } = createRequire(import.meta.url)(
"../../../../electron/isolated-window.cjs",
);
const created: FakeWindow[] = [];
class FakeWindow extends EventEmitter {
options: Record<string, unknown>;
destroyed = false;
webContents = Object.assign(new EventEmitter(), {
mainFrame: {},
setWindowOpenHandler: vi.fn(),
});
setMenu = vi.fn();
loadURL = vi.fn().mockResolvedValue(undefined);
constructor(options: Record<string, unknown>) {
super();
this.options = options;
created.push(this);
}
isDestroyed() {
return this.destroyed;
}
destroy() {
this.destroyed = true;
this.emit("closed");
}
}
let main: FakeWindow;
type TestSession = EventEmitter & {
setPermissionRequestHandler: ReturnType<typeof vi.fn>;
setPermissionCheckHandler: ReturnType<typeof vi.fn>;
webRequest: { onBeforeRequest: ReturnType<typeof vi.fn> };
clearStorageData: ReturnType<typeof vi.fn>;
clearCache: ReturnType<typeof vi.fn>;
closeAllConnections: ReturnType<typeof vi.fn>;
};
let sessions: TestSession[];
let fromPartition: ReturnType<typeof vi.fn>;
let manager: ReturnType<typeof createIsolatedWindows>;
beforeEach(() => {
created.length = 0;
sessions = [];
main = new FakeWindow({});
created.length = 0;
fromPartition = vi.fn(() => {
const session = Object.assign(new EventEmitter(), {
setPermissionRequestHandler: vi.fn(),
setPermissionCheckHandler: vi.fn(),
webRequest: { onBeforeRequest: vi.fn() },
clearStorageData: vi.fn().mockResolvedValue(undefined),
clearCache: vi.fn().mockResolvedValue(undefined),
closeAllConnections: vi.fn().mockResolvedValue(undefined),
});
sessions.push(session);
return session;
});
manager = createIsolatedWindows({
BrowserWindow: FakeWindow,
session: { fromPartition },
getMainWindow: () => main,
});
});
describe("isolated windows", () => {
it("creates unique non-persistent sessions without preload or Node privileges", async () => {
await manager.open({ url: "https://service.test" });
await manager.open({ url: "https://service.test" });
expect(fromPartition.mock.calls[0][0]).not.toBe(
fromPartition.mock.calls[1][0],
);
expect(fromPartition.mock.calls[0][0]).not.toMatch(/^persist:/);
expect(created[0].options.webPreferences).toEqual({
session: sessions[0],
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
webviewTag: false,
webSecurity: true,
});
});
it("uses a caller-supplied partition when given one", async () => {
await manager.open({ url: "https://service.test", partition: "fixed" });
expect(fromPartition.mock.calls[0][0]).toBe("fixed");
});
it("rejects non-web destinations before creating a session", async () => {
for (const url of [
"file:///etc/passwd",
"javascript:alert(1)",
"https://user:secret@service.test",
]) {
await expect(manager.open({ url })).rejects.toThrow();
}
expect(fromPartition).not.toHaveBeenCalled();
});
it("refuses when there is no window to attach to", async () => {
manager = createIsolatedWindows({
BrowserWindow: FakeWindow,
session: { fromPartition },
getMainWindow: () => null,
});
await expect(manager.open({ url: "https://service.test" })).rejects.toThrow(
/No window/,
);
expect(fromPartition).not.toHaveBeenCalled();
});
it("keeps login popups in the same isolated session and blocks native-protocol navigation", async () => {
await manager.open({ url: "https://service.test" });
const win = created[0],
open = win.webContents.setWindowOpenHandler.mock.calls[0][0];
expect(
open({ url: "https://login.test" }).overrideBrowserWindowOptions
.webPreferences.session,
).toBe(sessions[0]);
expect(open({ url: "file:///tmp/secret" }).action).toBe("deny");
const navigation = { preventDefault: vi.fn() };
win.webContents.emit("will-redirect", navigation, "file:///tmp/secret");
expect(navigation.preventDefault).toHaveBeenCalled();
const callback = vi.fn();
sessions[0].webRequest.onBeforeRequest.mock.calls[0][0](
{ url: "file:///tmp/secret" },
callback,
);
expect(callback).toHaveBeenCalledWith({ cancel: true });
});
it("limits invalid certificates to the opted-in origin, including its port", async () => {
await manager.open({
url: "https://service.test:8443",
ignoreCert: true,
});
const e = { preventDefault: vi.fn() },
callback = vi.fn();
expect(
manager.handleCertificateError(
e,
created[0].webContents,
"https://service.test:8443/path",
callback,
),
).toBe(true);
expect(callback).toHaveBeenLastCalledWith(true);
manager.handleCertificateError(
e,
created[0].webContents,
"https://service.test:9443",
callback,
);
expect(callback).toHaveBeenLastCalledWith(false);
expect(
manager.handleCertificateError(
e,
main.webContents,
"https://service.test:8443",
callback,
),
).toBe(false);
});
it("closes login popups and clears session data when the endpoint closes", async () => {
await manager.open({ url: "https://service.test" });
const root = created[0],
popup = new FakeWindow({});
root.webContents.emit("did-create-window", popup);
root.destroy();
expect(popup.isDestroyed()).toBe(true);
expect(sessions[0].clearStorageData).toHaveBeenCalledOnce();
expect(sessions[0].closeAllConnections).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,123 @@
/**
* ctx.desktop.openIsolatedWindow: desktop:window is checked and audited, and
* the call only reaches Electron's main process when the backend is actually
* running embedded in it.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const grants = new Map<string, string[]>();
const auditEntries: Array<Record<string, unknown>> = [];
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
(grants.get(pluginId) ?? []).map((capability) => ({
pluginId,
capability,
})),
}),
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
},
}));
const bridge = vi.hoisted(() => ({
available: false,
requestFromElectronMain: vi.fn(async () => ({ success: true as const })),
}));
vi.mock("../../utils/electron-ipc-bridge.js", () => ({
isElectronIpcAvailable: () => bridge.available,
requestFromElectronMain: bridge.requestFromElectronMain,
}));
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
function manifestFor(pluginId: string, capabilities: string[]): PluginManifest {
return {
id: pluginId,
name: pluginId,
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities,
} as PluginManifest;
}
function contextFor(pluginId: string, capabilities: string[]) {
const manifest = manifestFor(pluginId, capabilities);
const handle = createPluginHandle(pluginId, { activate: () => {} });
return { ctx: createPluginContext(manifest, handle), handle };
}
beforeEach(() => {
grants.clear();
auditEntries.length = 0;
invalidatePluginPermissionCache();
bridge.available = false;
bridge.requestFromElectronMain.mockClear();
});
describe("ctx.desktop.openIsolatedWindow", () => {
it("refuses without desktop:window, before reaching Electron", async () => {
grants.set("demo", []);
const { ctx } = contextFor("demo", []);
await expect(
ctx.desktop.openIsolatedWindow({ url: "https://example.test" }),
).rejects.toThrow(/desktop:window/);
expect(bridge.requestFromElectronMain).not.toHaveBeenCalled();
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_desktop_open_isolated_window",
success: false,
});
});
it("refuses when granted but not declared in the manifest", async () => {
grants.set("demo", ["desktop:window"]);
const { ctx } = contextFor("demo", []);
await expect(
ctx.desktop.openIsolatedWindow({ url: "https://example.test" }),
).rejects.toThrow(/desktop:window/);
});
it("refuses outside the desktop app even with the capability", async () => {
grants.set("demo", ["desktop:window"]);
bridge.available = false;
const { ctx } = contextFor("demo", ["desktop:window"]);
await expect(
ctx.desktop.openIsolatedWindow({ url: "https://example.test" }),
).rejects.toThrow(/desktop app/);
expect(bridge.requestFromElectronMain).not.toHaveBeenCalled();
});
it("relays to Electron main and audits success", async () => {
grants.set("demo", ["desktop:window"]);
bridge.available = true;
const { ctx } = contextFor("demo", ["desktop:window"]);
const request = { url: "https://example.test", title: "Example" };
await expect(ctx.desktop.openIsolatedWindow(request)).resolves.toEqual({
success: true,
});
expect(bridge.requestFromElectronMain).toHaveBeenCalledWith(
"open-isolated-window",
request,
);
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_desktop_open_isolated_window",
success: true,
});
});
});
@@ -0,0 +1,130 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
isElectronIpcAvailable,
requestFromElectronMain,
} from "../../utils/electron-ipc-bridge.js";
const originalEnv = process.env.ELECTRON_EMBEDDED;
const originalSend = process.send;
beforeEach(() => {
process.env.ELECTRON_EMBEDDED = "true";
});
afterEach(() => {
process.env.ELECTRON_EMBEDDED = originalEnv;
process.send = originalSend;
});
describe("isElectronIpcAvailable", () => {
it("is false outside the embedded desktop backend", () => {
delete process.env.ELECTRON_EMBEDDED;
process.send = (() => true) as typeof process.send;
expect(isElectronIpcAvailable()).toBe(false);
});
it("is false when there is no fork IPC channel", () => {
process.send = undefined;
expect(isElectronIpcAvailable()).toBe(false);
});
it("is true when embedded with a live channel", () => {
process.send = (() => true) as typeof process.send;
expect(isElectronIpcAvailable()).toBe(true);
});
});
describe("requestFromElectronMain", () => {
it("refuses when Electron IPC is unavailable", async () => {
process.send = undefined;
await expect(
requestFromElectronMain("open-isolated-window", {}),
).rejects.toThrow(/desktop app/);
});
// The module attaches its "message" listener to the real process.on only
// once, the first time requestFromElectronMain actually reaches that point
// (ensureListening's module-scoped flag mirrors how a real forked
// process's IPC channel is a single persistent thing it listens on for the
// process lifetime). vi.spyOn(process, "on") does not reliably intercept
// that call in this environment, so this replaces process.on directly for
// the one call the module makes and hands the captured listener a plain
// function this file can invoke to simulate a message from main -
// simpler and more direct than getting a spy to survive across a Node
// global.
let deliver: (msg: unknown) => void = () => {};
beforeAll(() => {
const originalOn = process.on.bind(process);
process.on = ((event: string, listener: (...args: unknown[]) => void) => {
if (event === "message") {
deliver = listener;
return process;
}
return originalOn(event, listener);
}) as typeof process.on;
});
let sent: unknown[] = [];
beforeEach(() => {
sent = [];
process.send = ((msg: unknown) => {
sent.push(msg);
return true;
}) as typeof process.send;
});
it("resolves with main's response, matched by request id", async () => {
const pending = requestFromElectronMain<{ success: true }>(
"open-isolated-window",
{ url: "https://example.test" },
);
const [message] = sent as Array<{
type: string;
id: string;
channel: string;
payload: unknown;
}>;
expect(message).toMatchObject({
type: "backend-request",
channel: "open-isolated-window",
payload: { url: "https://example.test" },
});
deliver({
type: "backend-response",
id: message.id,
ok: true,
result: { success: true },
});
await expect(pending).resolves.toEqual({ success: true });
});
it("rejects with main's error", async () => {
const pending = requestFromElectronMain("open-isolated-window", {});
const [message] = sent as Array<{ id: string }>;
deliver({
type: "backend-response",
id: message.id,
ok: false,
error: "refused",
});
await expect(pending).rejects.toThrow(/refused/);
});
it("ignores a response for a different request id", async () => {
const pending = requestFromElectronMain("open-isolated-window", {});
const [message] = sent as Array<{ id: string }>;
deliver({ type: "backend-response", id: "other", ok: true, result: {} });
deliver({
type: "backend-response",
id: message.id,
ok: true,
result: { success: true },
});
await expect(pending).resolves.toEqual({ success: true });
});
});
@@ -0,0 +1,184 @@
/**
* The web endpoint host-columns migration.
*
* An upgrade must be lossless: a host with web endpoints on and a saved
* config must keep both once the ssh_data columns are dropped. Running it
* twice must not duplicate or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface HostRow {
id: number;
enable_web_ui: boolean;
web_ui_config: string | null;
}
interface SettingsRow {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const hostRows: HostRow[] = [];
const settingsRows: SettingsRow[] = [];
const find = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(pluginId, scope, scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = find(pluginId, scope, scopeId, key);
if (existing) {
existing.value = value;
return;
}
settingsRows.push({ pluginId, scope, scopeId, key, value });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () =>
hostRows.filter((row) => row.enable_web_ui || row.web_ui_config !== null),
}),
}));
const { runWebEndpointSettingsMigration } =
await import("../../utils/crypto-migration/web-endpoint-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
(entry) => entry.scopeId === String(hostId) && entry.key === key,
);
return row?.value === null || row?.value === undefined
? undefined
: JSON.parse(row.value);
}
const endpoint = {
id: "e1",
label: "Proxmox",
scheme: "https",
port: 8006,
path: "/",
access: "direct",
render: "embedded",
};
beforeEach(() => {
hostRows.length = 0;
settingsRows.length = 0;
});
describe("runWebEndpointSettingsMigration", () => {
it("moves a host's switch and saved config into plugin settings", async () => {
hostRows.push({
id: 1,
enable_web_ui: true,
web_ui_config: JSON.stringify({ endpoints: [endpoint] }),
});
const result = await runWebEndpointSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(1, "enableWebUi")).toBe(true);
expect(storedValue(1, "webUiConfig")).toEqual({ endpoints: [endpoint] });
expect(settingsRows.every((row) => row.pluginId === "web-endpoint")).toBe(
true,
);
expect(settingsRows.every((row) => row.scope === "host")).toBe(true);
});
it("keeps a disabled host's saved config and its disabled switch", async () => {
hostRows.push({
id: 3,
enable_web_ui: false,
web_ui_config: JSON.stringify({ endpoints: [endpoint] }),
});
await runWebEndpointSettingsMigration();
expect(storedValue(3, "enableWebUi")).toBe(false);
expect(storedValue(3, "webUiConfig")).toEqual({ endpoints: [endpoint] });
});
it("stores an unreadable config as null rather than failing", async () => {
hostRows.push({ id: 4, enable_web_ui: true, web_ui_config: "{nope" });
const result = await runWebEndpointSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(4, "webUiConfig")).toBeNull();
});
it("does nothing on a fresh install", async () => {
const result = await runWebEndpointSettingsMigration();
expect(result.moved).toBe(0);
expect(settingsRows).toEqual([]);
});
it("is idempotent: a second run changes nothing", async () => {
hostRows.push({ id: 2, enable_web_ui: true, web_ui_config: null });
await runWebEndpointSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
const second = await runWebEndpointSettingsMigration();
expect(second.moved).toBe(0);
expect(second.skipped).toBe(1);
expect(settingsRows).toEqual(afterFirst);
});
it("does not overwrite a value the plugin already saved", async () => {
hostRows.push({
id: 5,
enable_web_ui: true,
web_ui_config: JSON.stringify({ endpoints: [endpoint] }),
});
await pluginSettingsRepository.set(
"web-endpoint",
"host",
"5",
"enableWebUi",
"false",
);
const result = await runWebEndpointSettingsMigration();
expect(result.skipped).toBe(1);
expect(storedValue(5, "enableWebUi")).toBe(false);
expect(storedValue(5, "webUiConfig")).toBeUndefined();
});
});
@@ -0,0 +1,99 @@
/**
* Moves the two web endpoint host columns into the web-endpoint plugin's
* host-scope settings, before those columns are dropped from ssh_data.
*
* Idempotent: skips a host that already has a web-endpoint settings row, so
* it is safe to run on every boot. Lossless: reads straight off the
* still-present ssh_data columns, so it must run and finish before those
* columns are ever dropped from schema.ts.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../logger.js";
import { getDb } from "../../database/db/index.js";
import { createCurrentPluginSettingsRepository } from "../../database/repositories/factory.js";
export interface WebEndpointSettingsMigrationResult {
moved: number;
skipped: number;
}
interface LegacyWebEndpointRow {
id: number;
enable_web_ui: number | boolean | null;
web_ui_config: string | null;
}
function parseConfig(raw: string | null): unknown {
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
export async function runWebEndpointSettingsMigration(): Promise<WebEndpointSettingsMigrationResult> {
const result: WebEndpointSettingsMigrationResult = { moved: 0, skipped: 0 };
try {
const drizzleDb = getDb();
// Raw SQL, not the typed schema, so this keeps working once schema.ts
// stops declaring these columns.
const rows = await drizzleDb.all<LegacyWebEndpointRow>(sql`
SELECT id, enable_web_ui, web_ui_config
FROM ssh_data
WHERE enable_web_ui = true OR web_ui_config IS NOT NULL
`);
if (rows.length === 0) return result;
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
for (const row of rows) {
const scopeId = String(row.id);
const existing = await pluginSettingsRepository.get(
"web-endpoint",
"host",
scopeId,
"enableWebUi",
);
if (existing && existing.value !== null) {
result.skipped++;
continue;
}
await pluginSettingsRepository.set(
"web-endpoint",
"host",
scopeId,
"enableWebUi",
JSON.stringify(!!row.enable_web_ui),
);
await pluginSettingsRepository.set(
"web-endpoint",
"host",
scopeId,
"webUiConfig",
JSON.stringify(parseConfig(row.web_ui_config)),
);
result.moved++;
}
if (result.moved > 0) {
databaseLogger.info(
`Moved web endpoint settings for ${result.moved} host(s) into plugin settings`,
{ operation: "web_endpoint_settings_migration", moved: result.moved },
);
}
} catch (error) {
// A failed migration must not stop the backend. Hosts simply keep
// whatever web endpoint settings they already had (none, on a fresh run).
databaseLogger.warn("Web endpoint settings migration failed", {
operation: "web_endpoint_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}
+123
View File
@@ -0,0 +1,123 @@
/**
* Request/response layer over the fork IPC channel between the embedded
* backend and Electron's main process (electron/main.cjs forks this backend
* with stdio: [..., "ipc"]). Main already sends a one-way "shutdown" message
* on this channel; this adds a matching request/response shape so the
* backend can ask main to do something only main can do - open a real
* BrowserWindow - and get a typed result back.
*
* Not available outside Electron: process.send is undefined for a plain
* `node` or Docker start, and ELECTRON_EMBEDDED distinguishes the embedded
* backend from a standalone one that happens to run under a process manager
* that also sets stdio to "ipc".
*/
import crypto from "node:crypto";
import { systemLogger } from "./logger.js";
const REQUEST_TIMEOUT_MS = 15_000;
interface ElectronIpcRequestMessage {
type: "backend-request";
id: string;
channel: string;
payload: unknown;
}
interface ElectronIpcResponseMessage {
type: "backend-response";
id: string;
ok: boolean;
result?: unknown;
error?: string;
}
function isResponseMessage(msg: unknown): msg is ElectronIpcResponseMessage {
return (
!!msg &&
typeof msg === "object" &&
(msg as { type?: unknown }).type === "backend-response"
);
}
export function isElectronIpcAvailable(): boolean {
return (
process.env.ELECTRON_EMBEDDED === "true" &&
typeof process.send === "function"
);
}
const pending = new Map<
string,
{ resolve: (value: unknown) => void; reject: (error: Error) => void }
>();
let listening = false;
function ensureListening(): void {
if (listening) return;
listening = true;
process.on("message", (msg: unknown) => {
if (!isResponseMessage(msg)) return;
const waiter = pending.get(msg.id);
if (!waiter) return;
pending.delete(msg.id);
if (msg.ok) waiter.resolve(msg.result);
else waiter.reject(new Error(msg.error || "Electron IPC request failed"));
});
}
/**
* Sends a request to Electron's main process over the fork IPC channel and
* waits for its response. Rejects if main never answers (main.cjs is where
* a killed or hung renderer would surface, not here) or process.send is
* unavailable.
*/
export async function requestFromElectronMain<T = unknown>(
channel: string,
payload: unknown,
): Promise<T> {
if (!isElectronIpcAvailable()) {
throw new Error("Electron IPC is only available in the desktop app");
}
ensureListening();
const id = crypto.randomUUID();
const message: ElectronIpcRequestMessage = {
type: "backend-request",
id,
channel,
payload,
};
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error(`Electron IPC request "${channel}" timed out`));
}, REQUEST_TIMEOUT_MS);
pending.set(id, {
resolve: (value) => {
clearTimeout(timer);
resolve(value as T);
},
reject: (error) => {
clearTimeout(timer);
reject(error);
},
});
try {
process.send?.(message);
} catch (error) {
clearTimeout(timer);
pending.delete(id);
systemLogger.warn("Failed to send Electron IPC request", {
operation: "electron_ipc_send_failed",
channel,
error: error instanceof Error ? error.message : String(error),
});
reject(error instanceof Error ? error : new Error(String(error)));
}
});
}
+1 -2
View File
@@ -1,5 +1,5 @@
import type { GuacamoleConfig } from "./guacamole-config.js";
import type { TerminalConfig, WebUiConfig } from "./index.js";
import type { TerminalConfig } from "./index.js";
import type { StatsConfig } from "./stats-widgets.js";
import type { HostAuthOverrides } from "./auth-protocols.js";
@@ -108,7 +108,6 @@ export type Host = {
runtime?: "docker" | "podman";
} | null;
enableWebUi?: boolean;
webUiConfig?: WebUiConfig | null;
enableProxmox: boolean;
enableTmuxMonitor: boolean;
enableTerminalToolbar: boolean;
+2 -1
View File
@@ -31,7 +31,8 @@ export function hostToSSHHost(h: Host): SSHHost {
enableAiAssistant: h.enableAiAssistant ?? false,
dockerConfig: h.dockerConfig ?? null,
enableWebUi: h.enableWebUi ?? false,
webUiConfig: h.webUiConfig ?? { endpoints: [] },
webUiConfig: (h.pluginSettings?.["web-endpoint"]?.webUiConfig as
SSHHost["webUiConfig"] | undefined) ?? { endpoints: [] },
showTerminalInSidebar: true,
showFileManagerInSidebar: true,
showTunnelInSidebar: true,
-4
View File
@@ -195,8 +195,6 @@ export function createHostEditorForm(
enableFileManager: host?.enableFileManager ?? false,
scpLegacy: host?.scpLegacy ?? false,
enableDocker: host?.enableDocker ?? false,
enableWebUi: host?.enableWebUi ?? false,
webUiConfig: host?.webUiConfig ?? { endpoints: [] },
dockerConfig: host?.dockerConfig ?? { runtime: "docker" as const },
enableTmuxMonitor: host?.enableTmuxMonitor ?? false,
enableTerminalToolbar: host?.enableTerminalToolbar ?? true,
@@ -508,8 +506,6 @@ export function buildHostEditorPayload(
scpLegacy: form.scpLegacy,
enableDocker: form.enableDocker,
dockerConfig: form.enableDocker ? form.dockerConfig : null,
enableWebUi: form.enableWebUi,
webUiConfig: form.enableWebUi ? form.webUiConfig : null,
enableTmuxMonitor: form.enableTmuxMonitor,
enableTerminalToolbar: form.enableTerminalToolbar,
enableAiAssistant: form.enableAiAssistant,
-1
View File
@@ -98,7 +98,6 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
enableDocker: h.enableDocker ?? false,
dockerConfig: h.dockerConfig ?? null,
enableWebUi: h.enableWebUi ?? false,
webUiConfig: h.webUiConfig ?? { endpoints: [] },
enableProxmox: (proxmoxSettings.enableProxmox as boolean) ?? false,
enableProxmoxStats:
(proxmoxSettings.enableProxmoxStats as boolean) ?? false,