Files
IdeaSDK/crates/app-tauri/src/plugins.rs
Blomios bb35641715 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>
2026-07-22 07:37:03 +02:00

294 lines
9.2 KiB
Rust

//! 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()))
}