Refactor plugin management system

- Updated PluginLoader to utilize the new plugin marketplace manager for loading and managing plugins.
- Simplified the plugin loading process by removing unnecessary state management and directly integrating with the plugin marketplace.
- Enhanced error handling during plugin installation and uninstallation processes.
- Removed legacy code related to Electron-specific plugin handling, streamlining the codebase for web compatibility.
- Updated Plugins component to fetch marketplace plugins and installed plugins using the new plugin marketplace manager.
- Refactored types related to plugins to import from the new plugin manager module, ensuring consistency and reducing redundancy.
- Removed unused calling configuration from client settings and adjusted related types accordingly.
- Cleaned up room state events by removing references to LiveKit service URLs, aligning with the updated architecture.
This commit is contained in:
2026-04-19 06:52:42 +10:00
parent d1d3033c15
commit 52e9cc360f
18 changed files with 283 additions and 1375 deletions

View File

@@ -10,10 +10,6 @@
],
"allowCustomHomeservers": true,
"calling": {
"livekitServiceUrl": "https://b.ruv.wtf/matrix-rtc/livekit/jwt"
},
"featuredCommunities": {
"openAsDefault": false,
"spaces": [

14
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@fontsource/inter": "4.5.14",
"@paarrot/plugin-manager": "file:../../PluginManager",
"@tanstack/react-query": "5.24.1",
"@tanstack/react-query-devtools": "5.24.1",
"@tanstack/react-virtual": "3.2.0",
@@ -113,6 +114,15 @@
"node": ">=16.0.0"
}
},
"../../PluginManager": {
"name": "@paarrot/plugin-manager",
"version": "1.0.0",
"devDependencies": {
"@types/node": "^25.6.0",
"rimraf": "^5.0.0",
"typescript": "^5.4.0"
}
},
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
@@ -2333,6 +2343,10 @@
"node": ">= 8"
}
},
"node_modules/@paarrot/plugin-manager": {
"resolved": "../../PluginManager",
"link": true
},
"node_modules/@react-aria/breadcrumbs": {
"version": "3.5.20",
"resolved": "https://registry.npmjs.org/@react-aria/breadcrumbs/-/breadcrumbs-3.5.20.tgz",

View File

@@ -24,6 +24,7 @@
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@fontsource/inter": "4.5.14",
"@paarrot/plugin-manager": "file:../../PluginManager",
"@tanstack/react-query": "5.24.1",
"@tanstack/react-query-devtools": "5.24.1",
"@tanstack/react-virtual": "3.2.0",

View File

@@ -2,7 +2,6 @@ import React, { ReactNode } from 'react';
import { CallProvider, useCallService } from './useCall';
import { CallOverlay } from './CallOverlay';
import { IncomingCallNotification } from './IncomingCallNotification';
import { useClientConfig } from '../../hooks/useClientConfig';
interface CallProviderWrapperProps {
children: ReactNode;
@@ -13,13 +12,7 @@ interface CallProviderWrapperProps {
* Also renders the CallOverlay for active call controls and monitors incoming calls
*/
export function CallProviderWrapper({ children }: CallProviderWrapperProps) {
const clientConfig = useClientConfig();
const livekitServiceUrl = clientConfig.calling?.livekitServiceUrl;
// eslint-disable-next-line no-console
console.log('CallProviderWrapper - config calling:', clientConfig.calling, 'livekitUrl:', livekitServiceUrl);
const callContextValue = useCallService({ livekitServiceUrl });
const callContextValue = useCallService();
return (
<CallProvider value={callContextValue}>

View File

@@ -300,26 +300,10 @@ export class CallService {
this.config.accessToken
);
// Step 2: Determine LiveKit service URL (check room-level config first)
let livekitServiceUrl = this.config.livekitServiceUrl;
// Check if room has a custom LiveKit config
const room = this.matrixClient.getRoom(roomId);
if (room) {
const livekitConfigEvent = room.currentState.getStateEvents('im.paarrot.room.livekit_config', '');
if (livekitConfigEvent) {
const roomLivekitUrl = livekitConfigEvent.getContent()?.service_url;
if (roomLivekitUrl) {
console.log('Using room-level LiveKit URL:', roomLivekitUrl);
livekitServiceUrl = roomLivekitUrl;
}
}
}
// Step 3: Exchange OpenID token for LiveKit JWT
// Step 2: Exchange OpenID token for LiveKit JWT
console.log('Requesting LiveKit JWT for Matrix room:', roomId);
this.livekitJwt = await fetchLiveKitJWT(
livekitServiceUrl,
this.config.livekitServiceUrl,
roomId,
this.config.userId,
this.config.deviceId,

View File

@@ -55,24 +55,17 @@ export function useCall(): CallContextValue {
export const CallProvider = CallContext.Provider;
interface UseCallServiceOptions {
livekitServiceUrl?: string;
}
/**
* Hook to initialize and manage the call service
* Should be used at the app level to provide call context
* @param options - Optional configuration including livekitServiceUrl from client config
*/
export function useCallService(options?: UseCallServiceOptions): CallContextValue {
export function useCallService(): CallContextValue {
const mx = useMatrixClient();
const [callService, setCallService] = useState<CallService | null>(null);
const [activeCall, setActiveCall] = useState<ActiveCall | null>(null);
const [callSupported, setCallSupported] = useState(false);
const [callSupportLoading, setCallSupportLoading] = useState(true);
const configuredLivekitUrl = options?.livekitServiceUrl;
useEffect(() => {
let unsubscribe: (() => void) | undefined;
let service: CallService | undefined;
@@ -89,30 +82,21 @@ export function useCallService(options?: UseCallServiceOptions): CallContextValu
}
try {
let livekitServiceUrl: string | undefined = configuredLivekitUrl;
// If no configured URL, try to get from well-known
if (!livekitServiceUrl) {
// Get LiveKit service URL from Matrix homeserver well-known
const wellKnown = await fetchWellKnownWithRTC(baseUrl);
// eslint-disable-next-line no-console
console.log('Call service init - well-known:', wellKnown);
const livekitFocus = getLiveKitFocus(wellKnown);
// eslint-disable-next-line no-console
console.log('Call service init - LiveKit focus:', livekitFocus);
livekitServiceUrl = livekitFocus?.livekit_service_url;
} else {
// eslint-disable-next-line no-console
console.log('Call service init - Using configured LiveKit URL:', livekitServiceUrl);
}
const livekitServiceUrl = livekitFocus?.livekit_service_url;
// If still no URL, log warning but allow service to initialize
// Individual rooms may have their own LiveKit configs
if (!livekitServiceUrl) {
// eslint-disable-next-line no-console
console.log('Call service init - No global LiveKit URL found');
console.log('Call service init - Will check for room-level configs when joining calls');
// Use a placeholder URL - room-level config will override this
livekitServiceUrl = 'https://placeholder.invalid/livekit';
console.log('Call service init - No LiveKit config in well-known, calls not supported');
setCallSupported(false);
setCallSupportLoading(false);
return;
}
service = new CallService(

View File

@@ -66,7 +66,7 @@ export async function requestOpenIdToken(
/**
* Fetches a LiveKit JWT for joining a call using OpenID token authentication
* @param livekitServiceUrl - The LiveKit service URL from well-known or config
* @param livekitServiceUrl - The LiveKit service URL from Matrix homeserver well-known
* @param roomId - The Matrix room ID to join
* @param userId - The Matrix user ID
* @param deviceId - The Matrix device ID

View File

@@ -1,156 +0,0 @@
import React, { FormEventHandler, useCallback, useState } from 'react';
import {
Box,
Button,
color,
Input,
Spinner,
Text,
} from 'folds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../room-settings/styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useRoom } from '../../../hooks/useRoom';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { StateEvent } from '../../../../types/matrix/room';
interface LivekitConfigContent {
service_url?: string;
}
/**
* Room-specific LiveKit configuration component.
* Allows room admins to override the LiveKit service URL for calls in this room.
*/
export function LivekitConfig() {
const mx = useMatrixClient();
const room = useRoom();
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const stateEvent = useStateEvent(room, StateEvent.RoomLivekitConfig);
const currentConfig = stateEvent?.getContent<LivekitConfigContent>();
const currentUrl = currentConfig?.service_url || '';
const [urlValue, setUrlValue] = useState(currentUrl);
const [urlError, setUrlError] = useState<string>();
const [saveState, saveConfig] = useAsyncCallback(
useCallback(
async (serviceUrl: string) => {
const content: LivekitConfigContent = serviceUrl
? { service_url: serviceUrl }
: {};
await mx.sendStateEvent(room.roomId, StateEvent.RoomLivekitConfig as any, content, '');
},
[mx, room]
)
);
const canEdit = permissions.stateEvent(StateEvent.RoomLivekitConfig, mx.getUserId()!);
const handleSave: FormEventHandler<HTMLFormElement> = async (evt) => {
evt.preventDefault();
const serviceUrl = urlValue.trim();
// Basic URL validation
if (serviceUrl && !serviceUrl.startsWith('http://') && !serviceUrl.startsWith('https://')) {
setUrlError('URL must start with http:// or https://');
return;
}
setUrlError(undefined);
await saveConfig(serviceUrl);
};
const handleClear = async () => {
setUrlValue('');
setUrlError(undefined);
await saveConfig('');
};
const hasChanges = urlValue !== currentUrl;
const isSaving = saveState.status === AsyncStatus.Loading;
return (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="LiveKit Service URL"
description="Override the LiveKit JWT service endpoint for Matrix RTC calls in this room. Leave empty to use the server default or user preference."
/>
<Box as="form" onSubmit={handleSave} direction="Column" gap="200" style={{ padding: '0 16px' }}>
<Input
value={urlValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setUrlValue(e.target.value);
setUrlError(undefined);
}}
placeholder="https://example.com/livekit/jwt"
size="300"
variant="Secondary"
radii="300"
disabled={!canEdit || isSaving}
style={{ width: '100%' }}
/>
{urlError && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{urlError}
</Text>
)}
{saveState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
Failed to save: {saveState.error instanceof Error ? saveState.error.message : 'Unknown error'}
</Text>
)}
{saveState.status === AsyncStatus.Success && !hasChanges && (
<Text size="T200" style={{ color: color.Success.Main }}>
Configuration saved
</Text>
)}
<Box gap="200" alignItems="Center">
<Button
type="submit"
size="300"
variant="Primary"
fill="Solid"
radii="300"
disabled={!canEdit || isSaving || !hasChanges}
before={isSaving && <Spinner size="100" variant="Primary" fill="Solid" />}
>
<Text size="B300">Save</Text>
</Button>
{currentUrl && (
<Button
type="button"
size="300"
variant="Critical"
fill="None"
radii="300"
disabled={!canEdit || isSaving}
onClick={handleClear}
>
<Text size="B300">Clear</Text>
</Button>
)}
{!canEdit && (
<Text size="T200" priority="300">
You don't have permission to change this setting
</Text>
)}
</Box>
</Box>
</SequenceCard>
);
}

View File

@@ -1,5 +1,4 @@
export * from './EmbedFilters';
export * from './LivekitConfig';
export * from './RoomAddress';
export * from './RoomEncryption';
export * from './RoomHistoryVisibility';

View File

@@ -5,7 +5,6 @@ import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { useRoom } from '../../../hooks/useRoom';
import {
EmbedFilters,
LivekitConfig,
RoomProfile,
RoomEncryption,
RoomHistoryVisibility,
@@ -59,10 +58,6 @@ export function General({ requestClose }: GeneralProps) {
<Text size="L400">Link Previews</Text>
<EmbedFilters />
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Voice & Video</Text>
<LivekitConfig />
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Addresses</Text>
<RoomPublishedAddresses permissions={permissions} />

View File

@@ -456,7 +456,6 @@ export function Audio({ requestClose }: AudioProps) {
const [speakerTestState, setSpeakerTestState] = useState<'idle' | 'testing' | 'done' | 'error'>('idle');
const micAnimFrameRef = useRef<number | null>(null);
const [showRemoteCursor, setShowRemoteCursor] = useSetting(settingsAtom, 'showRemoteCursor');
const [livekitServiceUrl, setLivekitServiceUrl] = useSetting(settingsAtom, 'livekitServiceUrl');
useEffect(() => {
const loadDevices = async () => {
@@ -848,33 +847,6 @@ export function Audio({ requestClose }: AudioProps) {
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<Header size="400">
<Box gap="200" grow="Yes">
<Text size="H4">LiveKit Configuration</Text>
</Box>
</Header>
<SettingTile
title="LiveKit Service URL"
description="Custom LiveKit JWT service endpoint for Matrix RTC calls. Leave empty to use the default configured server."
after={
<Input
size="300"
variant="Background"
value={livekitServiceUrl ?? ''}
onChange={(e) => setLivekitServiceUrl(e.target.value || undefined)}
placeholder="https://example.com/livekit/jwt"
style={{ minWidth: toRem(300) }}
/>
}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"

View File

@@ -1,698 +1,134 @@
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
import { ReactNode } from 'react';
/**
* Color group with five container variants and an OnContainer text color.
* Re-exports the full @paarrot/plugin-manager API and creates the shared
* singleton PluginRegistry instance for this application.
*
* All other modules in this folder should import from here rather than
* directly from the package so the singleton is guaranteed to be shared.
*/
export interface ThemeColorGroup {
container: string;
containerHover: string;
containerActive: string;
containerLine: string;
onContainer: string;
export {
PluginMarketplaceClient,
PluginMarketplaceManager,
PluginRegistry,
createPluginContext,
generateThemeCSS,
MemoryStorage,
} from '@paarrot/plugin-manager';
export type {
IPluginStorage,
IPluginEventClient,
PluginMarketplaceClientOptions,
PluginMarketplaceHostAdapter,
PluginMarketplaceInstalledRecord,
PluginMarketplaceManagerOptions,
PluginRegistryOptions,
PluginContextOptions,
PluginMetadata,
InstalledPlugin,
PluginIndex,
ThemeColorGroup,
ThemePaletteGroup,
PluginThemeColors,
PluginTheme,
CommandArg,
PluginCommand,
MessageInterceptor,
MessageContext,
CustomRenderer,
SettingDefinition,
SettingsSchema,
NotificationOptions,
PluginContext,
PluginSettingsSection,
UILocation,
Plugin,
PluginLogEntry,
} from '@paarrot/plugin-manager';
export { PluginTab } from '@paarrot/plugin-manager';
import {
PluginMarketplaceClient,
PluginMarketplaceManager,
PluginRegistry,
} from '@paarrot/plugin-manager';
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/';
function injectThemeStyle(styleId: string, css: string): void {
let el = document.getElementById(styleId) as HTMLStyleElement | null;
if (!el) {
el = document.createElement('style');
el.id = styleId;
document.head.appendChild(el);
}
el.textContent = css;
}
/**
* 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;
function removeThemeStyle(styleId: string): void {
document.getElementById(styleId)?.remove();
}
/**
* 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;
/** Shared singleton registry for the entire application. */
export const pluginRegistry = new PluginRegistry({
storage: localStorage,
onThemeRegistered: (themeId, _className, css) =>
injectThemeStyle(`plugin-theme-${themeId}`, css),
onThemeUnregistered: (themeId) => removeThemeStyle(`plugin-theme-${themeId}`),
});
return result;
/** Shared marketplace client for remote plugin directory reads. */
export const pluginMarketplaceClient = new PluginMarketplaceClient({
indexUrl: PLUGIN_INDEX_URL,
baseUrl: PLUGIN_BASE_URL,
});
/**
* Shared marketplace manager for install state, directory fetch, and enable flags.
*/
export const pluginMarketplaceManager = new PluginMarketplaceManager({
client: pluginMarketplaceClient,
storage: localStorage,
registry: pluginRegistry,
host: {
listInstalledPlugins: async () => {
if (!window.electron?.plugins) {
return [];
}
getCommands(): Array<{ name: string; pluginId: string; command: PluginCommand }> {
return Array.from(this.commands.entries()).map(([name, data]) => ({
name,
...data,
const result = await window.electron.plugins.list();
if (!result.success || !result.data) {
throw new Error(result.error ?? 'Failed to list installed plugins');
}
return result.data.map((plugin) => ({
id: plugin.id,
name: plugin.name,
installedDate: plugin.installedDate,
}));
}
// 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`);
},
installPlugin: async (plugin) => {
if (!window.electron?.plugins) {
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);
const result = await window.electron.plugins.download(plugin.id, plugin.downloadUrl, plugin.name);
if (!result.success) {
throw new Error(result.error ?? 'Failed to install plugin');
}
styleEl.textContent = generateThemeCSS(fullThemeId, theme);
this.themes.set(fullThemeId, { pluginId, theme: { ...theme, id: fullThemeId }, styleId });
console.log(`[PluginRegistry] Theme registered: ${fullThemeId}`);
},
uninstallPlugin: async (pluginId) => {
if (!window.electron?.plugins) {
return;
}
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();
const result = await window.electron.plugins.uninstall(pluginId);
if (!result.success) {
throw new Error(result.error ?? 'Failed to uninstall plugin');
}
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

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import { MatrixClient } from 'matrix-js-sdk';
import { Plugin, PluginContext, pluginRegistry } from './PluginAPI';
import { InstalledPlugin } from './types';
import { Plugin, createPluginContext } from '@paarrot/plugin-manager';
import { pluginMarketplaceManager, pluginRegistry } from './PluginAPI';
import { sendNotification } from '../../../utils/tauri';
interface PluginLoaderProps {
@@ -14,56 +14,26 @@ interface PluginLoaderProps {
* 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
);
const pluginsToLoad = await pluginMarketplaceManager.listEnabledPlugins();
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;
const plugin = normalisePlugin(pluginModule);
if (!plugin || typeof plugin !== 'object') {
console.error(
@@ -72,7 +42,6 @@ export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
continue;
}
// Validate plugin structure
if (typeof plugin.onLoad !== 'function') {
console.error(
`[PluginLoader] Plugin ${installedPlugin.id} does not have an onLoad function`
@@ -80,252 +49,127 @@ export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
continue;
}
// Create enhanced plugin context
const context = createPluginContext(installedPlugin.id, matrixClient, React);
const context = createPluginContext(
{
pluginId: installedPlugin.id,
eventClient: matrixClient as any,
onNotify: async (opts) => {
await sendNotification({
title: opts.title,
body: opts.body,
path: undefined,
});
},
},
pluginRegistry
);
const compatContext = Object.assign(context as Record<string, unknown>, {
matrix: {
on: (eventType: string, handler: (...args: unknown[]) => void) =>
matrixClient.on(eventType as any, handler as any),
off: (eventType: string, handler: (...args: unknown[]) => void) =>
matrixClient.off(eventType as any, handler as any),
},
React,
});
// Register plugin
pluginRegistry.registerPlugin(installedPlugin.id, plugin, context);
// Call plugin's onLoad
await plugin.onLoad(context);
loaded.push(installedPlugin.id);
await plugin.onLoad(compatContext as any);
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
* Reads and evaluates a plugin's JS module from the Electron filesystem.
* Supports both CommonJS and ESM-style exports.
*/
function createPluginContext(
pluginId: string,
matrixClient: MatrixClient,
React: typeof import('react')
): PluginContext {
return {
pluginId,
matrixClient,
React,
async function loadPluginModule(pluginId: string): Promise<any> {
if (!window.electron?.plugins) {
throw new Error('Plugins are only supported in the desktop app');
}
// 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));
},
},
const response = await window.electron.plugins.readPluginCode(pluginId);
if (!response.success || !response.data) {
throw new Error(response.error ?? 'Failed to read plugin code');
}
// 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']);
},
},
const pluginExports: any = {};
const mod = { exports: pluginExports };
// 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]);
// eslint-disable-next-line no-new-func
const pluginFunction = new Function('module', 'exports', response.data);
pluginFunction(mod, pluginExports);
} catch (error) {
if (!(error instanceof SyntaxError)) {
throw error;
}
}, 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]);
const dataUrl = `data:text/javascript;charset=utf-8,${encodeURIComponent(response.data)}`;
const importedModule = await import(/* @vite-ignore */ dataUrl);
return importedModule;
}
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;
},
};
return mod.exports;
}
/**
* Load a plugin module from the filesystem.
* Handles both Electron and web environments.
* Normalizes raw plugin exports to current plugin interface.
* Supports native `onLoad` and legacy `activate` / `deactivate` plugins.
*/
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');
function normalisePlugin(raw: Record<string, unknown>): Plugin {
if (typeof raw.onLoad === 'function') {
return raw as unknown as Plugin;
}
const pluginCode = response.data;
if (typeof raw.activate === 'function') {
const activate = raw.activate as (ctx: unknown) => void | Promise<void>;
const deactivate = raw.deactivate as ((ctx?: unknown) => void | Promise<void>) | undefined;
// Create a sandboxed environment for the plugin
const pluginExports: any = {};
const module = { exports: pluginExports };
return {
name: typeof raw.name === 'string' ? raw.name : undefined,
version: typeof raw.version === 'string' ? raw.version : undefined,
onLoad: async (ctx) => {
const legacyCtx = Object.assign(Object.create(ctx as object), {
registerHook: (name: string) =>
ctx.log(`[compat] registerHook("${name}") - not supported`),
runHook: (name: string) => {
ctx.log(`[compat] runHook("${name}")`);
return Promise.resolve([]);
},
getConfig: (key: string) => ctx.settings.get(key),
on: (event: string, handler: (...args: unknown[]) => void) =>
ctx.events.on(event, handler),
});
// 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;
await activate(legacyCtx);
},
onUnload: deactivate
? async () => {
await deactivate();
}
} else {
// In web mode, plugins are not supported
throw new Error('Plugins are only supported in the desktop app');
: undefined,
};
}
return raw as unknown as Plugin;
}

View File

@@ -24,21 +24,13 @@ import {
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 { PluginTab, PluginMetadata, 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 { pluginMarketplaceManager, pluginRegistry, 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
*/
@@ -257,27 +249,21 @@ export function Plugins({ requestClose }: PluginsProps) {
const [searchQuery, setSearchQuery] = useState('');
const [expandedSettings, setExpandedSettings] = useState<string | null>(null);
const refreshInstalledPlugins = useCallback(async () => {
try {
setInstalledPlugins(await pluginMarketplaceManager.listInstalledPlugins());
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to list installed plugins');
console.error('Failed to list installed plugins:', err);
}
}, []);
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));
setMarketplacePlugins(await pluginMarketplaceManager.fetchMarketplacePlugins());
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch plugins');
console.error('Error fetching marketplace plugins:', err);
@@ -293,112 +279,38 @@ export function Plugins({ requestClose }: PluginsProps) {
}, [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();
}, []);
refreshInstalledPlugins();
}, [refreshInstalledPlugins]);
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');
}
setError(null);
await pluginMarketplaceManager.installPlugin(plugin);
await refreshInstalledPlugins();
} 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]
[refreshInstalledPlugins]
);
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');
}
setError(null);
await pluginMarketplaceManager.uninstallPlugin(pluginId);
await refreshInstalledPlugins();
} 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]
[refreshInstalledPlugins]
);
const handleToggleEnabled = useCallback(
@@ -408,36 +320,19 @@ export function Plugins({ requestClose }: PluginsProps) {
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
setError(null);
await pluginMarketplaceManager.setPluginEnabled(pluginId, newEnabledState);
await refreshInstalledPlugins();
if (newEnabledState) {
console.log(`[Plugins] Plugin ${pluginId} enabled. Restart app to load it.`);
}
} catch (err) {
console.error(`Failed to toggle plugin ${pluginId}:`, err);
setError(err instanceof Error ? err.message : 'Failed to update plugin state');
}
},
[installedPlugins]
[installedPlugins, refreshInstalledPlugins]
);
const isPluginInstalled = useCallback(

View File

@@ -1,38 +1,2 @@
/**
* 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',
}
export type { InstalledPlugin, PluginIndex, PluginMetadata } from '@paarrot/plugin-manager';
export { PluginTab } from '@paarrot/plugin-manager';

View File

@@ -5,10 +5,6 @@ export type HashRouterConfig = {
basename?: string;
};
export type CallingConfig = {
livekitServiceUrl?: string;
};
export type ClientConfig = {
defaultHomeserver?: number;
homeserverList?: string[];
@@ -21,8 +17,6 @@ export type ClientConfig = {
servers?: string[];
};
calling?: CallingConfig;
hashRouter?: HashRouterConfig;
};

View File

@@ -56,8 +56,6 @@ export interface Settings {
developerTools: boolean;
autoJoinSpaceRooms: boolean;
livekitServiceUrl?: string;
}
const defaultSettings: Settings = {
@@ -99,8 +97,6 @@ const defaultSettings: Settings = {
developerTools: false,
autoJoinSpaceRooms: true,
livekitServiceUrl: undefined,
};
export const getSettings = () => {

View File

@@ -42,9 +42,6 @@ export enum StateEvent {
/** Room-wide link embed filter patterns (admin-controlled) */
RoomEmbedFilters = 'im.paarrot.room.embed_filters',
/** Room-specific LiveKit service URL (admin-controlled) */
RoomLivekitConfig = 'im.paarrot.room.livekit_config',
/** Sub-rooms: child rooms nested under this room */
PaarrotSubRooms = 'im.paarrot.sub_rooms',