feat: plugins v1.5
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
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,
|
||||
@@ -18,6 +19,7 @@ import type {
|
||||
} 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';
|
||||
@@ -25,21 +27,29 @@ 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 activationStateReady: Promise<void>;
|
||||
private activatedPluginIds = new Set<string>();
|
||||
|
||||
constructor() {
|
||||
this.registerDevelopmentPlugin();
|
||||
this.activationStateReady = this.loadActivationState();
|
||||
}
|
||||
|
||||
registerLocalManifest(manifestValue: unknown, sourcePath?: string): RegisteredPlugin {
|
||||
@@ -75,6 +85,8 @@ export class PluginHostService {
|
||||
}
|
||||
|
||||
async activateReadyPlugins(): Promise<void> {
|
||||
await this.activationStateReady;
|
||||
|
||||
const activated: TojuPluginActivationContext[] = [];
|
||||
|
||||
for (const manifest of this.registry.loadOrder().ordered) {
|
||||
@@ -90,29 +102,92 @@ export class PluginHostService {
|
||||
|
||||
if (active) {
|
||||
activated.push(active.context);
|
||||
this.activatedPluginIds.add(active.context.pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const context of activated) {
|
||||
const active = this.activePlugins.get(context.pluginId);
|
||||
await this.saveActivationState();
|
||||
|
||||
if (!active?.module.ready) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await active.module.ready(context);
|
||||
this.registry.setState(context.pluginId, 'ready');
|
||||
} catch (error) {
|
||||
this.failPlugin(context.pluginId, error);
|
||||
}
|
||||
}
|
||||
await this.runReadyHooks(activated);
|
||||
}
|
||||
|
||||
async deactivatePlugin(pluginId: string): Promise<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
await this.activatePlugin(entry);
|
||||
|
||||
const active = this.activePlugins.get(pluginId);
|
||||
|
||||
if (!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;
|
||||
}
|
||||
|
||||
await this.activatePlugin(entry);
|
||||
|
||||
const active = this.activePlugins.get(manifest.id);
|
||||
|
||||
if (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;
|
||||
@@ -132,6 +207,13 @@ export class PluginHostService {
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -150,6 +232,11 @@ export class PluginHostService {
|
||||
|
||||
if (entry?.enabled) {
|
||||
await this.activatePlugin(entry);
|
||||
|
||||
if (this.activePlugins.has(pluginId)) {
|
||||
this.activatedPluginIds.add(pluginId);
|
||||
await this.saveActivationState();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +248,23 @@ export class PluginHostService {
|
||||
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<void> {
|
||||
const manifest = entry.manifest;
|
||||
const missingCapabilities = this.capabilities.missing(manifest);
|
||||
@@ -179,7 +283,7 @@ export class PluginHostService {
|
||||
this.registry.setState(manifest.id, 'loading');
|
||||
|
||||
try {
|
||||
const module = await this.loadPluginModule(manifest, entry.sourcePath);
|
||||
const { module, moduleObjectUrl } = await this.loadPluginModule(manifest, entry.sourcePath);
|
||||
const context: TojuPluginActivationContext = {
|
||||
api: this.apiFactory.createApi(manifest),
|
||||
manifest,
|
||||
@@ -188,7 +292,7 @@ export class PluginHostService {
|
||||
};
|
||||
|
||||
await module.activate?.(context);
|
||||
this.activePlugins.set(manifest.id, { context, module });
|
||||
this.activePlugins.set(manifest.id, { context, module, moduleObjectUrl });
|
||||
this.registry.setState(manifest.id, 'loaded');
|
||||
this.logger.info(manifest.id, 'Plugin activated');
|
||||
} catch (error) {
|
||||
@@ -203,14 +307,51 @@ export class PluginHostService {
|
||||
this.logger.error(pluginId, message, error);
|
||||
this.uiRegistry.unregisterPlugin(pluginId);
|
||||
this.activePlugins.delete(pluginId);
|
||||
this.revokeModuleObjectUrl(pluginId);
|
||||
}
|
||||
|
||||
private async loadPluginModule(manifest: TojuPluginManifest, sourcePath?: string): Promise<TojuClientPluginModule> {
|
||||
private async loadPluginModule(
|
||||
manifest: TojuPluginManifest,
|
||||
sourcePath?: string
|
||||
): Promise<{ module: TojuClientPluginModule; moduleObjectUrl?: string }> {
|
||||
if (manifest.entrypoint === DEVELOPMENT_PLUGIN_ENTRYPOINT) {
|
||||
return DEVELOPMENT_PLUGIN_MODULE;
|
||||
return { module: DEVELOPMENT_PLUGIN_MODULE };
|
||||
}
|
||||
|
||||
return await import(/* @vite-ignore */ this.resolveEntrypoint(manifest, sourcePath)) as TojuClientPluginModule;
|
||||
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 };
|
||||
}
|
||||
|
||||
return {
|
||||
module: await import(/* @vite-ignore */ entrypointUrl) as TojuClientPluginModule
|
||||
};
|
||||
}
|
||||
|
||||
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 revokeModuleObjectUrl(pluginId: string): void {
|
||||
const moduleObjectUrl = this.activePlugins.get(pluginId)?.moduleObjectUrl;
|
||||
|
||||
if (moduleObjectUrl) {
|
||||
URL.revokeObjectURL(moduleObjectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private registerDevelopmentPlugin(): void {
|
||||
@@ -225,6 +366,22 @@ export class PluginHostService {
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -246,6 +403,17 @@ export class PluginHostService {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user