feat(window): support des fenêtres plugin-hébergées (backend)

Étend le port/usecases window et layout.customPluginLayout pour ouvrir et
piloter des fenêtres OS hébergeant un layout plugin, en réutilisant
contributes.layouts plutôt qu'un système de fenêtres parallèle. Câble la
commande Tauri et l'état app-tauri correspondants.

Cargo test -p domain --test window : 5/5
Cargo test -p application --test window_usecases : 7/7
Cargo test -p infrastructure --test window_state_store : 1/1
Cargo test -p app-tauri view_window_tests : 8/8

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 00:39:42 +02:00
parent 3ea1d58b38
commit 551eb09ad2
14 changed files with 884 additions and 95 deletions

View File

@ -15,7 +15,7 @@ use crate::error::AppError;
use super::store::{
default_tree, persist_doc, plugin_layout_tree, resolve_doc, LayoutKind, NamedLayout,
};
use crate::plugin::runtime_plugin_from_entry;
use crate::plugin::ensure_runtime_plugin_layout_contribution;
/// Lightweight descriptor of a named layout (no tree), for the layouts tab bar.
#[derive(Debug, Clone, PartialEq, Eq)]
@ -183,36 +183,15 @@ impl CreateLayout {
&self,
origin: &super::store::PluginLayoutOrigin,
) -> Result<(), AppError> {
let registry = self
.registry
.load_registry()
.await
.map_err(|e| AppError::Store(e.to_string()))?;
for entry in registry.plugins {
if entry.id != origin.plugin_id || !entry.lifecycle_state.is_runtime_active() {
continue;
}
let runtime =
runtime_plugin_from_entry(self.packages.as_ref(), self.validator.as_ref(), entry)
.await?;
if runtime
.contributes
.layouts
.iter()
.any(|layout| layout.layout_type == origin.layout_type)
{
return Ok(());
}
return Err(AppError::Invalid(format!(
"plugin `{}` does not contribute layout `{}`",
origin.plugin_id.as_str(),
origin.layout_type.as_str()
)));
}
Err(AppError::Invalid(format!(
"plugin `{}` is not active at runtime",
origin.plugin_id.as_str()
)))
ensure_runtime_plugin_layout_contribution(
self.packages.as_ref(),
self.registry.as_ref(),
self.validator.as_ref(),
&origin.plugin_id,
&origin.layout_type,
)
.await
.map(|_| ())
}
}

View File

@ -222,7 +222,8 @@ pub use ticket_assistant::{
OpenTicketAssistantOutput,
};
pub use window::{
MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput, RestoreOpenWindows,
MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput, OpenPluginLayoutWindow,
OpenPluginLayoutWindowInput, OpenPluginLayoutWindowOutput, RestoreOpenWindows,
RestoreOpenWindowsOutput, SnapshotOpenWindows, SnapshotOpenWindowsInput,
};
pub use workstate::{

View File

@ -14,8 +14,9 @@ use domain::ports::{
use domain::{
AgentId, BackgroundTask, BackgroundTaskState, BackgroundTaskWakePolicy, ContentHash,
DomainEvent, PluginContributionSet, PluginDescriptor, PluginId, PluginInstallSource,
PluginLifecycleState, PluginManifest, PluginMcpServerSpec, PluginRegistryEntry,
PluginTrustLevel, Project, ProjectId, ProjectPath, RemovalOutcome, StagedPluginPackage, TaskId,
PluginLayoutType, PluginLifecycleState, PluginManifest, PluginMcpServerSpec,
PluginRegistryEntry, PluginTrustLevel, Project, ProjectId, ProjectPath, RemovalOutcome,
StagedPluginPackage, TaskId,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
@ -159,6 +160,20 @@ pub struct PluginRuntimePlugin {
pub contributes: PluginContributionSet,
}
/// Runtime-validated plugin layout contribution.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginRuntimeLayoutContribution {
/// Plugin id.
pub plugin_id: String,
/// Provider display name.
pub provider_plugin_display_name: String,
/// Layout type.
pub layout_type: String,
/// Layout display label.
pub label: String,
}
/// Input for plugin-owned storage reads/deletes.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -2801,6 +2816,49 @@ pub(crate) async fn runtime_plugin_from_entry(
})
}
/// Validates that a plugin layout contribution exists and is active at runtime.
///
/// This is the canonical application-layer rule for every surface that wants to
/// host a plugin layout, whether inside a named project layout or a detached OS
/// window.
pub async fn ensure_runtime_plugin_layout_contribution(
packages: &dyn PluginPackageStore,
registry: &dyn PluginRegistryStore,
validator: &dyn PluginManifestValidator,
plugin_id: &PluginId,
layout_type: &PluginLayoutType,
) -> Result<PluginRuntimeLayoutContribution, AppError> {
let registry = registry.load_registry().await.map_err(map_registry)?;
for entry in registry.plugins {
if entry.id != *plugin_id || !entry.lifecycle_state.is_runtime_active() {
continue;
}
let runtime = runtime_plugin_from_entry(packages, validator, entry).await?;
if let Some(layout) = runtime
.contributes
.layouts
.iter()
.find(|layout| layout.layout_type == *layout_type)
{
return Ok(PluginRuntimeLayoutContribution {
plugin_id: runtime.id,
provider_plugin_display_name: runtime.display_name,
layout_type: layout.layout_type.as_str().to_owned(),
label: layout.label.clone(),
});
}
return Err(AppError::Invalid(format!(
"plugin `{}` does not contribute layout `{}`",
plugin_id.as_str(),
layout_type.as_str()
)));
}
Err(AppError::Invalid(format!(
"plugin `{}` is not active at runtime",
plugin_id.as_str()
)))
}
fn checked_plugin_asset_url(
packages: &dyn PluginPackageStore,
plugin_id: &PluginId,

View File

@ -4,6 +4,7 @@
mod usecases;
pub use usecases::{
MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput, RestoreOpenWindows,
MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput, OpenPluginLayoutWindow,
OpenPluginLayoutWindowInput, OpenPluginLayoutWindowOutput, RestoreOpenWindows,
RestoreOpenWindowsOutput, SnapshotOpenWindows, SnapshotOpenWindowsInput,
};

View File

@ -9,10 +9,18 @@ use std::sync::Arc;
use domain::ids::{TabId, WindowId};
use std::collections::HashSet;
use domain::layout::{PersistedWindowKind, PersistedWindowState, WindowStateSnapshot, Workspace};
use domain::ports::{IdGenerator, ProjectStore, WindowStateStore};
use domain::layout::{
PersistedPluginLayoutWindow, PersistedWindowKind, PersistedWindowState, WindowStateSnapshot,
Workspace,
};
use domain::ports::{
IdGenerator, PluginManifestValidator, PluginPackageStore, PluginRegistryStore, ProjectStore,
WindowStateStore,
};
use domain::{PluginId, PluginLayoutType};
use crate::error::AppError;
use crate::plugin::{ensure_runtime_plugin_layout_contribution, PluginRuntimeLayoutContribution};
/// Input for [`MoveTabToNewWindow::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
@ -116,15 +124,27 @@ pub struct RestoreOpenWindowsOutput {
pub struct RestoreOpenWindows {
windows: Arc<dyn WindowStateStore>,
_projects: Arc<dyn ProjectStore>,
packages: Arc<dyn PluginPackageStore>,
registry: Arc<dyn PluginRegistryStore>,
validator: Arc<dyn PluginManifestValidator>,
}
impl RestoreOpenWindows {
/// Builds the use case from its ports.
#[must_use]
pub fn new(windows: Arc<dyn WindowStateStore>, projects: Arc<dyn ProjectStore>) -> Self {
pub fn new(
windows: Arc<dyn WindowStateStore>,
projects: Arc<dyn ProjectStore>,
packages: Arc<dyn PluginPackageStore>,
registry: Arc<dyn PluginRegistryStore>,
validator: Arc<dyn PluginManifestValidator>,
) -> Self {
Self {
windows,
_projects: projects,
packages,
registry,
validator,
}
}
@ -154,9 +174,103 @@ impl RestoreOpenWindows {
}
windows.push(window);
}
PersistedWindowKind::PluginLayout => {
let Some(surface) = &window.plugin_layout else {
continue;
};
if self.validate_plugin_layout_surface(surface).await.is_ok() {
windows.push(window);
}
}
}
}
Ok(RestoreOpenWindowsOutput { windows })
}
async fn validate_plugin_layout_surface(
&self,
surface: &PersistedPluginLayoutWindow,
) -> Result<(), AppError> {
ensure_runtime_plugin_layout_contribution(
self.packages.as_ref(),
self.registry.as_ref(),
self.validator.as_ref(),
&surface.plugin_id,
&surface.layout_type,
)
.await
.map(|_| ())
}
}
/// Input for [`OpenPluginLayoutWindow::execute`].
#[derive(Debug, Clone, PartialEq)]
pub struct OpenPluginLayoutWindowInput {
/// Provider plugin id.
pub plugin_id: PluginId,
/// Layout type declared by the provider plugin.
pub layout_type: PluginLayoutType,
/// Opaque plugin-owned initial/window state.
pub state: serde_json::Value,
}
/// Output of [`OpenPluginLayoutWindow::execute`].
#[derive(Debug, Clone, PartialEq)]
pub struct OpenPluginLayoutWindowOutput {
/// Runtime contribution that was validated.
pub contribution: PluginRuntimeLayoutContribution,
/// Persistable plugin layout surface.
pub surface: PersistedPluginLayoutWindow,
}
/// Validates a plugin layout contribution before a detached OS window hosts it.
pub struct OpenPluginLayoutWindow {
packages: Arc<dyn PluginPackageStore>,
registry: Arc<dyn PluginRegistryStore>,
validator: Arc<dyn PluginManifestValidator>,
}
impl OpenPluginLayoutWindow {
/// Builds the use case from plugin runtime ports.
#[must_use]
pub fn new(
packages: Arc<dyn PluginPackageStore>,
registry: Arc<dyn PluginRegistryStore>,
validator: Arc<dyn PluginManifestValidator>,
) -> Self {
Self {
packages,
registry,
validator,
}
}
/// Executes the validation.
///
/// # Errors
/// [`AppError::Invalid`] when the plugin is inactive or does not declare the
/// requested layout contribution; other errors bubble from plugin loading.
pub async fn execute(
&self,
input: OpenPluginLayoutWindowInput,
) -> Result<OpenPluginLayoutWindowOutput, AppError> {
let contribution = ensure_runtime_plugin_layout_contribution(
self.packages.as_ref(),
self.registry.as_ref(),
self.validator.as_ref(),
&input.plugin_id,
&input.layout_type,
)
.await?;
let surface = PersistedPluginLayoutWindow {
plugin_id: input.plugin_id,
layout_type: input.layout_type,
state: input.state,
};
Ok(OpenPluginLayoutWindowOutput {
contribution,
surface,
})
}
}