feat: plugins v1.7

This commit is contained in:
2026-04-29 15:24:56 +02:00
parent eabbc08896
commit d261bac0ed
45 changed files with 5621 additions and 867 deletions

View File

@@ -44,6 +44,7 @@ export class PluginHostService {
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>();
@@ -96,11 +97,10 @@ export class PluginHostService {
continue;
}
await this.activatePlugin(entry);
const didActivate = await this.activatePlugin(entry);
const active = this.activePlugins.get(manifest.id);
if (active) {
if (didActivate && active) {
activated.push(active.context);
this.activatedPluginIds.add(active.context.pluginId);
}
@@ -126,11 +126,10 @@ export class PluginHostService {
return;
}
await this.activatePlugin(entry);
const didActivate = await this.activatePlugin(entry);
const active = this.activePlugins.get(pluginId);
if (!active) {
if (!didActivate || !active) {
return;
}
@@ -161,11 +160,10 @@ export class PluginHostService {
continue;
}
await this.activatePlugin(entry);
const didActivate = await this.activatePlugin(entry);
const active = this.activePlugins.get(manifest.id);
if (active) {
if (didActivate && active) {
activated.push(active.context);
}
}
@@ -265,19 +263,46 @@ export class PluginHostService {
}
}
private async activatePlugin(entry: RegisteredPlugin): Promise<void> {
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;
return false;
}
if (!manifest.entrypoint) {
this.registry.setState(manifest.id, 'ready');
return;
return false;
}
this.registry.setState(manifest.id, 'loading');
@@ -291,12 +316,14 @@ export class PluginHostService {
subscriptions: []
};
await module.activate?.(context);
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;
}
}
@@ -310,6 +337,27 @@ export class PluginHostService {
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
@@ -391,6 +439,10 @@ export class PluginHostService {
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();
}
@@ -421,3 +473,61 @@ function safeDispose(disposable: TojuPluginDisposable, pluginId: string, logger:
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);
}
}
}
};
}