Merge branch 'fix/ticket116-plugin-install-black-screen' into develop
This commit is contained in:
@ -302,7 +302,113 @@ fn open_folder(path: &PathBuf) -> Result<(), ErrorDto> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::block_on_protocol_future;
|
||||
use super::{asset_allowed, block_on_protocol_future};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{
|
||||
PluginManifestBytes, PluginManifestError, PluginPackageStore, PluginRegistryError,
|
||||
PluginRegistryStore, PluginStoreError,
|
||||
};
|
||||
use domain::{
|
||||
ContentHash, LocalPath, PluginId, PluginInstallSource, PluginLifecycleState,
|
||||
PluginRegistry, PluginRegistryEntry, RelativePath, RemovalOutcome, StagedPluginPackage,
|
||||
};
|
||||
use http::StatusCode;
|
||||
|
||||
struct FakeRegistry {
|
||||
registry: Mutex<PluginRegistry>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginRegistryStore for FakeRegistry {
|
||||
async fn load_registry(&self) -> Result<PluginRegistry, PluginRegistryError> {
|
||||
Ok(self.registry.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
async fn save_registry(
|
||||
&self,
|
||||
registry: &PluginRegistry,
|
||||
) -> Result<(), PluginRegistryError> {
|
||||
*self.registry.lock().unwrap() = registry.clone();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct FakePackages {
|
||||
manifest: Vec<u8>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginPackageStore for FakePackages {
|
||||
async fn list_installed(&self) -> Result<Vec<domain::PluginPackageRef>, PluginStoreError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn read_manifest(
|
||||
&self,
|
||||
_package: &domain::PluginPackageRef,
|
||||
) -> Result<PluginManifestBytes, PluginStoreError> {
|
||||
Ok(PluginManifestBytes {
|
||||
bytes: self.manifest.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn install_from_archive(
|
||||
&self,
|
||||
_archive: &LocalPath,
|
||||
) -> Result<StagedPluginPackage, PluginStoreError> {
|
||||
Err(PluginStoreError::Invalid("not used".to_owned()))
|
||||
}
|
||||
|
||||
async fn install_from_directory(
|
||||
&self,
|
||||
_dir: &LocalPath,
|
||||
) -> Result<StagedPluginPackage, PluginStoreError> {
|
||||
Err(PluginStoreError::Invalid("not used".to_owned()))
|
||||
}
|
||||
|
||||
async fn commit_install(
|
||||
&self,
|
||||
_staged: StagedPluginPackage,
|
||||
_plugin_id: &PluginId,
|
||||
) -> Result<domain::PluginPackageRef, PluginStoreError> {
|
||||
Err(PluginStoreError::Invalid("not used".to_owned()))
|
||||
}
|
||||
|
||||
async fn remove_package(
|
||||
&self,
|
||||
_plugin_id: &PluginId,
|
||||
) -> Result<RemovalOutcome, PluginStoreError> {
|
||||
Ok(RemovalOutcome::NotFound)
|
||||
}
|
||||
|
||||
fn bundle_url(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
entry: &RelativePath,
|
||||
hash: &ContentHash,
|
||||
) -> Result<domain::PluginBundleUrl, PluginStoreError> {
|
||||
Ok(domain::PluginBundleUrl::new(format!(
|
||||
"idea-plugin://{}/current/{}/{}",
|
||||
plugin_id.as_str(),
|
||||
hash.as_str(),
|
||||
entry.as_str()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
struct RejectingValidator;
|
||||
|
||||
impl domain::ports::PluginManifestValidator for RejectingValidator {
|
||||
fn validate(
|
||||
&self,
|
||||
_bytes: &[u8],
|
||||
_package: &domain::PluginPackageRef,
|
||||
) -> Result<domain::PluginManifest, PluginManifestError> {
|
||||
Err(PluginManifestError::Invalid("broken manifest".to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn protocol_future_can_be_waited_inside_tauri_runtime() {
|
||||
@ -310,4 +416,43 @@ mod tests {
|
||||
|
||||
assert_eq!(value, 42);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn asset_allowed_confines_invalid_active_manifest_to_forbidden() {
|
||||
let plugin_id = PluginId::new("dev.acme.gitgraph").unwrap();
|
||||
let hash = ContentHash::new("abc123").unwrap();
|
||||
let registry = FakeRegistry {
|
||||
registry: Mutex::new(PluginRegistry {
|
||||
version: 1,
|
||||
plugins: vec![PluginRegistryEntry {
|
||||
id: plugin_id.clone(),
|
||||
lifecycle_state: PluginLifecycleState::Enabled,
|
||||
source: PluginInstallSource::Directory {
|
||||
path_label: "/source/plugin".to_owned(),
|
||||
},
|
||||
content_hash: hash.clone(),
|
||||
restart_required: false,
|
||||
error: None,
|
||||
}],
|
||||
}),
|
||||
};
|
||||
let packages = FakePackages {
|
||||
manifest: br#"{"broken":true}"#.to_vec(),
|
||||
};
|
||||
let rel = RelativePath::new("dist/index.js").unwrap();
|
||||
|
||||
let err = asset_allowed(
|
||||
&plugin_id,
|
||||
hash.as_str(),
|
||||
&rel,
|
||||
®istry,
|
||||
&packages,
|
||||
&RejectingValidator,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.0, StatusCode::FORBIDDEN);
|
||||
assert!(err.1.contains("broken manifest"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -713,30 +713,75 @@ impl ListPluginRuntimeContributions {
|
||||
|
||||
/// Executes the use case.
|
||||
pub async fn execute(&self) -> Result<PluginRuntimeCatalog, AppError> {
|
||||
let registry = self.registry.load_registry().await.map_err(map_registry)?;
|
||||
let mut registry = self.registry.load_registry().await.map_err(map_registry)?;
|
||||
let mut plugins = Vec::new();
|
||||
for entry in registry.plugins {
|
||||
let mut invalid_plugins = Vec::new();
|
||||
for entry in registry.plugins.clone() {
|
||||
if !entry.lifecycle_state.is_runtime_active() {
|
||||
continue;
|
||||
}
|
||||
let descriptor =
|
||||
descriptor_for(self.packages.as_ref(), self.validator.as_ref(), entry).await?;
|
||||
let bundle = plugin_asset_url(
|
||||
match runtime_plugin_from_entry(
|
||||
self.packages.as_ref(),
|
||||
self.validator.as_ref(),
|
||||
entry.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(plugin) => plugins.push(plugin),
|
||||
Err(err) => {
|
||||
let message = format!(
|
||||
"runtime contributions disabled: plugin `{}` is not servable: {err}",
|
||||
entry.id.as_str()
|
||||
);
|
||||
crate::diag!("[plugins] {message}");
|
||||
invalid_plugins.push((entry.id, message));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !invalid_plugins.is_empty() {
|
||||
for (plugin_id, message) in invalid_plugins {
|
||||
if let Some(entry) = registry.plugins.iter_mut().find(|p| p.id == plugin_id) {
|
||||
entry.lifecycle_state = PluginLifecycleState::Invalid;
|
||||
entry.error = Some(message);
|
||||
}
|
||||
}
|
||||
if let Err(err) = self
|
||||
.registry
|
||||
.save_registry(®istry)
|
||||
.await
|
||||
.map_err(map_registry)
|
||||
{
|
||||
crate::diag!("[plugins] failed to persist invalid runtime plugin state: {err}");
|
||||
}
|
||||
}
|
||||
Ok(PluginRuntimeCatalog { plugins })
|
||||
}
|
||||
}
|
||||
|
||||
async fn runtime_plugin_from_entry(
|
||||
packages: &dyn PluginPackageStore,
|
||||
validator: &dyn PluginManifestValidator,
|
||||
entry: PluginRegistryEntry,
|
||||
) -> Result<PluginRuntimePlugin, AppError> {
|
||||
let descriptor = descriptor_for(packages, validator, entry).await?;
|
||||
let bundle = checked_plugin_asset_url(
|
||||
packages,
|
||||
&descriptor.manifest.id,
|
||||
descriptor.manifest.version.as_str(),
|
||||
&descriptor.registry.content_hash,
|
||||
&descriptor.manifest.main,
|
||||
);
|
||||
)?;
|
||||
let icon_url = match &descriptor.manifest.icon {
|
||||
Some(icon) => Some(plugin_asset_url(
|
||||
Some(icon) => Some(checked_plugin_asset_url(
|
||||
packages,
|
||||
&descriptor.manifest.id,
|
||||
descriptor.manifest.version.as_str(),
|
||||
&descriptor.registry.content_hash,
|
||||
icon,
|
||||
)),
|
||||
)?),
|
||||
None => None,
|
||||
};
|
||||
plugins.push(PluginRuntimePlugin {
|
||||
Ok(PluginRuntimePlugin {
|
||||
id: descriptor.manifest.id.as_str().to_owned(),
|
||||
display_name: descriptor.manifest.display_name,
|
||||
publisher: descriptor.manifest.publisher,
|
||||
@ -745,10 +790,20 @@ impl ListPluginRuntimeContributions {
|
||||
icon_url,
|
||||
content_hash: descriptor.registry.content_hash.as_str().to_owned(),
|
||||
contributes: descriptor.manifest.contributes,
|
||||
});
|
||||
}
|
||||
Ok(PluginRuntimeCatalog { plugins })
|
||||
})
|
||||
}
|
||||
|
||||
fn checked_plugin_asset_url(
|
||||
packages: &dyn PluginPackageStore,
|
||||
plugin_id: &PluginId,
|
||||
version: &str,
|
||||
hash: &ContentHash,
|
||||
path: &domain::RelativePath,
|
||||
) -> Result<String, AppError> {
|
||||
packages
|
||||
.bundle_url(plugin_id, path, hash)
|
||||
.map_err(map_store)?;
|
||||
Ok(plugin_asset_url(plugin_id, version, hash, path))
|
||||
}
|
||||
|
||||
/// Reconciles plugin MCP servers.
|
||||
@ -1541,6 +1596,34 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_catalog_marks_invalid_active_plugin_and_keeps_bootstrap_alive() {
|
||||
let packages = Arc::new(FakePackages::with_manifest(br#"{"broken":true}"#.to_vec()));
|
||||
let registry = Arc::new(FakeRegistry {
|
||||
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
|
||||
});
|
||||
let usecase =
|
||||
ListPluginRuntimeContributions::new(packages, registry.clone(), Arc::new(validator()));
|
||||
|
||||
let catalog = usecase.execute().await.unwrap();
|
||||
|
||||
assert!(
|
||||
catalog.plugins.is_empty(),
|
||||
"invalid active plugin must be excluded from runtime catalog"
|
||||
);
|
||||
let saved = registry.load_registry().await.unwrap();
|
||||
let entry = saved.find(&plugin_id()).unwrap();
|
||||
assert_eq!(entry.lifecycle_state, PluginLifecycleState::Invalid);
|
||||
assert!(
|
||||
entry
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("not servable"),
|
||||
"registry must carry a confined runtime error: {entry:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_mcp_uses_only_enabled_auto_start_servers_with_plugin_identity() {
|
||||
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
|
||||
|
||||
@ -75,6 +75,7 @@ describe("listPluginLayoutChoices / PluginLayoutSelectorSection", () => {
|
||||
menuItems: [],
|
||||
layouts: [
|
||||
{ type: "dev.acme.good", label: "Good layout", component: "X" },
|
||||
null,
|
||||
{ type: "dev.acme.no-label", label: undefined, component: "X" },
|
||||
{ type: undefined, label: "No type", component: "X" },
|
||||
] as unknown as PluginContributionDto["layouts"],
|
||||
|
||||
@ -30,6 +30,10 @@ function nonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function objectOrNull(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function finiteOrder(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
@ -42,8 +46,10 @@ export function listPluginLayoutChoices(registry: PluginRuntimeRegistry): Plugin
|
||||
return registry
|
||||
.layoutContributions()
|
||||
.flatMap(({ pluginId, pluginDisplayName, layout }) => {
|
||||
const type = nonEmptyString(layout.type);
|
||||
const label = nonEmptyString(layout.label);
|
||||
const layoutObject = objectOrNull(layout);
|
||||
if (!layoutObject) return [];
|
||||
const type = nonEmptyString(layoutObject.type);
|
||||
const label = nonEmptyString(layoutObject.label);
|
||||
if (!type || !label) return [];
|
||||
return [
|
||||
{
|
||||
@ -53,8 +59,8 @@ export function listPluginLayoutChoices(registry: PluginRuntimeRegistry): Plugin
|
||||
...layout,
|
||||
type,
|
||||
label,
|
||||
order: finiteOrder(layout.order),
|
||||
icon: nonEmptyString(layout.icon),
|
||||
order: finiteOrder(layoutObject.order),
|
||||
icon: nonEmptyString(layoutObject.icon),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
* acceptance criteria: "ordre déterministe, disabledReason, plugin disabled
|
||||
* absent, command handler appelé").
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { PluginContributionDto } from "@/domain";
|
||||
import { PluginRuntimeRegistry, type LoadedPlugin, type WhenContext } from "@/plugins/runtime";
|
||||
@ -19,11 +19,15 @@ const NO_CONTEXT: WhenContext = {
|
||||
};
|
||||
|
||||
function stubPlugin(pluginId: string, displayName: string, contributes: PluginContributionDto): LoadedPlugin {
|
||||
const declaredCommandIds = contributes.menuItems.flatMap((item) => {
|
||||
if (item === null || typeof item !== "object") return [];
|
||||
return typeof item.command === "string" ? [item.command] : [];
|
||||
});
|
||||
return {
|
||||
pluginId,
|
||||
displayName,
|
||||
contributes,
|
||||
commands: new PluginCommandRegistry(pluginId, new Set(contributes.menuItems.map((i) => i.command))),
|
||||
commands: new PluginCommandRegistry(pluginId, new Set(declaredCommandIds)),
|
||||
layouts: new PluginLayoutRegistry(pluginId, new Set(contributes.layouts.map((l) => l.type))),
|
||||
menu: new PluginMenuRegistry(pluginId),
|
||||
dispose: async () => {},
|
||||
@ -67,6 +71,7 @@ describe("resolveTopLevelMenus", () => {
|
||||
...empty(),
|
||||
menus: [
|
||||
{ id: "bad.menu", label: "Good", topLevel: true },
|
||||
null,
|
||||
{ id: "bad.empty", label: "", topLevel: true },
|
||||
{ id: undefined, label: "No id", topLevel: true },
|
||||
{ id: "bad.no-label", label: undefined, topLevel: true },
|
||||
@ -167,6 +172,7 @@ describe("resolveMenuItems", () => {
|
||||
label: "Say Hello",
|
||||
command: "hello-plugin.sayHello",
|
||||
},
|
||||
null,
|
||||
{
|
||||
id: "hello-plugin.broken.item",
|
||||
targetMenuId: "hello-plugin.menu",
|
||||
@ -199,4 +205,29 @@ describe("resolveMenuItems", () => {
|
||||
await registry.runCommand("dev.acme", "dev.acme.a.cmd");
|
||||
expect(ran).toBe(true);
|
||||
});
|
||||
|
||||
it("confines a throwing command handler to the plugin command dispatch", async () => {
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
const registry = new PluginRuntimeRegistry();
|
||||
const plugin = stubPlugin("dev.acme", "Acme", {
|
||||
...empty(),
|
||||
menuItems: [
|
||||
{ id: "dev.acme.a", targetMenuId: "panels", label: "Open A", command: "dev.acme.a.cmd" },
|
||||
],
|
||||
});
|
||||
plugin.commands.register("dev.acme.a.cmd", () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
registry.add(plugin);
|
||||
|
||||
await expect(registry.runCommand("dev.acme", "dev.acme.a.cmd")).resolves.toBeUndefined();
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'[plugin:dev.acme] command "dev.acme.a.cmd" failed',
|
||||
expect.any(Error),
|
||||
);
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -22,6 +22,10 @@ function nonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function objectOrNull(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function finiteOrder(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
@ -44,8 +48,10 @@ export function resolveTopLevelMenus(registry: PluginRuntimeRegistry): ResolvedT
|
||||
return registry
|
||||
.topLevelMenus()
|
||||
.flatMap(({ pluginId, pluginDisplayName, menu }) => {
|
||||
const id = nonEmptyString(menu.id);
|
||||
const label = nonEmptyString(menu.label);
|
||||
const menuObject = objectOrNull(menu);
|
||||
if (!menuObject) return [];
|
||||
const id = nonEmptyString(menuObject.id);
|
||||
const label = nonEmptyString(menuObject.label);
|
||||
if (!id || !label) return [];
|
||||
return [
|
||||
{
|
||||
@ -53,8 +59,8 @@ export function resolveTopLevelMenus(registry: PluginRuntimeRegistry): ResolvedT
|
||||
pluginId,
|
||||
pluginDisplayName,
|
||||
label,
|
||||
icon: nonEmptyString(menu.icon),
|
||||
order: finiteOrder(menu.order),
|
||||
icon: nonEmptyString(menuObject.icon),
|
||||
order: finiteOrder(menuObject.order),
|
||||
},
|
||||
];
|
||||
})
|
||||
@ -80,13 +86,14 @@ export function resolveMenuItems(
|
||||
): ResolvedPluginMenuItem[] {
|
||||
return registry
|
||||
.menuItems()
|
||||
.filter(({ item }) => targetMatches(item.targetMenuId, targetMenuId))
|
||||
.flatMap(({ pluginId, pluginDisplayName, item }) => {
|
||||
const id = nonEmptyString(item.id);
|
||||
const label = nonEmptyString(item.label);
|
||||
const command = nonEmptyString(item.command);
|
||||
const itemObject = objectOrNull(item);
|
||||
if (!itemObject || !targetMatches(itemObject.targetMenuId, targetMenuId)) return [];
|
||||
const id = nonEmptyString(itemObject.id);
|
||||
const label = nonEmptyString(itemObject.label);
|
||||
const command = nonEmptyString(itemObject.command);
|
||||
if (!id || !label || !command) return [];
|
||||
const result = evaluateWhen(item.when, whenCtx);
|
||||
const result = evaluateWhen(nonEmptyString(itemObject.when), whenCtx);
|
||||
return [
|
||||
{
|
||||
id,
|
||||
@ -98,8 +105,8 @@ export function resolveMenuItems(
|
||||
enabled: result.ok ? result.value : false,
|
||||
disabledReason: result.ok ? undefined : result.reason,
|
||||
groupLabel: nonEmptyString(pluginDisplayName) ?? pluginId,
|
||||
order: finiteOrder(item.order),
|
||||
iconUrl: nonEmptyString(item.icon),
|
||||
order: finiteOrder(itemObject.order),
|
||||
iconUrl: nonEmptyString(itemObject.icon),
|
||||
} satisfies ResolvedPluginMenuItem,
|
||||
];
|
||||
})
|
||||
|
||||
@ -195,6 +195,39 @@ describe("loadPlugins", () => {
|
||||
expect((globalThis as Record<string, unknown>).__helloArchiveCommandRan).toBe(true);
|
||||
});
|
||||
|
||||
it("confines a malformed runtime catalog entry and still loads healthy plugins", async () => {
|
||||
const bundle = dataUrl(`
|
||||
export function activate(ctx) {
|
||||
globalThis.__healthyPluginActivated = ctx.pluginId;
|
||||
}
|
||||
`);
|
||||
|
||||
const { registry, failures } = await loadPlugins(
|
||||
[
|
||||
null,
|
||||
entry({
|
||||
id: "dev.acme.healthy",
|
||||
displayName: "Healthy",
|
||||
bundleUrl: bundle,
|
||||
contributes: {
|
||||
menus: [null],
|
||||
menuItems: [null, { id: "x", targetMenuId: "panels", label: "X" }],
|
||||
layouts: [null, { label: "Missing type" }],
|
||||
} as unknown as PluginContributionDto,
|
||||
}),
|
||||
] as unknown as PluginRuntimePlugin[],
|
||||
gateways,
|
||||
);
|
||||
|
||||
expect(registry.list().map((p) => p.pluginId)).toEqual(["dev.acme.healthy"]);
|
||||
expect(failures).toEqual([
|
||||
{ pluginId: "<unknown-plugin>", reason: expect.stringContaining("bundle URL") },
|
||||
]);
|
||||
expect((globalThis as Record<string, unknown>).__healthyPluginActivated).toBe(
|
||||
"dev.acme.healthy",
|
||||
);
|
||||
});
|
||||
|
||||
it("calls dispose() on removal (best-effort)", async () => {
|
||||
const bundle = dataUrl(`
|
||||
export function activate(ctx) {
|
||||
|
||||
@ -82,7 +82,7 @@ function isIdeaPluginModule(mod: unknown): mod is IdeaPluginModule {
|
||||
}
|
||||
|
||||
function createPluginLogger(entry: PluginRuntimePlugin): PluginLogger {
|
||||
const prefix = `[plugin:${entry.id}]`;
|
||||
const prefix = `[plugin:${safePluginId(entry)}]`;
|
||||
return {
|
||||
debug: (message, ...args) => console.debug(prefix, message, ...args),
|
||||
info: (message, ...args) => console.info(prefix, message, ...args),
|
||||
@ -105,8 +105,38 @@ 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 safePluginId(entry: unknown): string {
|
||||
return nonEmptyString(objectOrEmpty(entry).id) ?? "<unknown-plugin>";
|
||||
}
|
||||
|
||||
function commandIdsFromContributes(contributes: PluginContributionDto): Set<string> {
|
||||
return new Set(
|
||||
contributes.menuItems.flatMap((item) => {
|
||||
const command = objectOrEmpty(item).command;
|
||||
return nonEmptyString(command) ? [command] : [];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function layoutTypesFromContributes(contributes: PluginContributionDto): Set<string> {
|
||||
return new Set(
|
||||
contributes.layouts.flatMap((layout) => {
|
||||
const type = objectOrEmpty(layout).type;
|
||||
return nonEmptyString(type) ? [type] : [];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeContributes(entry: PluginRuntimePlugin): PluginContributionDto {
|
||||
const contributes = entry.contributes as Partial<PluginContributionDto> | null | undefined;
|
||||
const contributes = objectOrEmpty(entry.contributes) as Partial<PluginContributionDto>;
|
||||
return {
|
||||
menus: arrayOrEmpty(contributes?.menus),
|
||||
menuItems: arrayOrEmpty(contributes?.menuItems),
|
||||
@ -135,32 +165,41 @@ async function loadOne(
|
||||
entry: PluginRuntimePlugin,
|
||||
gateways: PluginGatewaySet,
|
||||
): Promise<{ plugin: LoadedPlugin } | { failure: PluginLoadFailure }> {
|
||||
const entryObject = objectOrEmpty(entry);
|
||||
const pluginId = safePluginId(entry);
|
||||
const displayName = nonEmptyString(entryObject.displayName) ?? pluginId;
|
||||
const version = nonEmptyString(entryObject.version) ?? "";
|
||||
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 mod: unknown = await import(/* @vite-ignore */ entry.bundleUrl);
|
||||
const bundleUrl = nonEmptyString(entryObject.bundleUrl);
|
||||
if (!bundleUrl) {
|
||||
throw new Error("missing plugin bundle URL");
|
||||
}
|
||||
|
||||
const mod: unknown = await import(/* @vite-ignore */ bundleUrl);
|
||||
if (!isIdeaPluginModule(mod)) {
|
||||
return {
|
||||
failure: {
|
||||
pluginId: entry.id,
|
||||
pluginId,
|
||||
reason: `bundle does not export an "activate(ctx)" function`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const contributes = normalizeContributes(entry);
|
||||
const declaredCommandIds = new Set(contributes.menuItems.map((item) => item.command));
|
||||
const declaredLayoutTypes = new Set(contributes.layouts.map((layout) => layout.type));
|
||||
const commands = new PluginCommandRegistry(entry.id, declaredCommandIds);
|
||||
const layouts = new PluginLayoutRegistry(entry.id, declaredLayoutTypes);
|
||||
const menu = new PluginMenuRegistry(entry.id);
|
||||
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 subscriptions: Disposable[] = [];
|
||||
|
||||
const ctx: IdeaPluginContext = {
|
||||
pluginId: entry.id,
|
||||
pluginDisplayName: entry.displayName,
|
||||
version: entry.version,
|
||||
pluginId,
|
||||
pluginDisplayName: displayName,
|
||||
version,
|
||||
logger: createPluginLogger(entry),
|
||||
subscriptions,
|
||||
commands: createCommandContext(commands),
|
||||
@ -172,8 +211,8 @@ async function loadOne(
|
||||
const activation = await mod.activate(ctx);
|
||||
|
||||
const plugin: LoadedPlugin = {
|
||||
pluginId: entry.id,
|
||||
displayName: entry.displayName,
|
||||
pluginId,
|
||||
displayName,
|
||||
contributes,
|
||||
commands,
|
||||
layouts,
|
||||
@ -188,7 +227,7 @@ async function loadOne(
|
||||
} catch (e) {
|
||||
return {
|
||||
failure: {
|
||||
pluginId: entry.id,
|
||||
pluginId,
|
||||
reason: e instanceof Error ? e.message : String(e),
|
||||
},
|
||||
};
|
||||
@ -208,7 +247,8 @@ export async function loadPlugins(
|
||||
const registry = new PluginRuntimeRegistry();
|
||||
const failures: PluginLoadFailure[] = [];
|
||||
|
||||
const results = await Promise.all(catalogPlugins.map((entry) => loadOne(entry, gateways)));
|
||||
const entries = Array.isArray(catalogPlugins) ? catalogPlugins : [];
|
||||
const results = await Promise.all(entries.map((entry) => loadOne(entry, gateways)));
|
||||
for (const result of results) {
|
||||
if ("failure" in result) failures.push(result.failure);
|
||||
else registry.add(result.plugin);
|
||||
|
||||
@ -65,7 +65,14 @@ export class PluginCommandRegistry {
|
||||
async run(commandId: string, ...args: unknown[]): Promise<void> {
|
||||
const handler = this.handlers.get(commandId);
|
||||
if (!handler) return;
|
||||
try {
|
||||
await handler(...args);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[plugin:${this.pluginId}] command "${commandId}" failed`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
has(commandId: string): boolean {
|
||||
|
||||
Reference in New Issue
Block a user