feat: plugins v1
This commit is contained in:
@@ -49,6 +49,7 @@ import {
|
||||
readSavedTheme,
|
||||
writeSavedTheme
|
||||
} from '../theme-library';
|
||||
import { getLocalPluginsPath, listLocalPluginManifests } from '../plugin-library';
|
||||
import {
|
||||
eraseUserData,
|
||||
exportUserData,
|
||||
@@ -349,6 +350,8 @@ export function setupSystemHandlers(): void {
|
||||
ipcMain.handle('import-user-data', async () => await importUserData());
|
||||
ipcMain.handle('erase-user-data', async () => await eraseUserData());
|
||||
ipcMain.handle('get-saved-themes-path', async () => await getSavedThemesPath());
|
||||
ipcMain.handle('get-local-plugins-path', async () => await getLocalPluginsPath());
|
||||
ipcMain.handle('list-local-plugin-manifests', async () => await listLocalPluginManifests());
|
||||
ipcMain.handle('list-saved-themes', async () => await listSavedThemes());
|
||||
ipcMain.handle('read-saved-theme', async (_event, fileName: string) => await readSavedTheme(fileName));
|
||||
ipcMain.handle('write-saved-theme', async (_event, fileName: string, text: string) => {
|
||||
|
||||
126
electron/plugin-library.spec.ts
Normal file
126
electron/plugin-library.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import {
|
||||
cp,
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
rm,
|
||||
writeFile
|
||||
} from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { TEST_PLUGIN_FIXTURE_DIR, TEST_PLUGIN_ID } from '../e2e/helpers/plugin-api-test-fixture';
|
||||
|
||||
const { mockGetPath } = vi.hoisted(() => ({
|
||||
mockGetPath: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: mockGetPath
|
||||
}
|
||||
}));
|
||||
|
||||
import { getLocalPluginsPath, listLocalPluginManifests } from './plugin-library';
|
||||
|
||||
describe('plugin-library', () => {
|
||||
let userDataPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
userDataPath = await mkdtemp(join(tmpdir(), 'metoyou-plugin-library-'));
|
||||
mockGetPath.mockReturnValue(userDataPath);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(userDataPath, { recursive: true, force: true });
|
||||
mockGetPath.mockReset();
|
||||
});
|
||||
|
||||
it('creates and reports the local plugins folder', async () => {
|
||||
const pluginsPath = await getLocalPluginsPath();
|
||||
const result = await listLocalPluginManifests();
|
||||
|
||||
expect(pluginsPath).toBe(join(userDataPath, 'plugins'));
|
||||
expect(result).toEqual({
|
||||
errors: [],
|
||||
plugins: [],
|
||||
pluginsPath
|
||||
});
|
||||
});
|
||||
|
||||
it('discovers immediate child plugin manifests and safe relative files', async () => {
|
||||
const pluginRoot = join(userDataPath, 'plugins', 'api-test-plugin');
|
||||
|
||||
await cp(TEST_PLUGIN_FIXTURE_DIR, pluginRoot, { recursive: true });
|
||||
|
||||
const result = await listLocalPluginManifests();
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.plugins).toHaveLength(1);
|
||||
expect(result.plugins[0]).toEqual(expect.objectContaining({
|
||||
entrypointPath: join(pluginRoot, 'dist', 'main.js'),
|
||||
manifestPath: join(pluginRoot, 'toju-plugin.json'),
|
||||
pluginRoot,
|
||||
readmePath: join(pluginRoot, 'README.md')
|
||||
}));
|
||||
|
||||
expect(result.plugins[0]?.manifest).toEqual(expect.objectContaining({ id: TEST_PLUGIN_ID }));
|
||||
});
|
||||
|
||||
it('reports invalid JSON and keeps scanning other plugins', async () => {
|
||||
const invalidRoot = join(userDataPath, 'plugins', 'invalid-plugin');
|
||||
const validRoot = join(userDataPath, 'plugins', 'valid-plugin');
|
||||
|
||||
await mkdir(invalidRoot, { recursive: true });
|
||||
await mkdir(validRoot, { recursive: true });
|
||||
await writeFile(join(invalidRoot, 'plugin.json'), '{', 'utf8');
|
||||
await writeFile(join(validRoot, 'plugin.json'), JSON.stringify({
|
||||
apiVersion: '1.0.0',
|
||||
compatibility: { minimumTojuVersion: '1.0.0' },
|
||||
description: 'Valid plugin',
|
||||
entrypoint: './main.js',
|
||||
id: 'valid.plugin',
|
||||
kind: 'client',
|
||||
schemaVersion: 1,
|
||||
title: 'Valid Plugin',
|
||||
version: '1.0.0'
|
||||
}), 'utf8');
|
||||
|
||||
const result = await listLocalPluginManifests();
|
||||
|
||||
expect(result.plugins.map((plugin) => plugin.pluginRoot)).toEqual([validRoot]);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]).toEqual(expect.objectContaining({
|
||||
manifestPath: join(invalidRoot, 'plugin.json'),
|
||||
pluginRoot: invalidRoot
|
||||
}));
|
||||
});
|
||||
|
||||
it('does not resolve entrypoints outside the plugin folder', async () => {
|
||||
const pluginRoot = join(userDataPath, 'plugins', 'unsafe-plugin');
|
||||
|
||||
await mkdir(pluginRoot, { recursive: true });
|
||||
await writeFile(join(userDataPath, 'plugins', 'outside.js'), 'export default {};', 'utf8');
|
||||
await writeFile(join(pluginRoot, 'plugin.json'), JSON.stringify({
|
||||
apiVersion: '1.0.0',
|
||||
compatibility: { minimumTojuVersion: '1.0.0' },
|
||||
description: 'Unsafe plugin',
|
||||
entrypoint: '../outside.js',
|
||||
id: 'unsafe.plugin',
|
||||
kind: 'client',
|
||||
schemaVersion: 1,
|
||||
title: 'Unsafe Plugin',
|
||||
version: '1.0.0'
|
||||
}), 'utf8');
|
||||
|
||||
const result = await listLocalPluginManifests();
|
||||
|
||||
expect(result.plugins[0]?.entrypointPath).toBeUndefined();
|
||||
});
|
||||
});
|
||||
165
electron/plugin-library.ts
Normal file
165
electron/plugin-library.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -109,6 +109,28 @@ export interface SavedThemeFileDescriptor {
|
||||
path: string;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface ExportUserDataResult {
|
||||
cancelled: boolean;
|
||||
exported: boolean;
|
||||
@@ -181,6 +203,8 @@ export interface ElectronAPI {
|
||||
importUserData: () => Promise<ImportUserDataResult>;
|
||||
eraseUserData: () => Promise<EraseUserDataResult>;
|
||||
getSavedThemesPath: () => Promise<string>;
|
||||
getLocalPluginsPath: () => Promise<string>;
|
||||
listLocalPluginManifests: () => Promise<LocalPluginDiscoveryResult>;
|
||||
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
||||
readSavedTheme: (fileName: string) => Promise<string>;
|
||||
writeSavedTheme: (fileName: string, text: string) => Promise<boolean>;
|
||||
@@ -294,6 +318,8 @@ const electronAPI: ElectronAPI = {
|
||||
importUserData: () => ipcRenderer.invoke('import-user-data'),
|
||||
eraseUserData: () => ipcRenderer.invoke('erase-user-data'),
|
||||
getSavedThemesPath: () => ipcRenderer.invoke('get-saved-themes-path'),
|
||||
getLocalPluginsPath: () => ipcRenderer.invoke('get-local-plugins-path'),
|
||||
listLocalPluginManifests: () => ipcRenderer.invoke('list-local-plugin-manifests'),
|
||||
listSavedThemes: () => ipcRenderer.invoke('list-saved-themes'),
|
||||
readSavedTheme: (fileName) => ipcRenderer.invoke('read-saved-theme', fileName),
|
||||
writeSavedTheme: (fileName, text) => ipcRenderer.invoke('write-saved-theme', fileName, text),
|
||||
|
||||
Reference in New Issue
Block a user