import { Injectable, inject } from '@angular/core'; import { environment } from '../../../../../environments/environment'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; import type { TojuPluginManifest } from '../../../../shared-kernel'; import { DEVELOPMENT_PLUGIN_ENTRYPOINT, DEVELOPMENT_PLUGIN_MANIFEST, DEVELOPMENT_PLUGIN_MODULE } from '../../development/development-plugin'; import type { TojuClientPluginModule, TojuPluginActivationContext, TojuPluginDisposable } from '../../domain/models/plugin-api.models'; import type { LocalPluginDiscoveryError, LocalPluginRegistrationResult, RegisteredPlugin } from '../../domain/models/plugin-runtime.models'; import { LocalPluginDiscoveryService } from '../../infrastructure/local-plugin-discovery.service'; import { PluginCapabilityService } from './plugin-capability.service'; import { PluginDesktopStateService } from './plugin-desktop-state.service'; import { PluginClientApiService } from './plugin-client-api.service'; import { PluginLoggerService } from './plugin-logger.service'; import { PluginRegistryService } from './plugin-registry.service'; import { PluginUiRegistryService } from './plugin-ui-registry.service'; interface ActivePluginRuntime { context: TojuPluginActivationContext; moduleObjectUrl?: string; module: TojuClientPluginModule; } const STORAGE_KEY_PLUGIN_ACTIVATION = 'metoyou_plugin_activation_state'; @Injectable({ providedIn: 'root' }) export class PluginHostService { private readonly apiFactory = inject(PluginClientApiService); private readonly capabilities = inject(PluginCapabilityService); private readonly desktopState = inject(PluginDesktopStateService); private readonly electronBridge = inject(ElectronBridgeService, { optional: true }); private readonly localDiscovery = inject(LocalPluginDiscoveryService); private readonly logger = inject(PluginLoggerService); private readonly registry = inject(PluginRegistryService); private readonly uiRegistry = inject(PluginUiRegistryService); private readonly activePlugins = new Map(); private readonly activationRequests = new Map>(); private readonly activationStateReady: Promise; private activatedPluginIds = new Set(); constructor() { this.registerDevelopmentPlugin(); this.activationStateReady = this.loadActivationState(); } registerLocalManifest(manifestValue: unknown, sourcePath?: string): RegisteredPlugin { return this.registry.registerManifest(manifestValue, sourcePath); } async discoverLocalPlugins(): Promise { const discovery = await this.localDiscovery.discoverManifests(); const registered: RegisteredPlugin[] = []; const errors: LocalPluginDiscoveryError[] = [...discovery.errors]; for (const descriptor of discovery.plugins) { try { registered.push(this.registerLocalManifest(descriptor.manifest, descriptor.pluginRootUrl ?? descriptor.pluginRoot)); } catch (error) { errors.push({ manifestPath: descriptor.manifestPath, message: error instanceof Error ? error.message : 'Plugin manifest validation failed', pluginRoot: descriptor.pluginRoot }); } } return { discovery, errors, registered }; } getReadyManifests(): TojuPluginManifest[] { return this.registry.loadOrder().ordered; } async activateReadyPlugins(): Promise { await this.activationStateReady; const activated: TojuPluginActivationContext[] = []; for (const manifest of this.registry.loadOrder().ordered) { const entry = this.registry.find(manifest.id); if (!entry || !entry.enabled || this.activePlugins.has(manifest.id)) { continue; } const didActivate = await this.activatePlugin(entry); const active = this.activePlugins.get(manifest.id); if (didActivate && active) { activated.push(active.context); this.activatedPluginIds.add(active.context.pluginId); } } await this.saveActivationState(); await this.runReadyHooks(activated); } async activatePluginById(pluginId: string): Promise { await this.activationStateReady; if (this.activePlugins.has(pluginId)) { this.activatedPluginIds.add(pluginId); await this.saveActivationState(); return; } const entry = this.registry.find(pluginId); if (!entry?.enabled) { return; } const didActivate = await this.activatePlugin(entry); const active = this.activePlugins.get(pluginId); if (!didActivate || !active) { return; } this.activatedPluginIds.add(pluginId); await this.saveActivationState(); await this.runReadyHooks([active.context]); } async rememberActivation(pluginId: string): Promise { await this.activationStateReady; this.activatedPluginIds.add(pluginId); await this.saveActivationState(); } async activatePersistedPlugins(): Promise { await this.activationStateReady; const activated: TojuPluginActivationContext[] = []; for (const manifest of this.registry.loadOrder().ordered) { if (!this.activatedPluginIds.has(manifest.id) || this.activePlugins.has(manifest.id)) { continue; } const entry = this.registry.find(manifest.id); if (!entry?.enabled) { continue; } const didActivate = await this.activatePlugin(entry); const active = this.activePlugins.get(manifest.id); if (didActivate && active) { activated.push(active.context); } } await this.runReadyHooks(activated); } isPluginActive(pluginId: string): boolean { return this.activePlugins.has(pluginId); } async deactivatePlugin(pluginId: string, options: { forgetActivation?: boolean } = {}): Promise { await this.activationStateReady; const active = this.activePlugins.get(pluginId); if (!active) { if (options.forgetActivation) { this.activatedPluginIds.delete(pluginId); await this.saveActivationState(); } this.registry.setState(pluginId, 'unloaded'); this.uiRegistry.unregisterPlugin(pluginId); return; } this.registry.setState(pluginId, 'unloading'); try { await active.module.deactivate?.(active.context); } catch (error) { this.logger.warn(pluginId, 'Plugin deactivate failed', error); } for (const disposable of [...active.context.subscriptions].reverse()) { safeDispose(disposable, pluginId, this.logger); } this.uiRegistry.unregisterPlugin(pluginId); this.activePlugins.delete(pluginId); this.revokeModuleObjectUrl(pluginId); if (options.forgetActivation) { this.activatedPluginIds.delete(pluginId); await this.saveActivationState(); } this.registry.setState(pluginId, 'unloaded'); } async deactivateAll(): Promise { const pluginIds = Array.from(this.activePlugins.keys()).reverse(); for (const pluginId of pluginIds) { await this.deactivatePlugin(pluginId); } } async reloadPlugin(pluginId: string): Promise { await this.deactivatePlugin(pluginId); const entry = this.registry.find(pluginId); if (entry?.enabled) { await this.activatePlugin(entry); if (this.activePlugins.has(pluginId)) { this.activatedPluginIds.add(pluginId); await this.saveActivationState(); } } } markLoaded(pluginId: string): void { this.registry.setState(pluginId, 'loaded'); } markFailed(pluginId: string): void { this.registry.setState(pluginId, 'failed'); } private async runReadyHooks(contexts: TojuPluginActivationContext[]): Promise { for (const context of contexts) { const active = this.activePlugins.get(context.pluginId); if (!active?.module.ready) { continue; } try { await active.module.ready(context); this.registry.setState(context.pluginId, 'ready'); } catch (error) { this.failPlugin(context.pluginId, error); } } } private async activatePlugin(entry: RegisteredPlugin): Promise { const pluginId = entry.manifest.id; if (this.activePlugins.has(pluginId)) { return false; } const pendingActivation = this.activationRequests.get(pluginId); if (pendingActivation) { await pendingActivation; return false; } const activation = this.activatePluginInternal(entry); this.activationRequests.set(pluginId, activation); try { return await activation; } finally { if (this.activationRequests.get(pluginId) === activation) { this.activationRequests.delete(pluginId); } } } private async activatePluginInternal(entry: RegisteredPlugin): Promise { const manifest = entry.manifest; const missingCapabilities = this.capabilities.missing(manifest); if (missingCapabilities.length > 0) { this.registry.setFailed(manifest.id, `Missing capabilities: ${missingCapabilities.join(', ')}`); this.logger.warn(manifest.id, 'Plugin blocked by missing capability grants', missingCapabilities); return false; } if (!manifest.entrypoint) { this.registry.setState(manifest.id, 'ready'); return false; } this.registry.setState(manifest.id, 'loading'); try { const { module, moduleObjectUrl } = await this.loadPluginModule(manifest, entry.sourcePath); const context: TojuPluginActivationContext = { api: this.apiFactory.createApi(manifest), manifest, pluginId: manifest.id, subscriptions: [] }; await this.runWithPluginRuntimeGuards(manifest.id, () => module.activate?.(context)); this.activePlugins.set(manifest.id, { context, module, moduleObjectUrl }); this.registry.setState(manifest.id, 'loaded'); this.logger.info(manifest.id, 'Plugin activated'); return true; } catch (error) { this.failPlugin(manifest.id, error); return false; } } private failPlugin(pluginId: string, error: unknown): void { const message = error instanceof Error ? error.message : 'Plugin activation failed'; this.registry.setFailed(pluginId, message); this.logger.error(pluginId, message, error); this.uiRegistry.unregisterPlugin(pluginId); this.activePlugins.delete(pluginId); this.revokeModuleObjectUrl(pluginId); } private async runWithPluginRuntimeGuards(pluginId: string, activate: () => Promise | void): Promise { const originalMutationObserver = globalThis.MutationObserver; if (!originalMutationObserver) { await activate(); return; } const guardedMutationObserver = createGuardedMutationObserver(originalMutationObserver, pluginId, this.logger); globalThis.MutationObserver = guardedMutationObserver; try { await activate(); } finally { if (globalThis.MutationObserver === guardedMutationObserver) { globalThis.MutationObserver = originalMutationObserver; } } } private async loadPluginModule( manifest: TojuPluginManifest, sourcePath?: string ): Promise<{ module: TojuClientPluginModule; moduleObjectUrl?: string }> { if (manifest.entrypoint === DEVELOPMENT_PLUGIN_ENTRYPOINT) { return { module: DEVELOPMENT_PLUGIN_MODULE }; } const entrypointUrl = this.resolveEntrypoint(manifest, sourcePath); if (entrypointUrl.startsWith('file://')) { const moduleObjectUrl = await this.createLocalModuleObjectUrl(entrypointUrl); const module = await import(/* @vite-ignore */ moduleObjectUrl) as TojuClientPluginModule; return { module, moduleObjectUrl }; } try { return { module: await import(/* @vite-ignore */ entrypointUrl) as TojuClientPluginModule }; } catch (error) { if (!entrypointUrl.startsWith('http://') && !entrypointUrl.startsWith('https://')) { throw error; } const moduleObjectUrl = await this.createRemoteModuleObjectUrl(entrypointUrl); const module = await import(/* @vite-ignore */ moduleObjectUrl) as TojuClientPluginModule; return { module, moduleObjectUrl }; } } private async createLocalModuleObjectUrl(entrypointUrl: string): Promise { const api = this.electronBridge?.getApi(); if (!api) { throw new Error('Local plugin entrypoints require the desktop app'); } const base64Data = await api.readFile(fileUrlToPath(entrypointUrl)); const bytes = Uint8Array.from(atob(base64Data), (character) => character.charCodeAt(0)); const source = new TextDecoder().decode(bytes); return URL.createObjectURL(new Blob([source], { type: 'text/javascript' })); } private async createRemoteModuleObjectUrl(entrypointUrl: string): Promise { const response = await fetch(entrypointUrl, { headers: { Accept: 'text/javascript,*/*' } }); if (!response.ok) { throw new Error(`Plugin entrypoint returned ${response.status}`); } const source = await response.text(); return URL.createObjectURL(new Blob([`${source}\n//# sourceURL=${entrypointUrl}`], { type: 'text/javascript' })); } private revokeModuleObjectUrl(pluginId: string): void { const moduleObjectUrl = this.activePlugins.get(pluginId)?.moduleObjectUrl; if (moduleObjectUrl) { URL.revokeObjectURL(moduleObjectUrl); } } private registerDevelopmentPlugin(): void { if (environment.production) { return; } try { this.registry.registerManifest(DEVELOPMENT_PLUGIN_MANIFEST, DEVELOPMENT_PLUGIN_ENTRYPOINT); } catch (error) { this.logger.warn(DEVELOPMENT_PLUGIN_MANIFEST.id, 'Development plugin registration failed', error); } } private async loadActivationState(): Promise { const state = await this.desktopState.readJson<{ activatedPluginIds?: string[] }>(STORAGE_KEY_PLUGIN_ACTIVATION, {}); this.activatedPluginIds = new Set( Array.isArray(state.activatedPluginIds) ? state.activatedPluginIds.filter((pluginId): pluginId is string => typeof pluginId === 'string') : [] ); } private async saveActivationState(): Promise { await this.desktopState.writeJson(STORAGE_KEY_PLUGIN_ACTIVATION, { activatedPluginIds: Array.from(this.activatedPluginIds).sort() }); } private resolveEntrypoint(manifest: TojuPluginManifest, sourcePath?: string): string { if (!manifest.entrypoint) { throw new Error('Plugin entrypoint is missing'); } try { return new URL(manifest.entrypoint).toString(); } catch {} if (manifest.bundle?.url && !sourcePath?.startsWith('file://')) { return manifest.bundle.url; } if (sourcePath?.startsWith('http://') || sourcePath?.startsWith('https://') || sourcePath?.startsWith('file://')) { return new URL(manifest.entrypoint, sourcePath).toString(); } if (manifest.entrypoint.startsWith('/')) { return manifest.entrypoint; } throw new Error(`Plugin ${manifest.id} has no browser-importable entrypoint`); } } function fileUrlToPath(fileUrl: string): string { const url = new URL(fileUrl); const decodedPath = decodeURIComponent(url.pathname); if (/^\/[A-Za-z]:\//.test(decodedPath)) { return decodedPath.slice(1).replace(/\//g, '\\'); } return decodedPath; } function safeDispose(disposable: TojuPluginDisposable, pluginId: string, logger: PluginLoggerService): void { try { disposable.dispose(); } catch (error) { logger.warn(pluginId, 'Plugin disposable failed', error); } } function createGuardedMutationObserver( NativeMutationObserver: typeof MutationObserver, pluginId: string, logger: PluginLoggerService ): typeof MutationObserver { return class GuardedPluginMutationObserver implements MutationObserver { private readonly nativeObserver: MutationObserver; private readonly observations: { options?: MutationObserverInit; target: Node }[] = []; private isDispatching = false; constructor(private readonly callback: MutationCallback) { this.nativeObserver = new NativeMutationObserver((records) => this.dispatch(records)); } observe(target: Node, options?: MutationObserverInit): void { const existing = this.observations.find((observation) => observation.target === target); if (existing) { existing.options = options; } else { this.observations.push({ options, target }); } this.nativeObserver.observe(target, options); } disconnect(): void { this.observations.length = 0; this.nativeObserver.disconnect(); } takeRecords(): MutationRecord[] { return this.nativeObserver.takeRecords(); } private dispatch(records: MutationRecord[]): void { if (this.isDispatching) { return; } this.isDispatching = true; this.nativeObserver.disconnect(); try { this.callback(records, this); } catch (error) { logger.warn(pluginId, 'Plugin MutationObserver callback failed', error); } finally { this.isDispatching = false; for (const observation of this.observations) { this.nativeObserver.observe(observation.target, observation.options); } } } }; }