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:
@@ -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
14
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
// 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);
|
||||
const livekitServiceUrl = livekitFocus?.livekit_service_url;
|
||||
|
||||
// If no configured URL, try to get from well-known
|
||||
if (!livekitServiceUrl) {
|
||||
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);
|
||||
}
|
||||
|
||||
// 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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
export * from './EmbedFilters';
|
||||
export * from './LivekitConfig';
|
||||
export * from './RoomAddress';
|
||||
export * from './RoomEncryption';
|
||||
export * from './RoomHistoryVisibility';
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
}
|
||||
export { PluginTab } from '@paarrot/plugin-manager';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
import {
|
||||
PluginMarketplaceClient,
|
||||
PluginMarketplaceManager,
|
||||
PluginRegistry,
|
||||
} from '@paarrot/plugin-manager';
|
||||
|
||||
/**
|
||||
* Command argument definition
|
||||
*/
|
||||
export interface CommandArg {
|
||||
name: string;
|
||||
type?: 'string' | 'number' | 'boolean';
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
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/';
|
||||
|
||||
/**
|
||||
* 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, []);
|
||||
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;
|
||||
}
|
||||
|
||||
async unregisterPlugin(id: string): Promise<void> {
|
||||
const entry = this.plugins.get(id);
|
||||
if (!entry) return;
|
||||
function removeThemeStyle(styleId: string): void {
|
||||
document.getElementById(styleId)?.remove();
|
||||
}
|
||||
|
||||
// Call onUnload
|
||||
if (entry.plugin.onUnload) {
|
||||
try {
|
||||
await entry.plugin.onUnload();
|
||||
} catch (err) {
|
||||
console.error(`[PluginRegistry] Error unloading plugin ${id}:`, err);
|
||||
/** 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}`),
|
||||
});
|
||||
|
||||
/** 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 [];
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up commands
|
||||
for (const [cmdName, cmd] of this.commands.entries()) {
|
||||
if (cmd.pluginId === id) {
|
||||
this.commands.delete(cmdName);
|
||||
const result = await window.electron.plugins.list();
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error(result.error ?? 'Failed to list installed plugins');
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
return result.data.map((plugin) => ({
|
||||
id: plugin.id,
|
||||
name: plugin.name,
|
||||
installedDate: plugin.installedDate,
|
||||
}));
|
||||
},
|
||||
installPlugin: async (plugin) => {
|
||||
if (!window.electron?.plugins) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up themes
|
||||
for (const [themeId, theme] of this.themes.entries()) {
|
||||
if (theme.pluginId === id) {
|
||||
this.themes.delete(themeId);
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
},
|
||||
uninstallPlugin: async (pluginId) => {
|
||||
if (!window.electron?.plugins) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
const result = await window.electron.plugins.uninstall(pluginId);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? 'Failed to uninstall plugin');
|
||||
}
|
||||
}
|
||||
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();
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
try {
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
},
|
||||
},
|
||||
const dataUrl = `data:text/javascript;charset=utf-8,${encodeURIComponent(response.data)}`;
|
||||
const importedModule = await import(/* @vite-ignore */ dataUrl);
|
||||
return importedModule;
|
||||
}
|
||||
|
||||
// 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;
|
||||
},
|
||||
};
|
||||
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');
|
||||
}
|
||||
|
||||
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');
|
||||
function normalisePlugin(raw: Record<string, unknown>): Plugin {
|
||||
if (typeof raw.onLoad === 'function') {
|
||||
return raw as unknown as Plugin;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
await activate(legacyCtx);
|
||||
},
|
||||
onUnload: deactivate
|
||||
? async () => {
|
||||
await deactivate();
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return raw as unknown as Plugin;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
} 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));
|
||||
try {
|
||||
setLoading(true);
|
||||
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);
|
||||
}
|
||||
},
|
||||
[installedPlugins]
|
||||
[refreshInstalledPlugins]
|
||||
);
|
||||
|
||||
const handleUninstall = useCallback(
|
||||
async (pluginId: string) => {
|
||||
// First, unregister the plugin to trigger onUnload
|
||||
try {
|
||||
await pluginRegistry.unregisterPlugin(pluginId);
|
||||
setError(null);
|
||||
await pluginMarketplaceManager.uninstallPlugin(pluginId);
|
||||
await refreshInstalledPlugins();
|
||||
} 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));
|
||||
console.error('Failed to uninstall plugin:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to uninstall plugin');
|
||||
}
|
||||
},
|
||||
[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);
|
||||
try {
|
||||
setError(null);
|
||||
await pluginMarketplaceManager.setPluginEnabled(pluginId, newEnabledState);
|
||||
await refreshInstalledPlugins();
|
||||
if (newEnabledState) {
|
||||
console.log(`[Plugins] Plugin ${pluginId} enabled. Restart app to load it.`);
|
||||
}
|
||||
}
|
||||
|
||||
// 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.`);
|
||||
} 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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
Reference in New Issue
Block a user