From 52e9cc360f957c13f373bf8470a21bacad632532 Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Sun, 19 Apr 2026 06:52:42 +1000 Subject: [PATCH] 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. --- config.json | 4 - package-lock.json | 14 + package.json | 1 + src/app/features/call/CallProviderWrapper.tsx | 9 +- src/app/features/call/CallService.ts | 20 +- src/app/features/call/useCall.ts | 42 +- src/app/features/call/useRtcConfig.ts | 2 +- .../common-settings/general/LivekitConfig.tsx | 156 ---- .../features/common-settings/general/index.ts | 1 - .../room-settings/general/General.tsx | 5 - src/app/features/settings/audio/Audio.tsx | 28 - .../features/settings/plugins/PluginAPI.ts | 794 +++--------------- .../settings/plugins/PluginLoader.tsx | 340 ++------ src/app/features/settings/plugins/Plugins.tsx | 189 +---- src/app/features/settings/plugins/types.ts | 40 +- src/app/hooks/useClientConfig.ts | 6 - src/app/state/settings.ts | 4 - src/types/matrix/room.ts | 3 - 18 files changed, 283 insertions(+), 1375 deletions(-) delete mode 100644 src/app/features/common-settings/general/LivekitConfig.tsx diff --git a/config.json b/config.json index 57c577a..7b69ce2 100644 --- a/config.json +++ b/config.json @@ -10,10 +10,6 @@ ], "allowCustomHomeservers": true, - "calling": { - "livekitServiceUrl": "https://b.ruv.wtf/matrix-rtc/livekit/jwt" - }, - "featuredCommunities": { "openAsDefault": false, "spaces": [ diff --git a/package-lock.json b/package-lock.json index 9672954..df80177 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c0210f6..6718d66 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/features/call/CallProviderWrapper.tsx b/src/app/features/call/CallProviderWrapper.tsx index 5553d9d..b7fe4a6 100644 --- a/src/app/features/call/CallProviderWrapper.tsx +++ b/src/app/features/call/CallProviderWrapper.tsx @@ -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 ( diff --git a/src/app/features/call/CallService.ts b/src/app/features/call/CallService.ts index 9bbc289..d66d50a 100644 --- a/src/app/features/call/CallService.ts +++ b/src/app/features/call/CallService.ts @@ -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, diff --git a/src/app/features/call/useCall.ts b/src/app/features/call/useCall.ts index e4a62e0..09b9f9d 100644 --- a/src/app/features/call/useCall.ts +++ b/src/app/features/call/useCall.ts @@ -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(null); const [activeCall, setActiveCall] = useState(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( diff --git a/src/app/features/call/useRtcConfig.ts b/src/app/features/call/useRtcConfig.ts index 9456afd..88a0d58 100644 --- a/src/app/features/call/useRtcConfig.ts +++ b/src/app/features/call/useRtcConfig.ts @@ -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 diff --git a/src/app/features/common-settings/general/LivekitConfig.tsx b/src/app/features/common-settings/general/LivekitConfig.tsx deleted file mode 100644 index caa503e..0000000 --- a/src/app/features/common-settings/general/LivekitConfig.tsx +++ /dev/null @@ -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(); - const currentUrl = currentConfig?.service_url || ''; - - const [urlValue, setUrlValue] = useState(currentUrl); - const [urlError, setUrlError] = useState(); - - 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 = 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 ( - - - - ) => { - 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 && ( - - {urlError} - - )} - {saveState.status === AsyncStatus.Error && ( - - Failed to save: {saveState.error instanceof Error ? saveState.error.message : 'Unknown error'} - - )} - {saveState.status === AsyncStatus.Success && !hasChanges && ( - - Configuration saved - - )} - - - {currentUrl && ( - - )} - {!canEdit && ( - - You don't have permission to change this setting - - )} - - - - ); -} diff --git a/src/app/features/common-settings/general/index.ts b/src/app/features/common-settings/general/index.ts index cb9d8f0..9b2e2ac 100644 --- a/src/app/features/common-settings/general/index.ts +++ b/src/app/features/common-settings/general/index.ts @@ -1,5 +1,4 @@ export * from './EmbedFilters'; -export * from './LivekitConfig'; export * from './RoomAddress'; export * from './RoomEncryption'; export * from './RoomHistoryVisibility'; diff --git a/src/app/features/room-settings/general/General.tsx b/src/app/features/room-settings/general/General.tsx index 2a4fc4a..92eb32e 100644 --- a/src/app/features/room-settings/general/General.tsx +++ b/src/app/features/room-settings/general/General.tsx @@ -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) { Link Previews - - Voice & Video - - Addresses diff --git a/src/app/features/settings/audio/Audio.tsx b/src/app/features/settings/audio/Audio.tsx index 9f0e659..f1d211c 100644 --- a/src/app/features/settings/audio/Audio.tsx +++ b/src/app/features/settings/audio/Audio.tsx @@ -456,7 +456,6 @@ export function Audio({ requestClose }: AudioProps) { const [speakerTestState, setSpeakerTestState] = useState<'idle' | 'testing' | 'done' | 'error'>('idle'); const micAnimFrameRef = useRef(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) { /> - -
- - LiveKit Configuration - -
- setLivekitServiceUrl(e.target.value || undefined)} - placeholder="https://example.com/livekit/jwt" - style={{ minWidth: toRem(300) }} - /> - } - /> -
- , ctx: PluginContext) => string | void | Promise; -} - -/** - * Message interceptor callback - */ -export type MessageInterceptor = (msg: MessageContext) => void | Promise; - -/** - * Message context for interceptors - */ -export interface MessageContext { - content: string; - roomId: string; - eventType: string; - formatted?: string; - metadata?: Record; -} - -/** - * 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; - -/** - * 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) => Promise; - }; - - /** 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: (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; - - /** Called when the plugin is loaded */ - onLoad: (context: PluginContext) => void | Promise; - - /** Called when the plugin is unloaded/disabled */ - onUnload?: () => void | Promise; - - /** 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(); - private commands = new Map(); - private beforeSendInterceptors: Array<{ pluginId: string; interceptor: MessageInterceptor }> = []; - private receiveInterceptors: Array<{ pluginId: string; interceptor: MessageInterceptor }> = []; - private renderers = new Map(); - private settingsSchemas = new Map(); - private pluginSettings = new Map>(); - private matrixHandlers = new Map>(); - private timers = new Map(); - private themes = new Map(); - 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 { - 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 { - 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 { - if (!command.args || command.args.length === 0) { - return {}; - } - - const parts = rawArgs.trim().split(/\s+/); - const result: Record = {}; - - 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 { - 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 { - 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 = {}; - Object.keys(schema).forEach(key => { - const def = schema[key]; - if (def.default !== undefined) { - defaults[key] = def.default; - } - }); - this.pluginSettings.set(pluginId, defaults); - } - } - - getPluginSetting(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 { - 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(); + }, + }, +}); \ No newline at end of file diff --git a/src/app/features/settings/plugins/PluginLoader.tsx b/src/app/features/settings/plugins/PluginLoader.tsx index b0ebb61..9d8f5cf 100644 --- a/src/app/features/settings/plugins/PluginLoader.tsx +++ b/src/app/features/settings/plugins/PluginLoader.tsx @@ -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([]); - 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, { + 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 { + 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: (key: string): T | undefined => { - return pluginRegistry.getPluginSetting(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 { - // 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): 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; + const deactivate = raw.deactivate as ((ctx?: unknown) => void | Promise) | 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; } diff --git a/src/app/features/settings/plugins/Plugins.tsx b/src/app/features/settings/plugins/Plugins.tsx index 76f2524..2a47198 100644 --- a/src/app/features/settings/plugins/Plugins.tsx +++ b/src/app/features/settings/plugins/Plugins.tsx @@ -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(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( diff --git a/src/app/features/settings/plugins/types.ts b/src/app/features/settings/plugins/types.ts index 37279a0..5616e7f 100644 --- a/src/app/features/settings/plugins/types.ts +++ b/src/app/features/settings/plugins/types.ts @@ -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'; diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index d7592fe..e5fc6cc 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -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; }; diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index fae7eb6..80ba460 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -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 = () => { diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts index 5243335..f45511a 100644 --- a/src/types/matrix/room.ts +++ b/src/types/matrix/room.ts @@ -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',