mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
feat: finish the web-endpoint plugin, add ctx.desktop (B8)
This commit is contained in:
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;
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user