2026-03-05 00:04:26 -06:00
|
|
|
import fs from "fs/promises";
|
|
|
|
|
import path from "path";
|
|
|
|
|
import AdmZip from "adm-zip";
|
|
|
|
|
import { v4 as uuidv4 } from "uuid";
|
|
|
|
|
import type { ClientInfo } from "../types";
|
|
|
|
|
import { logger } from "../logger";
|
|
|
|
|
import { encodeMessage, type PluginManifest } from "../protocol";
|
|
|
|
|
|
|
|
|
|
export type PluginState = {
|
|
|
|
|
enabled: Record<string, boolean>;
|
|
|
|
|
lastError: Record<string, string>;
|
2026-03-23 10:58:52 -05:00
|
|
|
autoLoad: Record<string, boolean>;
|
|
|
|
|
autoStartEvents: Record<string, Array<{ event: string; payload: any }>>;
|
2026-03-05 00:04:26 -06:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export async function loadPluginStateFromDisk(pluginStatePath: string): Promise<PluginState> {
|
|
|
|
|
try {
|
|
|
|
|
const raw = await fs.readFile(pluginStatePath, "utf-8");
|
2026-03-23 10:58:52 -05:00
|
|
|
const parsed = JSON.parse(raw) as Partial<PluginState>;
|
2026-03-05 00:04:26 -06:00
|
|
|
return {
|
|
|
|
|
enabled: parsed.enabled || {},
|
|
|
|
|
lastError: parsed.lastError || {},
|
2026-03-23 10:58:52 -05:00
|
|
|
autoLoad: parsed.autoLoad || {},
|
|
|
|
|
autoStartEvents: parsed.autoStartEvents || {},
|
2026-03-05 00:04:26 -06:00
|
|
|
};
|
|
|
|
|
} catch {
|
2026-03-23 10:58:52 -05:00
|
|
|
return { enabled: {}, lastError: {}, autoLoad: {}, autoStartEvents: {} };
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function savePluginStateToDisk(
|
|
|
|
|
pluginRoot: string,
|
|
|
|
|
pluginStatePath: string,
|
|
|
|
|
pluginState: PluginState,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
await fs.mkdir(pluginRoot, { recursive: true });
|
|
|
|
|
await fs.writeFile(pluginStatePath, JSON.stringify(pluginState, null, 2));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function ensurePluginExtracted(
|
|
|
|
|
pluginRoot: string,
|
|
|
|
|
pluginId: string,
|
|
|
|
|
sanitizePluginId: (name: string) => string,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const safeId = sanitizePluginId(pluginId);
|
|
|
|
|
const zipPath = path.join(pluginRoot, `${safeId}.zip`);
|
|
|
|
|
const pluginDir = path.join(pluginRoot, safeId);
|
|
|
|
|
const manifestPath = path.join(pluginDir, "manifest.json");
|
|
|
|
|
|
|
|
|
|
let zipStat: any = null;
|
|
|
|
|
try {
|
|
|
|
|
zipStat = await fs.stat(zipPath);
|
|
|
|
|
} catch {
|
|
|
|
|
zipStat = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let manifestStat: any = null;
|
|
|
|
|
try {
|
|
|
|
|
manifestStat = await fs.stat(manifestPath);
|
|
|
|
|
} catch {
|
|
|
|
|
manifestStat = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!zipStat) {
|
|
|
|
|
if (manifestStat) return;
|
|
|
|
|
throw new Error(`Plugin bundle not found: ${safeId}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (manifestStat && manifestStat.mtimeMs >= zipStat.mtimeMs) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await fs.mkdir(pluginDir, { recursive: true });
|
|
|
|
|
const assetsDir = path.join(pluginDir, "assets");
|
|
|
|
|
await fs.mkdir(assetsDir, { recursive: true });
|
|
|
|
|
|
|
|
|
|
const zip = new AdmZip(zipPath);
|
|
|
|
|
const entries = zip.getEntries();
|
|
|
|
|
let htmlEntry: Buffer | null = null;
|
|
|
|
|
let cssEntry: Buffer | null = null;
|
|
|
|
|
let jsEntry: Buffer | null = null;
|
2026-03-19 16:29:26 -05:00
|
|
|
const nativeBinaries: Map<string, Buffer> = new Map();
|
2026-03-05 00:04:26 -06:00
|
|
|
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
if (entry.isDirectory) continue;
|
|
|
|
|
const base = path.basename(entry.entryName);
|
2026-03-19 16:29:26 -05:00
|
|
|
const lower = base.toLowerCase();
|
|
|
|
|
if (lower.endsWith(".so") || lower.endsWith(".dll") || lower.endsWith(".dylib")) {
|
|
|
|
|
nativeBinaries.set(base, entry.getData());
|
|
|
|
|
} else if (lower.endsWith(".html")) {
|
2026-03-05 00:04:26 -06:00
|
|
|
htmlEntry = entry.getData();
|
2026-03-19 16:29:26 -05:00
|
|
|
} else if (lower.endsWith(".css")) {
|
2026-03-05 00:04:26 -06:00
|
|
|
cssEntry = entry.getData();
|
2026-03-19 16:29:26 -05:00
|
|
|
} else if (lower.endsWith(".js")) {
|
2026-03-05 00:04:26 -06:00
|
|
|
jsEntry = entry.getData();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 16:29:26 -05:00
|
|
|
if (!htmlEntry || !cssEntry || !jsEntry) {
|
|
|
|
|
throw new Error(`Invalid plugin bundle: ${safeId} (missing .html, .css, or .js)`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const binariesMap: Record<string, string> = {};
|
|
|
|
|
for (const [filename, data] of nativeBinaries) {
|
|
|
|
|
await fs.writeFile(path.join(pluginDir, filename), data);
|
|
|
|
|
const platformKey = derivePlatformKey(filename);
|
|
|
|
|
if (platformKey) {
|
|
|
|
|
binariesMap[platformKey] = filename;
|
|
|
|
|
}
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await fs.writeFile(path.join(assetsDir, `${safeId}.html`), htmlEntry);
|
|
|
|
|
await fs.writeFile(path.join(assetsDir, `${safeId}.css`), cssEntry);
|
|
|
|
|
await fs.writeFile(path.join(assetsDir, `${safeId}.js`), jsEntry);
|
|
|
|
|
|
|
|
|
|
const manifest: PluginManifest = {
|
|
|
|
|
id: safeId,
|
|
|
|
|
name: safeId,
|
|
|
|
|
version: "1.0.0",
|
2026-03-19 16:29:26 -05:00
|
|
|
binaries: binariesMap,
|
2026-03-05 00:04:26 -06:00
|
|
|
entry: `${safeId}.html`,
|
|
|
|
|
assets: {
|
|
|
|
|
html: `${safeId}.html`,
|
|
|
|
|
css: `${safeId}.css`,
|
|
|
|
|
js: `${safeId}.js`,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function syncPluginBundles(
|
|
|
|
|
pluginRoot: string,
|
|
|
|
|
ensureExtracted: (pluginId: string) => Promise<void>,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
await fs.mkdir(pluginRoot, { recursive: true });
|
|
|
|
|
const entries = await fs.readdir(pluginRoot, { withFileTypes: true });
|
|
|
|
|
for (const ent of entries) {
|
|
|
|
|
if (ent.isFile() && ent.name.toLowerCase().endsWith(".zip")) {
|
|
|
|
|
const pluginId = ent.name.slice(0, -4);
|
|
|
|
|
try {
|
|
|
|
|
await ensureExtracted(pluginId);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
logger.warn(`[plugin] failed to extract ${pluginId}: ${(err as Error).message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function listPluginManifests(
|
|
|
|
|
pluginRoot: string,
|
|
|
|
|
pluginState: PluginState,
|
|
|
|
|
saveState: () => Promise<void>,
|
|
|
|
|
ensureExtracted: (pluginId: string) => Promise<void>,
|
|
|
|
|
): Promise<PluginManifest[]> {
|
|
|
|
|
try {
|
|
|
|
|
await syncPluginBundles(pluginRoot, ensureExtracted);
|
|
|
|
|
const entries = await fs.readdir(pluginRoot, { withFileTypes: true });
|
|
|
|
|
const manifests: PluginManifest[] = [];
|
|
|
|
|
for (const ent of entries) {
|
|
|
|
|
if (!ent.isDirectory()) continue;
|
|
|
|
|
const manifestPath = path.join(pluginRoot, ent.name, "manifest.json");
|
|
|
|
|
try {
|
|
|
|
|
const raw = await fs.readFile(manifestPath, "utf-8");
|
|
|
|
|
const manifest = JSON.parse(raw) as PluginManifest;
|
|
|
|
|
const id = manifest.id || ent.name;
|
|
|
|
|
const name = manifest.name || ent.name;
|
|
|
|
|
if (pluginState.enabled[id] === undefined) {
|
|
|
|
|
pluginState.enabled[id] = true;
|
|
|
|
|
}
|
|
|
|
|
manifests.push({ ...manifest, id, name });
|
|
|
|
|
} catch {}
|
|
|
|
|
}
|
|
|
|
|
await saveState();
|
|
|
|
|
return manifests;
|
|
|
|
|
} catch {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function loadPluginBundle(
|
|
|
|
|
pluginRoot: string,
|
|
|
|
|
pluginId: string,
|
|
|
|
|
ensureExtracted: (pluginId: string) => Promise<void>,
|
2026-03-19 16:29:26 -05:00
|
|
|
clientOS?: string,
|
|
|
|
|
clientArch?: string,
|
2026-03-19 20:05:45 -05:00
|
|
|
): Promise<{ manifest: PluginManifest; binary: Uint8Array | null }> {
|
2026-03-05 00:04:26 -06:00
|
|
|
await ensureExtracted(pluginId);
|
|
|
|
|
const dir = path.join(pluginRoot, pluginId);
|
|
|
|
|
const manifestPath = path.join(dir, "manifest.json");
|
|
|
|
|
const rawManifest = await fs.readFile(manifestPath, "utf-8");
|
|
|
|
|
const manifest = JSON.parse(rawManifest) as PluginManifest;
|
|
|
|
|
manifest.id = manifest.id || pluginId;
|
|
|
|
|
manifest.name = manifest.name || pluginId;
|
|
|
|
|
|
2026-03-19 20:05:45 -05:00
|
|
|
const hasBinaries = manifest.binaries && Object.keys(manifest.binaries).length > 0;
|
|
|
|
|
if (!hasBinaries) {
|
|
|
|
|
return { manifest, binary: null };
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 16:29:26 -05:00
|
|
|
let binaryPath: string | null = null;
|
|
|
|
|
|
|
|
|
|
if (manifest.binaries && clientOS && clientArch) {
|
|
|
|
|
const key = `${clientOS}-${clientArch}`;
|
|
|
|
|
const filename = manifest.binaries[key];
|
|
|
|
|
if (filename) {
|
|
|
|
|
const candidate = path.join(dir, filename);
|
|
|
|
|
try {
|
|
|
|
|
await fs.access(candidate);
|
|
|
|
|
binaryPath = candidate;
|
|
|
|
|
} catch {}
|
|
|
|
|
}
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
2026-03-19 16:29:26 -05:00
|
|
|
|
|
|
|
|
if (!binaryPath) {
|
2026-04-04 20:21:37 -05:00
|
|
|
const platformKey = clientOS && clientArch ? `${clientOS}-${clientArch}` : "unknown";
|
2026-03-19 16:29:26 -05:00
|
|
|
const files = await fs.readdir(dir);
|
2026-04-04 20:21:37 -05:00
|
|
|
const archRegex = /-(linux|darwin|windows|freebsd)-(amd64|arm64|arm|386)\.(so|dll|dylib)$/i;
|
|
|
|
|
|
|
|
|
|
const found = files.find((f) => {
|
|
|
|
|
const m = f.match(archRegex);
|
|
|
|
|
if (m) {
|
|
|
|
|
return (
|
|
|
|
|
m[1].toLowerCase() === (clientOS || "").toLowerCase() &&
|
|
|
|
|
m[2].toLowerCase() === (clientArch || "").toLowerCase()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
const fl = f.toLowerCase();
|
|
|
|
|
return fl.endsWith(".so") || fl.endsWith(".dll") || fl.endsWith(".dylib");
|
|
|
|
|
});
|
|
|
|
|
if (!found) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`No compatible plugin binary for ${pluginId} (client=${platformKey}, available=[${Object.keys(manifest.binaries || {}).join(", ")}])`,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-03-19 16:29:26 -05:00
|
|
|
binaryPath = path.join(dir, found);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const binary = new Uint8Array(await fs.readFile(binaryPath));
|
|
|
|
|
return { manifest, binary };
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sendPluginBundle(
|
|
|
|
|
target: ClientInfo,
|
2026-03-19 20:05:45 -05:00
|
|
|
bundle: { manifest: PluginManifest; binary: Uint8Array | null },
|
2026-03-05 00:04:26 -06:00
|
|
|
): void {
|
2026-03-19 20:05:45 -05:00
|
|
|
if (!bundle.binary) return;
|
2026-03-05 00:04:26 -06:00
|
|
|
const chunkSize = 16 * 1024;
|
2026-03-19 16:29:26 -05:00
|
|
|
const data = bundle.binary;
|
|
|
|
|
const totalChunks = Math.ceil(data.length / chunkSize);
|
2026-03-05 00:04:26 -06:00
|
|
|
const initPayload = {
|
|
|
|
|
manifest: bundle.manifest,
|
2026-03-19 16:29:26 -05:00
|
|
|
size: data.length,
|
2026-03-05 00:04:26 -06:00
|
|
|
chunks: totalChunks,
|
|
|
|
|
};
|
|
|
|
|
target.ws.send(
|
|
|
|
|
encodeMessage({
|
|
|
|
|
type: "command",
|
|
|
|
|
commandType: "plugin_load_init",
|
|
|
|
|
id: uuidv4(),
|
|
|
|
|
payload: initPayload,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < totalChunks; i++) {
|
|
|
|
|
const start = i * chunkSize;
|
2026-03-19 16:29:26 -05:00
|
|
|
const end = Math.min(start + chunkSize, data.length);
|
|
|
|
|
const chunk = data.slice(start, end);
|
2026-03-05 00:04:26 -06:00
|
|
|
target.ws.send(
|
|
|
|
|
encodeMessage({
|
|
|
|
|
type: "command",
|
|
|
|
|
commandType: "plugin_load_chunk",
|
|
|
|
|
id: uuidv4(),
|
|
|
|
|
payload: { pluginId: bundle.manifest.id, index: i, data: chunk },
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
target.ws.send(
|
|
|
|
|
encodeMessage({
|
|
|
|
|
type: "command",
|
|
|
|
|
commandType: "plugin_load_finish",
|
|
|
|
|
id: uuidv4(),
|
|
|
|
|
payload: { pluginId: bundle.manifest.id },
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-03-19 16:29:26 -05:00
|
|
|
|
2026-03-23 10:58:52 -05:00
|
|
|
export async function dispatchAutoLoadPlugins(
|
|
|
|
|
client: ClientInfo,
|
|
|
|
|
pluginState: PluginState,
|
|
|
|
|
isPluginLoaded: (clientId: string, pluginId: string) => boolean,
|
|
|
|
|
isPluginLoading: (clientId: string, pluginId: string) => boolean,
|
|
|
|
|
markPluginLoading: (clientId: string, pluginId: string) => void,
|
|
|
|
|
enqueuePluginEvent: (clientId: string, pluginId: string, event: string, payload: any) => void,
|
|
|
|
|
loadBundle: (pluginId: string, clientOS?: string, clientArch?: string) => Promise<{ manifest: PluginManifest; binary: Uint8Array | null }>,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const autoLoadIds = Object.entries(pluginState.autoLoad)
|
|
|
|
|
.filter(([id, enabled]) => enabled && pluginState.enabled[id] !== false)
|
|
|
|
|
.map(([id]) => id);
|
|
|
|
|
|
|
|
|
|
for (const pluginId of autoLoadIds) {
|
|
|
|
|
if (isPluginLoaded(client.id, pluginId) || isPluginLoading(client.id, pluginId)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const bundle = await loadBundle(pluginId, client.os, client.arch);
|
|
|
|
|
markPluginLoading(client.id, pluginId);
|
|
|
|
|
sendPluginBundle(client, bundle);
|
|
|
|
|
|
|
|
|
|
const autoEvents = pluginState.autoStartEvents[pluginId];
|
|
|
|
|
if (autoEvents && autoEvents.length > 0) {
|
|
|
|
|
for (const evt of autoEvents) {
|
|
|
|
|
enqueuePluginEvent(client.id, pluginId, evt.event, evt.payload);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.info(`[plugin-autoload] dispatched ${pluginId} to ${client.id}`);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
logger.warn(`[plugin-autoload] failed to load ${pluginId} for ${client.id}: ${(err as Error).message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 16:29:26 -05:00
|
|
|
function derivePlatformKey(filename: string): string {
|
|
|
|
|
const match = filename.match(/-(linux|darwin|windows|freebsd)-(amd64|arm64|arm|386)\.(so|dll|dylib)$/i);
|
|
|
|
|
if (match) {
|
|
|
|
|
return `${match[1].toLowerCase()}-${match[2].toLowerCase()}`;
|
|
|
|
|
}
|
|
|
|
|
const lower = filename.toLowerCase();
|
|
|
|
|
if (lower.endsWith(".dll")) return "windows-amd64";
|
|
|
|
|
if (lower.endsWith(".dylib")) return "darwin-amd64";
|
|
|
|
|
if (lower.endsWith(".so")) return "linux-amd64";
|
|
|
|
|
return "";
|
|
|
|
|
}
|