feat(plugins): implement plugin loading and management system

- Added PluginLoader component to dynamically load and initialize plugins.
- Created Plugins component for managing installed and marketplace plugins.
- Introduced PluginAPI for plugin interaction and settings management.
- Defined types for plugin metadata, installed plugins, and plugin index.
- Implemented settings rendering for plugins based on their schema.
- Integrated marketplace plugin fetching and installation logic.
- Added support for enabling/disabling and uninstalling plugins.
This commit is contained in:
2026-04-18 02:55:44 +10:00
parent 9eb5e4fa32
commit d1d3033c15
13 changed files with 2674 additions and 49 deletions

View File

@@ -3,6 +3,7 @@ import { Editor } from 'slate';
import { Box, config, MenuItem, Text } from 'folds';
import { Room } from 'matrix-js-sdk';
import { Command, useCommands } from '../../hooks/useCommands';
import { pluginRegistry } from '../settings/plugins/PluginAPI';
import {
AutocompleteMenu,
AutocompleteQuery,
@@ -38,7 +39,13 @@ export function CommandAutocomplete({
}: CommandAutocompleteProps) {
const mx = useMatrixClient();
const commands = useCommands(mx, room);
const commandNames = useMemo(() => Object.keys(commands) as Command[], [commands]);
// Merge built-in commands with plugin commands
const commandNames = useMemo(() => {
const builtInCommands = Object.keys(commands) as Command[];
const pluginCommands = pluginRegistry.getCommands().map(cmd => cmd.name);
return [...builtInCommands, ...pluginCommands];
}, [commands]);
const [result, search, resetSearch] = useAsyncSearch(
commandNames,
@@ -79,33 +86,40 @@ export function CommandAutocomplete({
}
requestClose={requestClose}
>
{autoCompleteNames.map((commandName) => (
<MenuItem
key={commandName}
as="button"
radii="300"
style={{ height: 'unset' }}
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
onTabPress(evt, () => handleAutocomplete(commandName))
}
onClick={() => handleAutocomplete(commandName)}
>
<Box
style={{ padding: `${config.space.S300} 0` }}
grow="Yes"
direction="Column"
gap="100"
justifyContent="SpaceBetween"
{autoCompleteNames.map((commandName) => {
// Get description from built-in commands or plugin commands
const builtInCmd = commands[commandName as Command];
const pluginCmd = pluginRegistry.getCommands().find(cmd => cmd.name === commandName);
const description = builtInCmd?.description || pluginCmd?.command.description || '';
return (
<MenuItem
key={commandName}
as="button"
radii="300"
style={{ height: 'unset' }}
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
onTabPress(evt, () => handleAutocomplete(commandName))
}
onClick={() => handleAutocomplete(commandName)}
>
<Text style={{ flexGrow: 1 }} size="B400" truncate>
{`/${commandName}`}
</Text>
<Text truncate priority="300" size="T200">
{commands[commandName].description}
</Text>
</Box>
</MenuItem>
))}
<Box
style={{ padding: `${config.space.S300} 0` }}
grow="Yes"
direction="Column"
gap="100"
justifyContent="SpaceBetween"
>
<Text style={{ flexGrow: 1 }} size="B400" truncate>
{`/${commandName}`}
</Text>
<Text truncate priority="300" size="T200">
{description}
</Text>
</Box>
</MenuItem>
);
})}
</AutocompleteMenu>
);
}

View File

@@ -102,6 +102,7 @@ import {
import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room';
import { CommandAutocomplete } from './CommandAutocomplete';
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
import { pluginRegistry } from '../settings/plugins/PluginAPI';
import { mobileOrTablet } from '../../utils/user-agent';
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
import { ReplyLayout, ThreadIndicator } from '../../components/message';
@@ -308,7 +309,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
contents.forEach((content) => mx.sendMessage(roomId, content as any));
};
const submit = useCallback(() => {
const submit = useCallback(async () => {
uploadBoardHandlers.current?.handleSend();
const commandName = getBeginCommand(editor);
@@ -340,9 +341,30 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
plainText = `${UNFLIP} ${plainText}`;
customHtml = `${UNFLIP} ${customHtml}`;
} else if (commandName) {
// Check built-in commands first
const commandContent = commands[commandName as Command];
if (commandContent) {
commandContent.exe(plainText);
resetEditor(editor);
resetEditorHistory(editor);
sendTypingStatus(false);
return;
}
// Check plugin commands
try {
const result = await pluginRegistry.executeCommand(commandName, plainText);
if (result) {
// If command returns text, send it as a message
const content: IContent = {
msgtype: MsgType.Text,
body: result,
};
mx.sendMessage(roomId, content as any);
}
} catch (err) {
console.error(`[RoomInput] Plugin command /${commandName} failed:`, err);
// Show error to user (could integrate with notification system)
}
resetEditor(editor);
resetEditorHistory(editor);
@@ -398,6 +420,27 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
'm.in_reply_to': { event_id: threadRootId },
};
}
// Process message through plugin interceptors
try {
const messageContext = {
content: content.body,
roomId,
eventType: 'm.room.message',
formatted: content.formatted_body,
metadata: {},
};
const processedContext = await pluginRegistry.processBeforeSend(messageContext);
// Update content with intercepted values
content.body = processedContext.content;
if (processedContext.formatted) {
content.formatted_body = processedContext.formatted;
}
} catch (err) {
console.error('[RoomInput] Plugin interceptor error:', err);
}
mx.sendMessage(roomId, content as any);
resetEditor(editor);
resetEditorHistory(editor);

View File

@@ -31,6 +31,7 @@ import { EmojisStickers } from './emojis-stickers';
import { DeveloperTools } from './developer-tools';
import { About } from './about';
import { Audio } from './audio';
import { Plugins } from './plugins';
import { UseStateProvider } from '../../components/UseStateProvider';
import { stopPropagation } from '../../utils/keyboard';
import { LogoutDialog } from '../../components/LogoutDialog';
@@ -42,6 +43,7 @@ export enum SettingsPages {
AudioPage,
DevicesPage,
EmojisStickersPage,
PluginsPage,
DeveloperToolsPage,
AboutPage,
}
@@ -85,6 +87,11 @@ const useSettingsMenuItems = (): SettingsMenuItem[] =>
name: 'Emojis & Stickers',
icon: Icons.Smile,
},
{
page: SettingsPages.PluginsPage,
name: 'Plugins',
icon: Icons.Download,
},
{
page: SettingsPages.DeveloperToolsPage,
name: 'Developer Tools',
@@ -235,6 +242,9 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
{activePage === SettingsPages.EmojisStickersPage && (
<EmojisStickers requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.PluginsPage && (
<Plugins requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.DeveloperToolsPage && (
<DeveloperTools requestClose={handlePageRequestClose} />
)}

View 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)

View File

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

View 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');
}
}

View 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>
);
}

View File

@@ -0,0 +1,4 @@
export * from './Plugins';
export * from './types';
export * from './PluginLoader';
export * from './PluginAPI';

View 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',
}

View File

@@ -4,6 +4,7 @@ import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
import { butterTheme, catppuccinMochaTheme, darkTheme, discordTheme, discordDarkerTheme, mochaTheme, silverTheme, twilightTheme } from '../../colors.css';
import { settingsAtom } from '../state/settings';
import { useSetting } from '../state/hooks/settings';
import { pluginRegistry } from '../features/settings/plugins/PluginAPI';
export enum ThemeKind {
Light = 'light',
@@ -64,14 +65,51 @@ export const CatppuccinMochaTheme: Theme = {
};
export const useThemes = (): Theme[] => {
const themes: Theme[] = useMemo(() => [LightTheme, SilverTheme, DarkTheme, ButterTheme, DiscordTheme, DiscordDarkerTheme, TwilightTheme, MochaTheme, CatppuccinMochaTheme], []);
const [pluginThemesUpdate, setPluginThemesUpdate] = useState(0);
// Force update when plugins change (this is a simple approach)
useEffect(() => {
const interval = setInterval(() => {
setPluginThemesUpdate(prev => prev + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
const builtInThemes: Theme[] = useMemo(() => [
LightTheme, SilverTheme, DarkTheme, ButterTheme,
DiscordTheme, DiscordDarkerTheme, TwilightTheme,
MochaTheme, CatppuccinMochaTheme
], []);
// Convert plugin themes to Theme format
const pluginThemes: Theme[] = useMemo(() => {
const themes = pluginRegistry.getPluginThemes();
return themes.map(pluginTheme => ({
id: pluginTheme.id,
kind: pluginTheme.kind === 'dark' ? ThemeKind.Dark : ThemeKind.Light,
classNames: [
pluginTheme.className,
pluginTheme.kind === 'dark' ? onDarkFontWeight : onLightFontWeight,
pluginTheme.kind === 'dark' ? 'prism-dark' : 'prism-light'
]
}));
}, [pluginThemesUpdate]);
return themes;
return [...builtInThemes, ...pluginThemes];
};
export const useThemeNames = (): Record<string, string> =>
useMemo(
() => ({
export const useThemeNames = (): Record<string, string> => {
const [pluginThemesUpdate, setPluginThemesUpdate] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setPluginThemesUpdate(prev => prev + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
return useMemo(() => {
const builtInNames = {
[LightTheme.id]: 'Light',
[SilverTheme.id]: 'Silver',
[DarkTheme.id]: 'Dark',
@@ -81,9 +119,17 @@ export const useThemeNames = (): Record<string, string> =>
[TwilightTheme.id]: 'Twilight',
[MochaTheme.id]: 'Mocha',
[CatppuccinMochaTheme.id]: 'Catppuccin Mocha',
}),
[]
);
};
const pluginThemes = pluginRegistry.getPluginThemes();
const pluginNames = pluginThemes.reduce((acc, theme) => {
acc[theme.id] = theme.name;
return acc;
}, {} as Record<string, string>);
return { ...builtInNames, ...pluginNames };
}, [pluginThemesUpdate]);
};
export const useSystemThemeKind = (): ThemeKind => {
const darkModeQueryList = useMemo(() => window.matchMedia('(prefers-color-scheme: dark)'), []);

View File

@@ -28,6 +28,7 @@ import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
import { MediaConfigProvider } from '../../hooks/useMediaConfig';
import { MatrixClientProvider } from '../../hooks/useMatrixClient';
import { PluginLoader } from '../../features/settings/plugins';
import { SpecVersions } from './SpecVersions';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useSyncState } from '../../hooks/useSyncState';
@@ -284,19 +285,21 @@ export function ClientRoot({ children }: ClientRootProps) {
<ClientRootLoading />
) : (
<MatrixClientProvider value={mx}>
<CallProviderWrapper>
<ServerConfigsLoader>
{(serverConfigs) => (
<CapabilitiesProvider value={serverConfigs.capabilities ?? {}}>
<MediaConfigProvider value={serverConfigs.mediaConfig ?? {}}>
<AuthMetadataProvider value={serverConfigs.authMetadata}>
{children}
</AuthMetadataProvider>
</MediaConfigProvider>
</CapabilitiesProvider>
)}
</ServerConfigsLoader>
</CallProviderWrapper>
<PluginLoader matrixClient={mx}>
<CallProviderWrapper>
<ServerConfigsLoader>
{(serverConfigs) => (
<CapabilitiesProvider value={serverConfigs.capabilities ?? {}}>
<MediaConfigProvider value={serverConfigs.mediaConfig ?? {}}>
<AuthMetadataProvider value={serverConfigs.authMetadata}>
{children}
</AuthMetadataProvider>
</MediaConfigProvider>
</CapabilitiesProvider>
)}
</ServerConfigsLoader>
</CallProviderWrapper>
</PluginLoader>
</MatrixClientProvider>
)}
</SpecVersions>

View File

@@ -721,3 +721,4 @@ export const catppuccinMochaTheme = createTheme(color, {
Overlay: 'rgba(0, 0, 0, 0.8)',
},
});

30
src/ext.d.ts vendored
View File

@@ -33,3 +33,33 @@ declare module '*.svg' {
const content: string;
export default content;
}
interface PluginAPI {
getPath: () => Promise<{ success: boolean; data?: string; error?: string }>;
download: (
pluginId: string,
downloadUrl: string,
name: string
) => Promise<{ success: boolean; data?: { path: string }; error?: string }>;
list: () => Promise<{
success: boolean;
data?: Array<{ id: string; name: string; installedDate: string; path: string }>;
error?: string;
}>;
uninstall: (pluginId: string) => Promise<{ success: boolean; error?: string }>;
readPluginCode: (pluginId: string) => Promise<{ success: boolean; data?: string; error?: string }>;
}
interface ElectronAPI {
platform?: string;
arch?: string;
plugins?: PluginAPI;
}
declare global {
interface Window {
electron?: ElectronAPI;
}
}
export {};