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 { Box, config, MenuItem, Text } from 'folds';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
import { Command, useCommands } from '../../hooks/useCommands';
|
import { Command, useCommands } from '../../hooks/useCommands';
|
||||||
|
import { pluginRegistry } from '../settings/plugins/PluginAPI';
|
||||||
import {
|
import {
|
||||||
AutocompleteMenu,
|
AutocompleteMenu,
|
||||||
AutocompleteQuery,
|
AutocompleteQuery,
|
||||||
@@ -38,7 +39,13 @@ export function CommandAutocomplete({
|
|||||||
}: CommandAutocompleteProps) {
|
}: CommandAutocompleteProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const commands = useCommands(mx, room);
|
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(
|
const [result, search, resetSearch] = useAsyncSearch(
|
||||||
commandNames,
|
commandNames,
|
||||||
@@ -79,33 +86,40 @@ export function CommandAutocomplete({
|
|||||||
}
|
}
|
||||||
requestClose={requestClose}
|
requestClose={requestClose}
|
||||||
>
|
>
|
||||||
{autoCompleteNames.map((commandName) => (
|
{autoCompleteNames.map((commandName) => {
|
||||||
<MenuItem
|
// Get description from built-in commands or plugin commands
|
||||||
key={commandName}
|
const builtInCmd = commands[commandName as Command];
|
||||||
as="button"
|
const pluginCmd = pluginRegistry.getCommands().find(cmd => cmd.name === commandName);
|
||||||
radii="300"
|
const description = builtInCmd?.description || pluginCmd?.command.description || '';
|
||||||
style={{ height: 'unset' }}
|
|
||||||
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
|
return (
|
||||||
onTabPress(evt, () => handleAutocomplete(commandName))
|
<MenuItem
|
||||||
}
|
key={commandName}
|
||||||
onClick={() => handleAutocomplete(commandName)}
|
as="button"
|
||||||
>
|
radii="300"
|
||||||
<Box
|
style={{ height: 'unset' }}
|
||||||
style={{ padding: `${config.space.S300} 0` }}
|
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
|
||||||
grow="Yes"
|
onTabPress(evt, () => handleAutocomplete(commandName))
|
||||||
direction="Column"
|
}
|
||||||
gap="100"
|
onClick={() => handleAutocomplete(commandName)}
|
||||||
justifyContent="SpaceBetween"
|
|
||||||
>
|
>
|
||||||
<Text style={{ flexGrow: 1 }} size="B400" truncate>
|
<Box
|
||||||
{`/${commandName}`}
|
style={{ padding: `${config.space.S300} 0` }}
|
||||||
</Text>
|
grow="Yes"
|
||||||
<Text truncate priority="300" size="T200">
|
direction="Column"
|
||||||
{commands[commandName].description}
|
gap="100"
|
||||||
</Text>
|
justifyContent="SpaceBetween"
|
||||||
</Box>
|
>
|
||||||
</MenuItem>
|
<Text style={{ flexGrow: 1 }} size="B400" truncate>
|
||||||
))}
|
{`/${commandName}`}
|
||||||
|
</Text>
|
||||||
|
<Text truncate priority="300" size="T200">
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</AutocompleteMenu>
|
</AutocompleteMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ import {
|
|||||||
import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room';
|
import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room';
|
||||||
import { CommandAutocomplete } from './CommandAutocomplete';
|
import { CommandAutocomplete } from './CommandAutocomplete';
|
||||||
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
|
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
|
||||||
|
import { pluginRegistry } from '../settings/plugins/PluginAPI';
|
||||||
import { mobileOrTablet } from '../../utils/user-agent';
|
import { mobileOrTablet } from '../../utils/user-agent';
|
||||||
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
|
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
|
||||||
import { ReplyLayout, ThreadIndicator } from '../../components/message';
|
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));
|
contents.forEach((content) => mx.sendMessage(roomId, content as any));
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = useCallback(() => {
|
const submit = useCallback(async () => {
|
||||||
uploadBoardHandlers.current?.handleSend();
|
uploadBoardHandlers.current?.handleSend();
|
||||||
|
|
||||||
const commandName = getBeginCommand(editor);
|
const commandName = getBeginCommand(editor);
|
||||||
@@ -340,9 +341,30 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
plainText = `${UNFLIP} ${plainText}`;
|
plainText = `${UNFLIP} ${plainText}`;
|
||||||
customHtml = `${UNFLIP} ${customHtml}`;
|
customHtml = `${UNFLIP} ${customHtml}`;
|
||||||
} else if (commandName) {
|
} else if (commandName) {
|
||||||
|
// Check built-in commands first
|
||||||
const commandContent = commands[commandName as Command];
|
const commandContent = commands[commandName as Command];
|
||||||
if (commandContent) {
|
if (commandContent) {
|
||||||
commandContent.exe(plainText);
|
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);
|
resetEditor(editor);
|
||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
@@ -398,6 +420,27 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
'm.in_reply_to': { event_id: threadRootId },
|
'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);
|
mx.sendMessage(roomId, content as any);
|
||||||
resetEditor(editor);
|
resetEditor(editor);
|
||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { EmojisStickers } from './emojis-stickers';
|
|||||||
import { DeveloperTools } from './developer-tools';
|
import { DeveloperTools } from './developer-tools';
|
||||||
import { About } from './about';
|
import { About } from './about';
|
||||||
import { Audio } from './audio';
|
import { Audio } from './audio';
|
||||||
|
import { Plugins } from './plugins';
|
||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { LogoutDialog } from '../../components/LogoutDialog';
|
import { LogoutDialog } from '../../components/LogoutDialog';
|
||||||
@@ -42,6 +43,7 @@ export enum SettingsPages {
|
|||||||
AudioPage,
|
AudioPage,
|
||||||
DevicesPage,
|
DevicesPage,
|
||||||
EmojisStickersPage,
|
EmojisStickersPage,
|
||||||
|
PluginsPage,
|
||||||
DeveloperToolsPage,
|
DeveloperToolsPage,
|
||||||
AboutPage,
|
AboutPage,
|
||||||
}
|
}
|
||||||
@@ -85,6 +87,11 @@ const useSettingsMenuItems = (): SettingsMenuItem[] =>
|
|||||||
name: 'Emojis & Stickers',
|
name: 'Emojis & Stickers',
|
||||||
icon: Icons.Smile,
|
icon: Icons.Smile,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
page: SettingsPages.PluginsPage,
|
||||||
|
name: 'Plugins',
|
||||||
|
icon: Icons.Download,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
page: SettingsPages.DeveloperToolsPage,
|
page: SettingsPages.DeveloperToolsPage,
|
||||||
name: 'Developer Tools',
|
name: 'Developer Tools',
|
||||||
@@ -235,6 +242,9 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
|||||||
{activePage === SettingsPages.EmojisStickersPage && (
|
{activePage === SettingsPages.EmojisStickersPage && (
|
||||||
<EmojisStickers requestClose={handlePageRequestClose} />
|
<EmojisStickers requestClose={handlePageRequestClose} />
|
||||||
)}
|
)}
|
||||||
|
{activePage === SettingsPages.PluginsPage && (
|
||||||
|
<Plugins requestClose={handlePageRequestClose} />
|
||||||
|
)}
|
||||||
{activePage === SettingsPages.DeveloperToolsPage && (
|
{activePage === SettingsPages.DeveloperToolsPage && (
|
||||||
<DeveloperTools requestClose={handlePageRequestClose} />
|
<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 { butterTheme, catppuccinMochaTheme, darkTheme, discordTheme, discordDarkerTheme, mochaTheme, silverTheme, twilightTheme } from '../../colors.css';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
|
import { pluginRegistry } from '../features/settings/plugins/PluginAPI';
|
||||||
|
|
||||||
export enum ThemeKind {
|
export enum ThemeKind {
|
||||||
Light = 'light',
|
Light = 'light',
|
||||||
@@ -64,14 +65,51 @@ export const CatppuccinMochaTheme: Theme = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useThemes = (): Theme[] => {
|
export const useThemes = (): Theme[] => {
|
||||||
const themes: Theme[] = useMemo(() => [LightTheme, SilverTheme, DarkTheme, ButterTheme, DiscordTheme, DiscordDarkerTheme, TwilightTheme, MochaTheme, CatppuccinMochaTheme], []);
|
const [pluginThemesUpdate, setPluginThemesUpdate] = useState(0);
|
||||||
|
|
||||||
|
// Force update when plugins change (this is a simple approach)
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setPluginThemesUpdate(prev => prev + 1);
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const builtInThemes: Theme[] = useMemo(() => [
|
||||||
|
LightTheme, SilverTheme, DarkTheme, ButterTheme,
|
||||||
|
DiscordTheme, DiscordDarkerTheme, TwilightTheme,
|
||||||
|
MochaTheme, CatppuccinMochaTheme
|
||||||
|
], []);
|
||||||
|
|
||||||
|
// Convert plugin themes to Theme format
|
||||||
|
const pluginThemes: Theme[] = useMemo(() => {
|
||||||
|
const themes = pluginRegistry.getPluginThemes();
|
||||||
|
return themes.map(pluginTheme => ({
|
||||||
|
id: pluginTheme.id,
|
||||||
|
kind: pluginTheme.kind === 'dark' ? ThemeKind.Dark : ThemeKind.Light,
|
||||||
|
classNames: [
|
||||||
|
pluginTheme.className,
|
||||||
|
pluginTheme.kind === 'dark' ? onDarkFontWeight : onLightFontWeight,
|
||||||
|
pluginTheme.kind === 'dark' ? 'prism-dark' : 'prism-light'
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
}, [pluginThemesUpdate]);
|
||||||
|
|
||||||
return themes;
|
return [...builtInThemes, ...pluginThemes];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useThemeNames = (): Record<string, string> =>
|
export const useThemeNames = (): Record<string, string> => {
|
||||||
useMemo(
|
const [pluginThemesUpdate, setPluginThemesUpdate] = useState(0);
|
||||||
() => ({
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setPluginThemesUpdate(prev => prev + 1);
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const builtInNames = {
|
||||||
[LightTheme.id]: 'Light',
|
[LightTheme.id]: 'Light',
|
||||||
[SilverTheme.id]: 'Silver',
|
[SilverTheme.id]: 'Silver',
|
||||||
[DarkTheme.id]: 'Dark',
|
[DarkTheme.id]: 'Dark',
|
||||||
@@ -81,9 +119,17 @@ export const useThemeNames = (): Record<string, string> =>
|
|||||||
[TwilightTheme.id]: 'Twilight',
|
[TwilightTheme.id]: 'Twilight',
|
||||||
[MochaTheme.id]: 'Mocha',
|
[MochaTheme.id]: 'Mocha',
|
||||||
[CatppuccinMochaTheme.id]: 'Catppuccin 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 => {
|
export const useSystemThemeKind = (): ThemeKind => {
|
||||||
const darkModeQueryList = useMemo(() => window.matchMedia('(prefers-color-scheme: dark)'), []);
|
const darkModeQueryList = useMemo(() => window.matchMedia('(prefers-color-scheme: dark)'), []);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
|
|||||||
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
|
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
|
||||||
import { MediaConfigProvider } from '../../hooks/useMediaConfig';
|
import { MediaConfigProvider } from '../../hooks/useMediaConfig';
|
||||||
import { MatrixClientProvider } from '../../hooks/useMatrixClient';
|
import { MatrixClientProvider } from '../../hooks/useMatrixClient';
|
||||||
|
import { PluginLoader } from '../../features/settings/plugins';
|
||||||
import { SpecVersions } from './SpecVersions';
|
import { SpecVersions } from './SpecVersions';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { useSyncState } from '../../hooks/useSyncState';
|
import { useSyncState } from '../../hooks/useSyncState';
|
||||||
@@ -284,19 +285,21 @@ export function ClientRoot({ children }: ClientRootProps) {
|
|||||||
<ClientRootLoading />
|
<ClientRootLoading />
|
||||||
) : (
|
) : (
|
||||||
<MatrixClientProvider value={mx}>
|
<MatrixClientProvider value={mx}>
|
||||||
<CallProviderWrapper>
|
<PluginLoader matrixClient={mx}>
|
||||||
<ServerConfigsLoader>
|
<CallProviderWrapper>
|
||||||
{(serverConfigs) => (
|
<ServerConfigsLoader>
|
||||||
<CapabilitiesProvider value={serverConfigs.capabilities ?? {}}>
|
{(serverConfigs) => (
|
||||||
<MediaConfigProvider value={serverConfigs.mediaConfig ?? {}}>
|
<CapabilitiesProvider value={serverConfigs.capabilities ?? {}}>
|
||||||
<AuthMetadataProvider value={serverConfigs.authMetadata}>
|
<MediaConfigProvider value={serverConfigs.mediaConfig ?? {}}>
|
||||||
{children}
|
<AuthMetadataProvider value={serverConfigs.authMetadata}>
|
||||||
</AuthMetadataProvider>
|
{children}
|
||||||
</MediaConfigProvider>
|
</AuthMetadataProvider>
|
||||||
</CapabilitiesProvider>
|
</MediaConfigProvider>
|
||||||
)}
|
</CapabilitiesProvider>
|
||||||
</ServerConfigsLoader>
|
)}
|
||||||
</CallProviderWrapper>
|
</ServerConfigsLoader>
|
||||||
|
</CallProviderWrapper>
|
||||||
|
</PluginLoader>
|
||||||
</MatrixClientProvider>
|
</MatrixClientProvider>
|
||||||
)}
|
)}
|
||||||
</SpecVersions>
|
</SpecVersions>
|
||||||
|
|||||||
@@ -721,3 +721,4 @@ export const catppuccinMochaTheme = createTheme(color, {
|
|||||||
Overlay: 'rgba(0, 0, 0, 0.8)',
|
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;
|
const content: string;
|
||||||
export default content;
|
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