import { app } from 'electron'; import * as fsp from 'fs/promises'; import * as path from 'path'; import { pathToFileURL } from 'url'; const PLUGINS_FOLDER_NAME = 'plugins'; const MANIFEST_FILE_NAMES = ['toju-plugin.json', 'plugin.json'] as const; export interface LocalPluginManifestDescriptor { discoveredAt: number; entrypointPath?: string; pluginRootUrl: string; manifest: unknown; manifestPath: string; pluginRoot: string; readmePath?: string; } export interface LocalPluginDiscoveryError { manifestPath?: string; message: string; pluginRoot?: string; } export interface LocalPluginDiscoveryResult { errors: LocalPluginDiscoveryError[]; plugins: LocalPluginManifestDescriptor[]; pluginsPath: string; } function resolvePluginsPath(): string { return path.join(app.getPath('userData'), PLUGINS_FOLDER_NAME); } async function ensurePluginsPath(): Promise { const pluginsPath = resolvePluginsPath(); await fsp.mkdir(pluginsPath, { recursive: true }); return pluginsPath; } async function realpathOrSelf(filePath: string): Promise { try { return await fsp.realpath(filePath); } catch { return filePath; } } function isPathInside(parentPath: string, candidatePath: string): boolean { const relativePath = path.relative(parentPath, candidatePath); return !!relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath); } function readManifestPath(manifestRecord: Record, key: string): string | undefined { const value = manifestRecord[key]; return typeof value === 'string' && value.trim() ? value.trim() : undefined; } async function resolveManifestRelativeFile(pluginRoot: string, relativeFilePath: string | undefined): Promise { if (!relativeFilePath || path.isAbsolute(relativeFilePath)) { return undefined; } const normalizedPath = path.normalize(relativeFilePath); if (normalizedPath.startsWith('..')) { return undefined; } const candidatePath = path.join(pluginRoot, normalizedPath); const [realPluginRoot, realCandidatePath] = await Promise.all([realpathOrSelf(pluginRoot), realpathOrSelf(candidatePath)]); if (!isPathInside(realPluginRoot, realCandidatePath)) { return undefined; } try { const stats = await fsp.stat(realCandidatePath); return stats.isFile() ? realCandidatePath : undefined; } catch { return undefined; } } async function findManifestPath(pluginRoot: string): Promise { for (const fileName of MANIFEST_FILE_NAMES) { const manifestPath = path.join(pluginRoot, fileName); try { const stats = await fsp.stat(manifestPath); if (stats.isFile()) { return manifestPath; } } catch { // Missing manifest candidates are expected while scanning folders. } } return undefined; } async function readPluginManifest(pluginRoot: string, manifestPath: string): Promise { const text = await fsp.readFile(manifestPath, 'utf8'); const manifest = JSON.parse(text) as unknown; const manifestRecord = manifest && typeof manifest === 'object' && !Array.isArray(manifest) ? manifest as Record : {}; const entrypointPromise = resolveManifestRelativeFile(pluginRoot, readManifestPath(manifestRecord, 'entrypoint')); const readmePromise = resolveManifestRelativeFile(pluginRoot, readManifestPath(manifestRecord, 'readme')); const [entrypointPath, readmePath] = await Promise.all([entrypointPromise, readmePromise]); return { discoveredAt: Date.now(), entrypointPath, pluginRootUrl: pathToFileURL(pluginRoot + path.sep).toString(), manifest, manifestPath, pluginRoot, readmePath }; } export async function getLocalPluginsPath(): Promise { return await ensurePluginsPath(); } export async function listLocalPluginManifests(): Promise { const pluginsPath = await ensurePluginsPath(); const entries = await fsp.readdir(pluginsPath, { withFileTypes: true }); const plugins: LocalPluginManifestDescriptor[] = []; const errors: LocalPluginDiscoveryError[] = []; for (const entry of entries.filter((candidate) => candidate.isDirectory())) { const pluginRoot = path.join(pluginsPath, entry.name); const manifestPath = await findManifestPath(pluginRoot); if (!manifestPath) { continue; } try { plugins.push(await readPluginManifest(pluginRoot, manifestPath)); } catch (error) { errors.push({ manifestPath, message: error instanceof Error ? error.message : 'Unable to read plugin manifest', pluginRoot }); } } return { errors, plugins: plugins.sort((left, right) => left.pluginRoot.localeCompare(right.pluginRoot)), pluginsPath }; }