openpetswithchatandmcp/apps/desktop/src/plugin-service.ts
2026-05-19 10:51:25 +01:00

320 lines
17 KiB
TypeScript

import { mkdirSync, promises as fs } from "node:fs";
import { join } from "node:path";
import { getCatalogPlugin, getPluginCatalog, type PluginCatalogOptions } from "./plugin-catalog.js";
import { getEffectivePluginConfig, validatePluginConfigReplacement, type PluginConfigValidationError, type PluginConfig } from "./plugin-config.js";
import { publishLocalPluginSnapshot, readLocalPluginSourceManifest } from "./plugin-local-loader.js";
import { readSafePluginManifest } from "./plugin-manifest-reader.js";
import { type OpenPetsPluginManifest, type PluginPermission } from "./plugin-manifest.js";
import { downloadCatalogPluginZip, installCatalogPluginPackage, readCatalogPluginManifestFromZip, resolveSafePluginInstallDir } from "./plugin-package.js";
import type { PluginPetApi } from "./plugin-pet-api.js";
import { PluginRuntime, type PluginRuntimeScheduler } from "./plugin-runtime.js";
import { PluginStateStore, type PluginSource, type PluginStateRecord } from "./plugin-state.js";
export type SafePluginRecord = {
readonly id: string;
readonly name?: string;
readonly version: string;
readonly source: PluginSource;
readonly enabled: boolean;
readonly brokenReason?: string;
readonly approvedPermissions: readonly PluginPermission[];
readonly configSchema?: OpenPetsPluginManifest["configSchema"];
readonly effectiveConfig?: PluginConfig;
readonly configErrors?: readonly PluginConfigValidationError[];
};
export type PluginServiceSnapshot = { readonly plugins: readonly SafePluginRecord[] };
export type SafeCatalogPluginRecord = { readonly id: string; readonly name: string; readonly version: string; readonly description: string; readonly runtime: "declarative"; readonly permissions: readonly PluginPermission[]; readonly installed: boolean };
export type PluginCatalogSnapshot = { readonly plugins: readonly SafeCatalogPluginRecord[] };
export type PluginServiceResult = { readonly ok: true; readonly snapshot: PluginServiceSnapshot } | { readonly ok: false; readonly error: string; readonly snapshot: PluginServiceSnapshot };
export type PluginFolderDialog = () => Promise<{ readonly canceled: boolean; readonly filePaths: readonly string[] }>;
export type PluginPermissionDialog = (manifest: OpenPetsPluginManifest) => Promise<boolean>;
export type PluginServiceOptions = {
readonly userDataPath?: string;
readonly stateStore?: PluginStateStore;
readonly runtime?: PluginRuntime;
readonly petApi?: PluginPetApi;
readonly scheduler?: PluginRuntimeScheduler;
readonly allowedPluginRoots?: readonly string[];
readonly maxManifestBytes?: number;
readonly showOpenDialog?: PluginFolderDialog;
readonly confirmPermissions?: PluginPermissionDialog;
readonly catalogOptions?: PluginCatalogOptions;
readonly fetchImpl?: typeof fetch;
readonly currentAppVersion?: string;
};
export class PluginService {
readonly stateStore: PluginStateStore;
readonly runtime: PluginRuntime;
readonly allowedPluginRoots: readonly string[];
readonly #maxManifestBytes?: number;
readonly #userDataPath?: string;
readonly #showOpenDialog?: PluginFolderDialog;
readonly #confirmPermissions?: PluginPermissionDialog;
readonly #catalogOptions?: PluginCatalogOptions;
readonly #fetchImpl?: typeof fetch;
readonly #currentAppVersion: string;
constructor(options: PluginServiceOptions) {
if (!options.stateStore && !options.userDataPath) throw new Error("Plugin service requires userDataPath or stateStore.");
if (!options.allowedPluginRoots && !options.userDataPath) throw new Error("Plugin service requires allowedPluginRoots when userDataPath is not provided.");
this.allowedPluginRoots = options.allowedPluginRoots ?? [join(options.userDataPath ?? "", "plugins"), join(options.userDataPath ?? "", "plugins-dev")];
this.#userDataPath = options.userDataPath;
this.#showOpenDialog = options.showOpenDialog;
this.#confirmPermissions = options.confirmPermissions;
this.#catalogOptions = options.catalogOptions;
this.#fetchImpl = options.fetchImpl;
this.#currentAppVersion = options.currentAppVersion ?? "0.0.0";
this.stateStore = options.stateStore ?? new PluginStateStore({ userDataPath: options.userDataPath ?? "" });
if (options.runtime) {
this.runtime = options.runtime;
} else {
if (!options.petApi) throw new Error("Plugin service requires petApi when runtime is not provided.");
this.runtime = new PluginRuntime({ stateStore: this.stateStore, petApi: options.petApi, scheduler: options.scheduler, allowedPluginRoots: this.allowedPluginRoots, maxManifestBytes: options.maxManifestBytes });
}
this.#maxManifestBytes = options.maxManifestBytes;
}
async start(): Promise<void> {
this.#ensureRoots();
this.stateStore.initialize();
await this.runtime.start();
}
stop(): void {
this.runtime.stop();
}
async getSnapshot(): Promise<PluginServiceSnapshot> {
const plugins = [] as SafePluginRecord[];
for (const record of this.stateStore.listRecords()) plugins.push(await this.#safeRecord(record));
return { plugins };
}
async setEnabled(id: string, enabled: boolean): Promise<PluginServiceResult> {
if (!this.stateStore.getRecord(id)) return this.#error("Plugin is not installed.");
this.stateStore.setEnabled(id, enabled);
await this.runtime.reloadPlugin(id);
return { ok: true, snapshot: await this.getSnapshot() };
}
async saveConfig(id: string, config: unknown): Promise<PluginServiceResult> {
const record = this.stateStore.getRecord(id);
if (!record) return this.#error("Plugin is not installed.");
let manifest: OpenPetsPluginManifest;
try {
manifest = await this.#readManifest(record);
} catch {
return this.#error("Plugin manifest is unavailable.");
}
const result = validatePluginConfigReplacement(manifest, config);
if (!result.ok) return this.#error("Plugin config is invalid.");
this.stateStore.replaceConfig(id, result.config);
await this.runtime.reloadPlugin(id);
return { ok: true, snapshot: await this.getSnapshot() };
}
async reload(id: string): Promise<PluginServiceResult> {
if (!this.stateStore.getRecord(id)) return this.#error("Plugin is not installed.");
await this.runtime.reloadPlugin(id);
return { ok: true, snapshot: await this.getSnapshot() };
}
async getCatalogSnapshot(refresh = false): Promise<PluginCatalogSnapshot> {
try {
const catalog = await getPluginCatalog({ ...this.#catalogOptions, fetchImpl: this.#fetchImpl ?? this.#catalogOptions?.fetchImpl, refresh });
return { plugins: catalog.plugins.filter((entry) => isCatalogEntryCompatible(entry.minOpenPetsVersion, this.#currentAppVersion)).map((entry) => ({ id: entry.id, name: entry.name, version: entry.version, description: entry.description, runtime: entry.runtime, permissions: entry.permissions, installed: this.stateStore.getRecord(entry.id)?.source === "catalog" })) };
} catch {
return { plugins: [] };
}
}
async installCatalog(id: string): Promise<PluginServiceResult> {
return this.#installOrUpdateCatalog(id, false);
}
async updateCatalog(id: string): Promise<PluginServiceResult> {
return this.#installOrUpdateCatalog(id, true);
}
async uninstall(id: string): Promise<PluginServiceResult> {
if (!this.#userDataPath) return this.#error("Plugin uninstall is unavailable.");
const record = this.stateStore.getRecord(id);
if (!record) return this.#error("Plugin is not installed.");
let realInstall: string;
try { realInstall = await resolveSafePluginInstallDir(this.#userDataPath, id, record.installPath, record.source); }
catch (error) { return this.#error(safeError(error)); }
this.stateStore.removeRecord(id);
await this.runtime.reloadPlugin(id);
try { await fs.rm(realInstall, { recursive: true, force: true }); }
catch (error) { return this.#error(safeError(error)); }
return { ok: true, snapshot: await this.getSnapshot() };
}
async loadLocal(): Promise<PluginServiceResult> {
if (!this.#userDataPath) return this.#error("Local plugin loading is unavailable.");
const picker = this.#showOpenDialog ?? defaultOpenDialog;
const confirm = this.#confirmPermissions ?? defaultConfirmPermissions;
const selection = await picker();
if (selection.canceled || selection.filePaths.length === 0) return { ok: true, snapshot: await this.getSnapshot() };
let source: Awaited<ReturnType<typeof readLocalPluginSourceManifest>>;
try {
source = await readLocalPluginSourceManifest({ sourceFolder: selection.filePaths[0] ?? "", maxManifestBytes: this.#maxManifestBytes });
} catch (error) {
return this.#error(safeError(error));
}
const existing = this.stateStore.getRecord(source.manifest.id);
if (existing?.source === "catalog") return this.#error("A catalog plugin with this id is already installed.");
const permissionsChanged = existing ? !isPermissionSubset(source.manifest.permissions, existing.approvedPermissions) : true;
if (permissionsChanged) {
const approved = await confirm(source.manifest);
if (!approved) return { ok: true, snapshot: await this.getSnapshot() };
}
let loaded: Awaited<ReturnType<typeof publishLocalPluginSnapshot>>;
try {
loaded = await publishLocalPluginSnapshot({ manifest: source.manifest, manifestText: source.manifestText, userDataPath: this.#userDataPath, maxManifestBytes: this.#maxManifestBytes });
} catch (error) {
return this.#error(safeError(error));
}
const wasEnabled = existing?.enabled === true;
const enabled = existing && !permissionsChanged ? existing.enabled : false;
this.stateStore.upsertRecord({
id: loaded.manifest.id,
version: loaded.manifest.version,
source: "local",
installPath: loaded.installPath,
manifestPath: loaded.manifestPath,
enabled,
approvedPermissions: loaded.manifest.permissions,
config: existing?.config ?? {},
});
if (enabled || wasEnabled) await this.runtime.reloadPlugin(loaded.manifest.id);
return { ok: true, snapshot: await this.getSnapshot() };
}
async #installOrUpdateCatalog(id: string, update: boolean): Promise<PluginServiceResult> {
if (!this.#userDataPath) return this.#error("Catalog plugin installation is unavailable.");
const existing = this.stateStore.getRecord(id);
if (!update && existing) return this.#error("Plugin is already installed.");
if (update && (!existing || existing.source !== "catalog")) return this.#error("Catalog plugin is not installed.");
if (existing?.source === "local") return this.#error("A local plugin with this id is already loaded.");
const confirm = this.#confirmPermissions ?? defaultConfirmPermissions;
try {
const entry = await getCatalogPlugin(id, { ...this.#catalogOptions, fetchImpl: this.#fetchImpl ?? this.#catalogOptions?.fetchImpl, refresh: update });
if (!isCatalogEntryCompatible(entry.minOpenPetsVersion, this.#currentAppVersion)) throw new Error("Plugin requires a newer OpenPets version.");
const zip = await downloadCatalogPluginZip(entry, this.#fetchImpl ?? this.#catalogOptions?.fetchImpl ?? fetch);
const preview = await readCatalogPluginManifestFromZip({ catalogEntry: entry, zip, maxManifestBytes: this.#maxManifestBytes });
const permissionsChanged = existing ? !isPermissionSubset(preview.manifest.permissions, existing.approvedPermissions) : true;
if (permissionsChanged && !(await confirm(preview.manifest))) return { ok: true, snapshot: await this.getSnapshot() };
const previousManifestText = existing ? await fs.readFile(existing.manifestPath, "utf8").catch(() => undefined) : undefined;
const loaded = await installCatalogPluginPackage({ userDataPath: this.#userDataPath, catalogEntry: entry, zip, maxManifestBytes: this.#maxManifestBytes });
const wasEnabled = existing?.enabled === true;
try {
this.stateStore.upsertRecord({ id: loaded.manifest.id, version: loaded.manifest.version, source: "catalog", installPath: loaded.installPath, manifestPath: loaded.manifestPath, enabled: existing && !permissionsChanged ? existing.enabled : false, approvedPermissions: loaded.manifest.permissions, config: existing?.config ?? {} });
} catch (error) {
if (previousManifestText !== undefined) await fs.writeFile(loaded.manifestPath, previousManifestText, { mode: 0o600 }).catch(() => undefined);
else await fs.rm(loaded.installPath, { recursive: true, force: true }).catch(() => undefined);
throw error;
}
if (wasEnabled || (existing && !permissionsChanged && existing.enabled)) await this.runtime.reloadPlugin(id);
return { ok: true, snapshot: await this.getSnapshot() };
} catch (error) { return this.#error(safeError(error)); }
}
async #safeRecord(record: PluginStateRecord): Promise<SafePluginRecord> {
const base = { id: record.id, version: record.version, source: record.source, enabled: record.enabled, brokenReason: record.brokenReason, approvedPermissions: record.approvedPermissions };
try {
const manifest = await this.#readManifest(record);
const config = getEffectivePluginConfig(manifest, record.config);
return { ...base, brokenReason: sanitizePluginUiMessage(record.brokenReason), name: manifest.name, configSchema: manifest.configSchema, effectiveConfig: config.ok ? config.config : undefined, configErrors: config.ok ? undefined : config.errors };
} catch (error) {
return { ...base, brokenReason: sanitizePluginUiMessage(record.brokenReason) ?? safeError(error) };
}
}
#readManifest(record: PluginStateRecord): Promise<OpenPetsPluginManifest> {
return readSafePluginManifest({ installPath: record.installPath, manifestPath: record.manifestPath, allowedPluginRoots: this.allowedPluginRoots, maxManifestBytes: this.#maxManifestBytes, expectedId: record.id, expectedVersion: record.version });
}
async #error(error: string): Promise<PluginServiceResult> {
return { ok: false, error, snapshot: await this.getSnapshot() };
}
#ensureRoots(): void {
for (const root of this.allowedPluginRoots) mkdirSync(root, { recursive: true });
}
}
let appPluginService: PluginService | null = null;
export function initializePluginService(userDataPath: string, petApi: PluginPetApi, currentAppVersion = "0.0.0"): PluginService {
appPluginService = new PluginService({ userDataPath, petApi, currentAppVersion });
return appPluginService;
}
export function stopPluginService(): void {
appPluginService?.stop();
}
export function getPluginService(): PluginService {
if (!appPluginService) throw new Error("Plugin service is not initialized.");
return appPluginService;
}
function safeError(error: unknown): string {
const message = error instanceof Error ? error.message : "Plugin manifest is unavailable.";
if (/manifest validation failed/i.test(message)) return "Plugin manifest validation failed.";
if (/too large/i.test(message)) return "Plugin manifest is too large.";
if (/outside allowed/i.test(message)) return "Plugin install path is outside allowed plugin roots.";
if (/outside install/i.test(message)) return "Plugin manifest path is outside install path.";
if (/path is invalid/i.test(message)) return "Plugin manifest path is invalid.";
if (/id\/version/i.test(message)) return "Plugin manifest id/version does not match installed state.";
if (/newer OpenPets version/i.test(message)) return "Plugin requires a newer OpenPets version.";
return "Plugin manifest is unavailable.";
}
function sanitizePluginUiMessage(value: string | undefined): string | undefined {
if (!value) return undefined;
if (looksPathLike(value)) return "Plugin needs attention. Check logs for details.";
return value;
}
function looksPathLike(value: string): boolean {
return /(?:[A-Za-z]:\\|\/[^\s]+|\\[^\s]+|file:\/\/|ENOENT|EACCES|EPERM)/.test(value);
}
function isPermissionSubset(next: readonly PluginPermission[], approved: readonly PluginPermission[]): boolean {
const approvedSet = new Set(approved);
return next.every((permission) => approvedSet.has(permission));
}
function isCatalogEntryCompatible(minOpenPetsVersion: string | undefined, currentAppVersion: string): boolean {
if (!minOpenPetsVersion) return true;
return compareSemver(currentAppVersion, minOpenPetsVersion) >= 0;
}
function compareSemver(a: string, b: string): number {
const pa = parseCoreVersion(a); const pb = parseCoreVersion(b);
for (let i = 0; i < 3; i += 1) if (pa[i] !== pb[i]) return pa[i] > pb[i] ? 1 : -1;
return 0;
}
function parseCoreVersion(version: string): [number, number, number] {
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version);
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : [0, 0, 0];
}
async function defaultOpenDialog(): Promise<{ canceled: boolean; filePaths: string[] }> {
const { dialog } = await import("electron");
return dialog.showOpenDialog({ properties: ["openDirectory"] });
}
async function defaultConfirmPermissions(manifest: OpenPetsPluginManifest): Promise<boolean> {
const { dialog } = await import("electron");
const permissions = manifest.permissions.length === 0 ? "No permissions" : manifest.permissions.join(", ");
const result = await dialog.showMessageBox({ type: "question", buttons: ["Load plugin", "Cancel"], defaultId: 0, cancelId: 1, title: "Load local OpenPets plugin?", message: `Load ${manifest.name}?`, detail: `Permissions: ${permissions}` });
return result.response === 0;
}