feat: plugins v1.7
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user