feat(plugins): implement plugin loading and management system
- Added PluginLoader component to dynamically load and initialize plugins. - Created Plugins component for managing installed and marketplace plugins. - Introduced PluginAPI for plugin interaction and settings management. - Defined types for plugin metadata, installed plugins, and plugin index. - Implemented settings rendering for plugins based on their schema. - Integrated marketplace plugin fetching and installation logic. - Added support for enabling/disabling and uninstalling plugins.
This commit is contained in:
331
src/app/features/settings/plugins/PluginLoader.tsx
Normal file
331
src/app/features/settings/plugins/PluginLoader.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import { Plugin, PluginContext, pluginRegistry } from './PluginAPI';
|
||||
import { InstalledPlugin } from './types';
|
||||
import { sendNotification } from '../../../utils/tauri';
|
||||
|
||||
interface PluginLoaderProps {
|
||||
matrixClient: MatrixClient;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that loads and initializes enabled plugins.
|
||||
* 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
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
if (!plugin || typeof plugin !== 'object') {
|
||||
console.error(
|
||||
`[PluginLoader] Plugin ${installedPlugin.id} does not export a valid plugin object`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate plugin structure
|
||||
if (typeof plugin.onLoad !== 'function') {
|
||||
console.error(
|
||||
`[PluginLoader] Plugin ${installedPlugin.id} does not have an onLoad function`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create enhanced plugin context
|
||||
const context = createPluginContext(installedPlugin.id, matrixClient, React);
|
||||
|
||||
// Register plugin
|
||||
pluginRegistry.registerPlugin(installedPlugin.id, plugin, context);
|
||||
|
||||
// Call plugin's onLoad
|
||||
await plugin.onLoad(context);
|
||||
|
||||
loaded.push(installedPlugin.id);
|
||||
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
|
||||
*/
|
||||
function createPluginContext(
|
||||
pluginId: string,
|
||||
matrixClient: MatrixClient,
|
||||
React: typeof import('react')
|
||||
): PluginContext {
|
||||
return {
|
||||
pluginId,
|
||||
matrixClient,
|
||||
React,
|
||||
|
||||
// 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));
|
||||
},
|
||||
},
|
||||
|
||||
// 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']);
|
||||
},
|
||||
},
|
||||
|
||||
// UI API
|
||||
ui: {
|
||||
registerRenderer: (type, renderer) => {
|
||||
pluginRegistry.registerRenderer(pluginId, type, renderer);
|
||||
pluginRegistry.addLog(pluginId, 'log', [`Registered renderer for: ${type}`]);
|
||||
},
|
||||
unregisterRenderer: (type) => {
|
||||
pluginRegistry.unregisterRenderer(type);
|
||||
},
|
||||
},
|
||||
|
||||
// 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);
|
||||
},
|
||||
},
|
||||
|
||||
// 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a plugin module from the filesystem.
|
||||
* Handles both Electron and web environments.
|
||||
*/
|
||||
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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user