feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): bibliothèque d'icônes projet personnalisées importées par l'utilisateur — #358 (QA verte)

domain: variante ProjectIconSet::Custom, métadonnées CustomProjectIconAsset (id opaque, media type png/jpeg/webp, byte_size, dimensions décodées, sha256, nom de fichier original sans chemin, created_at), CustomProjectIconContent et port CustomProjectIconAssetStore (list/read/exists/create/delete) avec CustomProjectIconStoreError dédiée.
application: use case project_icon_assets — import validé png/jpeg/webp avec rejet du contenu SVG, dimensionnement et digest sha256, bibliothèque partagée à l'instance indépendante de tout projet, suppression refusée tant que l'asset est référencé par un projet.
infrastructure: store fichier store/project_icon_asset.rs — index canonique + octets, création atomique sous extension validée.
backend/web-server: contrat HTTP /api/project-icon-assets (GET liste, POST import, GET lecture, GET /usage, DELETE) avec prévol OPTIONS et tests contrat incluant le rejet SVG.
app-tauri: commandes import/list/read/get_usage/delete_custom_project_icon_asset exposées au frontend.
frontend: port ProjectIconAssetGateway (list/import/delete/getUrl sans exposer de chemin filesystem), gateways http+mock avec tests, DI, section Icônes personnalisées du ProjectIconPicker (import fichier accept png/jpeg/webp, sélection role=option, suppression avec retrait de sélection, erreurs lisibles i18n FR/EN), tests projectIcons étendus.

QA verte: contrats HTTP et Tauri identiques, suites cargo et npm ciblées vertes.
This commit is contained in:
2026-09-15 07:57:51 +02:00
parent a082b55c75
commit 0f01d1f9b1
32 changed files with 2002 additions and 151 deletions

View File

@ -111,6 +111,7 @@ pub use sandbox::{default_enforcer, NoopSandbox};
pub use scheduler::TokioScheduler;
pub use session::{ClaudeSdkSession, CodexExecSession, FakeCli, StructuredSessionFactory};
pub use sprints::FsSprintStore;
pub use store::FsCustomProjectIconAssetStore;
pub use store::FsProjectPluginSettingsStore;
#[cfg(feature = "vector-onnx")]
pub use store::OnnxEmbedder;

View File

@ -15,6 +15,7 @@ mod open_project_restore;
mod permission;
mod profile;
mod project;
mod project_icon_asset;
mod project_plugin;
mod scheduled_task;
mod secrets;
@ -44,6 +45,7 @@ pub use open_project_restore::FsOpenProjectRestoreStore;
pub use permission::FsPermissionStore;
pub use profile::{FsEmbedderProfileStore, FsProfileStore};
pub use project::FsProjectStore;
pub use project_icon_asset::FsCustomProjectIconAssetStore;
pub use project_plugin::FsProjectPluginSettingsStore;
pub use scheduled_task::FsScheduledProjectTaskStore;
pub use secrets::FsSecretStore;

View File

@ -0,0 +1,277 @@
//! Filesystem adapter for the shared user-imported project icon library.
use async_trait::async_trait;
use domain::{
CustomProjectIconAsset, CustomProjectIconAssetStore, CustomProjectIconContent,
CustomProjectIconStoreError,
};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tokio::sync::Mutex;
const INDEX_VERSION: u32 = 1;
/// File-backed library rooted below app data at `.ideai/icon-assets`.
pub struct FsCustomProjectIconAssetStore {
root: PathBuf,
lock: Mutex<()>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AssetIndex {
#[serde(default = "index_version")]
version: u32,
#[serde(default)]
assets: Vec<AssetRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AssetRecord {
#[serde(flatten)]
asset: CustomProjectIconAsset,
extension: String,
}
const fn index_version() -> u32 {
INDEX_VERSION
}
impl FsCustomProjectIconAssetStore {
/// Builds an adapter rooted in the instance-local application data directory.
#[must_use]
pub fn new(app_data_dir: impl AsRef<Path>) -> Self {
Self {
root: app_data_dir.as_ref().join(".ideai").join("icon-assets"),
lock: Mutex::new(()),
}
}
fn index_path(&self) -> PathBuf {
self.root.join("index.json")
}
fn icons_dir(&self) -> PathBuf {
self.root.join("icons")
}
async fn load_index(&self) -> Result<AssetIndex, CustomProjectIconStoreError> {
match tokio::fs::read(self.index_path()).await {
Ok(bytes) => {
let index: AssetIndex = serde_json::from_slice(&bytes)
.map_err(|error| CustomProjectIconStoreError::Format(error.to_string()))?;
if index.version != INDEX_VERSION {
return Err(CustomProjectIconStoreError::Format(format!(
"unsupported index version {}",
index.version
)));
}
Ok(index)
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(AssetIndex {
version: INDEX_VERSION,
assets: Vec::new(),
}),
Err(error) => Err(io_error(&self.index_path(), error)),
}
}
async fn save_index(&self, index: &AssetIndex) -> Result<(), CustomProjectIconStoreError> {
tokio::fs::create_dir_all(&self.root)
.await
.map_err(|error| io_error(&self.root, error))?;
let mut bytes = serde_json::to_vec_pretty(index)
.map_err(|error| CustomProjectIconStoreError::Format(error.to_string()))?;
bytes.push(b'\n');
let temporary = self.root.join("index.json.tmp");
tokio::fs::write(&temporary, bytes)
.await
.map_err(|error| io_error(&temporary, error))?;
tokio::fs::rename(&temporary, self.index_path())
.await
.map_err(|error| io_error(&self.index_path(), error))
}
}
#[async_trait]
impl CustomProjectIconAssetStore for FsCustomProjectIconAssetStore {
async fn list(&self) -> Result<Vec<CustomProjectIconAsset>, CustomProjectIconStoreError> {
let _guard = self.lock.lock().await;
Ok(self
.load_index()
.await?
.assets
.into_iter()
.map(|record| record.asset)
.collect())
}
async fn read(
&self,
asset_id: &str,
) -> Result<CustomProjectIconContent, CustomProjectIconStoreError> {
validate_component(asset_id)?;
let _guard = self.lock.lock().await;
let index = self.load_index().await?;
let record = index
.assets
.into_iter()
.find(|record| record.asset.id == asset_id)
.ok_or(CustomProjectIconStoreError::NotFound)?;
validate_extension(&record.extension)?;
let path = self
.icons_dir()
.join(format!("{}.{}", record.asset.id, record.extension));
let bytes = tokio::fs::read(&path).await.map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
CustomProjectIconStoreError::Format(format!(
"indexed asset file is missing: {}",
record.asset.id
))
} else {
io_error(&path, error)
}
})?;
Ok(CustomProjectIconContent {
asset: record.asset,
bytes,
})
}
async fn exists(&self, asset_id: &str) -> Result<bool, CustomProjectIconStoreError> {
validate_component(asset_id)?;
let _guard = self.lock.lock().await;
Ok(self
.load_index()
.await?
.assets
.iter()
.any(|record| record.asset.id == asset_id))
}
async fn create(
&self,
asset: &CustomProjectIconAsset,
extension: &str,
bytes: &[u8],
) -> Result<(), CustomProjectIconStoreError> {
validate_component(&asset.id)?;
validate_extension(extension)?;
let _guard = self.lock.lock().await;
let mut index = self.load_index().await?;
if index
.assets
.iter()
.any(|record| record.asset.id == asset.id)
{
return Err(CustomProjectIconStoreError::Format(format!(
"duplicate asset id {}",
asset.id
)));
}
let icons = self.icons_dir();
tokio::fs::create_dir_all(&icons)
.await
.map_err(|error| io_error(&icons, error))?;
let path = icons.join(format!("{}.{}", asset.id, extension));
tokio::fs::write(&path, bytes)
.await
.map_err(|error| io_error(&path, error))?;
index.assets.push(AssetRecord {
asset: asset.clone(),
extension: extension.to_owned(),
});
if let Err(error) = self.save_index(&index).await {
let _ = tokio::fs::remove_file(&path).await;
return Err(error);
}
Ok(())
}
async fn delete(&self, asset_id: &str) -> Result<(), CustomProjectIconStoreError> {
validate_component(asset_id)?;
let _guard = self.lock.lock().await;
let mut index = self.load_index().await?;
let position = index
.assets
.iter()
.position(|record| record.asset.id == asset_id)
.ok_or(CustomProjectIconStoreError::NotFound)?;
let record = index.assets.remove(position);
validate_extension(&record.extension)?;
self.save_index(&index).await?;
let path = self
.icons_dir()
.join(format!("{}.{}", asset_id, record.extension));
match tokio::fs::remove_file(&path).await {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(io_error(&path, error)),
}
}
}
fn validate_component(value: &str) -> Result<(), CustomProjectIconStoreError> {
(!value.is_empty()
&& value.len() <= 64
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')))
.then_some(())
.ok_or_else(|| CustomProjectIconStoreError::Format("unsafe asset id".to_owned()))
}
fn validate_extension(value: &str) -> Result<(), CustomProjectIconStoreError> {
matches!(value, "png" | "jpg" | "webp")
.then_some(())
.ok_or_else(|| {
CustomProjectIconStoreError::Format("unsupported stored extension".to_owned())
})
}
fn io_error(path: &Path, error: std::io::Error) -> CustomProjectIconStoreError {
CustomProjectIconStoreError::Io(format!("{}: {error}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
fn asset(id: &str) -> CustomProjectIconAsset {
CustomProjectIconAsset {
id: id.to_owned(),
media_type: "image/png".to_owned(),
byte_size: 3,
width: 1,
height: 1,
sha256: "abc".to_owned(),
original_filename: "icon.png".to_owned(),
created_at: 1,
}
}
#[tokio::test]
async fn persists_lists_reads_and_deletes_inside_confined_root() {
let root = std::env::temp_dir().join(format!("idea-icon-assets-{}", uuid::Uuid::new_v4()));
let store = FsCustomProjectIconAssetStore::new(&root);
store
.create(&asset("stable_id"), "png", b"png")
.await
.unwrap();
assert_eq!(store.list().await.unwrap(), vec![asset("stable_id")]);
assert_eq!(store.read("stable_id").await.unwrap().bytes, b"png");
assert!(root
.join(".ideai/icon-assets/icons/stable_id.png")
.is_file());
store.delete("stable_id").await.unwrap();
assert!(!store.exists("stable_id").await.unwrap());
let _ = std::fs::remove_dir_all(root);
}
#[tokio::test]
async fn rejects_path_like_ids_and_extensions() {
let store = FsCustomProjectIconAssetStore::new(std::env::temp_dir());
assert!(store.read("../escape").await.is_err());
assert!(store.create(&asset("safe"), "svg", b"x").await.is_err());
}
}