557 lines
17 KiB
TypeScript
557 lines
17 KiB
TypeScript
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<string, ActivePluginRuntime>();
|
|
private readonly activationRequests = new Map<string, Promise<boolean>>();
|
|
private readonly activationStateReady: Promise<void>;
|
|
private activatedPluginIds = new Set<string>();
|
|
|
|
constructor() {
|
|
this.registerDevelopmentPlugin();
|
|
this.activationStateReady = this.loadActivationState();
|
|
}
|
|
|
|
registerLocalManifest(manifestValue: unknown, sourcePath?: string): RegisteredPlugin {
|
|
return this.registry.registerManifest(manifestValue, sourcePath);
|
|
}
|
|
|
|
async discoverLocalPlugins(): Promise<LocalPluginRegistrationResult> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
await this.activationStateReady;
|
|
this.activatedPluginIds.add(pluginId);
|
|
await this.saveActivationState();
|
|
}
|
|
|
|
async activatePersistedPlugins(): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
const pluginIds = Array.from(this.activePlugins.keys()).reverse();
|
|
|
|
for (const pluginId of pluginIds) {
|
|
await this.deactivatePlugin(pluginId);
|
|
}
|
|
}
|
|
|
|
async reloadPlugin(pluginId: string): Promise<void> {
|
|
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<void> {
|
|
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<boolean> {
|
|
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<boolean> {
|
|
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> | void): Promise<void> {
|
|
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<string> {
|
|
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<string> {
|
|
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<void> {
|
|
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<void> {
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
}
|