166 lines
4.7 KiB
TypeScript
166 lines
4.7 KiB
TypeScript
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<string> {
|
|
const pluginsPath = resolvePluginsPath();
|
|
|
|
await fsp.mkdir(pluginsPath, { recursive: true });
|
|
|
|
return pluginsPath;
|
|
}
|
|
|
|
async function realpathOrSelf(filePath: string): Promise<string> {
|
|
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<string, unknown>, 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<string | undefined> {
|
|
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<string | undefined> {
|
|
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<LocalPluginManifestDescriptor> {
|
|
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<string, unknown>
|
|
: {};
|
|
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<string> {
|
|
return await ensurePluginsPath();
|
|
}
|
|
|
|
export async function listLocalPluginManifests(): Promise<LocalPluginDiscoveryResult> {
|
|
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
|
|
};
|
|
}
|