feat(backend): système de plugins — domaine, application, infrastructure (#43)
Lots B1-B4 : modèle de domaine des plugins (manifeste, capacités menus/layouts/MCP), port et registre applicatif, chargement/validation en infrastructure, exposition DTO et commandes Tauri. Tests cargo verts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -32,12 +32,12 @@ tauri-plugin-dialog = { workspace = true }
|
||||
tokio = { workspace = true, features = ["io-std", "rt", "net"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
http = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
base64 = "0.22"
|
||||
bytes = "1.11"
|
||||
cookie = "0.18"
|
||||
http = "1.4"
|
||||
http-body-util = "0.1"
|
||||
# `AppAgentResumer` implements the application's async `AgentResumer` port (LS7).
|
||||
async-trait = { workspace = true }
|
||||
|
||||
@ -21,6 +21,7 @@ pub mod events;
|
||||
pub mod mcp_bridge;
|
||||
pub mod mcp_endpoint;
|
||||
pub mod openai_tools;
|
||||
pub mod plugins;
|
||||
pub mod pty;
|
||||
pub mod server;
|
||||
pub mod state;
|
||||
@ -133,6 +134,9 @@ pub fn dispatch() -> ExitCode {
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.register_uri_scheme_protocol("idea-plugin", |ctx, request| {
|
||||
plugins::plugin_asset_protocol(ctx.app_handle(), request)
|
||||
})
|
||||
.setup(|app| {
|
||||
// Resolve the machine-local IDE data directory (ARCHITECTURE §9.2)
|
||||
// and build the composition root once the app handle exists, so the
|
||||
@ -154,6 +158,7 @@ pub fn run() {
|
||||
events::spawn_relay(app.handle().clone(), &app_state.event_bus);
|
||||
|
||||
let embedded_server = Arc::clone(&app_state.embedded_server);
|
||||
let plugin_mcp_reconcile = Arc::clone(&app_state.reconcile_plugin_mcp_servers);
|
||||
let core = app_state.core();
|
||||
app.manage(app_state);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
@ -165,6 +170,11 @@ pub fn run() {
|
||||
);
|
||||
}
|
||||
});
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(err) = plugin_mcp_reconcile.execute().await {
|
||||
application::diag!("[plugins] MCP reconcile at boot failed: {err}");
|
||||
}
|
||||
});
|
||||
|
||||
// Kill all live PTYs cleanly when the main window is closing. This is
|
||||
// independent of the per-view (navigation/layout) lifecycle — those
|
||||
@ -334,6 +344,14 @@ pub fn run() {
|
||||
commands::cancel_background_task,
|
||||
commands::retry_background_task,
|
||||
commands::list_background_tasks,
|
||||
plugins::plugin_list_plugins,
|
||||
plugins::plugin_review_package,
|
||||
plugins::plugin_install_from_archive,
|
||||
plugins::plugin_install_from_directory,
|
||||
plugins::plugin_set_enabled,
|
||||
plugins::plugin_uninstall,
|
||||
plugins::plugin_list_runtime_contributions,
|
||||
plugins::plugin_open_plugins_folder,
|
||||
commands::get_server_exposure_settings,
|
||||
commands::save_server_exposure_settings,
|
||||
commands::preview_server_exposure_settings,
|
||||
|
||||
293
crates/app-tauri/src/plugins.rs
Normal file
293
crates/app-tauri/src/plugins.rs
Normal file
@ -0,0 +1,293 @@
|
||||
//! Plugin Tauri commands and asset protocol.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use application::{ReviewPluginPackageInput, SetPluginEnabledInput, UninstallPluginInput};
|
||||
use backend::dto::{
|
||||
ErrorDto, PluginAdminDto, PluginInstallResultDto, PluginReviewDto,
|
||||
PluginRuntimeContributionCatalogDto, PluginUninstallResultDto, ReviewPluginPackageDto,
|
||||
};
|
||||
use domain::ports::{PluginManifestValidator, PluginPackageStore, PluginRegistryStore};
|
||||
use domain::{PluginId, RelativePath};
|
||||
use http::{header, Response, StatusCode};
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Lists installed plugins for the admin surface.
|
||||
#[tauri::command]
|
||||
pub async fn plugin_list_plugins(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<PluginAdminDto>, ErrorDto> {
|
||||
state
|
||||
.list_plugins
|
||||
.execute()
|
||||
.await
|
||||
.map(|plugins| plugins.into_iter().map(PluginAdminDto::from).collect())
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// Reviews a local plugin package without committing it.
|
||||
#[tauri::command]
|
||||
pub async fn plugin_review_package(
|
||||
input: ReviewPluginPackageDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<PluginReviewDto, ErrorDto> {
|
||||
let input = match input.source_kind.as_str() {
|
||||
"archive" => ReviewPluginPackageInput::Archive { path: input.path },
|
||||
"directory" => ReviewPluginPackageInput::Directory { path: input.path },
|
||||
other => {
|
||||
return Err(ErrorDto::invalid(format!(
|
||||
"invalid plugin source kind: {other}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
state
|
||||
.review_plugin_package
|
||||
.execute(input)
|
||||
.await
|
||||
.map(PluginReviewDto::from)
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// Installs a plugin from a local archive.
|
||||
#[tauri::command]
|
||||
pub async fn plugin_install_from_archive(
|
||||
path: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<PluginInstallResultDto, ErrorDto> {
|
||||
state
|
||||
.install_plugin_from_archive
|
||||
.execute(path)
|
||||
.await
|
||||
.map(PluginInstallResultDto::from)
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// Installs a plugin from a local directory snapshot.
|
||||
#[tauri::command]
|
||||
pub async fn plugin_install_from_directory(
|
||||
path: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<PluginInstallResultDto, ErrorDto> {
|
||||
state
|
||||
.install_plugin_from_directory
|
||||
.execute(path)
|
||||
.await
|
||||
.map(PluginInstallResultDto::from)
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// Enables or disables a plugin.
|
||||
#[tauri::command]
|
||||
pub async fn plugin_set_enabled(
|
||||
plugin_id: String,
|
||||
enabled: bool,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<PluginAdminDto, ErrorDto> {
|
||||
state
|
||||
.set_plugin_enabled
|
||||
.execute(SetPluginEnabledInput { plugin_id, enabled })
|
||||
.await
|
||||
.map(PluginAdminDto::from)
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// Uninstalls a plugin.
|
||||
#[tauri::command]
|
||||
pub async fn plugin_uninstall(
|
||||
plugin_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<PluginUninstallResultDto, ErrorDto> {
|
||||
state
|
||||
.uninstall_plugin
|
||||
.execute(UninstallPluginInput { plugin_id })
|
||||
.await
|
||||
.map(PluginUninstallResultDto::from)
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// Lists active runtime contributions for frontend bootstrap.
|
||||
#[tauri::command]
|
||||
pub async fn plugin_list_runtime_contributions(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<PluginRuntimeContributionCatalogDto, ErrorDto> {
|
||||
state
|
||||
.list_plugin_runtime_contributions
|
||||
.execute()
|
||||
.await
|
||||
.map(PluginRuntimeContributionCatalogDto::from)
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// Opens the plugin store folder, or one plugin folder when an id is provided.
|
||||
#[tauri::command]
|
||||
pub fn plugin_open_plugins_folder(
|
||||
plugin_id: Option<String>,
|
||||
app: AppHandle,
|
||||
) -> Result<(), ErrorDto> {
|
||||
let mut path = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| ErrorDto::invalid(e.to_string()))?
|
||||
.join("plugins");
|
||||
if let Some(id) = plugin_id {
|
||||
let id = PluginId::new(id).map_err(|e| ErrorDto::invalid(e.to_string()))?;
|
||||
path = path.join("installed").join(id.as_str());
|
||||
}
|
||||
open_folder(&path)
|
||||
}
|
||||
|
||||
/// Serves `idea-plugin://<pluginId>/<version>/<contentHash>/<relativePath>` after
|
||||
/// checking registry state, hash and root confinement.
|
||||
pub fn plugin_asset_protocol(
|
||||
app: &AppHandle,
|
||||
request: http::Request<Vec<u8>>,
|
||||
) -> Response<Vec<u8>> {
|
||||
match plugin_asset_response(app, request) {
|
||||
Ok(response) => response,
|
||||
Err((status, message)) => Response::builder()
|
||||
.status(status)
|
||||
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||
.body(message.into_bytes())
|
||||
.expect("valid protocol error response"),
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_asset_response(
|
||||
app: &AppHandle,
|
||||
request: http::Request<Vec<u8>>,
|
||||
) -> Result<Response<Vec<u8>>, (StatusCode, String)> {
|
||||
let uri = request.uri();
|
||||
let plugin_id = uri
|
||||
.host()
|
||||
.ok_or_else(|| (StatusCode::BAD_REQUEST, "missing plugin id".to_owned()))
|
||||
.and_then(|raw| {
|
||||
PluginId::new(raw.to_owned()).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))
|
||||
})?;
|
||||
let mut parts = uri.path().trim_start_matches('/').splitn(3, '/');
|
||||
let version_segment = parts.next().unwrap_or_default();
|
||||
let hash = parts.next().unwrap_or_default();
|
||||
let rel = parts.next().unwrap_or_default();
|
||||
if version_segment.is_empty() || hash.is_empty() {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid plugin asset URL".to_owned(),
|
||||
));
|
||||
}
|
||||
let rel =
|
||||
RelativePath::new(rel.to_owned()).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
|
||||
let state = app.state::<AppState>();
|
||||
let allowed = tauri::async_runtime::block_on(asset_allowed(
|
||||
&plugin_id,
|
||||
hash,
|
||||
&rel,
|
||||
state.plugin_registry_store.as_ref(),
|
||||
state.plugin_package_store.as_ref(),
|
||||
state.plugin_manifest_validator.as_ref(),
|
||||
))?;
|
||||
if !allowed {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"plugin asset is not active".to_owned(),
|
||||
));
|
||||
}
|
||||
let app_data = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
let root = app_data
|
||||
.join("plugins")
|
||||
.join("installed")
|
||||
.join(plugin_id.as_str());
|
||||
let target = root.join(rel.as_str());
|
||||
let root = root
|
||||
.canonicalize()
|
||||
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
||||
let target = target
|
||||
.canonicalize()
|
||||
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
||||
if !target.starts_with(&root) {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"asset escapes plugin root".to_owned(),
|
||||
));
|
||||
}
|
||||
let bytes = std::fs::read(&target).map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type(&target))
|
||||
.body(bytes)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
|
||||
}
|
||||
|
||||
async fn asset_allowed(
|
||||
plugin_id: &PluginId,
|
||||
hash: &str,
|
||||
rel: &RelativePath,
|
||||
registry_store: &dyn PluginRegistryStore,
|
||||
package_store: &dyn PluginPackageStore,
|
||||
validator: &dyn PluginManifestValidator,
|
||||
) -> Result<bool, (StatusCode, String)> {
|
||||
let registry = registry_store
|
||||
.load_registry()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
let Some(entry) = registry.find(plugin_id) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if !entry.lifecycle_state.is_runtime_active() || entry.content_hash.as_str() != hash {
|
||||
return Ok(false);
|
||||
}
|
||||
let package = domain::PluginPackageRef {
|
||||
plugin_id: Some(plugin_id.clone()),
|
||||
root: plugin_id.as_str().to_owned(),
|
||||
};
|
||||
let manifest_bytes = package_store
|
||||
.read_manifest(&package)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
||||
let manifest = validator
|
||||
.validate(&manifest_bytes.bytes, &package)
|
||||
.map_err(|e| (StatusCode::FORBIDDEN, e.to_string()))?;
|
||||
let declared_icon = manifest.icon.as_ref() == Some(rel);
|
||||
let declared_main = manifest.main == *rel;
|
||||
Ok(declared_main || declared_icon || rel.as_str().starts_with("assets/"))
|
||||
}
|
||||
|
||||
fn content_type(path: &Path) -> &'static str {
|
||||
match path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"js" | "mjs" => "text/javascript; charset=utf-8",
|
||||
"json" => "application/json; charset=utf-8",
|
||||
"svg" => "image/svg+xml",
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"webp" => "image/webp",
|
||||
"css" => "text/css; charset=utf-8",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
fn open_folder(path: &PathBuf) -> Result<(), ErrorDto> {
|
||||
let mut command = if cfg!(target_os = "macos") {
|
||||
let mut c = std::process::Command::new("open");
|
||||
c.arg(path);
|
||||
c
|
||||
} else if cfg!(target_os = "windows") {
|
||||
let mut c = std::process::Command::new("cmd");
|
||||
c.args(["/C", "start", ""]).arg(path);
|
||||
c
|
||||
} else {
|
||||
let mut c = std::process::Command::new("xdg-open");
|
||||
c.arg(path);
|
||||
c
|
||||
};
|
||||
command
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|e| ErrorDto::invalid(e.to_string()))
|
||||
}
|
||||
65
crates/app-tauri/tests/dto_plugins.rs
Normal file
65
crates/app-tauri/tests/dto_plugins.rs
Normal file
@ -0,0 +1,65 @@
|
||||
use app_tauri_lib::dto::{
|
||||
PluginAdminDto, PluginContributionSummaryDto, PluginRuntimeContributionCatalogDto,
|
||||
PluginRuntimePluginDto,
|
||||
};
|
||||
use domain::{PluginContributionSet, PluginLifecycleState, PluginTrustLevel};
|
||||
|
||||
#[test]
|
||||
fn plugin_admin_dto_serialises_exact_contract_shape() {
|
||||
let dto = PluginAdminDto {
|
||||
id: "dev.acme.gitgraph".to_owned(),
|
||||
display_name: "Git Graph".to_owned(),
|
||||
publisher: Some("Acme".to_owned()),
|
||||
version: "1.2.3".to_owned(),
|
||||
description: Some("Graph".to_owned()),
|
||||
icon_url: Some("idea-plugin://dev.acme.gitgraph/1.2.3/abc/assets/icon.svg".to_owned()),
|
||||
source_kind: "archive".to_owned(),
|
||||
source_label: Some("/tmp/gitgraph.ideaplug".to_owned()),
|
||||
lifecycle_state: PluginLifecycleState::PendingDisable,
|
||||
enabled: false,
|
||||
pending_enable_state: Some(false),
|
||||
pending_uninstall: false,
|
||||
restart_required: true,
|
||||
trust_level: PluginTrustLevel::Full,
|
||||
contribution_summary: PluginContributionSummaryDto {
|
||||
top_level_menus: 1,
|
||||
menu_items: 2,
|
||||
layouts: 3,
|
||||
mcp_servers: 4,
|
||||
},
|
||||
error: None,
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(dto).unwrap();
|
||||
assert_eq!(value["displayName"], "Git Graph");
|
||||
assert_eq!(value["lifecycleState"], "pending-disable");
|
||||
assert_eq!(value["trustLevel"], "full");
|
||||
assert_eq!(value["contributionSummary"]["topLevelMenus"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_catalog_dto_carries_bundle_hash_and_contributions() {
|
||||
let dto = PluginRuntimeContributionCatalogDto {
|
||||
plugins: vec![PluginRuntimePluginDto {
|
||||
id: "dev.acme.gitgraph".to_owned(),
|
||||
display_name: "Git Graph".to_owned(),
|
||||
publisher: None,
|
||||
version: "1.2.3".to_owned(),
|
||||
bundle_url: "idea-plugin://dev.acme.gitgraph/1.2.3/abc/dist/index.js".to_owned(),
|
||||
icon_url: None,
|
||||
content_hash: "abc".to_owned(),
|
||||
contributes: PluginContributionSet::default(),
|
||||
}],
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(dto).unwrap();
|
||||
assert_eq!(
|
||||
value["plugins"][0]["bundleUrl"],
|
||||
"idea-plugin://dev.acme.gitgraph/1.2.3/abc/dist/index.js"
|
||||
);
|
||||
assert_eq!(value["plugins"][0]["contentHash"], "abc");
|
||||
assert!(value["plugins"][0]["contributes"]["menus"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
}
|
||||
Reference in New Issue
Block a user