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,
|
"allowCustomHomeservers": true,
|
||||||
|
|
||||||
"calling": {
|
|
||||||
"livekitServiceUrl": "https://b.ruv.wtf/matrix-rtc/livekit/jwt"
|
|
||||||
},
|
|
||||||
|
|
||||||
"featuredCommunities": {
|
"featuredCommunities": {
|
||||||
"openAsDefault": false,
|
"openAsDefault": false,
|
||||||
"spaces": [
|
"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-auto-scroll": "1.3.0",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||||
"@fontsource/inter": "4.5.14",
|
"@fontsource/inter": "4.5.14",
|
||||||
|
"@paarrot/plugin-manager": "file:../../PluginManager",
|
||||||
"@tanstack/react-query": "5.24.1",
|
"@tanstack/react-query": "5.24.1",
|
||||||
"@tanstack/react-query-devtools": "5.24.1",
|
"@tanstack/react-query-devtools": "5.24.1",
|
||||||
"@tanstack/react-virtual": "3.2.0",
|
"@tanstack/react-virtual": "3.2.0",
|
||||||
@@ -113,6 +114,15 @@
|
|||||||
"node": ">=16.0.0"
|
"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": {
|
"node_modules/@ampproject/remapping": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||||
@@ -2333,6 +2343,10 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@paarrot/plugin-manager": {
|
||||||
|
"resolved": "../../PluginManager",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@react-aria/breadcrumbs": {
|
"node_modules/@react-aria/breadcrumbs": {
|
||||||
"version": "3.5.20",
|
"version": "3.5.20",
|
||||||
"resolved": "https://registry.npmjs.org/@react-aria/breadcrumbs/-/breadcrumbs-3.5.20.tgz",
|
"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-auto-scroll": "1.3.0",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||||
"@fontsource/inter": "4.5.14",
|
"@fontsource/inter": "4.5.14",
|
||||||
|
"@paarrot/plugin-manager": "file:../../PluginManager",
|
||||||
"@tanstack/react-query": "5.24.1",
|
"@tanstack/react-query": "5.24.1",
|
||||||
"@tanstack/react-query-devtools": "5.24.1",
|
"@tanstack/react-query-devtools": "5.24.1",
|
||||||
"@tanstack/react-virtual": "3.2.0",
|
"@tanstack/react-virtual": "3.2.0",
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import React, { ReactNode } from 'react';
|
|||||||
import { CallProvider, useCallService } from './useCall';
|
import { CallProvider, useCallService } from './useCall';
|
||||||
import { CallOverlay } from './CallOverlay';
|
import { CallOverlay } from './CallOverlay';
|
||||||
import { IncomingCallNotification } from './IncomingCallNotification';
|
import { IncomingCallNotification } from './IncomingCallNotification';
|
||||||
import { useClientConfig } from '../../hooks/useClientConfig';
|
|
||||||
|
|
||||||
interface CallProviderWrapperProps {
|
interface CallProviderWrapperProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -13,13 +12,7 @@ interface CallProviderWrapperProps {
|
|||||||
* Also renders the CallOverlay for active call controls and monitors incoming calls
|
* Also renders the CallOverlay for active call controls and monitors incoming calls
|
||||||
*/
|
*/
|
||||||
export function CallProviderWrapper({ children }: CallProviderWrapperProps) {
|
export function CallProviderWrapper({ children }: CallProviderWrapperProps) {
|
||||||
const clientConfig = useClientConfig();
|
const callContextValue = useCallService();
|
||||||
const livekitServiceUrl = clientConfig.calling?.livekitServiceUrl;
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log('CallProviderWrapper - config calling:', clientConfig.calling, 'livekitUrl:', livekitServiceUrl);
|
|
||||||
|
|
||||||
const callContextValue = useCallService({ livekitServiceUrl });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CallProvider value={callContextValue}>
|
<CallProvider value={callContextValue}>
|
||||||
|
|||||||
@@ -300,26 +300,10 @@ export class CallService {
|
|||||||
this.config.accessToken
|
this.config.accessToken
|
||||||
);
|
);
|
||||||
|
|
||||||
// Step 2: Determine LiveKit service URL (check room-level config first)
|
// Step 2: Exchange OpenID token for LiveKit JWT
|
||||||
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
|
|
||||||
console.log('Requesting LiveKit JWT for Matrix room:', roomId);
|
console.log('Requesting LiveKit JWT for Matrix room:', roomId);
|
||||||
this.livekitJwt = await fetchLiveKitJWT(
|
this.livekitJwt = await fetchLiveKitJWT(
|
||||||
livekitServiceUrl,
|
this.config.livekitServiceUrl,
|
||||||
roomId,
|
roomId,
|
||||||
this.config.userId,
|
this.config.userId,
|
||||||
this.config.deviceId,
|
this.config.deviceId,
|
||||||
|
|||||||
@@ -55,24 +55,17 @@ export function useCall(): CallContextValue {
|
|||||||
|
|
||||||
export const CallProvider = CallContext.Provider;
|
export const CallProvider = CallContext.Provider;
|
||||||
|
|
||||||
interface UseCallServiceOptions {
|
|
||||||
livekitServiceUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to initialize and manage the call service
|
* Hook to initialize and manage the call service
|
||||||
* Should be used at the app level to provide call context
|
* 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 mx = useMatrixClient();
|
||||||
const [callService, setCallService] = useState<CallService | null>(null);
|
const [callService, setCallService] = useState<CallService | null>(null);
|
||||||
const [activeCall, setActiveCall] = useState<ActiveCall | null>(null);
|
const [activeCall, setActiveCall] = useState<ActiveCall | null>(null);
|
||||||
const [callSupported, setCallSupported] = useState(false);
|
const [callSupported, setCallSupported] = useState(false);
|
||||||
const [callSupportLoading, setCallSupportLoading] = useState(true);
|
const [callSupportLoading, setCallSupportLoading] = useState(true);
|
||||||
|
|
||||||
const configuredLivekitUrl = options?.livekitServiceUrl;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unsubscribe: (() => void) | undefined;
|
let unsubscribe: (() => void) | undefined;
|
||||||
let service: CallService | undefined;
|
let service: CallService | undefined;
|
||||||
@@ -89,30 +82,21 @@ export function useCallService(options?: UseCallServiceOptions): CallContextValu
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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) {
|
if (!livekitServiceUrl) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log('Call service init - No global LiveKit URL found');
|
console.log('Call service init - No LiveKit config in well-known, calls not supported');
|
||||||
console.log('Call service init - Will check for room-level configs when joining calls');
|
setCallSupported(false);
|
||||||
// Use a placeholder URL - room-level config will override this
|
setCallSupportLoading(false);
|
||||||
livekitServiceUrl = 'https://placeholder.invalid/livekit';
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
service = new CallService(
|
service = new CallService(
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export async function requestOpenIdToken(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches a LiveKit JWT for joining a call using OpenID token authentication
|
* 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 roomId - The Matrix room ID to join
|
||||||
* @param userId - The Matrix user ID
|
* @param userId - The Matrix user ID
|
||||||
* @param deviceId - The Matrix device 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 './EmbedFilters';
|
||||||
export * from './LivekitConfig';
|
|
||||||
export * from './RoomAddress';
|
export * from './RoomAddress';
|
||||||
export * from './RoomEncryption';
|
export * from './RoomEncryption';
|
||||||
export * from './RoomHistoryVisibility';
|
export * from './RoomHistoryVisibility';
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { usePowerLevels } from '../../../hooks/usePowerLevels';
|
|||||||
import { useRoom } from '../../../hooks/useRoom';
|
import { useRoom } from '../../../hooks/useRoom';
|
||||||
import {
|
import {
|
||||||
EmbedFilters,
|
EmbedFilters,
|
||||||
LivekitConfig,
|
|
||||||
RoomProfile,
|
RoomProfile,
|
||||||
RoomEncryption,
|
RoomEncryption,
|
||||||
RoomHistoryVisibility,
|
RoomHistoryVisibility,
|
||||||
@@ -59,10 +58,6 @@ export function General({ requestClose }: GeneralProps) {
|
|||||||
<Text size="L400">Link Previews</Text>
|
<Text size="L400">Link Previews</Text>
|
||||||
<EmbedFilters />
|
<EmbedFilters />
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Column" gap="100">
|
|
||||||
<Text size="L400">Voice & Video</Text>
|
|
||||||
<LivekitConfig />
|
|
||||||
</Box>
|
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Addresses</Text>
|
<Text size="L400">Addresses</Text>
|
||||||
<RoomPublishedAddresses permissions={permissions} />
|
<RoomPublishedAddresses permissions={permissions} />
|
||||||
|
|||||||
@@ -456,7 +456,6 @@ export function Audio({ requestClose }: AudioProps) {
|
|||||||
const [speakerTestState, setSpeakerTestState] = useState<'idle' | 'testing' | 'done' | 'error'>('idle');
|
const [speakerTestState, setSpeakerTestState] = useState<'idle' | 'testing' | 'done' | 'error'>('idle');
|
||||||
const micAnimFrameRef = useRef<number | null>(null);
|
const micAnimFrameRef = useRef<number | null>(null);
|
||||||
const [showRemoteCursor, setShowRemoteCursor] = useSetting(settingsAtom, 'showRemoteCursor');
|
const [showRemoteCursor, setShowRemoteCursor] = useSetting(settingsAtom, 'showRemoteCursor');
|
||||||
const [livekitServiceUrl, setLivekitServiceUrl] = useSetting(settingsAtom, 'livekitServiceUrl');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadDevices = async () => {
|
const loadDevices = async () => {
|
||||||
@@ -848,33 +847,6 @@ export function Audio({ requestClose }: AudioProps) {
|
|||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</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
|
<SequenceCard
|
||||||
className={SequenceCardStyle}
|
className={SequenceCardStyle}
|
||||||
variant="SurfaceVariant"
|
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 {
|
export {
|
||||||
container: string;
|
PluginMarketplaceClient,
|
||||||
containerHover: string;
|
PluginMarketplaceManager,
|
||||||
containerActive: string;
|
PluginRegistry,
|
||||||
containerLine: string;
|
createPluginContext,
|
||||||
onContainer: string;
|
generateThemeCSS,
|
||||||
}
|
MemoryStorage,
|
||||||
|
} from '@paarrot/plugin-manager';
|
||||||
|
|
||||||
/**
|
export type {
|
||||||
* Color group for interactive palette entries (Main + Container sub-groups).
|
IPluginStorage,
|
||||||
*/
|
IPluginEventClient,
|
||||||
export interface ThemePaletteGroup {
|
PluginMarketplaceClientOptions,
|
||||||
main: string;
|
PluginMarketplaceHostAdapter,
|
||||||
mainHover: string;
|
PluginMarketplaceInstalledRecord,
|
||||||
mainActive: string;
|
PluginMarketplaceManagerOptions,
|
||||||
mainLine: string;
|
PluginRegistryOptions,
|
||||||
onMain: string;
|
PluginContextOptions,
|
||||||
container: string;
|
PluginMetadata,
|
||||||
containerHover: string;
|
InstalledPlugin,
|
||||||
containerActive: string;
|
PluginIndex,
|
||||||
containerLine: string;
|
ThemeColorGroup,
|
||||||
onContainer: string;
|
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';
|
||||||
* 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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
import {
|
||||||
* Theme definition for plugins. Provide human-readable colors and the system
|
PluginMarketplaceClient,
|
||||||
* maps them to the correct compiled CSS variables automatically.
|
PluginMarketplaceManager,
|
||||||
*/
|
PluginRegistry,
|
||||||
export interface PluginTheme {
|
} from '@paarrot/plugin-manager';
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
kind: 'light' | 'dark';
|
|
||||||
colors: PluginThemeColors;
|
|
||||||
/** Optional extra CSS injected alongside the theme (body backgrounds, scrollbars, etc.) */
|
|
||||||
extraCss?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
const PLUGIN_INDEX_URL =
|
||||||
* Command argument definition
|
'https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/index.json';
|
||||||
*/
|
const PLUGIN_BASE_URL =
|
||||||
export interface CommandArg {
|
'https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/';
|
||||||
name: string;
|
|
||||||
type?: 'string' | 'number' | 'boolean';
|
|
||||||
required?: boolean;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
function injectThemeStyle(styleId: string, css: string): void {
|
||||||
* Enhanced command with argument parsing
|
let el = document.getElementById(styleId) as HTMLStyleElement | null;
|
||||||
*/
|
if (!el) {
|
||||||
export interface PluginCommand {
|
el = document.createElement('style');
|
||||||
name: string;
|
el.id = styleId;
|
||||||
description?: string;
|
document.head.appendChild(el);
|
||||||
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, []);
|
|
||||||
}
|
}
|
||||||
|
el.textContent = css;
|
||||||
|
}
|
||||||
|
|
||||||
async unregisterPlugin(id: string): Promise<void> {
|
function removeThemeStyle(styleId: string): void {
|
||||||
const entry = this.plugins.get(id);
|
document.getElementById(styleId)?.remove();
|
||||||
if (!entry) return;
|
}
|
||||||
|
|
||||||
// Call onUnload
|
/** Shared singleton registry for the entire application. */
|
||||||
if (entry.plugin.onUnload) {
|
export const pluginRegistry = new PluginRegistry({
|
||||||
try {
|
storage: localStorage,
|
||||||
await entry.plugin.onUnload();
|
onThemeRegistered: (themeId, _className, css) =>
|
||||||
} catch (err) {
|
injectThemeStyle(`plugin-theme-${themeId}`, css),
|
||||||
console.error(`[PluginRegistry] Error unloading plugin ${id}:`, err);
|
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
|
const result = await window.electron.plugins.list();
|
||||||
for (const [cmdName, cmd] of this.commands.entries()) {
|
if (!result.success || !result.data) {
|
||||||
if (cmd.pluginId === id) {
|
throw new Error(result.error ?? 'Failed to list installed plugins');
|
||||||
this.commands.delete(cmdName);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up interceptors
|
return result.data.map((plugin) => ({
|
||||||
this.beforeSendInterceptors = this.beforeSendInterceptors.filter(i => i.pluginId !== id);
|
id: plugin.id,
|
||||||
this.receiveInterceptors = this.receiveInterceptors.filter(i => i.pluginId !== id);
|
name: plugin.name,
|
||||||
|
installedDate: plugin.installedDate,
|
||||||
// Clean up renderers
|
}));
|
||||||
for (const [type, renderer] of this.renderers.entries()) {
|
},
|
||||||
if (renderer.pluginId === id) {
|
installPlugin: async (plugin) => {
|
||||||
this.renderers.delete(type);
|
if (!window.electron?.plugins) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up themes
|
const result = await window.electron.plugins.download(plugin.id, plugin.downloadUrl, plugin.name);
|
||||||
for (const [themeId, theme] of this.themes.entries()) {
|
if (!result.success) {
|
||||||
if (theme.pluginId === id) {
|
throw new Error(result.error ?? 'Failed to install plugin');
|
||||||
this.themes.delete(themeId);
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
uninstallPlugin: async (pluginId) => {
|
||||||
// Clean up timers
|
if (!window.electron?.plugins) {
|
||||||
const timerIds = this.timers.get(id) || [];
|
return;
|
||||||
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);
|
const result = await window.electron.plugins.uninstall(pluginId);
|
||||||
}
|
if (!result.success) {
|
||||||
|
throw new Error(result.error ?? 'Failed to uninstall plugin');
|
||||||
// Command management
|
|
||||||
registerCommand(pluginId: string, command: PluginCommand): void {
|
|
||||||
if (this.commands.has(command.name)) {
|
|
||||||
console.warn(`[PluginRegistry] Command /${command.name} already exists`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.commands.set(command.name, { pluginId, command });
|
|
||||||
}
|
|
||||||
|
|
||||||
unregisterCommand(name: string): void {
|
|
||||||
this.commands.delete(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
async executeCommand(name: string, rawArgs: string): Promise<string | void> {
|
|
||||||
const cmd = this.commands.get(name);
|
|
||||||
if (!cmd) {
|
|
||||||
throw new Error(`Command /${name} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = this.parseCommandArgs(cmd.command, rawArgs);
|
|
||||||
const entry = this.plugins.get(cmd.pluginId);
|
|
||||||
if (!entry) {
|
|
||||||
throw new Error(`Plugin ${cmd.pluginId} not loaded`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await cmd.command.run(args, entry.context);
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseCommandArgs(command: PluginCommand, rawArgs: string): Record<string, any> {
|
|
||||||
if (!command.args || command.args.length === 0) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = rawArgs.trim().split(/\s+/);
|
|
||||||
const result: Record<string, any> = {};
|
|
||||||
|
|
||||||
command.args.forEach((arg, index) => {
|
|
||||||
const argName = typeof arg === 'string' ? arg : arg.name;
|
|
||||||
const value = parts[index] || undefined;
|
|
||||||
result[argName] = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
getCommands(): Array<{ name: string; pluginId: string; command: PluginCommand }> {
|
|
||||||
return Array.from(this.commands.entries()).map(([name, data]) => ({
|
|
||||||
name,
|
|
||||||
...data,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Message interceptors
|
|
||||||
addBeforeSendInterceptor(pluginId: string, interceptor: MessageInterceptor): void {
|
|
||||||
this.beforeSendInterceptors.push({ pluginId, interceptor });
|
|
||||||
}
|
|
||||||
|
|
||||||
addReceiveInterceptor(pluginId: string, interceptor: MessageInterceptor): void {
|
|
||||||
this.receiveInterceptors.push({ pluginId, interceptor });
|
|
||||||
}
|
|
||||||
|
|
||||||
async processBeforeSend(msg: MessageContext): Promise<MessageContext> {
|
|
||||||
let processedMsg = { ...msg };
|
|
||||||
for (const { interceptor } of this.beforeSendInterceptors) {
|
|
||||||
try {
|
|
||||||
await interceptor(processedMsg);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[PluginRegistry] Error in beforeSend interceptor:', err);
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
return processedMsg;
|
},
|
||||||
}
|
});
|
||||||
|
|
||||||
async processReceive(msg: MessageContext): Promise<MessageContext> {
|
|
||||||
let processedMsg = { ...msg };
|
|
||||||
for (const { interceptor } of this.receiveInterceptors) {
|
|
||||||
try {
|
|
||||||
await interceptor(processedMsg);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[PluginRegistry] Error in receive interceptor:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return processedMsg;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Renderers
|
|
||||||
registerRenderer(pluginId: string, type: string, renderer: CustomRenderer): void {
|
|
||||||
this.renderers.set(type, { pluginId, renderer });
|
|
||||||
}
|
|
||||||
|
|
||||||
unregisterRenderer(type: string): void {
|
|
||||||
this.renderers.delete(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
getRenderer(type: string): CustomRenderer | undefined {
|
|
||||||
return this.renderers.get(type)?.renderer;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings
|
|
||||||
defineSettings(pluginId: string, schema: SettingsSchema): void {
|
|
||||||
this.settingsSchemas.set(pluginId, schema);
|
|
||||||
|
|
||||||
// Load from localStorage
|
|
||||||
const stored = localStorage.getItem(`plugin:${pluginId}:settings`);
|
|
||||||
if (stored) {
|
|
||||||
try {
|
|
||||||
this.pluginSettings.set(pluginId, JSON.parse(stored));
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[PluginRegistry] Failed to load settings for ${pluginId}:`, err);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Initialize with defaults
|
|
||||||
const defaults: Record<string, any> = {};
|
|
||||||
Object.keys(schema).forEach(key => {
|
|
||||||
const def = schema[key];
|
|
||||||
if (def.default !== undefined) {
|
|
||||||
defaults[key] = def.default;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.pluginSettings.set(pluginId, defaults);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getPluginSetting<T = any>(pluginId: string, key: string): T | undefined {
|
|
||||||
return this.pluginSettings.get(pluginId)?.[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
setPluginSetting(pluginId: string, key: string, value: any): void {
|
|
||||||
const settings = this.pluginSettings.get(pluginId) || {};
|
|
||||||
settings[key] = value;
|
|
||||||
this.pluginSettings.set(pluginId, settings);
|
|
||||||
|
|
||||||
// Save to localStorage
|
|
||||||
localStorage.setItem(`plugin:${pluginId}:settings`, JSON.stringify(settings));
|
|
||||||
}
|
|
||||||
|
|
||||||
getPluginSettingsSchema(pluginId: string): SettingsSchema | undefined {
|
|
||||||
return this.settingsSchemas.get(pluginId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Theme management
|
|
||||||
registerTheme(pluginId: string, theme: PluginTheme): void {
|
|
||||||
const fullThemeId = `plugin-${pluginId}-${theme.id}`;
|
|
||||||
if (this.themes.has(fullThemeId)) {
|
|
||||||
console.warn(`[PluginRegistry] Theme ${fullThemeId} already exists`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const styleId = `plugin-theme-${fullThemeId}`;
|
|
||||||
let styleEl = document.getElementById(styleId) as HTMLStyleElement;
|
|
||||||
if (!styleEl) {
|
|
||||||
styleEl = document.createElement('style');
|
|
||||||
styleEl.id = styleId;
|
|
||||||
document.head.appendChild(styleEl);
|
|
||||||
}
|
|
||||||
styleEl.textContent = generateThemeCSS(fullThemeId, theme);
|
|
||||||
|
|
||||||
this.themes.set(fullThemeId, { pluginId, theme: { ...theme, id: fullThemeId }, styleId });
|
|
||||||
console.log(`[PluginRegistry] Theme registered: ${fullThemeId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
unregisterTheme(pluginId: string, themeId: string): void {
|
|
||||||
const fullThemeId = `plugin-${pluginId}-${themeId}`;
|
|
||||||
const themeData = this.themes.get(fullThemeId);
|
|
||||||
if (themeData) {
|
|
||||||
// Remove injected CSS
|
|
||||||
const styleEl = document.getElementById(themeData.styleId);
|
|
||||||
if (styleEl) {
|
|
||||||
styleEl.remove();
|
|
||||||
}
|
|
||||||
this.themes.delete(fullThemeId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getPluginThemes(): Array<{ id: string; name: string; kind: 'light' | 'dark'; className: string }> {
|
|
||||||
return Array.from(this.themes.values()).map(({ theme }) => ({
|
|
||||||
id: theme.id,
|
|
||||||
name: theme.name,
|
|
||||||
kind: theme.kind,
|
|
||||||
className: theme.id // Use the full theme ID as the class name
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Matrix event handlers
|
|
||||||
addMatrixHandler(pluginId: string, eventType: string, handler: Function): void {
|
|
||||||
if (!this.matrixHandlers.has(eventType)) {
|
|
||||||
this.matrixHandlers.set(eventType, []);
|
|
||||||
}
|
|
||||||
this.matrixHandlers.get(eventType)!.push({ pluginId, handler });
|
|
||||||
}
|
|
||||||
|
|
||||||
removeMatrixHandler(pluginId: string, eventType: string, handler: Function): void {
|
|
||||||
const handlers = this.matrixHandlers.get(eventType);
|
|
||||||
if (handlers) {
|
|
||||||
const filtered = handlers.filter(h => h.pluginId !== pluginId || h.handler !== handler);
|
|
||||||
this.matrixHandlers.set(eventType, filtered);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getMatrixHandlers(eventType: string): Array<{ pluginId: string; handler: Function }> {
|
|
||||||
return this.matrixHandlers.get(eventType) || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timers
|
|
||||||
addTimer(pluginId: string, timerId: number): void {
|
|
||||||
const timers = this.timers.get(pluginId) || [];
|
|
||||||
timers.push(timerId);
|
|
||||||
this.timers.set(pluginId, timers);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeTimer(pluginId: string, timerId: number): void {
|
|
||||||
const timers = this.timers.get(pluginId) || [];
|
|
||||||
const filtered = timers.filter(id => id !== timerId);
|
|
||||||
this.timers.set(pluginId, filtered);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logging
|
|
||||||
addLog(pluginId: string, level: 'log' | 'warn' | 'error', args: any[]): void {
|
|
||||||
this.logs.push({
|
|
||||||
pluginId,
|
|
||||||
level,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
args,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Keep only last N logs
|
|
||||||
if (this.logs.length > this.maxLogs) {
|
|
||||||
this.logs = this.logs.slice(-this.maxLogs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getLogs(pluginId?: string): PluginLogEntry[] {
|
|
||||||
if (pluginId) {
|
|
||||||
return this.logs.filter(log => log.pluginId === pluginId);
|
|
||||||
}
|
|
||||||
return this.logs;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearLogs(pluginId?: string): void {
|
|
||||||
if (pluginId) {
|
|
||||||
this.logs = this.logs.filter(log => log.pluginId !== pluginId);
|
|
||||||
} else {
|
|
||||||
this.logs = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plugin exports (for require())
|
|
||||||
getPluginExports(pluginId: string): any {
|
|
||||||
const entry = this.plugins.get(pluginId);
|
|
||||||
return entry?.plugin.exports;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hot reload
|
|
||||||
async reloadPlugin(pluginId: string, newPlugin: Plugin, newContext: PluginContext): Promise<void> {
|
|
||||||
await this.unregisterPlugin(pluginId);
|
|
||||||
this.registerPlugin(pluginId, newPlugin, newContext);
|
|
||||||
await newPlugin.onLoad(newContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
clear(): void {
|
|
||||||
// Unload all plugins
|
|
||||||
for (const pluginId of this.plugins.keys()) {
|
|
||||||
this.unregisterPlugin(pluginId).catch(err => {
|
|
||||||
console.error(`[PluginRegistry] Error clearing plugin ${pluginId}:`, err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.plugins.clear();
|
|
||||||
this.commands.clear();
|
|
||||||
this.beforeSendInterceptors = [];
|
|
||||||
this.receiveInterceptors = [];
|
|
||||||
this.renderers.clear();
|
|
||||||
this.settingsSchemas.clear();
|
|
||||||
this.matrixHandlers.clear();
|
|
||||||
this.timers.clear();
|
|
||||||
this.logs = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global plugin registry instance
|
|
||||||
export const pluginRegistry = new PluginRegistry();
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { MatrixClient } from 'matrix-js-sdk';
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
import { Plugin, PluginContext, pluginRegistry } from './PluginAPI';
|
import { Plugin, createPluginContext } from '@paarrot/plugin-manager';
|
||||||
import { InstalledPlugin } from './types';
|
import { pluginMarketplaceManager, pluginRegistry } from './PluginAPI';
|
||||||
import { sendNotification } from '../../../utils/tauri';
|
import { sendNotification } from '../../../utils/tauri';
|
||||||
|
|
||||||
interface PluginLoaderProps {
|
interface PluginLoaderProps {
|
||||||
@@ -14,56 +14,26 @@ interface PluginLoaderProps {
|
|||||||
* Should be placed high in the component tree after MatrixClient is available.
|
* Should be placed high in the component tree after MatrixClient is available.
|
||||||
*/
|
*/
|
||||||
export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
|
export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [loadedPlugins, setLoadedPlugins] = useState<string[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
|
||||||
|
|
||||||
const loadPlugins = async () => {
|
const loadPlugins = async () => {
|
||||||
try {
|
try {
|
||||||
console.log('[PluginLoader] Loading plugins...');
|
console.log('[PluginLoader] Loading plugins...');
|
||||||
|
|
||||||
// Get list of installed plugins from Electron
|
|
||||||
if (!window.electron?.plugins) {
|
if (!window.electron?.plugins) {
|
||||||
console.log('[PluginLoader] Running in web mode, plugins not supported');
|
console.log('[PluginLoader] Running in web mode, plugins not supported');
|
||||||
setLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await window.electron.plugins.list();
|
const pluginsToLoad = await pluginMarketplaceManager.listEnabledPlugins();
|
||||||
if (!response.success || !response.data) {
|
|
||||||
console.error('[PluginLoader] Failed to list plugins:', response.error);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const installedPlugins = response.data;
|
|
||||||
console.log('[PluginLoader] Found installed plugins:', installedPlugins.length);
|
|
||||||
|
|
||||||
// Check which plugins are enabled in localStorage
|
|
||||||
const enabledPluginsStr = localStorage.getItem('enabledPlugins');
|
|
||||||
const enabledPlugins = enabledPluginsStr ? JSON.parse(enabledPluginsStr) : {};
|
|
||||||
|
|
||||||
const pluginsToLoad = installedPlugins.filter(
|
|
||||||
(plugin: any) => enabledPlugins[plugin.id] !== false
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log('[PluginLoader] Plugins to load:', pluginsToLoad.length);
|
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) {
|
for (const installedPlugin of pluginsToLoad) {
|
||||||
try {
|
try {
|
||||||
console.log(`[PluginLoader] Loading plugin: ${installedPlugin.id}`);
|
console.log(`[PluginLoader] Loading plugin: ${installedPlugin.id}`);
|
||||||
|
|
||||||
// Dynamically import the plugin
|
|
||||||
const pluginModule = await loadPluginModule(installedPlugin.id);
|
const pluginModule = await loadPluginModule(installedPlugin.id);
|
||||||
|
const plugin = normalisePlugin(pluginModule);
|
||||||
// Handle both CommonJS (module.exports = {...}) and ES6 (export default {...})
|
|
||||||
const plugin: Plugin = pluginModule?.default || pluginModule;
|
|
||||||
|
|
||||||
if (!plugin || typeof plugin !== 'object') {
|
if (!plugin || typeof plugin !== 'object') {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -72,7 +42,6 @@ export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate plugin structure
|
|
||||||
if (typeof plugin.onLoad !== 'function') {
|
if (typeof plugin.onLoad !== 'function') {
|
||||||
console.error(
|
console.error(
|
||||||
`[PluginLoader] Plugin ${installedPlugin.id} does not have an onLoad function`
|
`[PluginLoader] Plugin ${installedPlugin.id} does not have an onLoad function`
|
||||||
@@ -80,252 +49,127 @@ export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create enhanced plugin context
|
const context = createPluginContext(
|
||||||
const context = createPluginContext(installedPlugin.id, matrixClient, React);
|
{
|
||||||
|
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);
|
pluginRegistry.registerPlugin(installedPlugin.id, plugin, context);
|
||||||
|
await plugin.onLoad(compatContext as any);
|
||||||
// Call plugin's onLoad
|
|
||||||
await plugin.onLoad(context);
|
|
||||||
|
|
||||||
loaded.push(installedPlugin.id);
|
|
||||||
console.log(`[PluginLoader] Successfully loaded plugin: ${installedPlugin.id}`);
|
console.log(`[PluginLoader] Successfully loaded plugin: ${installedPlugin.id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[PluginLoader] Failed to load plugin ${installedPlugin.id}:`, error);
|
console.error(`[PluginLoader] Failed to load plugin ${installedPlugin.id}:`, error);
|
||||||
pluginRegistry.addLog(installedPlugin.id, 'error', ['Failed to load:', error]);
|
pluginRegistry.addLog(installedPlugin.id, 'error', ['Failed to load:', error]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
setLoadedPlugins(loaded);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[PluginLoader] Error loading plugins:', error);
|
console.error('[PluginLoader] Error loading plugins:', error);
|
||||||
if (mounted) {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadPlugins();
|
loadPlugins();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
|
||||||
// Cleanup: unload all plugins
|
|
||||||
console.log('[PluginLoader] Cleaning up plugins');
|
console.log('[PluginLoader] Cleaning up plugins');
|
||||||
pluginRegistry.clear();
|
pluginRegistry.clear();
|
||||||
};
|
};
|
||||||
}, [matrixClient]);
|
}, [matrixClient]);
|
||||||
|
|
||||||
// Show children immediately, plugins load in background
|
|
||||||
return <>{children}</>;
|
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(
|
async function loadPluginModule(pluginId: string): Promise<any> {
|
||||||
pluginId: string,
|
if (!window.electron?.plugins) {
|
||||||
matrixClient: MatrixClient,
|
throw new Error('Plugins are only supported in the desktop app');
|
||||||
React: typeof import('react')
|
}
|
||||||
): PluginContext {
|
|
||||||
return {
|
|
||||||
pluginId,
|
|
||||||
matrixClient,
|
|
||||||
React,
|
|
||||||
|
|
||||||
// Commands API
|
const response = await window.electron.plugins.readPluginCode(pluginId);
|
||||||
commands: {
|
if (!response.success || !response.data) {
|
||||||
register: (command) => {
|
throw new Error(response.error ?? 'Failed to read plugin code');
|
||||||
pluginRegistry.registerCommand(pluginId, command);
|
}
|
||||||
pluginRegistry.addLog(pluginId, 'log', [`Registered command: /${command.name}`]);
|
|
||||||
},
|
|
||||||
unregister: (name) => {
|
|
||||||
pluginRegistry.unregisterCommand(name);
|
|
||||||
},
|
|
||||||
execute: async (name, args) => {
|
|
||||||
return pluginRegistry.executeCommand(name, JSON.stringify(args));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Messages API
|
const pluginExports: any = {};
|
||||||
messages: {
|
const mod = { exports: pluginExports };
|
||||||
onBeforeSend: (interceptor) => {
|
|
||||||
pluginRegistry.addBeforeSendInterceptor(pluginId, interceptor);
|
|
||||||
pluginRegistry.addLog(pluginId, 'log', ['Registered beforeSend interceptor']);
|
|
||||||
},
|
|
||||||
onReceive: (interceptor) => {
|
|
||||||
pluginRegistry.addReceiveInterceptor(pluginId, interceptor);
|
|
||||||
pluginRegistry.addLog(pluginId, 'log', ['Registered receive interceptor']);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// UI API
|
try {
|
||||||
ui: {
|
// eslint-disable-next-line no-new-func
|
||||||
registerRenderer: (type, renderer) => {
|
const pluginFunction = new Function('module', 'exports', response.data);
|
||||||
pluginRegistry.registerRenderer(pluginId, type, renderer);
|
pluginFunction(mod, pluginExports);
|
||||||
pluginRegistry.addLog(pluginId, 'log', [`Registered renderer for: ${type}`]);
|
} catch (error) {
|
||||||
},
|
if (!(error instanceof SyntaxError)) {
|
||||||
unregisterRenderer: (type) => {
|
throw error;
|
||||||
pluginRegistry.unregisterRenderer(type);
|
}
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Settings API
|
const dataUrl = `data:text/javascript;charset=utf-8,${encodeURIComponent(response.data)}`;
|
||||||
settings: {
|
const importedModule = await import(/* @vite-ignore */ dataUrl);
|
||||||
define: (schema) => {
|
return importedModule;
|
||||||
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
|
return mod.exports;
|
||||||
themes: {
|
|
||||||
register: (theme) => {
|
|
||||||
pluginRegistry.registerTheme(pluginId, theme);
|
|
||||||
pluginRegistry.addLog(pluginId, 'log', [`Registered theme: ${theme.name}`]);
|
|
||||||
},
|
|
||||||
unregister: (themeId) => {
|
|
||||||
pluginRegistry.unregisterTheme(pluginId, themeId);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Matrix events API
|
|
||||||
matrix: {
|
|
||||||
on: (eventType, handler) => {
|
|
||||||
pluginRegistry.addMatrixHandler(pluginId, eventType, handler);
|
|
||||||
matrixClient.on(eventType as any, handler as any);
|
|
||||||
pluginRegistry.addLog(pluginId, 'log', [`Listening to Matrix event: ${eventType}`]);
|
|
||||||
},
|
|
||||||
off: (eventType, handler) => {
|
|
||||||
pluginRegistry.removeMatrixHandler(pluginId, eventType, handler);
|
|
||||||
matrixClient.off(eventType as any, handler as any);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Timers API
|
|
||||||
timers: {
|
|
||||||
setInterval: (callback, ms) => {
|
|
||||||
const id = window.setInterval(() => {
|
|
||||||
try {
|
|
||||||
callback();
|
|
||||||
} catch (err) {
|
|
||||||
pluginRegistry.addLog(pluginId, 'error', ['Interval error:', err]);
|
|
||||||
}
|
|
||||||
}, ms);
|
|
||||||
pluginRegistry.addTimer(pluginId, id);
|
|
||||||
return id;
|
|
||||||
},
|
|
||||||
setTimeout: (callback, ms) => {
|
|
||||||
const id = window.setTimeout(() => {
|
|
||||||
try {
|
|
||||||
callback();
|
|
||||||
} catch (err) {
|
|
||||||
pluginRegistry.addLog(pluginId, 'error', ['Timeout error:', err]);
|
|
||||||
}
|
|
||||||
pluginRegistry.removeTimer(pluginId, id);
|
|
||||||
}, ms);
|
|
||||||
pluginRegistry.addTimer(pluginId, id);
|
|
||||||
return id;
|
|
||||||
},
|
|
||||||
clearInterval: (id) => {
|
|
||||||
window.clearInterval(id);
|
|
||||||
pluginRegistry.removeTimer(pluginId, id);
|
|
||||||
},
|
|
||||||
clearTimeout: (id) => {
|
|
||||||
window.clearTimeout(id);
|
|
||||||
pluginRegistry.removeTimer(pluginId, id);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Notifications API
|
|
||||||
notify: async (options) => {
|
|
||||||
const opts = typeof options === 'string' ? { title: 'Plugin', body: options } : options;
|
|
||||||
console.log(`[Plugin ${pluginId}] Notification:`, opts.title, opts.body);
|
|
||||||
pluginRegistry.addLog(pluginId, 'log', ['Notification:', opts]);
|
|
||||||
|
|
||||||
// Send actual notification
|
|
||||||
try {
|
|
||||||
await sendNotification({
|
|
||||||
title: opts.title || 'Plugin',
|
|
||||||
body: opts.body || '',
|
|
||||||
path: undefined, // Could be enhanced to support navigation
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[Plugin ${pluginId}] Notification failed:`, err);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Logging API
|
|
||||||
log: (...args) => {
|
|
||||||
console.log(`[Plugin ${pluginId}]`, ...args);
|
|
||||||
pluginRegistry.addLog(pluginId, 'log', args);
|
|
||||||
},
|
|
||||||
warn: (...args) => {
|
|
||||||
console.warn(`[Plugin ${pluginId}]`, ...args);
|
|
||||||
pluginRegistry.addLog(pluginId, 'warn', args);
|
|
||||||
},
|
|
||||||
error: (...args) => {
|
|
||||||
console.error(`[Plugin ${pluginId}]`, ...args);
|
|
||||||
pluginRegistry.addLog(pluginId, 'error', args);
|
|
||||||
},
|
|
||||||
|
|
||||||
// Require API
|
|
||||||
require: (requiredPluginId) => {
|
|
||||||
const exports = pluginRegistry.getPluginExports(requiredPluginId);
|
|
||||||
if (!exports) {
|
|
||||||
throw new Error(`Plugin ${requiredPluginId} not found or has no exports`);
|
|
||||||
}
|
|
||||||
pluginRegistry.addLog(pluginId, 'log', [`Required plugin: ${requiredPluginId}`]);
|
|
||||||
return exports;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load a plugin module from the filesystem.
|
* Normalizes raw plugin exports to current plugin interface.
|
||||||
* Handles both Electron and web environments.
|
* Supports native `onLoad` and legacy `activate` / `deactivate` plugins.
|
||||||
*/
|
*/
|
||||||
async function loadPluginModule(pluginId: string): Promise<any> {
|
function normalisePlugin(raw: Record<string, unknown>): Plugin {
|
||||||
// Check if we're in Electron
|
if (typeof raw.onLoad === 'function') {
|
||||||
if (window.electron?.plugins) {
|
return raw as unknown as Plugin;
|
||||||
// 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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 { HexColorPicker } from 'react-colorful';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
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 { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
import { pluginRegistry, SettingsSchema, SettingDefinition } from './PluginAPI';
|
import { pluginMarketplaceManager, pluginRegistry, SettingDefinition } from './PluginAPI';
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
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
|
* 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 [searchQuery, setSearchQuery] = useState('');
|
||||||
const [expandedSettings, setExpandedSettings] = useState<string | null>(null);
|
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 () => {
|
const fetchMarketplacePlugins = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const indexResponse = await fetch(PLUGIN_INDEX_URL);
|
setMarketplacePlugins(await pluginMarketplaceManager.fetchMarketplacePlugins());
|
||||||
if (!indexResponse.ok) {
|
|
||||||
throw new Error('Failed to fetch plugin index');
|
|
||||||
}
|
|
||||||
const index: PluginIndex = await indexResponse.json();
|
|
||||||
|
|
||||||
const pluginPromises = index.plugins.map(async (pluginId) => {
|
|
||||||
const pluginResponse = await fetch(`${PLUGIN_BASE_URL}${pluginId}.json`);
|
|
||||||
if (!pluginResponse.ok) {
|
|
||||||
console.error(`Failed to fetch plugin ${pluginId}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return pluginResponse.json();
|
|
||||||
});
|
|
||||||
|
|
||||||
const plugins = await Promise.all(pluginPromises);
|
|
||||||
setMarketplacePlugins(plugins.filter((p): p is PluginMetadata => p !== null));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to fetch plugins');
|
setError(err instanceof Error ? err.message : 'Failed to fetch plugins');
|
||||||
console.error('Error fetching marketplace plugins:', err);
|
console.error('Error fetching marketplace plugins:', err);
|
||||||
@@ -293,112 +279,38 @@ export function Plugins({ requestClose }: PluginsProps) {
|
|||||||
}, [activeTab, fetchMarketplacePlugins]);
|
}, [activeTab, fetchMarketplacePlugins]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadInstalledPlugins = async () => {
|
refreshInstalledPlugins();
|
||||||
if (isElectron) {
|
}, [refreshInstalledPlugins]);
|
||||||
// Load from filesystem via Electron
|
|
||||||
try {
|
|
||||||
const result = await (window as any).electron.plugins.list();
|
|
||||||
if (result.success && result.data) {
|
|
||||||
// Merge filesystem data with metadata from marketplace
|
|
||||||
const fsPlugins = result.data.map((fsPlugin: any) => ({
|
|
||||||
...fsPlugin,
|
|
||||||
enabled: true, // Default to enabled
|
|
||||||
}));
|
|
||||||
setInstalledPlugins(fsPlugins);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to list plugins from filesystem:', err);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback to localStorage for web
|
|
||||||
const stored = localStorage.getItem('installedPlugins');
|
|
||||||
if (stored) {
|
|
||||||
try {
|
|
||||||
setInstalledPlugins(JSON.parse(stored));
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to parse installed plugins:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadInstalledPlugins();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleInstall = useCallback(
|
const handleInstall = useCallback(
|
||||||
async (plugin: PluginMetadata) => {
|
async (plugin: PluginMetadata) => {
|
||||||
if (isElectron) {
|
try {
|
||||||
// Download and install to filesystem via Electron
|
setLoading(true);
|
||||||
try {
|
setError(null);
|
||||||
setLoading(true);
|
await pluginMarketplaceManager.installPlugin(plugin);
|
||||||
const result = await (window as any).electron.plugins.download(
|
await refreshInstalledPlugins();
|
||||||
plugin.id,
|
} catch (err) {
|
||||||
plugin.downloadUrl,
|
console.error('Failed to install plugin:', err);
|
||||||
plugin.name
|
setError(err instanceof Error ? err.message : 'Failed to install plugin');
|
||||||
);
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
if (result.success) {
|
|
||||||
const newPlugin: InstalledPlugin = {
|
|
||||||
...plugin,
|
|
||||||
installedDate: new Date().toISOString(),
|
|
||||||
enabled: true,
|
|
||||||
};
|
|
||||||
setInstalledPlugins([...installedPlugins, newPlugin]);
|
|
||||||
} else {
|
|
||||||
setError(result.error || 'Failed to install plugin');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to install plugin:', err);
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to install plugin');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback to localStorage for web
|
|
||||||
const newPlugin: InstalledPlugin = {
|
|
||||||
...plugin,
|
|
||||||
installedDate: new Date().toISOString(),
|
|
||||||
enabled: true,
|
|
||||||
};
|
|
||||||
const updated = [...installedPlugins, newPlugin];
|
|
||||||
setInstalledPlugins(updated);
|
|
||||||
localStorage.setItem('installedPlugins', JSON.stringify(updated));
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[installedPlugins]
|
[refreshInstalledPlugins]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUninstall = useCallback(
|
const handleUninstall = useCallback(
|
||||||
async (pluginId: string) => {
|
async (pluginId: string) => {
|
||||||
// First, unregister the plugin to trigger onUnload
|
|
||||||
try {
|
try {
|
||||||
await pluginRegistry.unregisterPlugin(pluginId);
|
setError(null);
|
||||||
|
await pluginMarketplaceManager.uninstallPlugin(pluginId);
|
||||||
|
await refreshInstalledPlugins();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to unregister plugin ${pluginId}:`, err);
|
console.error('Failed to uninstall plugin:', err);
|
||||||
}
|
setError(err instanceof Error ? err.message : 'Failed to uninstall plugin');
|
||||||
|
|
||||||
if (isElectron) {
|
|
||||||
// Uninstall from filesystem via Electron
|
|
||||||
try {
|
|
||||||
const result = await (window as any).electron.plugins.uninstall(pluginId);
|
|
||||||
if (result.success) {
|
|
||||||
const updated = installedPlugins.filter((p) => p.id !== pluginId);
|
|
||||||
setInstalledPlugins(updated);
|
|
||||||
} else {
|
|
||||||
setError(result.error || 'Failed to uninstall plugin');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to uninstall plugin:', err);
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to uninstall plugin');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback to localStorage for web
|
|
||||||
const updated = installedPlugins.filter((p) => p.id !== pluginId);
|
|
||||||
setInstalledPlugins(updated);
|
|
||||||
localStorage.setItem('installedPlugins', JSON.stringify(updated));
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[installedPlugins]
|
[refreshInstalledPlugins]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleToggleEnabled = useCallback(
|
const handleToggleEnabled = useCallback(
|
||||||
@@ -408,36 +320,19 @@ export function Plugins({ requestClose }: PluginsProps) {
|
|||||||
|
|
||||||
const newEnabledState = !plugin.enabled;
|
const newEnabledState = !plugin.enabled;
|
||||||
|
|
||||||
if (plugin.enabled) {
|
try {
|
||||||
// Disabling: unregister the plugin to trigger onUnload
|
setError(null);
|
||||||
try {
|
await pluginMarketplaceManager.setPluginEnabled(pluginId, newEnabledState);
|
||||||
await pluginRegistry.unregisterPlugin(pluginId);
|
await refreshInstalledPlugins();
|
||||||
console.log(`[Plugins] Plugin ${pluginId} disabled and unloaded`);
|
if (newEnabledState) {
|
||||||
} catch (err) {
|
console.log(`[Plugins] Plugin ${pluginId} enabled. Restart app to load it.`);
|
||||||
console.error(`Failed to unregister plugin ${pluginId}:`, err);
|
|
||||||
}
|
}
|
||||||
}
|
} catch (err) {
|
||||||
|
console.error(`Failed to toggle plugin ${pluginId}:`, err);
|
||||||
// Update installedPlugins list
|
setError(err instanceof Error ? err.message : 'Failed to update plugin state');
|
||||||
const updated = installedPlugins.map((p) =>
|
|
||||||
p.id === pluginId ? { ...p, enabled: newEnabledState } : p
|
|
||||||
);
|
|
||||||
setInstalledPlugins(updated);
|
|
||||||
localStorage.setItem('installedPlugins', JSON.stringify(updated));
|
|
||||||
|
|
||||||
// Also update enabledPlugins for PluginLoader
|
|
||||||
const enabledPluginsStr = localStorage.getItem('enabledPlugins');
|
|
||||||
const enabledPlugins = enabledPluginsStr ? JSON.parse(enabledPluginsStr) : {};
|
|
||||||
enabledPlugins[pluginId] = newEnabledState;
|
|
||||||
localStorage.setItem('enabledPlugins', JSON.stringify(enabledPlugins));
|
|
||||||
|
|
||||||
// Note: Enabling a plugin requires app restart to load it
|
|
||||||
// PluginLoader only loads plugins on mount
|
|
||||||
if (newEnabledState) {
|
|
||||||
console.log(`[Plugins] Plugin ${pluginId} enabled. Restart app to load it.`);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[installedPlugins]
|
[installedPlugins, refreshInstalledPlugins]
|
||||||
);
|
);
|
||||||
|
|
||||||
const isPluginInstalled = useCallback(
|
const isPluginInstalled = useCallback(
|
||||||
|
|||||||
@@ -1,38 +1,2 @@
|
|||||||
/**
|
export type { InstalledPlugin, PluginIndex, PluginMetadata } from '@paarrot/plugin-manager';
|
||||||
* Plugin metadata from the marketplace
|
export { PluginTab } from '@paarrot/plugin-manager';
|
||||||
*/
|
|
||||||
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',
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ export type HashRouterConfig = {
|
|||||||
basename?: string;
|
basename?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CallingConfig = {
|
|
||||||
livekitServiceUrl?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ClientConfig = {
|
export type ClientConfig = {
|
||||||
defaultHomeserver?: number;
|
defaultHomeserver?: number;
|
||||||
homeserverList?: string[];
|
homeserverList?: string[];
|
||||||
@@ -21,8 +17,6 @@ export type ClientConfig = {
|
|||||||
servers?: string[];
|
servers?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
calling?: CallingConfig;
|
|
||||||
|
|
||||||
hashRouter?: HashRouterConfig;
|
hashRouter?: HashRouterConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -56,8 +56,6 @@ export interface Settings {
|
|||||||
developerTools: boolean;
|
developerTools: boolean;
|
||||||
|
|
||||||
autoJoinSpaceRooms: boolean;
|
autoJoinSpaceRooms: boolean;
|
||||||
|
|
||||||
livekitServiceUrl?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultSettings: Settings = {
|
const defaultSettings: Settings = {
|
||||||
@@ -99,8 +97,6 @@ const defaultSettings: Settings = {
|
|||||||
developerTools: false,
|
developerTools: false,
|
||||||
|
|
||||||
autoJoinSpaceRooms: true,
|
autoJoinSpaceRooms: true,
|
||||||
|
|
||||||
livekitServiceUrl: undefined,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSettings = () => {
|
export const getSettings = () => {
|
||||||
|
|||||||
@@ -42,9 +42,6 @@ export enum StateEvent {
|
|||||||
/** Room-wide link embed filter patterns (admin-controlled) */
|
/** Room-wide link embed filter patterns (admin-controlled) */
|
||||||
RoomEmbedFilters = 'im.paarrot.room.embed_filters',
|
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 */
|
/** Sub-rooms: child rooms nested under this room */
|
||||||
PaarrotSubRooms = 'im.paarrot.sub_rooms',
|
PaarrotSubRooms = 'im.paarrot.sub_rooms',
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user