diff --git a/src/app/features/room/CommandAutocomplete.tsx b/src/app/features/room/CommandAutocomplete.tsx index 6b7ba56..dbf04db 100644 --- a/src/app/features/room/CommandAutocomplete.tsx +++ b/src/app/features/room/CommandAutocomplete.tsx @@ -3,6 +3,7 @@ import { Editor } from 'slate'; import { Box, config, MenuItem, Text } from 'folds'; import { Room } from 'matrix-js-sdk'; import { Command, useCommands } from '../../hooks/useCommands'; +import { pluginRegistry } from '../settings/plugins/PluginAPI'; import { AutocompleteMenu, AutocompleteQuery, @@ -38,7 +39,13 @@ export function CommandAutocomplete({ }: CommandAutocompleteProps) { const mx = useMatrixClient(); const commands = useCommands(mx, room); - const commandNames = useMemo(() => Object.keys(commands) as Command[], [commands]); + + // Merge built-in commands with plugin commands + const commandNames = useMemo(() => { + const builtInCommands = Object.keys(commands) as Command[]; + const pluginCommands = pluginRegistry.getCommands().map(cmd => cmd.name); + return [...builtInCommands, ...pluginCommands]; + }, [commands]); const [result, search, resetSearch] = useAsyncSearch( commandNames, @@ -79,33 +86,40 @@ export function CommandAutocomplete({ } requestClose={requestClose} > - {autoCompleteNames.map((commandName) => ( - ) => - onTabPress(evt, () => handleAutocomplete(commandName)) - } - onClick={() => handleAutocomplete(commandName)} - > - { + // Get description from built-in commands or plugin commands + const builtInCmd = commands[commandName as Command]; + const pluginCmd = pluginRegistry.getCommands().find(cmd => cmd.name === commandName); + const description = builtInCmd?.description || pluginCmd?.command.description || ''; + + return ( + ) => + onTabPress(evt, () => handleAutocomplete(commandName)) + } + onClick={() => handleAutocomplete(commandName)} > - - {`/${commandName}`} - - - {commands[commandName].description} - - - - ))} + + + {`/${commandName}`} + + + {description} + + + + ); + })} ); } diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index e270a54..397d3f5 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -102,6 +102,7 @@ import { import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room'; import { CommandAutocomplete } from './CommandAutocomplete'; import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands'; +import { pluginRegistry } from '../settings/plugins/PluginAPI'; import { mobileOrTablet } from '../../utils/user-agent'; import { useElementSizeObserver } from '../../hooks/useElementSizeObserver'; import { ReplyLayout, ThreadIndicator } from '../../components/message'; @@ -308,7 +309,7 @@ export const RoomInput = forwardRef( contents.forEach((content) => mx.sendMessage(roomId, content as any)); }; - const submit = useCallback(() => { + const submit = useCallback(async () => { uploadBoardHandlers.current?.handleSend(); const commandName = getBeginCommand(editor); @@ -340,9 +341,30 @@ export const RoomInput = forwardRef( plainText = `${UNFLIP} ${plainText}`; customHtml = `${UNFLIP} ${customHtml}`; } else if (commandName) { + // Check built-in commands first const commandContent = commands[commandName as Command]; if (commandContent) { commandContent.exe(plainText); + resetEditor(editor); + resetEditorHistory(editor); + sendTypingStatus(false); + return; + } + + // Check plugin commands + try { + const result = await pluginRegistry.executeCommand(commandName, plainText); + if (result) { + // If command returns text, send it as a message + const content: IContent = { + msgtype: MsgType.Text, + body: result, + }; + mx.sendMessage(roomId, content as any); + } + } catch (err) { + console.error(`[RoomInput] Plugin command /${commandName} failed:`, err); + // Show error to user (could integrate with notification system) } resetEditor(editor); resetEditorHistory(editor); @@ -398,6 +420,27 @@ export const RoomInput = forwardRef( 'm.in_reply_to': { event_id: threadRootId }, }; } + + // Process message through plugin interceptors + try { + const messageContext = { + content: content.body, + roomId, + eventType: 'm.room.message', + formatted: content.formatted_body, + metadata: {}, + }; + const processedContext = await pluginRegistry.processBeforeSend(messageContext); + + // Update content with intercepted values + content.body = processedContext.content; + if (processedContext.formatted) { + content.formatted_body = processedContext.formatted; + } + } catch (err) { + console.error('[RoomInput] Plugin interceptor error:', err); + } + mx.sendMessage(roomId, content as any); resetEditor(editor); resetEditorHistory(editor); diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx index 72068de..2f1f58c 100644 --- a/src/app/features/settings/Settings.tsx +++ b/src/app/features/settings/Settings.tsx @@ -31,6 +31,7 @@ import { EmojisStickers } from './emojis-stickers'; import { DeveloperTools } from './developer-tools'; import { About } from './about'; import { Audio } from './audio'; +import { Plugins } from './plugins'; import { UseStateProvider } from '../../components/UseStateProvider'; import { stopPropagation } from '../../utils/keyboard'; import { LogoutDialog } from '../../components/LogoutDialog'; @@ -42,6 +43,7 @@ export enum SettingsPages { AudioPage, DevicesPage, EmojisStickersPage, + PluginsPage, DeveloperToolsPage, AboutPage, } @@ -85,6 +87,11 @@ const useSettingsMenuItems = (): SettingsMenuItem[] => name: 'Emojis & Stickers', icon: Icons.Smile, }, + { + page: SettingsPages.PluginsPage, + name: 'Plugins', + icon: Icons.Download, + }, { page: SettingsPages.DeveloperToolsPage, name: 'Developer Tools', @@ -235,6 +242,9 @@ export function Settings({ initialPage, requestClose }: SettingsProps) { {activePage === SettingsPages.EmojisStickersPage && ( )} + {activePage === SettingsPages.PluginsPage && ( + + )} {activePage === SettingsPages.DeveloperToolsPage && ( )} diff --git a/src/app/features/settings/plugins/PLUGIN_DEVELOPMENT.md b/src/app/features/settings/plugins/PLUGIN_DEVELOPMENT.md new file mode 100644 index 0000000..18ff2bf --- /dev/null +++ b/src/app/features/settings/plugins/PLUGIN_DEVELOPMENT.md @@ -0,0 +1,612 @@ +# Plugin Development Guide + +## Plugin Structure + +Each plugin must be a directory containing at least: +- `index.js` - Main entry point +- `plugin-metadata.json` - Plugin metadata + +### Example Directory Structure +``` +my-plugin/ +├── index.js +├── plugin-metadata.json +└── assets/ + └── icon.png +``` + +## Plugin Metadata + +The `plugin-metadata.json` file contains information about your plugin: + +```json +{ + "name": "My Cool Plugin", + "version": "1.0.0", + "description": "Does cool things", + "author": "Your Name", + "homepage": "https://github.com/yourusername/my-plugin", + "thumbnail": "https://example.com/thumbnail.png", + "tags": ["utility", "ui"] +} +``` + +## Plugin Entry Point (index.js) + +Your `index.js` must export a default plugin object with an `onLoad` function: + +```javascript +// index.js +module.exports = { + name: "My Plugin", + version: "1.0.0", + dependencies: { + "paarrot.utils": "^1.0.0" // Optional: depend on other plugins + }, + + /** + * Called when the plugin is loaded + * @param {PluginContext} ctx - The plugin context + */ + onLoad: async (ctx) => { + ctx.log('Plugin loaded!'); + + // Your plugin code here + }, + + /** + * Called when the plugin is unloaded (optional) + */ + onUnload: async () => { + ctx.log('Plugin unloaded!'); + // Cleanup resources here + }, + + /** + * Exports for other plugins to use (optional) + */ + exports: { + myFunction: () => { /* ... */ } + } +}; +``` + +## Plugin Context API + +The `ctx` object provided to your plugin contains: + +### 🎯 Commands API + +Register slash commands with argument parsing: + +```javascript +// Simple command +ctx.commands.register({ + name: "shrug", + run: () => "¯\\_(ツ)_/¯" +}); + +// Command with args +ctx.commands.register({ + name: "echo", + args: ["text"], + run: ({ text }) => text +}); + +// Advanced command +ctx.commands.register({ + name: "greet", + description: "Greet someone", + args: ["name", "greeting"], + run: ({ name, greeting }) => { + return `${greeting || 'Hello'}, ${name || 'stranger'}!`; + } +}); + +// Execute commands programmatically +ctx.commands.execute("shrug", {}); + +// Unregister a command +ctx.commands.unregister("mycommand"); +``` + +### 💬 Messages API + +Intercept and modify messages: + +```javascript +// Before send interceptor +ctx.messages.onBeforeSend((msg) => { + if (msg.content === "brb") { + msg.content = "be right back!"; + } + + // Add prefix + msg.content = `[Bot] ${msg.content}`; + + // Access metadata + msg.metadata = { processed: true }; +}); + +// Receive interceptor +ctx.messages.onReceive((msg) => { + if (msg.content.includes("@bot")) { + ctx.log('Bot was mentioned!'); + } +}); +``` + +MessageContext interface: +```typescript +interface MessageContext { + content: string; + roomId: string; + eventType: string; + formatted?: string; + metadata?: Record; +} +``` + +### 🎨 UI API + +Register custom renderers: + +```javascript +ctx.ui.registerRenderer("message", (msg, defaultRenderer) => { + if (msg.type === "image") { + return ``; + } + // Fall back to default + return defaultRenderer?.(); +}); + +// Unregister renderer +ctx.ui.unregisterRenderer("message"); +``` + +### ⚙️ Settings API + +Define plugin settings with UI: + +```javascript +ctx.settings.define({ + theme: { + type: "select", + label: "Theme", + description: "Choose your theme", + options: [ + { value: "dark", label: "Dark" }, + { value: "light", label: "Light" } + ], + default: "dark" + }, + spamFilter: { + type: "boolean", + label: "Spam Filter", + description: "Enable spam filtering", + default: true + }, + prefix: { + type: "string", + label: "Command Prefix", + default: "!" + }, + maxMessages: { + type: "number", + label: "Max Messages", + default: 100 + }, + accentColor: { + type: "color", + label: "Accent Color", + default: "#6366f1" + } +}); + +// Get/set settings +const theme = ctx.settings.get('theme'); +ctx.settings.set('theme', 'light'); +``` + +### 🪝 Matrix Events API + +Listen to raw Matrix events: + +```javascript +ctx.matrix.on("m.room.message", (event) => { + const content = event.getContent(); + ctx.log('Message received:', content.body); +}); + +ctx.matrix.on("Room.timeline", (event) => { + if (event.getType() === 'm.room.member') { + ctx.log('Member event:', event.getSender()); + } +}); + +// Remove listener +ctx.matrix.off("m.room.message", handler); +``` + +Common events: +- `"m.room.message"` - Room messages +- `"m.room.member"` - Membership changes +- `"Room.timeline"` - Timeline events +- `"sync"` - Sync state changes +- `"RoomState.events"` - State events + +### ⏱️ Timers API + +Run background tasks: + +```javascript +// Set interval +const intervalId = ctx.timers.setInterval(() => { + ctx.log('Running every 10 seconds'); + // Auto-cleanup bots, sync data, etc. +}, 10000); + +// Set timeout +const timeoutId = ctx.timers.setTimeout(() => { + ctx.log('Running once after 5 seconds'); +}, 5000); + +// Clear timers +ctx.timers.clearInterval(intervalId); +ctx.timers.clearTimeout(timeoutId); + +// Timers are auto-cleaned on plugin unload +``` + +### 📣 Notifications API + +Show notifications to users: + +```javascript +// Simple notification +ctx.notify("Plugin loaded!"); + +// Advanced notification +ctx.notify({ + title: "New Message", + body: "You have a new message from Steve", + type: "info", // info, success, error, warning + duration: 5000, + actions: [ + { + label: "View", + action: () => { + // Handle action + } + } + ] +}); +``` + +### 📝 Logging API + +Per-plugin logging: + +```javascript +ctx.log('Info message', { data: 123 }); +ctx.warn('Warning message'); +ctx.error('Error message', error); + +// Logs are visible in plugin logs panel +// Format: [Plugin pluginId] message +``` + +### 🔗 Require API + +Use other plugins: + +```javascript +// Require another plugin's exports +const utils = ctx.require("paarrot.utils"); +utils.formatDate(new Date()); + +// Check if plugin exists +try { + const plugin = ctx.require("optional.plugin"); +} catch (err) { + ctx.warn('Optional plugin not available'); +} +``` + +### 🔌 Matrix Client + +Direct access to Matrix.js SDK: + +```javascript +const mx = ctx.matrixClient; + +// Get user info +const userId = mx.getUserId(); +const user = mx.getUser(userId); + +// Get rooms +const rooms = mx.getRooms(); +const room = mx.getRoom(roomId); + +// Send messages +await mx.sendTextMessage(roomId, "Hello!"); + +// Full Matrix.js SDK available +``` + +### ⚛️ React + +Access to React: + +```javascript +const { useState, useEffect } = ctx.React; + +// Create custom UI components +const MyComponent = () => { + const [count, setCount] = useState(0); + return ctx.React.createElement('button', { + onClick: () => setCount(count + 1) + }, `Count: ${count}`); +}; +``` + +## Complete Example + +See [`example-plugin/index.js`](../../../../example-plugin/index.js) for a full-featured example. + +```javascript +module.exports = { + name: "Awesome Plugin", + version: "1.0.0", + + onLoad: async (ctx) => { + // Register commands + ctx.commands.register({ + name: "shrug", + run: () => "¯\\_(ツ)_/¯" + }); + + // Message interceptors + ctx.messages.onBeforeSend((msg) => { + if (msg.content === "brb") { + msg.content = "be right back!"; + } + }); + + // Settings + ctx.settings.define({ + enabled: { type: "boolean", default: true } + }); + + // Matrix events + ctx.matrix.on("m.room.message", (event) => { + ctx.log('Message:', event.getContent().body); + }); + + // Background task + ctx.timers.setInterval(() => { + ctx.log('Checking for updates...'); + }, 60000); + + ctx.notify("Plugin loaded!"); + }, + + onUnload: async () => { + // Cleanup happens automatically + }, + + exports: { + version: "1.0.0" + } +}; +``` + +## Best Practices + +### 1. Error Handling +Always wrap your code in try-catch blocks: + +```javascript +onLoad: async (ctx) => { + try { + // Your code + } catch (error) { + ctx.error('Failed to load:', error); + } +} +``` + +### 2. Cleanup Resources +Timers and Matrix handlers are auto-cleaned, but clean up other resources: + +```javascript +onUnload: async () => { + // Close connections, save state, etc. +} +``` + +### 3. Performance +Don't block the main thread: + +```javascript +// Bad +for (let i = 0; i < 1000000; i++) { /* ... */ } + +// Good +ctx.timers.setTimeout(() => { + // Heavy work in background +}, 0); +``` + +### 4. Settings Best Practices +Provide defaults and descriptions: + +```javascript +ctx.settings.define({ + maxRetries: { + type: "number", + label: "Max Retries", + description: "Number of retry attempts", + default: 3 + } +}); +``` + +### 5. Logging +Use ctx.log() instead of console.log(): + +```javascript +ctx.log('Info'); // Shows in plugin logs panel +ctx.warn('Warning'); // Highlighted as warning +ctx.error('Error'); // Highlighted as error +``` + +## Advanced Patterns + +### Plugin Dependencies + +Require and use other plugins: + +```javascript +onLoad: async (ctx) => { + try { + const utils = ctx.require("paarrot.utils"); + const formatted = utils.formatDate(new Date()); + ctx.log('Using utils:', formatted); + } catch (err) { + ctx.warn('Utils plugin not available, using fallback'); + } +} +``` + +### Conditional Features + +Enable features based on settings: + +```javascript +const autoReply = ctx.settings.get('autoReply'); +if (autoReply) { + ctx.matrix.on("m.room.message", (event) => { + // Auto-reply logic + }); +} +``` + +### State Management + +Store plugin state in settings: + +```javascript +// Save state +ctx.settings.set('lastRun', Date.now()); +ctx.settings.set('messageCount', count); + +// Load state +const lastRun = ctx.settings.get('lastRun'); +``` + +### Command Aliases + +Create multiple commands for the same function: + +```javascript +const shrugFn = () => "¯\\_(ツ)_/¯"; + +ctx.commands.register({ name: "shrug", run: shrugFn }); +ctx.commands.register({ name: "dunno", run: shrugFn }); +``` + +### Hot Reload Support + +Make your plugin hot-reload friendly: + +```javascript +let messageHandler = null; + +onLoad: async (ctx) => { + messageHandler = (event) => { /* ... */ }; + ctx.matrix.on("m.room.message", messageHandler); +}, + +onUnload: async () => { + if (messageHandler) { + // Cleanup happens automatically but explicit is good + } +} +``` + +## Security Considerations + +1. **Never store passwords or tokens in plaintext** +2. **Validate all user input** +3. **Don't execute arbitrary code from messages** +4. **Use HTTPS for external requests** +5. **Respect user privacy - don't log sensitive data** + +## Packaging + +To package your plugin: + +1. Create a ZIP file containing your plugin directory +2. The ZIP should contain the plugin folder at the root: + ``` + my-plugin.zip + └── my-plugin/ + ├── index.js + ├── plugin-metadata.json + └── assets/ + ``` +3. Upload to a URL accessible by users +4. Add your plugin to the Plugin Directory repository + +## Plugin Directory + +To list your plugin in the marketplace: + +1. Fork the [Paarrot Plugin Directory](https://github.com/Paarrot/Plugin-Directory) +2. Add your plugin metadata to `plugins/index.json` +3. Create a plugin JSON file in `plugins/your-plugin.json` +4. Submit a pull request + +## Debugging + +### Browser Console +Check the browser console (F12) for plugin logs: +- `[PluginLoader]` - Plugin loading messages +- `[Plugin yourplugin]` - Your plugin's logs + +### Plugin Logs Panel +View per-plugin logs in Settings → Plugins → Logs (coming soon) + +### Common Issues + +**Plugin won't load:** +- Check `index.js` exists +- Verify `module.exports` is correct +- Look for JavaScript syntax errors + +**Command not working:** +- Ensure command is registered in onLoad +- Check command name doesn't conflict +- Verify run function returns a value + +**Settings not saving:** +- Use ctx.settings.set() not localStorage +- Check setting key matches defined schema + +## Testing + +Test your plugin locally: + +1. Copy plugin folder to `%APPDATA%/paarrot/plugins/` (Windows) or `~/.config/Paarrot/plugins/` (Linux) +2. Restart Paarrot +3. Enable plugin in Settings → Plugins +4. Check console for errors +5. Test all features + +## Resources + +- [Matrix.js SDK Docs](https://matrix-org.github.io/matrix-js-sdk/) +- [Paarrot Plugin Examples](../../../../example-plugin/) +- [Plugin Directory](https://github.com/Paarrot/Plugin-Directory) +- [Report Issues](https://github.com/Paarrot/cinny-desktop/issues) diff --git a/src/app/features/settings/plugins/PluginAPI.ts b/src/app/features/settings/plugins/PluginAPI.ts new file mode 100644 index 0000000..1d8e2bc --- /dev/null +++ b/src/app/features/settings/plugins/PluginAPI.ts @@ -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, ctx: PluginContext) => string | void | Promise; +} + +/** + * Message interceptor callback + */ +export type MessageInterceptor = (msg: MessageContext) => void | Promise; + +/** + * Message context for interceptors + */ +export interface MessageContext { + content: string; + roomId: string; + eventType: string; + formatted?: string; + metadata?: Record; +} + +/** + * 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; + +/** + * 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) => Promise; + }; + + /** 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: (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; + + /** Called when the plugin is loaded */ + onLoad: (context: PluginContext) => void | Promise; + + /** Called when the plugin is unloaded/disabled */ + onUnload?: () => void | Promise; + + /** 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(); + private commands = new Map(); + private beforeSendInterceptors: Array<{ pluginId: string; interceptor: MessageInterceptor }> = []; + private receiveInterceptors: Array<{ pluginId: string; interceptor: MessageInterceptor }> = []; + private renderers = new Map(); + private settingsSchemas = new Map(); + private pluginSettings = new Map>(); + private matrixHandlers = new Map>(); + private timers = new Map(); + private themes = new Map(); + 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 { + 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 { + 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 { + if (!command.args || command.args.length === 0) { + return {}; + } + + const parts = rawArgs.trim().split(/\s+/); + const result: Record = {}; + + 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 { + 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 { + 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 = {}; + Object.keys(schema).forEach(key => { + const def = schema[key]; + if (def.default !== undefined) { + defaults[key] = def.default; + } + }); + this.pluginSettings.set(pluginId, defaults); + } + } + + getPluginSetting(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 { + 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(); diff --git a/src/app/features/settings/plugins/PluginLoader.tsx b/src/app/features/settings/plugins/PluginLoader.tsx new file mode 100644 index 0000000..b0ebb61 --- /dev/null +++ b/src/app/features/settings/plugins/PluginLoader.tsx @@ -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([]); + + 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: (key: string): T | undefined => { + return pluginRegistry.getPluginSetting(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 { + // 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'); + } +} diff --git a/src/app/features/settings/plugins/Plugins.tsx b/src/app/features/settings/plugins/Plugins.tsx new file mode 100644 index 0000000..76f2524 --- /dev/null +++ b/src/app/features/settings/plugins/Plugins.tsx @@ -0,0 +1,795 @@ +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { + Avatar, + Badge, + Box, + Button, + color, + config, + Header, + Icon, + IconButton, + Icons, + Input, + Menu, + MenuItem, + PopOut, + RectCords, + Scroll, + Spinner, + Switch, + Text, + toRem, +} from 'folds'; +import { HexColorPicker } from 'react-colorful'; +import FocusTrap from 'focus-trap-react'; +import { Page, PageContent, PageHeader } from '../../../components/page'; +import { PluginTab, PluginMetadata, PluginIndex, InstalledPlugin } from './types'; +import { SequenceCard } from '../../../components/sequence-card'; +import { SettingTile } from '../../../components/setting-tile'; +import { SequenceCardStyle } from '../styles.css'; +import { pluginRegistry, SettingsSchema, SettingDefinition } from './PluginAPI'; +import { stopPropagation } from '../../../utils/keyboard'; + +const PLUGIN_INDEX_URL = + 'https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/index.json'; +const PLUGIN_BASE_URL = + 'https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/'; + +// Check if running in Electron +const isElectron = typeof window !== 'undefined' && (window as any).electron?.plugins; + +/** + * Renders settings UI for a plugin based on its settings schema + */ +function PluginSettingsRenderer({ pluginId }: { pluginId: string }) { + const schema = pluginRegistry.getPluginSettingsSchema(pluginId); + const [, forceUpdate] = useState({}); + + if (!schema || Object.keys(schema).length === 0) { + return ( + + No settings available for this plugin. + + ); + } + + const handleChange = (key: string, value: any) => { + pluginRegistry.setPluginSetting(pluginId, key, value); + forceUpdate({}); + }; + + return ( + + {Object.entries(schema).map(([key, def]) => ( + handleChange(key, value)} + /> + ))} + + ); +} + +/** + * Renders a single setting based on its type + */ +function SettingRenderer({ + settingKey, + definition, + value, + onChange, +}: { + settingKey: string; + definition: SettingDefinition; + value: any; + onChange: (value: any) => void; +}) { + const [colorMenuCords, setColorMenuCords] = useState(); + const [selectMenuCords, setSelectMenuCords] = useState(); + + const label = definition.label || settingKey; + const description = definition.description; + + // Boolean - Switch + if (definition.type === 'boolean') { + return ( + } + /> + ); + } + + // String - Input + if (definition.type === 'string') { + return ( + + onChange(e.target.value)} + placeholder={definition.default} + /> + + ); + } + + // Number - Input + if (definition.type === 'number') { + return ( + + onChange(parseFloat(e.target.value) || 0)} + /> + + ); + } + + // Select - Dropdown + if (definition.type === 'select' && definition.options) { + const currentValue = value ?? definition.default ?? definition.options[0]?.value; + const selectedOption = definition.options.find(opt => opt.value === currentValue); + + return ( + + + setSelectMenuCords(undefined), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + + {definition.options.map((option) => ( + : undefined} + radii="300" + onClick={() => { + onChange(option.value); + setSelectMenuCords(undefined); + }} + > + {option.label} + + ))} + + + } + /> + + ); + } + + // Color - Color Picker + if (definition.type === 'color') { + const currentColor = value ?? definition.default ?? '#000000'; + + return ( + + + setColorMenuCords(undefined), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + + + + + } + /> + + ); + } + + return null; +} + +type PluginsProps = { + requestClose: () => void; +}; + +export function Plugins({ requestClose }: PluginsProps) { + const [activeTab, setActiveTab] = useState(PluginTab.Marketplace); + const [marketplacePlugins, setMarketplacePlugins] = useState([]); + const [installedPlugins, setInstalledPlugins] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [expandedSettings, setExpandedSettings] = useState(null); + + const fetchMarketplacePlugins = useCallback(async () => { + setLoading(true); + setError(null); + try { + const indexResponse = await fetch(PLUGIN_INDEX_URL); + if (!indexResponse.ok) { + throw new Error('Failed to fetch plugin index'); + } + const index: PluginIndex = await indexResponse.json(); + + const pluginPromises = index.plugins.map(async (pluginId) => { + const pluginResponse = await fetch(`${PLUGIN_BASE_URL}${pluginId}.json`); + if (!pluginResponse.ok) { + console.error(`Failed to fetch plugin ${pluginId}`); + return null; + } + return pluginResponse.json(); + }); + + const plugins = await Promise.all(pluginPromises); + setMarketplacePlugins(plugins.filter((p): p is PluginMetadata => p !== null)); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch plugins'); + console.error('Error fetching marketplace plugins:', err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (activeTab === PluginTab.Marketplace) { + fetchMarketplacePlugins(); + } + }, [activeTab, fetchMarketplacePlugins]); + + useEffect(() => { + const loadInstalledPlugins = async () => { + if (isElectron) { + // Load from filesystem via Electron + try { + const result = await (window as any).electron.plugins.list(); + if (result.success && result.data) { + // Merge filesystem data with metadata from marketplace + const fsPlugins = result.data.map((fsPlugin: any) => ({ + ...fsPlugin, + enabled: true, // Default to enabled + })); + setInstalledPlugins(fsPlugins); + } + } catch (err) { + console.error('Failed to list plugins from filesystem:', err); + } + } else { + // Fallback to localStorage for web + const stored = localStorage.getItem('installedPlugins'); + if (stored) { + try { + setInstalledPlugins(JSON.parse(stored)); + } catch (err) { + console.error('Failed to parse installed plugins:', err); + } + } + } + }; + + loadInstalledPlugins(); + }, []); + + const handleInstall = useCallback( + async (plugin: PluginMetadata) => { + if (isElectron) { + // Download and install to filesystem via Electron + try { + setLoading(true); + const result = await (window as any).electron.plugins.download( + plugin.id, + plugin.downloadUrl, + plugin.name + ); + + if (result.success) { + const newPlugin: InstalledPlugin = { + ...plugin, + installedDate: new Date().toISOString(), + enabled: true, + }; + setInstalledPlugins([...installedPlugins, newPlugin]); + } else { + setError(result.error || 'Failed to install plugin'); + } + } catch (err) { + console.error('Failed to install plugin:', err); + setError(err instanceof Error ? err.message : 'Failed to install plugin'); + } finally { + setLoading(false); + } + } else { + // Fallback to localStorage for web + const newPlugin: InstalledPlugin = { + ...plugin, + installedDate: new Date().toISOString(), + enabled: true, + }; + const updated = [...installedPlugins, newPlugin]; + setInstalledPlugins(updated); + localStorage.setItem('installedPlugins', JSON.stringify(updated)); + } + }, + [installedPlugins] + ); + + const handleUninstall = useCallback( + async (pluginId: string) => { + // First, unregister the plugin to trigger onUnload + try { + await pluginRegistry.unregisterPlugin(pluginId); + } catch (err) { + console.error(`Failed to unregister plugin ${pluginId}:`, err); + } + + if (isElectron) { + // Uninstall from filesystem via Electron + try { + const result = await (window as any).electron.plugins.uninstall(pluginId); + if (result.success) { + const updated = installedPlugins.filter((p) => p.id !== pluginId); + setInstalledPlugins(updated); + } else { + setError(result.error || 'Failed to uninstall plugin'); + } + } catch (err) { + console.error('Failed to uninstall plugin:', err); + setError(err instanceof Error ? err.message : 'Failed to uninstall plugin'); + } + } else { + // Fallback to localStorage for web + const updated = installedPlugins.filter((p) => p.id !== pluginId); + setInstalledPlugins(updated); + localStorage.setItem('installedPlugins', JSON.stringify(updated)); + } + }, + [installedPlugins] + ); + + const handleToggleEnabled = useCallback( + async (pluginId: string) => { + const plugin = installedPlugins.find((p) => p.id === pluginId); + if (!plugin) return; + + const newEnabledState = !plugin.enabled; + + if (plugin.enabled) { + // Disabling: unregister the plugin to trigger onUnload + try { + await pluginRegistry.unregisterPlugin(pluginId); + console.log(`[Plugins] Plugin ${pluginId} disabled and unloaded`); + } catch (err) { + console.error(`Failed to unregister plugin ${pluginId}:`, err); + } + } + + // Update installedPlugins list + const updated = installedPlugins.map((p) => + p.id === pluginId ? { ...p, enabled: newEnabledState } : p + ); + setInstalledPlugins(updated); + localStorage.setItem('installedPlugins', JSON.stringify(updated)); + + // Also update enabledPlugins for PluginLoader + const enabledPluginsStr = localStorage.getItem('enabledPlugins'); + const enabledPlugins = enabledPluginsStr ? JSON.parse(enabledPluginsStr) : {}; + enabledPlugins[pluginId] = newEnabledState; + localStorage.setItem('enabledPlugins', JSON.stringify(enabledPlugins)); + + // Note: Enabling a plugin requires app restart to load it + // PluginLoader only loads plugins on mount + if (newEnabledState) { + console.log(`[Plugins] Plugin ${pluginId} enabled. Restart app to load it.`); + } + }, + [installedPlugins] + ); + + const isPluginInstalled = useCallback( + (pluginId: string) => installedPlugins.some((p) => p.id === pluginId), + [installedPlugins] + ); + + const fuzzySearch = (query: string, text: string): boolean => { + if (!query) return true; + const searchLower = query.toLowerCase(); + const textLower = text.toLowerCase(); + return textLower.includes(searchLower); + }; + + const PluginAvatar = ({ thumbnail, name }: { thumbnail: string; name: string }) => { + const [imageError, setImageError] = useState(false); + + return ( + + {!imageError ? ( + {name} setImageError(true)} + /> + ) : ( + + + {name.charAt(0).toUpperCase()} + + + )} + + ); + }; + + const filteredMarketplacePlugins = useMemo(() => { + if (!searchQuery) return marketplacePlugins; + return marketplacePlugins.filter((plugin) => + fuzzySearch(searchQuery, plugin.name) || + fuzzySearch(searchQuery, plugin.description) || + fuzzySearch(searchQuery, plugin.author) || + plugin.tags.some((tag) => fuzzySearch(searchQuery, tag)) + ); + }, [marketplacePlugins, searchQuery]); + + const filteredInstalledPlugins = useMemo(() => { + if (!searchQuery) return installedPlugins; + return installedPlugins.filter((plugin) => + fuzzySearch(searchQuery, plugin.name) || + fuzzySearch(searchQuery, plugin.description) || + fuzzySearch(searchQuery, plugin.author) + ); + }, [installedPlugins, searchQuery]); + + return ( + + + +
+ Plugins +
+
+ + + +
+ + + setActiveTab(PluginTab.Marketplace)} + > + + Marketplace + + + setActiveTab(PluginTab.Installed)} + > + + Installed + + + + setSearchQuery(e.target.value)} + before={} + after={ + searchQuery && ( + setSearchQuery('')} + > + + + ) + } + /> + + + + + + {activeTab === PluginTab.Marketplace && ( + + {loading && ( + + + + )} + {error && ( + + + Error: {error} + + + + )} + {!loading && !error && filteredMarketplacePlugins.length === 0 && ( + + + {searchQuery ? 'No plugins found matching your search' : 'No plugins available'} + + + )} + {!loading && + !error && + filteredMarketplacePlugins.map((plugin) => ( + { + e.currentTarget.style.transform = 'translateY(-2px)'; + e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'translateY(0)'; + e.currentTarget.style.boxShadow = 'none'; + }} + > + + + + + + + {plugin.name} + + + by {plugin.author} + + + + v{plugin.version} + + + + + {plugin.description} + + {plugin.tags.length > 0 && ( + + {plugin.tags.map((tag) => ( + + {tag} + + ))} + + )} + + + + + + + ))} + + )} + {activeTab === PluginTab.Installed && ( + + {filteredInstalledPlugins.length === 0 && ( + + + {searchQuery ? 'No installed plugins found matching your search' : 'No plugins installed'} + + + )} + {filteredInstalledPlugins.map((plugin) => { + const hasSettings = pluginRegistry.getPluginSettingsSchema(plugin.id) && + Object.keys(pluginRegistry.getPluginSettingsSchema(plugin.id)!).length > 0; + const settingsExpanded = expandedSettings === plugin.id; + + return ( + + { + e.currentTarget.parentElement!.style.transform = 'translateY(-2px)'; + e.currentTarget.parentElement!.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)'; + }} + onMouseLeave={(e) => { + e.currentTarget.parentElement!.style.transform = 'translateY(0)'; + e.currentTarget.parentElement!.style.boxShadow = 'none'; + }} + > + + + + + + + {plugin.name} + + + by {plugin.author} + + + + v{plugin.version} + + + + + {plugin.description} + + + + {plugin.enabled ? 'Enabled' : 'Disabled'} + + + + + {hasSettings && ( + + )} + + + + + + {hasSettings && settingsExpanded && ( + + + + )} + + ); + })} + + )} + + + + +
+ ); +} diff --git a/src/app/features/settings/plugins/index.ts b/src/app/features/settings/plugins/index.ts new file mode 100644 index 0000000..d871849 --- /dev/null +++ b/src/app/features/settings/plugins/index.ts @@ -0,0 +1,4 @@ +export * from './Plugins'; +export * from './types'; +export * from './PluginLoader'; +export * from './PluginAPI'; diff --git a/src/app/features/settings/plugins/types.ts b/src/app/features/settings/plugins/types.ts new file mode 100644 index 0000000..37279a0 --- /dev/null +++ b/src/app/features/settings/plugins/types.ts @@ -0,0 +1,38 @@ +/** + * Plugin metadata from the marketplace + */ +export interface PluginMetadata { + id: string; + name: string; + version: string; + description: string; + author: string; + repository: string; + thumbnail: string; + downloadUrl: string; + homepage: string; + tags: string[]; + addedDate: string; +} + +/** + * Plugin directory index response + */ +export interface PluginIndex { + version: string; + updatedAt: string; + plugins: string[]; +} + +/** + * Installed plugin information + */ +export interface InstalledPlugin extends PluginMetadata { + installedDate: string; + enabled: boolean; +} + +export enum PluginTab { + Marketplace = 'marketplace', + Installed = 'installed', +} diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts index 82265f3..36c1cea 100644 --- a/src/app/hooks/useTheme.ts +++ b/src/app/hooks/useTheme.ts @@ -4,6 +4,7 @@ import { onDarkFontWeight, onLightFontWeight } from '../../config.css'; import { butterTheme, catppuccinMochaTheme, darkTheme, discordTheme, discordDarkerTheme, mochaTheme, silverTheme, twilightTheme } from '../../colors.css'; import { settingsAtom } from '../state/settings'; import { useSetting } from '../state/hooks/settings'; +import { pluginRegistry } from '../features/settings/plugins/PluginAPI'; export enum ThemeKind { Light = 'light', @@ -64,14 +65,51 @@ export const CatppuccinMochaTheme: Theme = { }; export const useThemes = (): Theme[] => { - const themes: Theme[] = useMemo(() => [LightTheme, SilverTheme, DarkTheme, ButterTheme, DiscordTheme, DiscordDarkerTheme, TwilightTheme, MochaTheme, CatppuccinMochaTheme], []); + const [pluginThemesUpdate, setPluginThemesUpdate] = useState(0); + + // Force update when plugins change (this is a simple approach) + useEffect(() => { + const interval = setInterval(() => { + setPluginThemesUpdate(prev => prev + 1); + }, 1000); + return () => clearInterval(interval); + }, []); + + const builtInThemes: Theme[] = useMemo(() => [ + LightTheme, SilverTheme, DarkTheme, ButterTheme, + DiscordTheme, DiscordDarkerTheme, TwilightTheme, + MochaTheme, CatppuccinMochaTheme + ], []); + + // Convert plugin themes to Theme format + const pluginThemes: Theme[] = useMemo(() => { + const themes = pluginRegistry.getPluginThemes(); + return themes.map(pluginTheme => ({ + id: pluginTheme.id, + kind: pluginTheme.kind === 'dark' ? ThemeKind.Dark : ThemeKind.Light, + classNames: [ + pluginTheme.className, + pluginTheme.kind === 'dark' ? onDarkFontWeight : onLightFontWeight, + pluginTheme.kind === 'dark' ? 'prism-dark' : 'prism-light' + ] + })); + }, [pluginThemesUpdate]); - return themes; + return [...builtInThemes, ...pluginThemes]; }; -export const useThemeNames = (): Record => - useMemo( - () => ({ +export const useThemeNames = (): Record => { + const [pluginThemesUpdate, setPluginThemesUpdate] = useState(0); + + useEffect(() => { + const interval = setInterval(() => { + setPluginThemesUpdate(prev => prev + 1); + }, 1000); + return () => clearInterval(interval); + }, []); + + return useMemo(() => { + const builtInNames = { [LightTheme.id]: 'Light', [SilverTheme.id]: 'Silver', [DarkTheme.id]: 'Dark', @@ -81,9 +119,17 @@ export const useThemeNames = (): Record => [TwilightTheme.id]: 'Twilight', [MochaTheme.id]: 'Mocha', [CatppuccinMochaTheme.id]: 'Catppuccin Mocha', - }), - [] - ); + }; + + const pluginThemes = pluginRegistry.getPluginThemes(); + const pluginNames = pluginThemes.reduce((acc, theme) => { + acc[theme.id] = theme.name; + return acc; + }, {} as Record); + + return { ...builtInNames, ...pluginNames }; + }, [pluginThemesUpdate]); +}; export const useSystemThemeKind = (): ThemeKind => { const darkModeQueryList = useMemo(() => window.matchMedia('(prefers-color-scheme: dark)'), []); diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 3a3ff23..4534809 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -28,6 +28,7 @@ import { ServerConfigsLoader } from '../../components/ServerConfigsLoader'; import { CapabilitiesProvider } from '../../hooks/useCapabilities'; import { MediaConfigProvider } from '../../hooks/useMediaConfig'; import { MatrixClientProvider } from '../../hooks/useMatrixClient'; +import { PluginLoader } from '../../features/settings/plugins'; import { SpecVersions } from './SpecVersions'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { useSyncState } from '../../hooks/useSyncState'; @@ -284,19 +285,21 @@ export function ClientRoot({ children }: ClientRootProps) { ) : ( - - - {(serverConfigs) => ( - - - - {children} - - - - )} - - + + + + {(serverConfigs) => ( + + + + {children} + + + + )} + + + )} diff --git a/src/colors.css.ts b/src/colors.css.ts index baca59d..a8abd1d 100644 --- a/src/colors.css.ts +++ b/src/colors.css.ts @@ -721,3 +721,4 @@ export const catppuccinMochaTheme = createTheme(color, { Overlay: 'rgba(0, 0, 0, 0.8)', }, }); + diff --git a/src/ext.d.ts b/src/ext.d.ts index 72acc58..e89820f 100644 --- a/src/ext.d.ts +++ b/src/ext.d.ts @@ -33,3 +33,33 @@ declare module '*.svg' { const content: string; export default content; } + +interface PluginAPI { + getPath: () => Promise<{ success: boolean; data?: string; error?: string }>; + download: ( + pluginId: string, + downloadUrl: string, + name: string + ) => Promise<{ success: boolean; data?: { path: string }; error?: string }>; + list: () => Promise<{ + success: boolean; + data?: Array<{ id: string; name: string; installedDate: string; path: string }>; + error?: string; + }>; + uninstall: (pluginId: string) => Promise<{ success: boolean; error?: string }>; + readPluginCode: (pluginId: string) => Promise<{ success: boolean; data?: string; error?: string }>; +} + +interface ElectronAPI { + platform?: string; + arch?: string; + plugins?: PluginAPI; +} + +declare global { + interface Window { + electron?: ElectronAPI; + } +} + +export {};