Refactor plugin management system

- Updated PluginLoader to utilize the new plugin marketplace manager for loading and managing plugins.
- Simplified the plugin loading process by removing unnecessary state management and directly integrating with the plugin marketplace.
- Enhanced error handling during plugin installation and uninstallation processes.
- Removed legacy code related to Electron-specific plugin handling, streamlining the codebase for web compatibility.
- Updated Plugins component to fetch marketplace plugins and installed plugins using the new plugin marketplace manager.
- Refactored types related to plugins to import from the new plugin manager module, ensuring consistency and reducing redundancy.
- Removed unused calling configuration from client settings and adjusted related types accordingly.
- Cleaned up room state events by removing references to LiveKit service URLs, aligning with the updated architecture.
This commit is contained in:
2026-04-19 06:52:42 +10:00
parent d1d3033c15
commit 52e9cc360f
18 changed files with 283 additions and 1375 deletions

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import { MatrixClient } from 'matrix-js-sdk';
import { Plugin, PluginContext, pluginRegistry } from './PluginAPI';
import { InstalledPlugin } from './types';
import { Plugin, createPluginContext } from '@paarrot/plugin-manager';
import { pluginMarketplaceManager, pluginRegistry } from './PluginAPI';
import { sendNotification } from '../../../utils/tauri';
interface PluginLoaderProps {
@@ -14,56 +14,26 @@ interface PluginLoaderProps {
* Should be placed high in the component tree after MatrixClient is available.
*/
export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
const [loading, setLoading] = useState(true);
const [loadedPlugins, setLoadedPlugins] = useState<string[]>([]);
useEffect(() => {
let mounted = true;
const loadPlugins = async () => {
try {
console.log('[PluginLoader] Loading plugins...');
// Get list of installed plugins from Electron
if (!window.electron?.plugins) {
console.log('[PluginLoader] Running in web mode, plugins not supported');
setLoading(false);
return;
}
const response = await window.electron.plugins.list();
if (!response.success || !response.data) {
console.error('[PluginLoader] Failed to list plugins:', response.error);
setLoading(false);
return;
}
const installedPlugins = response.data;
console.log('[PluginLoader] Found installed plugins:', installedPlugins.length);
// Check which plugins are enabled in localStorage
const enabledPluginsStr = localStorage.getItem('enabledPlugins');
const enabledPlugins = enabledPluginsStr ? JSON.parse(enabledPluginsStr) : {};
const pluginsToLoad = installedPlugins.filter(
(plugin: any) => enabledPlugins[plugin.id] !== false
);
const pluginsToLoad = await pluginMarketplaceManager.listEnabledPlugins();
console.log('[PluginLoader] Plugins to load:', pluginsToLoad.length);
const loaded: string[] = [];
const React = await import('react');
// Load each enabled plugin
for (const installedPlugin of pluginsToLoad) {
try {
console.log(`[PluginLoader] Loading plugin: ${installedPlugin.id}`);
// Dynamically import the plugin
const pluginModule = await loadPluginModule(installedPlugin.id);
// Handle both CommonJS (module.exports = {...}) and ES6 (export default {...})
const plugin: Plugin = pluginModule?.default || pluginModule;
const plugin = normalisePlugin(pluginModule);
if (!plugin || typeof plugin !== 'object') {
console.error(
@@ -72,7 +42,6 @@ export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
continue;
}
// Validate plugin structure
if (typeof plugin.onLoad !== 'function') {
console.error(
`[PluginLoader] Plugin ${installedPlugin.id} does not have an onLoad function`
@@ -80,252 +49,127 @@ export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
continue;
}
// Create enhanced plugin context
const context = createPluginContext(installedPlugin.id, matrixClient, React);
const context = createPluginContext(
{
pluginId: installedPlugin.id,
eventClient: matrixClient as any,
onNotify: async (opts) => {
await sendNotification({
title: opts.title,
body: opts.body,
path: undefined,
});
},
},
pluginRegistry
);
const compatContext = Object.assign(context as Record<string, unknown>, {
matrix: {
on: (eventType: string, handler: (...args: unknown[]) => void) =>
matrixClient.on(eventType as any, handler as any),
off: (eventType: string, handler: (...args: unknown[]) => void) =>
matrixClient.off(eventType as any, handler as any),
},
React,
});
// Register plugin
pluginRegistry.registerPlugin(installedPlugin.id, plugin, context);
// Call plugin's onLoad
await plugin.onLoad(context);
loaded.push(installedPlugin.id);
await plugin.onLoad(compatContext as any);
console.log(`[PluginLoader] Successfully loaded plugin: ${installedPlugin.id}`);
} catch (error) {
console.error(`[PluginLoader] Failed to load plugin ${installedPlugin.id}:`, error);
pluginRegistry.addLog(installedPlugin.id, 'error', ['Failed to load:', error]);
}
}
if (mounted) {
setLoadedPlugins(loaded);
setLoading(false);
}
} catch (error) {
console.error('[PluginLoader] Error loading plugins:', error);
if (mounted) {
setLoading(false);
}
}
};
loadPlugins();
return () => {
mounted = false;
// Cleanup: unload all plugins
console.log('[PluginLoader] Cleaning up plugins');
pluginRegistry.clear();
};
}, [matrixClient]);
// Show children immediately, plugins load in background
return <>{children}</>;
}
/**
* Create enhanced plugin context with all APIs
* Reads and evaluates a plugin's JS module from the Electron filesystem.
* Supports both CommonJS and ESM-style exports.
*/
function createPluginContext(
pluginId: string,
matrixClient: MatrixClient,
React: typeof import('react')
): PluginContext {
return {
pluginId,
matrixClient,
React,
async function loadPluginModule(pluginId: string): Promise<any> {
if (!window.electron?.plugins) {
throw new Error('Plugins are only supported in the desktop app');
}
// Commands API
commands: {
register: (command) => {
pluginRegistry.registerCommand(pluginId, command);
pluginRegistry.addLog(pluginId, 'log', [`Registered command: /${command.name}`]);
},
unregister: (name) => {
pluginRegistry.unregisterCommand(name);
},
execute: async (name, args) => {
return pluginRegistry.executeCommand(name, JSON.stringify(args));
},
},
const response = await window.electron.plugins.readPluginCode(pluginId);
if (!response.success || !response.data) {
throw new Error(response.error ?? 'Failed to read plugin code');
}
// Messages API
messages: {
onBeforeSend: (interceptor) => {
pluginRegistry.addBeforeSendInterceptor(pluginId, interceptor);
pluginRegistry.addLog(pluginId, 'log', ['Registered beforeSend interceptor']);
},
onReceive: (interceptor) => {
pluginRegistry.addReceiveInterceptor(pluginId, interceptor);
pluginRegistry.addLog(pluginId, 'log', ['Registered receive interceptor']);
},
},
const pluginExports: any = {};
const mod = { exports: pluginExports };
// UI API
ui: {
registerRenderer: (type, renderer) => {
pluginRegistry.registerRenderer(pluginId, type, renderer);
pluginRegistry.addLog(pluginId, 'log', [`Registered renderer for: ${type}`]);
},
unregisterRenderer: (type) => {
pluginRegistry.unregisterRenderer(type);
},
},
try {
// eslint-disable-next-line no-new-func
const pluginFunction = new Function('module', 'exports', response.data);
pluginFunction(mod, pluginExports);
} catch (error) {
if (!(error instanceof SyntaxError)) {
throw error;
}
// Settings API
settings: {
define: (schema) => {
pluginRegistry.defineSettings(pluginId, schema);
pluginRegistry.addLog(pluginId, 'log', ['Defined settings schema']);
},
get: <T = any,>(key: string): T | undefined => {
return pluginRegistry.getPluginSetting<T>(pluginId, key);
},
set: (key, value) => {
pluginRegistry.setPluginSetting(pluginId, key, value);
},
},
// Themes API
themes: {
register: (theme) => {
pluginRegistry.registerTheme(pluginId, theme);
pluginRegistry.addLog(pluginId, 'log', [`Registered theme: ${theme.name}`]);
},
unregister: (themeId) => {
pluginRegistry.unregisterTheme(pluginId, themeId);
},
},
const dataUrl = `data:text/javascript;charset=utf-8,${encodeURIComponent(response.data)}`;
const importedModule = await import(/* @vite-ignore */ dataUrl);
return importedModule;
}
// Matrix events API
matrix: {
on: (eventType, handler) => {
pluginRegistry.addMatrixHandler(pluginId, eventType, handler);
matrixClient.on(eventType as any, handler as any);
pluginRegistry.addLog(pluginId, 'log', [`Listening to Matrix event: ${eventType}`]);
},
off: (eventType, handler) => {
pluginRegistry.removeMatrixHandler(pluginId, eventType, handler);
matrixClient.off(eventType as any, handler as any);
},
},
// Timers API
timers: {
setInterval: (callback, ms) => {
const id = window.setInterval(() => {
try {
callback();
} catch (err) {
pluginRegistry.addLog(pluginId, 'error', ['Interval error:', err]);
}
}, ms);
pluginRegistry.addTimer(pluginId, id);
return id;
},
setTimeout: (callback, ms) => {
const id = window.setTimeout(() => {
try {
callback();
} catch (err) {
pluginRegistry.addLog(pluginId, 'error', ['Timeout error:', err]);
}
pluginRegistry.removeTimer(pluginId, id);
}, ms);
pluginRegistry.addTimer(pluginId, id);
return id;
},
clearInterval: (id) => {
window.clearInterval(id);
pluginRegistry.removeTimer(pluginId, id);
},
clearTimeout: (id) => {
window.clearTimeout(id);
pluginRegistry.removeTimer(pluginId, id);
},
},
// Notifications API
notify: async (options) => {
const opts = typeof options === 'string' ? { title: 'Plugin', body: options } : options;
console.log(`[Plugin ${pluginId}] Notification:`, opts.title, opts.body);
pluginRegistry.addLog(pluginId, 'log', ['Notification:', opts]);
// Send actual notification
try {
await sendNotification({
title: opts.title || 'Plugin',
body: opts.body || '',
path: undefined, // Could be enhanced to support navigation
});
} catch (err) {
console.error(`[Plugin ${pluginId}] Notification failed:`, err);
}
},
// Logging API
log: (...args) => {
console.log(`[Plugin ${pluginId}]`, ...args);
pluginRegistry.addLog(pluginId, 'log', args);
},
warn: (...args) => {
console.warn(`[Plugin ${pluginId}]`, ...args);
pluginRegistry.addLog(pluginId, 'warn', args);
},
error: (...args) => {
console.error(`[Plugin ${pluginId}]`, ...args);
pluginRegistry.addLog(pluginId, 'error', args);
},
// Require API
require: (requiredPluginId) => {
const exports = pluginRegistry.getPluginExports(requiredPluginId);
if (!exports) {
throw new Error(`Plugin ${requiredPluginId} not found or has no exports`);
}
pluginRegistry.addLog(pluginId, 'log', [`Required plugin: ${requiredPluginId}`]);
return exports;
},
};
return mod.exports;
}
/**
* Load a plugin module from the filesystem.
* Handles both Electron and web environments.
* Normalizes raw plugin exports to current plugin interface.
* Supports native `onLoad` and legacy `activate` / `deactivate` plugins.
*/
async function loadPluginModule(pluginId: string): Promise<any> {
// Check if we're in Electron
if (window.electron?.plugins) {
// In Electron, we need to read the plugin file and evaluate it
// This is necessary because dynamic imports don't work with file:// URLs in all cases
try {
// Request Electron to load the plugin code
const response = await window.electron.plugins.readPluginCode(pluginId);
if (!response.success || !response.data) {
throw new Error(response.error || 'Failed to read plugin code');
}
const pluginCode = response.data;
// Create a sandboxed environment for the plugin
const pluginExports: any = {};
const module = { exports: pluginExports };
// Evaluate the plugin code
// eslint-disable-next-line no-new-func
const pluginFunction = new Function('module', 'exports', pluginCode);
pluginFunction(module, pluginExports);
return module.exports;
} catch (error) {
console.error(`[PluginLoader] Error evaluating plugin ${pluginId}:`, error);
throw error;
}
} else {
// In web mode, plugins are not supported
throw new Error('Plugins are only supported in the desktop app');
function normalisePlugin(raw: Record<string, unknown>): Plugin {
if (typeof raw.onLoad === 'function') {
return raw as unknown as Plugin;
}
if (typeof raw.activate === 'function') {
const activate = raw.activate as (ctx: unknown) => void | Promise<void>;
const deactivate = raw.deactivate as ((ctx?: unknown) => void | Promise<void>) | undefined;
return {
name: typeof raw.name === 'string' ? raw.name : undefined,
version: typeof raw.version === 'string' ? raw.version : undefined,
onLoad: async (ctx) => {
const legacyCtx = Object.assign(Object.create(ctx as object), {
registerHook: (name: string) =>
ctx.log(`[compat] registerHook("${name}") - not supported`),
runHook: (name: string) => {
ctx.log(`[compat] runHook("${name}")`);
return Promise.resolve([]);
},
getConfig: (key: string) => ctx.settings.get(key),
on: (event: string, handler: (...args: unknown[]) => void) =>
ctx.events.on(event, handler),
});
await activate(legacyCtx);
},
onUnload: deactivate
? async () => {
await deactivate();
}
: undefined,
};
}
return raw as unknown as Plugin;
}