feat(plugins,host): pluginRoot officiel transmis au contexte activate(ctx) des plugins — #285 (domain ports + infrastructure plugin: racine du package installé résolue depuis la même source de vérité que la substitution manifest ${pluginRoot}; application runtime_catalog + slash_command; backend dto + app-tauri dto_plugins; frontend domain + plugins runtime loader transmettent pluginRoot au contexte activé; bump pointeur IdeaSDK e3a0f46 via feature/285-plugin-root-runtime-api; QA verte: cargo check domain/application/infrastructure/backend/app-tauri, runtime_catalog 5/5, plugin_install_load 5/5, dto_plugins 2/2, frontend typecheck + 33 tests/3 fichiers, cargo fmt --check, git diff --check; réserves: rebuild AppImage + validation live ctx.pluginRoot dans le binaire actif restants)

This commit is contained in:
2026-09-08 18:35:34 +02:00
parent 46a1d1a31b
commit e499a0fd34
13 changed files with 88 additions and 4 deletions

View File

@ -50,6 +50,7 @@ fn runtime_catalog_dto_carries_bundle_hash_and_contributions() {
publisher: None,
version: "1.2.3".to_owned(),
bundle_url: "idea-plugin://dev.acme.gitgraph/1.2.3/abc/dist/index.js".to_owned(),
plugin_root: "/data/idea/plugins/installed/dev.acme.gitgraph".to_owned(),
icon_url: None,
content_hash: "abc".to_owned(),
capabilities: vec![PluginCapability::Ui, PluginCapability::Tooling],
@ -64,6 +65,10 @@ fn runtime_catalog_dto_carries_bundle_hash_and_contributions() {
"idea-plugin://dev.acme.gitgraph/1.2.3/abc/dist/index.js"
);
assert_eq!(value["plugins"][0]["contentHash"], "abc");
assert_eq!(
value["plugins"][0]["pluginRoot"],
"/data/idea/plugins/installed/dev.acme.gitgraph"
);
assert_eq!(
value["plugins"][0]["capabilities"],
serde_json::json!(["ui", "tooling"])

View File

@ -184,6 +184,8 @@ pub struct PluginRuntimePlugin {
pub version: String,
/// Bundle URL.
pub bundle_url: String,
/// Canonical host-local root of the installed plugin package.
pub plugin_root: String,
/// Icon URL.
pub icon_url: Option<String>,
/// Content hash.
@ -3022,6 +3024,10 @@ pub(crate) async fn runtime_plugin_from_entry(
&descriptor.registry.content_hash,
&descriptor.manifest.main,
)?;
let plugin_root = packages
.installed_plugin_root(&descriptor.manifest.id)
.await
.map_err(map_store)?;
let icon_url = match &descriptor.manifest.icon {
Some(icon) => Some(checked_plugin_asset_url(
packages,
@ -3038,6 +3044,7 @@ pub(crate) async fn runtime_plugin_from_entry(
publisher: descriptor.manifest.publisher,
version: descriptor.manifest.version.as_str().to_owned(),
bundle_url: bundle,
plugin_root: plugin_root.as_str().to_owned(),
icon_url,
content_hash: descriptor.registry.content_hash.as_str().to_owned(),
capabilities: descriptor.manifest.capabilities,
@ -4203,6 +4210,10 @@ mod tests {
let catalog = usecase.execute().await.unwrap();
assert_eq!(catalog.plugins.len(), 1);
assert_eq!(
catalog.plugins[0].plugin_root,
"/installed/dev.acme.gitgraph"
);
assert_eq!(
catalog.plugins[0].capabilities,
vec![

View File

@ -426,6 +426,7 @@ mod tests {
publisher: None,
version: "1.0.0".to_owned(),
bundle_url: "idea-plugin://dev.acme.plugin/1.0.0/hash/dist/index.js".to_owned(),
plugin_root: "/plugins/installed/dev.acme.plugin".to_owned(),
icon_url: None,
content_hash: "abc".to_owned(),
capabilities: vec![domain::PluginCapability::Ui],

View File

@ -382,6 +382,8 @@ pub struct PluginRuntimePluginDto {
pub version: String,
/// Bundle URL.
pub bundle_url: String,
/// Canonical host-local root of the installed plugin package.
pub plugin_root: String,
/// Icon URL.
pub icon_url: Option<String>,
/// Content hash.
@ -414,6 +416,7 @@ impl From<application::PluginRuntimePlugin> for PluginRuntimePluginDto {
publisher: value.publisher,
version: value.version,
bundle_url: value.bundle_url,
plugin_root: value.plugin_root,
icon_url: value.icon_url,
content_hash: value.content_hash,
capabilities: value.capabilities,

View File

@ -440,6 +440,19 @@ pub trait PluginPackageStore: Send + Sync {
/// Lists committed package roots.
async fn list_installed(&self) -> Result<Vec<PluginPackageRef>, PluginStoreError>;
/// Resolves the host-local root of one committed plugin package.
async fn installed_plugin_root(
&self,
plugin_id: &crate::PluginId,
) -> Result<LocalPath, PluginStoreError> {
self.list_installed()
.await?
.into_iter()
.find(|package| package.plugin_id.as_ref() == Some(plugin_id))
.map(|package| LocalPath::new(package.root))
.ok_or(PluginStoreError::NotFound)
}
/// Reads `idea-plugin.json` from a package root.
async fn read_manifest(
&self,

View File

@ -141,6 +141,21 @@ impl PluginPackageStore for FsPluginPackageStore {
Ok(out)
}
async fn installed_plugin_root(
&self,
plugin_id: &PluginId,
) -> Result<LocalPath, PluginStoreError> {
let root = self.installed_plugin_dir(plugin_id);
let canonical = root.canonicalize().map_err(|error| {
if error.kind() == io::ErrorKind::NotFound {
PluginStoreError::NotFound
} else {
PluginStoreError::Io(error.to_string())
}
})?;
Ok(LocalPath::new(canonical.to_string_lossy().into_owned()))
}
async fn read_manifest(
&self,
package: &PluginPackageRef,

View File

@ -134,6 +134,13 @@ async fn installs_reference_fixture_and_loads_runtime_catalog() {
assert!(plugin
.bundle_url
.starts_with("idea-plugin://dev.idea.fixtures.reference-minimal/0.1.0/"));
assert_eq!(
PathBuf::from(&plugin.plugin_root),
app_data
.join("plugins/installed/dev.idea.fixtures.reference-minimal")
.canonicalize()
.unwrap()
);
assert_eq!(plugin.contributes.menus.len(), 1);
assert_eq!(plugin.contributes.menu_items.len(), 1);
@ -183,6 +190,13 @@ async fn installs_sdk_hello_plugin_and_loads_runtime_catalog() {
.bundle_url
.starts_with("idea-plugin://com.example.hello-plugin/0.1.0/"));
assert!(plugin.bundle_url.ends_with("/dist/index.js"));
assert_eq!(
PathBuf::from(&plugin.plugin_root),
app_data
.join("plugins/installed/com.example.hello-plugin")
.canonicalize()
.unwrap()
);
assert_eq!(plugin.contributes.menus[0].id, "hello-plugin.menu");
assert_eq!(plugin.contributes.menus[0].label.as_str(), "Hello Plugin");
assert_eq!(

View File

@ -2004,6 +2004,8 @@ export interface PluginRuntimePlugin {
*/
activationScope?: "app" | "project";
bundleUrl: string;
/** Canonical host-local root of the active installed plugin package. */
pluginRoot: string;
iconUrl?: string;
contentHash: string;
contributes: PluginContributionDto;

View File

@ -23,6 +23,7 @@ function runtimePlugin(overrides: Partial<PluginRuntimePlugin> = {}): PluginRunt
displayName: "Refreshable v1",
version: "1.0.0",
bundleUrl: dataUrl("export function activate() {}"),
pluginRoot: "/data/idea/plugins/installed/dev.acme.refreshable",
contentHash: "v1",
contributes: { menus: [], menuItems: [], layouts: [], mcpServers: [] },
...overrides,

View File

@ -101,6 +101,7 @@ class ProjectScopedRuntimeCatalogPluginGateway extends MockPluginGateway {
version: "1.0.0",
activationScope: "project",
bundleUrl: this.bundleUrl,
pluginRoot: "/data/idea/plugins/installed/dev.acme.project-plugin",
contentHash: "project-plugin",
contributes: { menus: [], menuItems: [], layouts: [], mcpServers: [] },
},
@ -127,6 +128,7 @@ class MixedActivationScopeRuntimeCatalogPluginGateway extends MockPluginGateway
capabilities: ["tooling"],
activationScope: "app",
bundleUrl: this.failingAppBundleUrl,
pluginRoot: "/data/idea/plugins/installed/dev.acme.app-needs-project",
contentHash: "app-needs-project",
contributes: { menus: [], menuItems: [], layouts: [], mcpServers: [] },
},
@ -136,6 +138,7 @@ class MixedActivationScopeRuntimeCatalogPluginGateway extends MockPluginGateway
version: "1.0.0",
activationScope: "project",
bundleUrl: this.projectBundleUrl,
pluginRoot: "/data/idea/plugins/installed/dev.acme.project-plugin",
contentHash: "project-plugin",
contributes: { menus: [], menuItems: [], layouts: [], mcpServers: [] },
},

View File

@ -29,6 +29,7 @@ function entry(overrides: Partial<PluginRuntimePlugin> & { bundleUrl: string }):
id: "mock.plugin",
displayName: "Mock Plugin",
version: "1.0.0",
pluginRoot: "/data/idea/plugins/installed/mock.plugin",
contentHash: "abc123",
contributes: emptyContributes(),
...overrides,
@ -39,17 +40,25 @@ describe("loadPlugins", () => {
it("loads a well-formed plugin bundle and calls activate(ctx)", async () => {
const bundle = dataUrl(`
export function activate(ctx) {
globalThis.__activatedWith = ctx.pluginId;
globalThis.__activatedWith = { pluginId: ctx.pluginId, pluginRoot: ctx.pluginRoot };
return {};
}
`);
const { registry, failures } = await loadPlugins(
[entry({ id: "dev.acme.one", displayName: "One", bundleUrl: bundle })],
[entry({
id: "dev.acme.one",
displayName: "One",
bundleUrl: bundle,
pluginRoot: "/data/idea/plugins/installed/dev.acme.one",
})],
gateways,
);
expect(failures).toEqual([]);
expect(registry.list().map((p) => p.pluginId)).toEqual(["dev.acme.one"]);
expect((globalThis as Record<string, unknown>).__activatedWith).toBe("dev.acme.one");
expect((globalThis as Record<string, unknown>).__activatedWith).toEqual({
pluginId: "dev.acme.one",
pluginRoot: "/data/idea/plugins/installed/dev.acme.one",
});
});
it("loads an SDK-style default export containing activate(ctx)", async () => {
@ -242,6 +251,7 @@ describe("loadPlugins", () => {
"menu",
"pluginDisplayName",
"pluginId",
"pluginRoot",
"services",
"storage",
"subscriptions",

View File

@ -38,6 +38,7 @@ export type { PluginGatewaySet } from "./registry";
export interface IdeaPluginContext {
pluginId: string;
pluginRoot: string;
pluginDisplayName: string;
version: string;
logger: PluginLogger;
@ -270,6 +271,7 @@ async function loadOne(
const pluginId = safePluginId(entry);
const displayName = nonEmptyString(entryObject.displayName) ?? pluginId;
const version = nonEmptyString(entryObject.version) ?? "";
const pluginRoot = nonEmptyString(entryObject.pluginRoot);
let activation: void | PluginActivation = undefined;
const subscriptions: Disposable[] = [];
try {
@ -280,6 +282,9 @@ async function loadOne(
if (!bundleUrl) {
throw new Error("missing plugin bundle URL");
}
if (!pluginRoot) {
throw new Error("missing installed plugin root");
}
installHostReactImportMap();
const mod = resolveIdeaPluginModule(
@ -308,6 +313,7 @@ async function loadOne(
const ctx: IdeaPluginContext = {
pluginId,
pluginRoot,
pluginDisplayName: displayName,
version,
logger: createPluginLogger(entry),