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:
2026-04-18 02:55:44 +10:00
parent 9eb5e4fa32
commit d1d3033c15
13 changed files with 2674 additions and 49 deletions

View File

@@ -0,0 +1,698 @@
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
import { ReactNode } from 'react';
/**
* Color group with five container variants and an OnContainer text color.
*/
export interface ThemeColorGroup {
container: string;
containerHover: string;
containerActive: string;
containerLine: string;
onContainer: string;
}
/**
* Color group for interactive palette entries (Main + Container sub-groups).
*/
export interface ThemePaletteGroup {
main: string;
mainHover: string;
mainActive: string;
mainLine: string;
onMain: string;
container: string;
containerHover: string;
containerActive: string;
containerLine: string;
onContainer: string;
}
/**
* Full color token set for a plugin theme.
* Property names map directly to the folds design-token contract.
*/
export interface PluginThemeColors {
background: ThemeColorGroup;
surface: ThemeColorGroup;
surfaceVariant: ThemeColorGroup;
primary: ThemePaletteGroup;
secondary: ThemePaletteGroup;
success: ThemePaletteGroup;
warning: ThemePaletteGroup;
critical: ThemePaletteGroup;
other: {
focusRing: string;
shadow: string;
overlay: string;
};
}
/**
* Theme definition for plugins. Provide human-readable colors and the system
* maps them to the correct compiled CSS variables automatically.
*/
export interface PluginTheme {
id: string;
name: string;
kind: 'light' | 'dark';
colors: PluginThemeColors;
/** Optional extra CSS injected alongside the theme (body backgrounds, scrollbars, etc.) */
extraCss?: string;
}
/**
* Command argument definition
*/
export interface CommandArg {
name: string;
type?: 'string' | 'number' | 'boolean';
required?: boolean;
description?: string;
}
/**
* Enhanced command with argument parsing
*/
export interface PluginCommand {
name: string;
description?: string;
args?: string[] | CommandArg[];
run: (args: Record<string, any>, ctx: PluginContext) => string | void | Promise<string | void>;
}
/**
* Message interceptor callback
*/
export type MessageInterceptor = (msg: MessageContext) => void | Promise<void>;
/**
* Message context for interceptors
*/
export interface MessageContext {
content: string;
roomId: string;
eventType: string;
formatted?: string;
metadata?: Record<string, any>;
}
/**
* Custom renderer function
*/
export type CustomRenderer = (data: any, defaultRenderer?: () => ReactNode) => ReactNode;
/**
* Setting definition
*/
export interface SettingDefinition {
type: 'string' | 'number' | 'boolean' | 'select' | 'color';
label?: string;
description?: string;
default?: any;
options?: Array<{ value: any; label: string }>;
}
/**
* Plugin settings schema
*/
export type SettingsSchema = Record<string, SettingDefinition>;
/**
* Notification options
*/
export interface NotificationOptions {
title: string;
body: string;
type?: 'info' | 'success' | 'error' | 'warning';
duration?: number;
actions?: Array<{ label: string; action: () => void }>;
}
/**
* The context provided to plugins when they are initialized.
* This gives plugins access to core app functionality.
*/
export interface PluginContext {
/** Plugin ID */
pluginId: string;
/** The Matrix client instance */
matrixClient: MatrixClient;
/** React for creating UI components */
React: typeof import('react');
/** Commands API */
commands: {
register: (command: PluginCommand) => void;
unregister: (name: string) => void;
execute: (name: string, args: Record<string, any>) => Promise<string | void>;
};
/** Messages API */
messages: {
onBeforeSend: (interceptor: MessageInterceptor) => void;
onReceive: (interceptor: MessageInterceptor) => void;
};
/** UI API */
ui: {
registerRenderer: (type: string, renderer: CustomRenderer) => void;
unregisterRenderer: (type: string) => void;
};
/** Settings API */
settings: {
define: (schema: SettingsSchema) => void;
get: <T = any>(key: string) => T | undefined;
set: (key: string, value: any) => void;
};
/** Themes API */
themes: {
register: (theme: PluginTheme) => void;
unregister: (themeId: string) => void;
};
/** Matrix raw events API */
matrix: {
on: (eventType: string, handler: (event: MatrixEvent) => void) => void;
off: (eventType: string, handler: (event: MatrixEvent) => void) => void;
};
/** Background tasks API */
timers: {
setInterval: (callback: () => void, ms: number) => number;
setTimeout: (callback: () => void, ms: number) => number;
clearInterval: (id: number) => void;
clearTimeout: (id: number) => void;
};
/** Notifications API */
notify: (options: NotificationOptions | string) => void;
/** Logging API */
log: (...args: any[]) => void;
warn: (...args: any[]) => void;
error: (...args: any[]) => void;
/** Require other plugins */
require: (pluginId: string) => any;
}
/**
* A custom settings section registered by a plugin
*/
export interface PluginSettingsSection {
id: string;
name: string;
icon?: string;
component: ReactNode;
}
/**
* UI locations where plugins can inject content
*/
export type UILocation =
| 'room-header'
| 'message-actions'
| 'user-menu'
| 'room-menu'
| 'composer-actions';
/**
* The main plugin interface that all plugins must implement
*/
export interface Plugin {
/** Plugin metadata */
name?: string;
version?: string;
dependencies?: Record<string, string>;
/** Called when the plugin is loaded */
onLoad: (context: PluginContext) => void | Promise<void>;
/** Called when the plugin is unloaded/disabled */
onUnload?: () => void | Promise<void>;
/** Exposed API for other plugins to require */
exports?: any;
}
/**
* Plugin log entry
*/
export interface PluginLogEntry {
pluginId: string;
level: 'log' | 'warn' | 'error';
timestamp: number;
args: any[];
}
/**
* Maps a PluginThemeColors object to a CSS block overriding the folds
* vanilla-extract compiled custom properties. The variable names (--oq6d07*)
* are stable for the installed version of folds and match the order of the
* color contract: Background → Surface → SurfaceVariant → Primary →
* Secondary → Success → Warning → Critical → Other.
*/
function generateThemeCSS(className: string, theme: PluginTheme): string {
const { background: bg, surface: sf, surfaceVariant: sv, primary: pr, secondary: sc, success: su, warning: wa, critical: cr, other: ot } = theme.colors;
const vars = `
.${className} {
--oq6d070: ${bg.container};
--oq6d071: ${bg.containerHover};
--oq6d072: ${bg.containerActive};
--oq6d073: ${bg.containerLine};
--oq6d074: ${bg.onContainer};
--oq6d075: ${sf.container};
--oq6d076: ${sf.containerHover};
--oq6d077: ${sf.containerActive};
--oq6d078: ${sf.containerLine};
--oq6d079: ${sf.onContainer};
--oq6d07a: ${sv.container};
--oq6d07b: ${sv.containerHover};
--oq6d07c: ${sv.containerActive};
--oq6d07d: ${sv.containerLine};
--oq6d07e: ${sv.onContainer};
--oq6d07f: ${pr.main};
--oq6d07g: ${pr.mainHover};
--oq6d07h: ${pr.mainActive};
--oq6d07i: ${pr.mainLine};
--oq6d07j: ${pr.onMain};
--oq6d07k: ${pr.container};
--oq6d07l: ${pr.containerHover};
--oq6d07m: ${pr.containerActive};
--oq6d07n: ${pr.containerLine};
--oq6d07o: ${pr.onContainer};
--oq6d07p: ${sc.main};
--oq6d07q: ${sc.mainHover};
--oq6d07r: ${sc.mainActive};
--oq6d07s: ${sc.mainLine};
--oq6d07t: ${sc.onMain};
--oq6d07u: ${sc.container};
--oq6d07v: ${sc.containerHover};
--oq6d07w: ${sc.containerActive};
--oq6d07x: ${sc.containerLine};
--oq6d07y: ${sc.onContainer};
--oq6d07z: ${su.main};
--oq6d0710: ${su.mainHover};
--oq6d0711: ${su.mainActive};
--oq6d0712: ${su.mainLine};
--oq6d0713: ${su.onMain};
--oq6d0714: ${su.container};
--oq6d0715: ${su.containerHover};
--oq6d0716: ${su.containerActive};
--oq6d0717: ${su.containerLine};
--oq6d0718: ${su.onContainer};
--oq6d0719: ${wa.main};
--oq6d071a: ${wa.mainHover};
--oq6d071b: ${wa.mainActive};
--oq6d071c: ${wa.mainLine};
--oq6d071d: ${wa.onMain};
--oq6d071e: ${wa.container};
--oq6d071f: ${wa.containerHover};
--oq6d071g: ${wa.containerActive};
--oq6d071h: ${wa.containerLine};
--oq6d071i: ${wa.onContainer};
--oq6d071j: ${cr.main};
--oq6d071k: ${cr.mainHover};
--oq6d071l: ${cr.mainActive};
--oq6d071m: ${cr.mainLine};
--oq6d071n: ${cr.onMain};
--oq6d071o: ${cr.container};
--oq6d071p: ${cr.containerHover};
--oq6d071q: ${cr.containerActive};
--oq6d071r: ${cr.containerLine};
--oq6d071s: ${cr.onContainer};
--oq6d071t: ${ot.focusRing};
--oq6d071u: ${ot.shadow};
--oq6d071v: ${ot.overlay};
}`;
return theme.extraCss ? `${vars}\n${theme.extraCss}` : vars;
}
/**
* Plugin registry to track loaded plugins and their features
*/
export class PluginRegistry {
private plugins = new Map<string, { plugin: Plugin; context: PluginContext }>();
private commands = new Map<string, { pluginId: string; command: PluginCommand }>();
private beforeSendInterceptors: Array<{ pluginId: string; interceptor: MessageInterceptor }> = [];
private receiveInterceptors: Array<{ pluginId: string; interceptor: MessageInterceptor }> = [];
private renderers = new Map<string, { pluginId: string; renderer: CustomRenderer }>();
private settingsSchemas = new Map<string, SettingsSchema>();
private pluginSettings = new Map<string, Record<string, any>>();
private matrixHandlers = new Map<string, Array<{ pluginId: string; handler: Function }>>();
private timers = new Map<string, number[]>();
private themes = new Map<string, { pluginId: string; theme: PluginTheme; styleId: string }>();
private logs: PluginLogEntry[] = [];
private maxLogs = 1000;
registerPlugin(id: string, plugin: Plugin, context: PluginContext): void {
if (this.plugins.has(id)) {
console.warn(`[PluginRegistry] Plugin ${id} is already registered`);
return;
}
this.plugins.set(id, { plugin, context });
this.timers.set(id, []);
}
async unregisterPlugin(id: string): Promise<void> {
const entry = this.plugins.get(id);
if (!entry) return;
// Call onUnload
if (entry.plugin.onUnload) {
try {
await entry.plugin.onUnload();
} catch (err) {
console.error(`[PluginRegistry] Error unloading plugin ${id}:`, err);
}
}
// Clean up commands
for (const [cmdName, cmd] of this.commands.entries()) {
if (cmd.pluginId === id) {
this.commands.delete(cmdName);
}
}
// Clean up interceptors
this.beforeSendInterceptors = this.beforeSendInterceptors.filter(i => i.pluginId !== id);
this.receiveInterceptors = this.receiveInterceptors.filter(i => i.pluginId !== id);
// Clean up renderers
for (const [type, renderer] of this.renderers.entries()) {
if (renderer.pluginId === id) {
this.renderers.delete(type);
}
}
// Clean up themes
for (const [themeId, theme] of this.themes.entries()) {
if (theme.pluginId === id) {
this.themes.delete(themeId);
}
}
// Clean up timers
const timerIds = this.timers.get(id) || [];
timerIds.forEach(timerId => clearInterval(timerId));
this.timers.delete(id);
// Clean up Matrix handlers
for (const [eventType, handlers] of this.matrixHandlers.entries()) {
const filtered = handlers.filter(h => h.pluginId !== id);
if (filtered.length === 0) {
this.matrixHandlers.delete(eventType);
} else {
this.matrixHandlers.set(eventType, filtered);
}
}
this.plugins.delete(id);
}
// Command management
registerCommand(pluginId: string, command: PluginCommand): void {
if (this.commands.has(command.name)) {
console.warn(`[PluginRegistry] Command /${command.name} already exists`);
return;
}
this.commands.set(command.name, { pluginId, command });
}
unregisterCommand(name: string): void {
this.commands.delete(name);
}
async executeCommand(name: string, rawArgs: string): Promise<string | void> {
const cmd = this.commands.get(name);
if (!cmd) {
throw new Error(`Command /${name} not found`);
}
const args = this.parseCommandArgs(cmd.command, rawArgs);
const entry = this.plugins.get(cmd.pluginId);
if (!entry) {
throw new Error(`Plugin ${cmd.pluginId} not loaded`);
}
return await cmd.command.run(args, entry.context);
}
private parseCommandArgs(command: PluginCommand, rawArgs: string): Record<string, any> {
if (!command.args || command.args.length === 0) {
return {};
}
const parts = rawArgs.trim().split(/\s+/);
const result: Record<string, any> = {};
command.args.forEach((arg, index) => {
const argName = typeof arg === 'string' ? arg : arg.name;
const value = parts[index] || undefined;
result[argName] = value;
});
return result;
}
getCommands(): Array<{ name: string; pluginId: string; command: PluginCommand }> {
return Array.from(this.commands.entries()).map(([name, data]) => ({
name,
...data,
}));
}
// Message interceptors
addBeforeSendInterceptor(pluginId: string, interceptor: MessageInterceptor): void {
this.beforeSendInterceptors.push({ pluginId, interceptor });
}
addReceiveInterceptor(pluginId: string, interceptor: MessageInterceptor): void {
this.receiveInterceptors.push({ pluginId, interceptor });
}
async processBeforeSend(msg: MessageContext): Promise<MessageContext> {
let processedMsg = { ...msg };
for (const { interceptor } of this.beforeSendInterceptors) {
try {
await interceptor(processedMsg);
} catch (err) {
console.error('[PluginRegistry] Error in beforeSend interceptor:', err);
}
}
return processedMsg;
}
async processReceive(msg: MessageContext): Promise<MessageContext> {
let processedMsg = { ...msg };
for (const { interceptor } of this.receiveInterceptors) {
try {
await interceptor(processedMsg);
} catch (err) {
console.error('[PluginRegistry] Error in receive interceptor:', err);
}
}
return processedMsg;
}
// Renderers
registerRenderer(pluginId: string, type: string, renderer: CustomRenderer): void {
this.renderers.set(type, { pluginId, renderer });
}
unregisterRenderer(type: string): void {
this.renderers.delete(type);
}
getRenderer(type: string): CustomRenderer | undefined {
return this.renderers.get(type)?.renderer;
}
// Settings
defineSettings(pluginId: string, schema: SettingsSchema): void {
this.settingsSchemas.set(pluginId, schema);
// Load from localStorage
const stored = localStorage.getItem(`plugin:${pluginId}:settings`);
if (stored) {
try {
this.pluginSettings.set(pluginId, JSON.parse(stored));
} catch (err) {
console.error(`[PluginRegistry] Failed to load settings for ${pluginId}:`, err);
}
} else {
// Initialize with defaults
const defaults: Record<string, any> = {};
Object.keys(schema).forEach(key => {
const def = schema[key];
if (def.default !== undefined) {
defaults[key] = def.default;
}
});
this.pluginSettings.set(pluginId, defaults);
}
}
getPluginSetting<T = any>(pluginId: string, key: string): T | undefined {
return this.pluginSettings.get(pluginId)?.[key];
}
setPluginSetting(pluginId: string, key: string, value: any): void {
const settings = this.pluginSettings.get(pluginId) || {};
settings[key] = value;
this.pluginSettings.set(pluginId, settings);
// Save to localStorage
localStorage.setItem(`plugin:${pluginId}:settings`, JSON.stringify(settings));
}
getPluginSettingsSchema(pluginId: string): SettingsSchema | undefined {
return this.settingsSchemas.get(pluginId);
}
// Theme management
registerTheme(pluginId: string, theme: PluginTheme): void {
const fullThemeId = `plugin-${pluginId}-${theme.id}`;
if (this.themes.has(fullThemeId)) {
console.warn(`[PluginRegistry] Theme ${fullThemeId} already exists`);
return;
}
const styleId = `plugin-theme-${fullThemeId}`;
let styleEl = document.getElementById(styleId) as HTMLStyleElement;
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = styleId;
document.head.appendChild(styleEl);
}
styleEl.textContent = generateThemeCSS(fullThemeId, theme);
this.themes.set(fullThemeId, { pluginId, theme: { ...theme, id: fullThemeId }, styleId });
console.log(`[PluginRegistry] Theme registered: ${fullThemeId}`);
}
unregisterTheme(pluginId: string, themeId: string): void {
const fullThemeId = `plugin-${pluginId}-${themeId}`;
const themeData = this.themes.get(fullThemeId);
if (themeData) {
// Remove injected CSS
const styleEl = document.getElementById(themeData.styleId);
if (styleEl) {
styleEl.remove();
}
this.themes.delete(fullThemeId);
}
}
getPluginThemes(): Array<{ id: string; name: string; kind: 'light' | 'dark'; className: string }> {
return Array.from(this.themes.values()).map(({ theme }) => ({
id: theme.id,
name: theme.name,
kind: theme.kind,
className: theme.id // Use the full theme ID as the class name
}));
}
// Matrix event handlers
addMatrixHandler(pluginId: string, eventType: string, handler: Function): void {
if (!this.matrixHandlers.has(eventType)) {
this.matrixHandlers.set(eventType, []);
}
this.matrixHandlers.get(eventType)!.push({ pluginId, handler });
}
removeMatrixHandler(pluginId: string, eventType: string, handler: Function): void {
const handlers = this.matrixHandlers.get(eventType);
if (handlers) {
const filtered = handlers.filter(h => h.pluginId !== pluginId || h.handler !== handler);
this.matrixHandlers.set(eventType, filtered);
}
}
getMatrixHandlers(eventType: string): Array<{ pluginId: string; handler: Function }> {
return this.matrixHandlers.get(eventType) || [];
}
// Timers
addTimer(pluginId: string, timerId: number): void {
const timers = this.timers.get(pluginId) || [];
timers.push(timerId);
this.timers.set(pluginId, timers);
}
removeTimer(pluginId: string, timerId: number): void {
const timers = this.timers.get(pluginId) || [];
const filtered = timers.filter(id => id !== timerId);
this.timers.set(pluginId, filtered);
}
// Logging
addLog(pluginId: string, level: 'log' | 'warn' | 'error', args: any[]): void {
this.logs.push({
pluginId,
level,
timestamp: Date.now(),
args,
});
// Keep only last N logs
if (this.logs.length > this.maxLogs) {
this.logs = this.logs.slice(-this.maxLogs);
}
}
getLogs(pluginId?: string): PluginLogEntry[] {
if (pluginId) {
return this.logs.filter(log => log.pluginId === pluginId);
}
return this.logs;
}
clearLogs(pluginId?: string): void {
if (pluginId) {
this.logs = this.logs.filter(log => log.pluginId !== pluginId);
} else {
this.logs = [];
}
}
// Plugin exports (for require())
getPluginExports(pluginId: string): any {
const entry = this.plugins.get(pluginId);
return entry?.plugin.exports;
}
// Hot reload
async reloadPlugin(pluginId: string, newPlugin: Plugin, newContext: PluginContext): Promise<void> {
await this.unregisterPlugin(pluginId);
this.registerPlugin(pluginId, newPlugin, newContext);
await newPlugin.onLoad(newContext);
}
clear(): void {
// Unload all plugins
for (const pluginId of this.plugins.keys()) {
this.unregisterPlugin(pluginId).catch(err => {
console.error(`[PluginRegistry] Error clearing plugin ${pluginId}:`, err);
});
}
this.plugins.clear();
this.commands.clear();
this.beforeSendInterceptors = [];
this.receiveInterceptors = [];
this.renderers.clear();
this.settingsSchemas.clear();
this.matrixHandlers.clear();
this.timers.clear();
this.logs = [];
}
}
// Global plugin registry instance
export const pluginRegistry = new PluginRegistry();