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:
@@ -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) => (
|
||||
<MenuItem
|
||||
key={commandName}
|
||||
as="button"
|
||||
radii="300"
|
||||
style={{ height: 'unset' }}
|
||||
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
|
||||
onTabPress(evt, () => handleAutocomplete(commandName))
|
||||
}
|
||||
onClick={() => handleAutocomplete(commandName)}
|
||||
>
|
||||
<Box
|
||||
style={{ padding: `${config.space.S300} 0` }}
|
||||
grow="Yes"
|
||||
direction="Column"
|
||||
gap="100"
|
||||
justifyContent="SpaceBetween"
|
||||
{autoCompleteNames.map((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 (
|
||||
<MenuItem
|
||||
key={commandName}
|
||||
as="button"
|
||||
radii="300"
|
||||
style={{ height: 'unset' }}
|
||||
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
|
||||
onTabPress(evt, () => handleAutocomplete(commandName))
|
||||
}
|
||||
onClick={() => handleAutocomplete(commandName)}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} size="B400" truncate>
|
||||
{`/${commandName}`}
|
||||
</Text>
|
||||
<Text truncate priority="300" size="T200">
|
||||
{commands[commandName].description}
|
||||
</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
<Box
|
||||
style={{ padding: `${config.space.S300} 0` }}
|
||||
grow="Yes"
|
||||
direction="Column"
|
||||
gap="100"
|
||||
justifyContent="SpaceBetween"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} size="B400" truncate>
|
||||
{`/${commandName}`}
|
||||
</Text>
|
||||
<Text truncate priority="300" size="T200">
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</AutocompleteMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement, RoomInputProps>(
|
||||
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<HTMLDivElement, RoomInputProps>(
|
||||
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<HTMLDivElement, RoomInputProps>(
|
||||
'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);
|
||||
|
||||
@@ -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 && (
|
||||
<EmojisStickers requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === SettingsPages.PluginsPage && (
|
||||
<Plugins requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === SettingsPages.DeveloperToolsPage && (
|
||||
<DeveloperTools requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
|
||||
612
src/app/features/settings/plugins/PLUGIN_DEVELOPMENT.md
Normal file
612
src/app/features/settings/plugins/PLUGIN_DEVELOPMENT.md
Normal file
@@ -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<string, any>;
|
||||
}
|
||||
```
|
||||
|
||||
### 🎨 UI API
|
||||
|
||||
Register custom renderers:
|
||||
|
||||
```javascript
|
||||
ctx.ui.registerRenderer("message", (msg, defaultRenderer) => {
|
||||
if (msg.type === "image") {
|
||||
return `<img src="${msg.url}" style="border-radius:12px"/>`;
|
||||
}
|
||||
// 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)
|
||||
698
src/app/features/settings/plugins/PluginAPI.ts
Normal file
698
src/app/features/settings/plugins/PluginAPI.ts
Normal 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();
|
||||
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');
|
||||
}
|
||||
}
|
||||
795
src/app/features/settings/plugins/Plugins.tsx
Normal file
795
src/app/features/settings/plugins/Plugins.tsx
Normal file
@@ -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 (
|
||||
<Box padding="300">
|
||||
<Text size="T300" priority="300">No settings available for this plugin.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const handleChange = (key: string, value: any) => {
|
||||
pluginRegistry.setPluginSetting(pluginId, key, value);
|
||||
forceUpdate({});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="300" style={{ padding: config.space.S400 }}>
|
||||
{Object.entries(schema).map(([key, def]) => (
|
||||
<SettingRenderer
|
||||
key={key}
|
||||
settingKey={key}
|
||||
definition={def}
|
||||
value={pluginRegistry.getPluginSetting(pluginId, key)}
|
||||
onChange={(value) => handleChange(key, value)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<RectCords>();
|
||||
const [selectMenuCords, setSelectMenuCords] = useState<RectCords>();
|
||||
|
||||
const label = definition.label || settingKey;
|
||||
const description = definition.description;
|
||||
|
||||
// Boolean - Switch
|
||||
if (definition.type === 'boolean') {
|
||||
return (
|
||||
<SettingTile
|
||||
title={label}
|
||||
description={description}
|
||||
after={<Switch value={value ?? definition.default ?? false} onChange={onChange} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// String - Input
|
||||
if (definition.type === 'string') {
|
||||
return (
|
||||
<SettingTile title={label} description={description}>
|
||||
<Input
|
||||
size="400"
|
||||
variant="Background"
|
||||
value={value ?? definition.default ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={definition.default}
|
||||
/>
|
||||
</SettingTile>
|
||||
);
|
||||
}
|
||||
|
||||
// Number - Input
|
||||
if (definition.type === 'number') {
|
||||
return (
|
||||
<SettingTile title={label} description={description}>
|
||||
<Input
|
||||
size="400"
|
||||
variant="Background"
|
||||
type="number"
|
||||
value={value ?? definition.default ?? 0}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
</SettingTile>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<SettingTile title={label} description={description}>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Primary"
|
||||
outlined
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
after={<Icon size="300" src={Icons.ChevronBottom} />}
|
||||
onClick={(evt) => setSelectMenuCords(evt.currentTarget.getBoundingClientRect())}
|
||||
>
|
||||
<Text size="T300">{selectedOption?.label || currentValue}</Text>
|
||||
</Button>
|
||||
<PopOut
|
||||
anchor={selectMenuCords}
|
||||
offset={5}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setSelectMenuCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu>
|
||||
{definition.options.map((option) => (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
size="300"
|
||||
after={currentValue === option.value ? <Icon size="100" src={Icons.Check} /> : undefined}
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
setSelectMenuCords(undefined);
|
||||
}}
|
||||
>
|
||||
<Text size="T300">{option.label}</Text>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</SettingTile>
|
||||
);
|
||||
}
|
||||
|
||||
// Color - Color Picker
|
||||
if (definition.type === 'color') {
|
||||
const currentColor = value ?? definition.default ?? '#000000';
|
||||
|
||||
return (
|
||||
<SettingTile title={label} description={description}>
|
||||
<Button
|
||||
size="400"
|
||||
variant="Primary"
|
||||
outlined
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
before={
|
||||
<Box
|
||||
style={{
|
||||
width: toRem(20),
|
||||
height: toRem(20),
|
||||
borderRadius: config.radii.R300,
|
||||
backgroundColor: currentColor,
|
||||
border: `1px solid ${color.Surface.ContainerLine}`,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onClick={(evt) => setColorMenuCords(evt.currentTarget.getBoundingClientRect())}
|
||||
>
|
||||
<Text size="T300">{currentColor}</Text>
|
||||
</Button>
|
||||
<PopOut
|
||||
anchor={colorMenuCords}
|
||||
offset={5}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setColorMenuCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Box style={{ padding: config.space.S300 }}>
|
||||
<HexColorPicker color={currentColor} onChange={onChange} />
|
||||
</Box>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</SettingTile>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
type PluginsProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
|
||||
export function Plugins({ requestClose }: PluginsProps) {
|
||||
const [activeTab, setActiveTab] = useState<PluginTab>(PluginTab.Marketplace);
|
||||
const [marketplacePlugins, setMarketplacePlugins] = useState<PluginMetadata[]>([]);
|
||||
const [installedPlugins, setInstalledPlugins] = useState<InstalledPlugin[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [expandedSettings, setExpandedSettings] = useState<string | null>(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 (
|
||||
<Avatar size="500" radii="400">
|
||||
{!imageError ? (
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt={name}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
borderRadius: 'inherit',
|
||||
}}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
justifyContent="Center"
|
||||
alignItems="Center"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: `linear-gradient(135deg, ${color.Secondary.Container} 0%, ${color.Primary.Container} 100%)`,
|
||||
borderRadius: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Text size="H3" priority="300" style={{ fontWeight: 600 }}>
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Avatar>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Header size="H3" className="truncate">
|
||||
Plugins
|
||||
</Header>
|
||||
</Box>
|
||||
<Box shrink="No" gap="200">
|
||||
<Button variant="Background" size="300" onClick={requestClose}>
|
||||
<Text size="B300">Close</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</PageHeader>
|
||||
<Box shrink="No" style={{ padding: `0 ${config.space.S400}` }} direction="Column" gap="300">
|
||||
<Box gap="200">
|
||||
<Badge
|
||||
style={{ cursor: 'pointer' }}
|
||||
as="button"
|
||||
variant="Secondary"
|
||||
fill={activeTab === PluginTab.Marketplace ? 'Solid' : 'None'}
|
||||
size="500"
|
||||
onClick={() => setActiveTab(PluginTab.Marketplace)}
|
||||
>
|
||||
<Text as="span" size="L400">
|
||||
Marketplace
|
||||
</Text>
|
||||
</Badge>
|
||||
<Badge
|
||||
style={{ cursor: 'pointer' }}
|
||||
as="button"
|
||||
variant="Secondary"
|
||||
fill={activeTab === PluginTab.Installed ? 'Solid' : 'None'}
|
||||
size="500"
|
||||
onClick={() => setActiveTab(PluginTab.Installed)}
|
||||
>
|
||||
<Text as="span" size="L400">
|
||||
Installed
|
||||
</Text>
|
||||
</Badge>
|
||||
</Box>
|
||||
<Input
|
||||
variant="Background"
|
||||
size="400"
|
||||
placeholder="Search plugins..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
before={<Icon size="100" src={Icons.Search} />}
|
||||
after={
|
||||
searchQuery && (
|
||||
<IconButton
|
||||
size="300"
|
||||
variant="SurfaceLow"
|
||||
radii="300"
|
||||
onClick={() => setSearchQuery('')}
|
||||
>
|
||||
<Icon size="50" src={Icons.Cross} filled />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Scroll variant="Background" visibility="Hover" hideTrack size="300">
|
||||
<PageContent>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
{activeTab === PluginTab.Marketplace && (
|
||||
<Box direction="Column" gap="400">
|
||||
{loading && (
|
||||
<Box justifyContent="Center" alignItems="Center" style={{ padding: toRem(32) }}>
|
||||
<Spinner variant="Secondary" size="400" />
|
||||
</Box>
|
||||
)}
|
||||
{error && (
|
||||
<Box direction="Column" gap="200" style={{ padding: toRem(16) }}>
|
||||
<Text size="T400" priority="300">
|
||||
Error: {error}
|
||||
</Text>
|
||||
<Button onClick={fetchMarketplacePlugins} size="300" variant="Primary">
|
||||
<Text size="B300">Retry</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
{!loading && !error && filteredMarketplacePlugins.length === 0 && (
|
||||
<Box justifyContent="Center" style={{ padding: toRem(32) }}>
|
||||
<Text size="T400" priority="300">
|
||||
{searchQuery ? 'No plugins found matching your search' : 'No plugins available'}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{!loading &&
|
||||
!error &&
|
||||
filteredMarketplacePlugins.map((plugin) => (
|
||||
<Box
|
||||
key={plugin.id}
|
||||
style={{
|
||||
padding: config.space.S500,
|
||||
background: color.Surface.Container,
|
||||
borderRadius: config.radii.R400,
|
||||
border: `1px solid ${color.Surface.ContainerLine}`,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
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';
|
||||
}}
|
||||
>
|
||||
<Box gap="500" alignItems="Start">
|
||||
<Box shrink="No">
|
||||
<PluginAvatar thumbnail={plugin.thumbnail} name={plugin.name} />
|
||||
</Box>
|
||||
<Box grow="Yes" direction="Column" gap="200">
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="H5">{plugin.name}</Text>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200" priority="400">
|
||||
by {plugin.author}
|
||||
</Text>
|
||||
<Text size="T200" priority="400">•</Text>
|
||||
<Text size="T200" priority="400">
|
||||
v{plugin.version}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Text size="T300" priority="300">
|
||||
{plugin.description}
|
||||
</Text>
|
||||
{plugin.tags.length > 0 && (
|
||||
<Box gap="100" wrap="Wrap">
|
||||
{plugin.tags.map((tag) => (
|
||||
<Badge key={tag} size="300" variant="Secondary" fill="Soft">
|
||||
<Text size="L400">{tag}</Text>
|
||||
</Badge>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<Button
|
||||
variant={isPluginInstalled(plugin.id) ? 'Success' : 'Primary'}
|
||||
size="400"
|
||||
onClick={() => handleInstall(plugin)}
|
||||
disabled={isPluginInstalled(plugin.id)}
|
||||
>
|
||||
<Text size="B400">
|
||||
{isPluginInstalled(plugin.id) ? 'Installed' : 'Install'}
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{activeTab === PluginTab.Installed && (
|
||||
<Box direction="Column" gap="400">
|
||||
{filteredInstalledPlugins.length === 0 && (
|
||||
<Box justifyContent="Center" style={{ padding: toRem(32) }}>
|
||||
<Text size="T400" priority="300">
|
||||
{searchQuery ? 'No installed plugins found matching your search' : 'No plugins installed'}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{filteredInstalledPlugins.map((plugin) => {
|
||||
const hasSettings = pluginRegistry.getPluginSettingsSchema(plugin.id) &&
|
||||
Object.keys(pluginRegistry.getPluginSettingsSchema(plugin.id)!).length > 0;
|
||||
const settingsExpanded = expandedSettings === plugin.id;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={plugin.id}
|
||||
direction="Column"
|
||||
style={{
|
||||
background: color.Surface.Container,
|
||||
borderRadius: config.radii.R400,
|
||||
border: `1px solid ${color.Surface.ContainerLine}`,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
padding: config.space.S500,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
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';
|
||||
}}
|
||||
>
|
||||
<Box gap="500" alignItems="Start">
|
||||
<Box shrink="No">
|
||||
<PluginAvatar thumbnail={plugin.thumbnail} name={plugin.name} />
|
||||
</Box>
|
||||
<Box grow="Yes" direction="Column" gap="200">
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="H5">{plugin.name}</Text>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200" priority="400">
|
||||
by {plugin.author}
|
||||
</Text>
|
||||
<Text size="T200" priority="400">•</Text>
|
||||
<Text size="T200" priority="400">
|
||||
v{plugin.version}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Text size="T300" priority="300">
|
||||
{plugin.description}
|
||||
</Text>
|
||||
<Box gap="100" wrap="Wrap">
|
||||
<Badge
|
||||
size="300"
|
||||
variant={plugin.enabled ? 'Success' : 'Secondary'}
|
||||
fill="Soft"
|
||||
>
|
||||
<Text size="L400">{plugin.enabled ? 'Enabled' : 'Disabled'}</Text>
|
||||
</Badge>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box shrink="No" direction="Column" gap="200">
|
||||
{hasSettings && (
|
||||
<Button
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="400"
|
||||
before={<Icon size="200" src={Icons.Setting} />}
|
||||
after={<Icon size="200" src={settingsExpanded ? Icons.ChevronTop : Icons.ChevronBottom} />}
|
||||
onClick={() => setExpandedSettings(settingsExpanded ? null : plugin.id)}
|
||||
>
|
||||
<Text size="B400">Settings</Text>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="Secondary"
|
||||
size="400"
|
||||
onClick={() => handleToggleEnabled(plugin.id)}
|
||||
>
|
||||
<Text size="B400">{plugin.enabled ? 'Disable' : 'Enable'}</Text>
|
||||
</Button>
|
||||
<Button
|
||||
variant="Critical"
|
||||
fill="None"
|
||||
size="400"
|
||||
onClick={() => handleUninstall(plugin.id)}
|
||||
>
|
||||
<Text size="B400">Uninstall</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
{hasSettings && settingsExpanded && (
|
||||
<Box
|
||||
direction="Column"
|
||||
style={{
|
||||
borderTop: `1px solid ${color.Surface.ContainerLine}`,
|
||||
background: color.Surface.ContainerLowest,
|
||||
}}
|
||||
>
|
||||
<PluginSettingsRenderer pluginId={plugin.id} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</SequenceCard>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
4
src/app/features/settings/plugins/index.ts
Normal file
4
src/app/features/settings/plugins/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './Plugins';
|
||||
export * from './types';
|
||||
export * from './PluginLoader';
|
||||
export * from './PluginAPI';
|
||||
38
src/app/features/settings/plugins/types.ts
Normal file
38
src/app/features/settings/plugins/types.ts
Normal file
@@ -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',
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
return themes;
|
||||
// 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 [...builtInThemes, ...pluginThemes];
|
||||
};
|
||||
|
||||
export const useThemeNames = (): Record<string, string> =>
|
||||
useMemo(
|
||||
() => ({
|
||||
export const useThemeNames = (): Record<string, string> => {
|
||||
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<string, string> =>
|
||||
[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<string, string>);
|
||||
|
||||
return { ...builtInNames, ...pluginNames };
|
||||
}, [pluginThemesUpdate]);
|
||||
};
|
||||
|
||||
export const useSystemThemeKind = (): ThemeKind => {
|
||||
const darkModeQueryList = useMemo(() => window.matchMedia('(prefers-color-scheme: dark)'), []);
|
||||
|
||||
@@ -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) {
|
||||
<ClientRootLoading />
|
||||
) : (
|
||||
<MatrixClientProvider value={mx}>
|
||||
<CallProviderWrapper>
|
||||
<ServerConfigsLoader>
|
||||
{(serverConfigs) => (
|
||||
<CapabilitiesProvider value={serverConfigs.capabilities ?? {}}>
|
||||
<MediaConfigProvider value={serverConfigs.mediaConfig ?? {}}>
|
||||
<AuthMetadataProvider value={serverConfigs.authMetadata}>
|
||||
{children}
|
||||
</AuthMetadataProvider>
|
||||
</MediaConfigProvider>
|
||||
</CapabilitiesProvider>
|
||||
)}
|
||||
</ServerConfigsLoader>
|
||||
</CallProviderWrapper>
|
||||
<PluginLoader matrixClient={mx}>
|
||||
<CallProviderWrapper>
|
||||
<ServerConfigsLoader>
|
||||
{(serverConfigs) => (
|
||||
<CapabilitiesProvider value={serverConfigs.capabilities ?? {}}>
|
||||
<MediaConfigProvider value={serverConfigs.mediaConfig ?? {}}>
|
||||
<AuthMetadataProvider value={serverConfigs.authMetadata}>
|
||||
{children}
|
||||
</AuthMetadataProvider>
|
||||
</MediaConfigProvider>
|
||||
</CapabilitiesProvider>
|
||||
)}
|
||||
</ServerConfigsLoader>
|
||||
</CallProviderWrapper>
|
||||
</PluginLoader>
|
||||
</MatrixClientProvider>
|
||||
)}
|
||||
</SpecVersions>
|
||||
|
||||
@@ -721,3 +721,4 @@ export const catppuccinMochaTheme = createTheme(color, {
|
||||
Overlay: 'rgba(0, 0, 0, 0.8)',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
30
src/ext.d.ts
vendored
30
src/ext.d.ts
vendored
@@ -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 {};
|
||||
|
||||
Reference in New Issue
Block a user