Ajoute la vue window host qui charge le layout plugin dans une fenêtre OS détachée, expose react/react-dom/jsx-runtime hébergés sous frontend/public/plugin-host pour que le runtime SDK y résolve React, et câble services.windows.open() côté runtime plugin (loader/services/registry). npm test -- --run src/adapters/window.test.ts src/app/ViewWindow.test.tsx src/plugins/runtime/loader.test.ts src/plugins/runtime/services.test.ts : 39/39 npm run typecheck : OK npm run build : OK Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
414 lines
13 KiB
TypeScript
414 lines
13 KiB
TypeScript
/**
|
|
* Plugin bootstrap loader (ticket #43, F1, carnet §1.3/§6).
|
|
*
|
|
* At UI bootstrap, the app calls {@link loadPlugins} once with the catalog
|
|
* from `PluginGateway.listRuntimeContributions()` (already filtered by the
|
|
* backend to `enabled && !pendingUninstall`, carnet §1.3) and the gateway set
|
|
* used to build the public service facade. For each entry it dynamically imports
|
|
* the bundle URL, validates the module shape, and calls `activate(ctx)`,
|
|
* scoping the command/layout registries to exactly the ids declared in that
|
|
* plugin's manifest (enforced by {@link PluginCommandRegistry}/
|
|
* {@link PluginLayoutRegistry} themselves).
|
|
*
|
|
* A single plugin failing to load/activate must not break the rest of the
|
|
* app or the other plugins — each is loaded independently and failures are
|
|
* collected, never thrown past `loadPlugins`.
|
|
*/
|
|
|
|
import * as React from "react";
|
|
import * as ReactDom from "react-dom";
|
|
import * as ReactDomClient from "react-dom/client";
|
|
import * as ReactJsxRuntime from "react/jsx-runtime";
|
|
import * as ReactJsxDevRuntime from "react/jsx-dev-runtime";
|
|
|
|
import type { JsonValue, PluginContributionDto, PluginRuntimePlugin } from "@/domain";
|
|
import {
|
|
PluginCommandRegistry,
|
|
PluginLayoutRegistry,
|
|
PluginMenuRegistry,
|
|
PluginRuntimeRegistry,
|
|
type Disposable,
|
|
type LoadedPlugin,
|
|
type PluginGatewaySet,
|
|
} from "./registry";
|
|
import { createPluginServices, type PluginServices } from "./services";
|
|
|
|
export type { PluginGatewaySet } from "./registry";
|
|
|
|
export interface IdeaPluginContext {
|
|
pluginId: string;
|
|
pluginDisplayName: string;
|
|
version: string;
|
|
logger: PluginLogger;
|
|
subscriptions: Disposable[];
|
|
commands: PluginCommandContext;
|
|
layouts: PluginLayoutRegistry;
|
|
menu: PluginMenuRegistry;
|
|
storage: PluginStorage;
|
|
services?: PluginServices;
|
|
}
|
|
|
|
export interface PluginStorage {
|
|
get<T extends JsonValue = JsonValue>(key: string): Promise<T | undefined>;
|
|
set(key: string, value: JsonValue): Promise<void>;
|
|
delete(key: string): Promise<void>;
|
|
}
|
|
|
|
export interface PluginActivation {
|
|
dispose?: () => void | Promise<void>;
|
|
}
|
|
|
|
export interface PluginLogger {
|
|
debug(message: string, ...args: unknown[]): void;
|
|
info(message: string, ...args: unknown[]): void;
|
|
warn(message: string, ...args: unknown[]): void;
|
|
error(message: string, ...args: unknown[]): void;
|
|
}
|
|
|
|
export interface PluginCommandContext {
|
|
register(commandId: string, handler: (...args: unknown[]) => void | Promise<void>): Disposable;
|
|
registerCommand(
|
|
commandId: string,
|
|
handler: (...args: unknown[]) => unknown | Promise<unknown>,
|
|
): Disposable;
|
|
}
|
|
|
|
export interface IdeaPluginModule {
|
|
activate(ctx: IdeaPluginContext): void | PluginActivation | Promise<void | PluginActivation>;
|
|
}
|
|
|
|
export interface PluginLoadFailure {
|
|
pluginId: string;
|
|
reason: string;
|
|
}
|
|
|
|
export interface PluginLoadPending {
|
|
pluginId: string;
|
|
displayName: string;
|
|
reason: string;
|
|
}
|
|
|
|
export interface PluginLoadResult {
|
|
registry: PluginRuntimeRegistry;
|
|
failures: PluginLoadFailure[];
|
|
pending: PluginLoadPending[];
|
|
}
|
|
|
|
export interface PluginLoadOptions {
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
const DEFAULT_PLUGIN_LOAD_TIMEOUT_MS = 10_000;
|
|
let hostReactImportMapInstalled = false;
|
|
|
|
async function withTimeout<T>(
|
|
promise: Promise<T>,
|
|
timeoutMs: number,
|
|
label: string,
|
|
): Promise<T> {
|
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
return await Promise.race([
|
|
promise,
|
|
new Promise<never>((_, reject) => {
|
|
timeout = setTimeout(() => {
|
|
reject(new Error(`${label} timed out after ${timeoutMs} ms`));
|
|
}, timeoutMs);
|
|
}),
|
|
]);
|
|
} finally {
|
|
if (timeout) clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
function isIdeaPluginModule(mod: unknown): mod is IdeaPluginModule {
|
|
return (
|
|
typeof mod === "object" &&
|
|
mod !== null &&
|
|
"activate" in mod &&
|
|
typeof (mod as { activate: unknown }).activate === "function"
|
|
);
|
|
}
|
|
|
|
function resolveIdeaPluginModule(mod: unknown): IdeaPluginModule | null {
|
|
if (isIdeaPluginModule(mod)) return mod;
|
|
const defaultExport = objectOrEmpty(mod).default;
|
|
return isIdeaPluginModule(defaultExport) ? defaultExport : null;
|
|
}
|
|
|
|
function createPluginLogger(entry: PluginRuntimePlugin): PluginLogger {
|
|
const prefix = `[plugin:${safePluginId(entry)}]`;
|
|
return {
|
|
debug: (message, ...args) => console.debug(prefix, message, ...args),
|
|
info: (message, ...args) => console.info(prefix, message, ...args),
|
|
warn: (message, ...args) => console.warn(prefix, message, ...args),
|
|
error: (message, ...args) => console.error(prefix, message, ...args),
|
|
};
|
|
}
|
|
|
|
function createCommandContext(commands: PluginCommandRegistry): PluginCommandContext {
|
|
return {
|
|
register: (commandId, handler) => commands.register(commandId, handler),
|
|
registerCommand: (commandId, handler) =>
|
|
commands.register(commandId, async (...args) => {
|
|
await handler(...args);
|
|
}),
|
|
};
|
|
}
|
|
|
|
function arrayOrEmpty<T>(value: unknown): T[] {
|
|
return Array.isArray(value) ? (value as T[]) : [];
|
|
}
|
|
|
|
function objectOrEmpty(value: unknown): Record<string, unknown> {
|
|
return value !== null && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
|
}
|
|
|
|
function nonEmptyString(value: unknown): string | undefined {
|
|
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
}
|
|
|
|
function installHostReactImportMap(): void {
|
|
if (hostReactImportMapInstalled) {
|
|
hostReactImportMapInstalled = true;
|
|
return;
|
|
}
|
|
|
|
const globalHost = globalThis as unknown as {
|
|
__IDEA_PLUGIN_HOST_REACT__?: typeof React;
|
|
__IDEA_PLUGIN_HOST_REACT_DOM__?: typeof ReactDom;
|
|
__IDEA_PLUGIN_HOST_REACT_DOM_CLIENT__?: typeof ReactDomClient;
|
|
__IDEA_PLUGIN_HOST_REACT_JSX_RUNTIME__?: typeof ReactJsxRuntime;
|
|
__IDEA_PLUGIN_HOST_REACT_JSX_DEV_RUNTIME__?: typeof ReactJsxDevRuntime;
|
|
};
|
|
globalHost.__IDEA_PLUGIN_HOST_REACT__ = React;
|
|
globalHost.__IDEA_PLUGIN_HOST_REACT_DOM__ = ReactDom;
|
|
globalHost.__IDEA_PLUGIN_HOST_REACT_DOM_CLIENT__ = ReactDomClient;
|
|
globalHost.__IDEA_PLUGIN_HOST_REACT_JSX_RUNTIME__ = ReactJsxRuntime;
|
|
globalHost.__IDEA_PLUGIN_HOST_REACT_JSX_DEV_RUNTIME__ = ReactJsxDevRuntime;
|
|
hostReactImportMapInstalled = true;
|
|
}
|
|
|
|
function safePluginId(entry: unknown): string {
|
|
return nonEmptyString(objectOrEmpty(entry).id) ?? "<unknown-plugin>";
|
|
}
|
|
|
|
function commandIdsFromContributes(contributes: PluginContributionDto): Set<string> {
|
|
return new Set<string>(
|
|
contributes.menuItems.flatMap<string>((item) => {
|
|
const command = nonEmptyString(objectOrEmpty(item).command);
|
|
return command ? [command] : [];
|
|
}),
|
|
);
|
|
}
|
|
|
|
function layoutTypesFromContributes(contributes: PluginContributionDto): Set<string> {
|
|
return new Set<string>(
|
|
contributes.layouts.flatMap<string>((layout) => {
|
|
const type = nonEmptyString(objectOrEmpty(layout).type);
|
|
return type ? [type] : [];
|
|
}),
|
|
);
|
|
}
|
|
|
|
function normalizeContributes(entry: PluginRuntimePlugin): PluginContributionDto {
|
|
const contributes = objectOrEmpty(entry.contributes) as Partial<PluginContributionDto>;
|
|
return {
|
|
menus: arrayOrEmpty(contributes?.menus),
|
|
menuItems: arrayOrEmpty(contributes?.menuItems),
|
|
layouts: arrayOrEmpty(contributes?.layouts),
|
|
mcpServers: arrayOrEmpty(contributes?.mcpServers),
|
|
};
|
|
}
|
|
|
|
function hasCapability(entry: PluginRuntimePlugin, capability: string): boolean {
|
|
return arrayOrEmpty<string>(objectOrEmpty(entry).capabilities).includes(capability);
|
|
}
|
|
|
|
function activationScope(entry: PluginRuntimePlugin): "app" | "project" {
|
|
return objectOrEmpty(entry).activationScope === "project" ? "project" : "app";
|
|
}
|
|
|
|
function pendingForProjectFocus(entry: PluginRuntimePlugin): PluginLoadPending {
|
|
const entryObject = objectOrEmpty(entry);
|
|
const pluginId = safePluginId(entry);
|
|
return {
|
|
pluginId,
|
|
displayName: nonEmptyString(entryObject.displayName) ?? pluginId,
|
|
reason: "En attente d'un projet actif.",
|
|
};
|
|
}
|
|
|
|
async function disposeAll(disposables: Disposable[], activation?: void | PluginActivation): Promise<void> {
|
|
try {
|
|
await activation?.dispose?.();
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
|
|
for (const disposable of disposables.splice(0).reverse()) {
|
|
try {
|
|
disposable.dispose();
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
}
|
|
}
|
|
|
|
async function loadOne(
|
|
entry: PluginRuntimePlugin,
|
|
gateways: PluginGatewaySet,
|
|
options: Required<PluginLoadOptions>,
|
|
): Promise<{ plugin: LoadedPlugin } | { failure: PluginLoadFailure }> {
|
|
const entryObject = objectOrEmpty(entry);
|
|
const pluginId = safePluginId(entry);
|
|
const displayName = nonEmptyString(entryObject.displayName) ?? pluginId;
|
|
const version = nonEmptyString(entryObject.version) ?? "";
|
|
let activation: void | PluginActivation = undefined;
|
|
const subscriptions: Disposable[] = [];
|
|
try {
|
|
// The bundle URL is a plugin-scoped, content-hashed local protocol URL
|
|
// served by the backend (carnet §1.3) — never a disk path or arbitrary
|
|
// remote URL, and the content hash busts the module cache after updates.
|
|
const bundleUrl = nonEmptyString(entryObject.bundleUrl);
|
|
if (!bundleUrl) {
|
|
throw new Error("missing plugin bundle URL");
|
|
}
|
|
|
|
installHostReactImportMap();
|
|
const mod = resolveIdeaPluginModule(
|
|
await withTimeout(
|
|
import(/* @vite-ignore */ bundleUrl),
|
|
options.timeoutMs,
|
|
"importing plugin bundle",
|
|
),
|
|
);
|
|
if (!mod) {
|
|
return {
|
|
failure: {
|
|
pluginId,
|
|
reason: `bundle does not export an "activate(ctx)" function`,
|
|
},
|
|
};
|
|
}
|
|
|
|
const contributes = normalizeContributes(entry);
|
|
const declaredCommandIds = commandIdsFromContributes(contributes);
|
|
const declaredLayoutTypes = layoutTypesFromContributes(contributes);
|
|
const commands = new PluginCommandRegistry(pluginId, declaredCommandIds);
|
|
const layouts = new PluginLayoutRegistry(pluginId, declaredLayoutTypes);
|
|
const menu = new PluginMenuRegistry(pluginId);
|
|
const storage = createPluginStorage(gateways, pluginId);
|
|
|
|
const ctx: IdeaPluginContext = {
|
|
pluginId,
|
|
pluginDisplayName: displayName,
|
|
version,
|
|
logger: createPluginLogger(entry),
|
|
subscriptions,
|
|
commands: createCommandContext(commands),
|
|
layouts,
|
|
menu,
|
|
storage,
|
|
};
|
|
if (hasCapability(entry, "tooling") || hasCapability(entry, "ui")) {
|
|
ctx.services = createPluginServices(gateways, {
|
|
pluginId,
|
|
declaredLayoutTypes,
|
|
});
|
|
}
|
|
|
|
activation = await withTimeout(
|
|
Promise.resolve(mod.activate(ctx)),
|
|
options.timeoutMs,
|
|
"activating plugin",
|
|
);
|
|
|
|
const plugin: LoadedPlugin = {
|
|
pluginId,
|
|
displayName,
|
|
contributes,
|
|
commands,
|
|
layouts,
|
|
menu,
|
|
dispose: async () => {
|
|
// Best-effort, full-trust (carnet §1.3) — a broken `dispose()` or
|
|
// subscription must not prevent removing the plugin from the registry.
|
|
await disposeAll(subscriptions, activation);
|
|
},
|
|
};
|
|
return { plugin };
|
|
} catch (e) {
|
|
await disposeAll(subscriptions, activation);
|
|
return {
|
|
failure: {
|
|
pluginId,
|
|
reason: e instanceof Error ? e.message : String(e),
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
function createPluginStorage(gateways: PluginGatewaySet, pluginId: string): PluginStorage {
|
|
return {
|
|
async get<T extends JsonValue = JsonValue>(key: string): Promise<T | undefined> {
|
|
const value = await gateways.pluginStorage.get({ pluginId, key });
|
|
return value === null ? undefined : (value as T);
|
|
},
|
|
async set(key, value) {
|
|
await gateways.pluginStorage.set({ pluginId, key, value });
|
|
},
|
|
async delete(key) {
|
|
await gateways.pluginStorage.delete({ pluginId, key });
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Loads every plugin in the catalog into a fresh {@link PluginRuntimeRegistry}.
|
|
* Called once at app bootstrap (and never again in the same session — carnet
|
|
* §1.3: disable/uninstall only masks contributions in-session, the actual
|
|
* unload happens on next restart via a fresh `loadPlugins` call).
|
|
*/
|
|
export async function loadPlugins(
|
|
catalogPlugins: PluginRuntimePlugin[],
|
|
gateways: PluginGatewaySet,
|
|
options: PluginLoadOptions = {},
|
|
): Promise<PluginLoadResult> {
|
|
const registry = new PluginRuntimeRegistry();
|
|
const failures: PluginLoadFailure[] = [];
|
|
const pending: PluginLoadPending[] = [];
|
|
const resolvedOptions: Required<PluginLoadOptions> = {
|
|
timeoutMs: options.timeoutMs ?? DEFAULT_PLUGIN_LOAD_TIMEOUT_MS,
|
|
};
|
|
|
|
const entries = Array.isArray(catalogPlugins) ? catalogPlugins : [];
|
|
const focus = await gateways.focusedProject?.getFocusedProject?.();
|
|
const loadableEntries = entries.filter((entry) => {
|
|
if (activationScope(entry) !== "project" || focus) return true;
|
|
pending.push(pendingForProjectFocus(entry));
|
|
return false;
|
|
});
|
|
const results = await Promise.all(
|
|
loadableEntries.map((entry) => loadOne(entry, gateways, resolvedOptions)),
|
|
);
|
|
for (const result of results) {
|
|
if ("failure" in result) {
|
|
failures.push(result.failure);
|
|
console.warn(
|
|
`[plugins] load failed plugin=${result.failure.pluginId}: ${result.failure.reason}`,
|
|
);
|
|
} else {
|
|
registry.add(result.plugin);
|
|
}
|
|
}
|
|
|
|
if (entries.length > 0) {
|
|
console.info(
|
|
`[plugins] load complete loaded=${registry.list().length} failed=${failures.length} pending=${pending.length}`,
|
|
);
|
|
}
|
|
|
|
return { registry, failures, pending };
|
|
}
|