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

@@ -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)));
}
});
}