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

@ -13,6 +13,7 @@ async-trait = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }
subtle = { workspace = true }
# Resolves the OpenCode cache dir (`~/.cache/opencode/models.json`) for the
# dynamic provider catalogue (ticket #92 follow-up). See

View File

@ -41,6 +41,15 @@ pub enum AppError {
#[error("concurrency conflict: {0}")]
Conflict(String),
/// A custom icon cannot be removed while projects reference it.
#[error("custom icon {asset_id} is used by {used_by_project_count} project(s)")]
CustomIconInUse {
/// Stable asset id.
asset_id: String,
/// Current usage count.
used_by_project_count: u64,
},
/// A filesystem operation failed.
#[error("filesystem error: {0}")]
FileSystem(String),
@ -161,6 +170,7 @@ impl AppError {
Self::Invalid(_) => "INVALID",
Self::AmbiguousSkillName { .. } => "ambiguousSkillName",
Self::Conflict(_) => "CONFLICT",
Self::CustomIconInUse { .. } => "CUSTOM_ICON_IN_USE",
Self::FileSystem(_) => "FILESYSTEM",
Self::Store(_) => "STORE",
Self::Process(_) => "PROCESS",

View File

@ -33,6 +33,7 @@ pub mod orchestrator;
pub mod permission;
pub mod plugin;
pub mod project;
pub mod project_icon_assets;
pub mod project_plugin;
pub mod remote;
pub mod scheduled_task;
@ -220,6 +221,12 @@ pub use project::{
RetryOpenProjectRestore, SaveOpenProjectRestoreOrder, UpdateProjectContext,
UpdateProjectContextInput, PROJECT_CONTEXT_FILE,
};
pub use project_icon_assets::{
CustomProjectIconAssetView, CustomProjectIconContentView, CustomProjectIconUsage,
DeleteCustomProjectIconAsset, GetCustomProjectIconAssetUsage, ImportCustomProjectIconAsset,
ListCustomProjectIconAssets, ReadCustomProjectIconAsset, CUSTOM_PROJECT_ICON_MAX_BYTES,
CUSTOM_PROJECT_ICON_MAX_DIMENSION,
};
pub use project_plugin::{
plugin_skill_ref, plugin_tool_ref, AssignmentMutation, ListProjectPluginSettings,
MutateAgentPluginSkill, MutateAgentPluginTool, ProjectPluginResolver,

View File

@ -0,0 +1,502 @@
//! Use cases for the instance-local custom project icon library.
use crate::AppError;
use domain::{
Clock, CustomProjectIconAsset, CustomProjectIconAssetStore, CustomProjectIconContent,
CustomProjectIconStoreError, IdGenerator, ProjectIconSet, ProjectSettingsStore, ProjectStore,
};
use sha2::{Digest, Sha256};
use std::sync::Arc;
/// Maximum accepted encoded image size (2 MiB).
pub const CUSTOM_PROJECT_ICON_MAX_BYTES: usize = 2 * 1024 * 1024;
/// Maximum accepted width or height.
pub const CUSTOM_PROJECT_ICON_MAX_DIMENSION: u32 = 4096;
/// Asset metadata enriched with its current project usage count.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CustomProjectIconAssetView {
/// Persisted metadata.
pub asset: CustomProjectIconAsset,
/// Number of registered projects currently referencing the asset.
pub used_by_project_count: u64,
}
/// Asset bytes enriched with usage metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CustomProjectIconContentView {
/// Metadata and usage.
pub metadata: CustomProjectIconAssetView,
/// Exact validated raster bytes.
pub bytes: Vec<u8>,
}
/// Usage result returned before deletion or for user feedback.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CustomProjectIconUsage {
/// Stable asset id.
pub asset_id: String,
/// Number of registered projects referencing it.
pub used_by_project_count: u64,
}
/// Imports one validated raster into the shared library.
pub struct ImportCustomProjectIconAsset {
store: Arc<dyn CustomProjectIconAssetStore>,
ids: Arc<dyn IdGenerator>,
clock: Arc<dyn Clock>,
}
impl ImportCustomProjectIconAsset {
/// Builds the use case.
#[must_use]
pub fn new(
store: Arc<dyn CustomProjectIconAssetStore>,
ids: Arc<dyn IdGenerator>,
clock: Arc<dyn Clock>,
) -> Self {
Self { store, ids, clock }
}
/// Validates real content, declared MIME and filename extension before persisting.
pub async fn execute(
&self,
original_filename: String,
declared_media_type: String,
bytes: Vec<u8>,
) -> Result<CustomProjectIconAssetView, AppError> {
if bytes.is_empty() || bytes.len() > CUSTOM_PROJECT_ICON_MAX_BYTES {
return Err(AppError::Invalid(format!(
"custom project icon must contain 1..={CUSTOM_PROJECT_ICON_MAX_BYTES} bytes"
)));
}
let filename = safe_basename(&original_filename)?;
let detected = detect_raster(&bytes)?;
if declared_media_type.trim().to_ascii_lowercase() != detected.media_type {
return Err(AppError::Invalid(format!(
"declared media type does not match {} content",
detected.media_type
)));
}
let filename_extension = filename
.rsplit_once('.')
.map(|(_, extension)| extension.to_ascii_lowercase())
.ok_or_else(|| {
AppError::Invalid("custom project icon filename needs an extension".to_owned())
})?;
if !detected.extensions.contains(&filename_extension.as_str()) {
return Err(AppError::Invalid(
"custom project icon filename extension does not match its content".to_owned(),
));
}
if detected.width == 0
|| detected.height == 0
|| detected.width > CUSTOM_PROJECT_ICON_MAX_DIMENSION
|| detected.height > CUSTOM_PROJECT_ICON_MAX_DIMENSION
{
return Err(AppError::Invalid(format!(
"custom project icon dimensions must be 1..={CUSTOM_PROJECT_ICON_MAX_DIMENSION} pixels"
)));
}
let asset = CustomProjectIconAsset {
id: self.ids.new_uuid().simple().to_string(),
media_type: detected.media_type.to_owned(),
byte_size: bytes.len() as u64,
width: detected.width,
height: detected.height,
sha256: hex::encode(Sha256::digest(&bytes)),
original_filename: filename,
created_at: self.clock.now_millis(),
};
self.store
.create(&asset, detected.extension, &bytes)
.await?;
Ok(CustomProjectIconAssetView {
asset,
used_by_project_count: 0,
})
}
}
/// Lists all custom icons with live usage counts.
pub struct ListCustomProjectIconAssets {
store: Arc<dyn CustomProjectIconAssetStore>,
projects: Arc<dyn ProjectStore>,
settings: Arc<dyn ProjectSettingsStore>,
}
impl ListCustomProjectIconAssets {
/// Builds the use case.
#[must_use]
pub fn new(
store: Arc<dyn CustomProjectIconAssetStore>,
projects: Arc<dyn ProjectStore>,
settings: Arc<dyn ProjectSettingsStore>,
) -> Self {
Self {
store,
projects,
settings,
}
}
/// Lists metadata in stable order.
pub async fn execute(&self) -> Result<Vec<CustomProjectIconAssetView>, AppError> {
let assets = self.store.list().await?;
let mut output = Vec::with_capacity(assets.len());
for asset in assets {
let count = usage_count(&asset.id, &self.projects, &self.settings).await?;
output.push(CustomProjectIconAssetView {
asset,
used_by_project_count: count,
});
}
Ok(output)
}
}
/// Reads one custom icon and its current usage.
pub struct ReadCustomProjectIconAsset {
store: Arc<dyn CustomProjectIconAssetStore>,
projects: Arc<dyn ProjectStore>,
settings: Arc<dyn ProjectSettingsStore>,
}
impl ReadCustomProjectIconAsset {
/// Builds the use case.
#[must_use]
pub fn new(
store: Arc<dyn CustomProjectIconAssetStore>,
projects: Arc<dyn ProjectStore>,
settings: Arc<dyn ProjectSettingsStore>,
) -> Self {
Self {
store,
projects,
settings,
}
}
/// Reads exact bytes after validating the opaque id shape.
pub async fn execute(&self, asset_id: &str) -> Result<CustomProjectIconContentView, AppError> {
validate_asset_id(asset_id)?;
let CustomProjectIconContent { asset, bytes } = self.store.read(asset_id).await?;
let count = usage_count(asset_id, &self.projects, &self.settings).await?;
Ok(CustomProjectIconContentView {
metadata: CustomProjectIconAssetView {
asset,
used_by_project_count: count,
},
bytes,
})
}
}
/// Gets live project usage for one existing asset.
pub struct GetCustomProjectIconAssetUsage {
store: Arc<dyn CustomProjectIconAssetStore>,
projects: Arc<dyn ProjectStore>,
settings: Arc<dyn ProjectSettingsStore>,
}
impl GetCustomProjectIconAssetUsage {
/// Builds the use case.
#[must_use]
pub fn new(
store: Arc<dyn CustomProjectIconAssetStore>,
projects: Arc<dyn ProjectStore>,
settings: Arc<dyn ProjectSettingsStore>,
) -> Self {
Self {
store,
projects,
settings,
}
}
/// Returns usage, rejecting unknown ids.
pub async fn execute(&self, asset_id: &str) -> Result<CustomProjectIconUsage, AppError> {
validate_asset_id(asset_id)?;
if !self.store.exists(asset_id).await? {
return Err(AppError::NotFound(format!(
"custom project icon asset {asset_id}"
)));
}
Ok(CustomProjectIconUsage {
asset_id: asset_id.to_owned(),
used_by_project_count: usage_count(asset_id, &self.projects, &self.settings).await?,
})
}
}
/// Deletes one asset only when no registered project references it.
pub struct DeleteCustomProjectIconAsset {
store: Arc<dyn CustomProjectIconAssetStore>,
projects: Arc<dyn ProjectStore>,
settings: Arc<dyn ProjectSettingsStore>,
}
impl DeleteCustomProjectIconAsset {
/// Builds the use case.
#[must_use]
pub fn new(
store: Arc<dyn CustomProjectIconAssetStore>,
projects: Arc<dyn ProjectStore>,
settings: Arc<dyn ProjectSettingsStore>,
) -> Self {
Self {
store,
projects,
settings,
}
}
/// Deletes an unused asset and returns its former usage.
pub async fn execute(&self, asset_id: &str) -> Result<CustomProjectIconUsage, AppError> {
validate_asset_id(asset_id)?;
if !self.store.exists(asset_id).await? {
return Err(AppError::NotFound(format!(
"custom project icon asset {asset_id}"
)));
}
let used_by_project_count = usage_count(asset_id, &self.projects, &self.settings).await?;
if used_by_project_count > 0 {
return Err(AppError::CustomIconInUse {
asset_id: asset_id.to_owned(),
used_by_project_count,
});
}
self.store.delete(asset_id).await?;
Ok(CustomProjectIconUsage {
asset_id: asset_id.to_owned(),
used_by_project_count,
})
}
}
pub(crate) fn validate_asset_id(asset_id: &str) -> Result<(), AppError> {
let valid = !asset_id.is_empty()
&& asset_id.len() <= 64
&& asset_id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'));
valid
.then_some(())
.ok_or_else(|| AppError::Invalid("invalid custom project icon asset id".to_owned()))
}
async fn usage_count(
asset_id: &str,
projects: &Arc<dyn ProjectStore>,
settings: &Arc<dyn ProjectSettingsStore>,
) -> Result<u64, AppError> {
let mut count = 0_u64;
for project in projects.list_projects().await? {
let project_settings = settings.load(&project.root).await?;
if project_settings
.icon
.as_ref()
.is_some_and(|icon| icon.set() == ProjectIconSet::Custom && icon.name() == asset_id)
{
count += 1;
}
}
Ok(count)
}
fn safe_basename(raw: &str) -> Result<String, AppError> {
let name = raw.rsplit(['/', '\\']).next().unwrap_or("").trim();
if name.is_empty() || name.len() > 255 || name.chars().any(char::is_control) {
return Err(AppError::Invalid(
"invalid custom project icon filename".to_owned(),
));
}
Ok(name.to_owned())
}
struct DetectedRaster {
media_type: &'static str,
extension: &'static str,
extensions: &'static [&'static str],
width: u32,
height: u32,
}
fn detect_raster(bytes: &[u8]) -> Result<DetectedRaster, AppError> {
if bytes.len() >= 45
&& bytes.starts_with(b"\x89PNG\r\n\x1a\n")
&& bytes[8..12] == 13_u32.to_be_bytes()
&& &bytes[12..16] == b"IHDR"
&& valid_png_chunks(bytes)
{
return Ok(DetectedRaster {
media_type: "image/png",
extension: "png",
extensions: &["png"],
width: u32::from_be_bytes(bytes[16..20].try_into().unwrap()),
height: u32::from_be_bytes(bytes[20..24].try_into().unwrap()),
});
}
if bytes.len() >= 30
&& bytes.starts_with(b"RIFF")
&& &bytes[8..12] == b"WEBP"
&& usize::try_from(u32::from_le_bytes(bytes[4..8].try_into().unwrap())).ok()
== Some(bytes.len() - 8)
{
if &bytes[12..16] == b"VP8X" {
let width = 1 + u32::from_le_bytes([bytes[24], bytes[25], bytes[26], 0]);
let height = 1 + u32::from_le_bytes([bytes[27], bytes[28], bytes[29], 0]);
return Ok(DetectedRaster {
media_type: "image/webp",
extension: "webp",
extensions: &["webp"],
width,
height,
});
}
if &bytes[12..16] == b"VP8L" && bytes.len() >= 25 && bytes[20] == 0x2f {
let bits = u32::from_le_bytes(bytes[21..25].try_into().unwrap());
return Ok(DetectedRaster {
media_type: "image/webp",
extension: "webp",
extensions: &["webp"],
width: (bits & 0x3fff) + 1,
height: ((bits >> 14) & 0x3fff) + 1,
});
}
if &bytes[12..16] == b"VP8 " && bytes.len() >= 30 && bytes[23..26] == [0x9d, 0x01, 0x2a] {
let width = u16::from_le_bytes(bytes[26..28].try_into().unwrap()) & 0x3fff;
let height = u16::from_le_bytes(bytes[28..30].try_into().unwrap()) & 0x3fff;
return Ok(DetectedRaster {
media_type: "image/webp",
extension: "webp",
extensions: &["webp"],
width: u32::from(width),
height: u32::from(height),
});
}
return Err(AppError::Invalid(
"unsupported or malformed WebP image".to_owned(),
));
}
if bytes.starts_with(&[0xff, 0xd8]) && bytes.ends_with(&[0xff, 0xd9]) {
let (width, height) = jpeg_dimensions(bytes)?;
return Ok(DetectedRaster {
media_type: "image/jpeg",
extension: "jpg",
extensions: &["jpg", "jpeg"],
width,
height,
});
}
Err(AppError::Invalid(
"custom project icon must be PNG, JPEG or WebP; SVG and GIF are not supported".to_owned(),
))
}
fn valid_png_chunks(bytes: &[u8]) -> bool {
let mut cursor = 8_usize;
while cursor + 12 <= bytes.len() {
let length = u32::from_be_bytes(bytes[cursor..cursor + 4].try_into().unwrap()) as usize;
let Some(end) = cursor
.checked_add(12)
.and_then(|value| value.checked_add(length))
else {
return false;
};
if end > bytes.len() {
return false;
}
if &bytes[cursor + 4..cursor + 8] == b"IEND" {
return length == 0 && end == bytes.len();
}
cursor = end;
}
false
}
fn jpeg_dimensions(bytes: &[u8]) -> Result<(u32, u32), AppError> {
let mut cursor = 2;
while cursor + 4 <= bytes.len() {
while cursor < bytes.len() && bytes[cursor] != 0xff {
cursor += 1;
}
while cursor < bytes.len() && bytes[cursor] == 0xff {
cursor += 1;
}
if cursor >= bytes.len() {
break;
}
let marker = bytes[cursor];
cursor += 1;
if marker == 0xd9 || marker == 0xda {
break;
}
if marker == 0x01 || (0xd0..=0xd7).contains(&marker) {
continue;
}
if cursor + 2 > bytes.len() {
break;
}
let length = usize::from(u16::from_be_bytes(
bytes[cursor..cursor + 2].try_into().unwrap(),
));
if length < 2 || cursor + length > bytes.len() {
break;
}
if matches!(marker, 0xc0..=0xc3 | 0xc5..=0xc7 | 0xc9..=0xcb | 0xcd..=0xcf) && length >= 7 {
let height = u32::from(u16::from_be_bytes(
bytes[cursor + 3..cursor + 5].try_into().unwrap(),
));
let width = u32::from(u16::from_be_bytes(
bytes[cursor + 5..cursor + 7].try_into().unwrap(),
));
return Ok((width, height));
}
cursor += length;
}
Err(AppError::Invalid("malformed JPEG image".to_owned()))
}
impl From<CustomProjectIconStoreError> for AppError {
fn from(error: CustomProjectIconStoreError) -> Self {
match error {
CustomProjectIconStoreError::NotFound => {
Self::NotFound("custom project icon asset".to_owned())
}
CustomProjectIconStoreError::Format(message) => Self::Store(message),
CustomProjectIconStoreError::Io(message) => Self::FileSystem(message),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_png_dimensions_and_rejects_svg() {
let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
png.extend_from_slice(&32_u32.to_be_bytes());
png.extend_from_slice(&48_u32.to_be_bytes());
png.extend_from_slice(&[8, 6, 0, 0, 0]);
png.extend_from_slice(&[0, 0, 0, 0]);
png.extend_from_slice(&[0, 0, 0, 0]);
png.extend_from_slice(b"IEND");
png.extend_from_slice(&[0, 0, 0, 0]);
assert_eq!(
(
detect_raster(&png).unwrap().width,
detect_raster(&png).unwrap().height
),
(32, 48)
);
assert!(detect_raster(b"<svg></svg>").is_err());
}
#[test]
fn confines_filename_to_basename() {
assert_eq!(safe_basename("/tmp/user/icon.png").unwrap(), "icon.png");
assert_eq!(
safe_basename(r"C:\\Users\\me\\icon.webp").unwrap(),
"icon.webp"
);
}
}

View File

@ -2,8 +2,8 @@
use crate::AppError;
use domain::{
IdeaUserSettings, Project, ProjectIconRef, ProjectSettings, ProjectSettingsStore, ThemeColor,
UserSettingsStore,
CustomProjectIconAssetStore, IdeaUserSettings, Project, ProjectIconRef, ProjectIconSet,
ProjectSettings, ProjectSettingsStore, ThemeColor, UserSettingsStore,
};
use std::sync::Arc;
@ -56,12 +56,16 @@ impl ReadProjectSettings {
/// Updates the settings owned by one project.
pub struct UpdateProjectSettings {
store: Arc<dyn ProjectSettingsStore>,
icon_assets: Arc<dyn CustomProjectIconAssetStore>,
}
impl UpdateProjectSettings {
/// Builds the use case.
#[must_use]
pub fn new(store: Arc<dyn ProjectSettingsStore>) -> Self {
Self { store }
pub fn new(
store: Arc<dyn ProjectSettingsStore>,
icon_assets: Arc<dyn CustomProjectIconAssetStore>,
) -> Self {
Self { store, icon_assets }
}
/// Applies the supplied fields and returns the accepted project settings.
///
@ -73,6 +77,17 @@ impl UpdateProjectSettings {
theme_color: Option<Option<ThemeColor>>,
icon: Option<Option<ProjectIconRef>>,
) -> Result<ProjectSettings, AppError> {
if let Some(Some(icon)) = &icon {
if icon.set() == ProjectIconSet::Custom {
crate::project_icon_assets::validate_asset_id(icon.name())?;
if !self.icon_assets.exists(icon.name()).await? {
return Err(AppError::Invalid(format!(
"custom project icon asset does not exist: {}",
icon.name()
)));
}
}
}
let mut settings = self.store.load(&project.root).await?;
let mut changed = false;
if let Some(theme_color) = theme_color {
@ -94,10 +109,41 @@ impl UpdateProjectSettings {
mod tests {
use super::*;
use async_trait::async_trait;
use domain::{ProjectIconSet, ProjectPath, RemoteRef, StoreError};
use domain::{
CustomProjectIconAsset, CustomProjectIconContent, CustomProjectIconStoreError,
ProjectIconSet, ProjectPath, RemoteRef, StoreError,
};
use std::sync::Mutex;
struct MemoryProjectSettings(Mutex<ProjectSettings>);
struct MemoryIconAssets;
#[async_trait]
impl CustomProjectIconAssetStore for MemoryIconAssets {
async fn list(&self) -> Result<Vec<CustomProjectIconAsset>, CustomProjectIconStoreError> {
Ok(Vec::new())
}
async fn read(
&self,
_asset_id: &str,
) -> Result<CustomProjectIconContent, CustomProjectIconStoreError> {
Err(CustomProjectIconStoreError::NotFound)
}
async fn exists(&self, asset_id: &str) -> Result<bool, CustomProjectIconStoreError> {
Ok(asset_id == "known")
}
async fn create(
&self,
_asset: &CustomProjectIconAsset,
_extension: &str,
_bytes: &[u8],
) -> Result<(), CustomProjectIconStoreError> {
Ok(())
}
async fn delete(&self, _asset_id: &str) -> Result<(), CustomProjectIconStoreError> {
Ok(())
}
}
#[async_trait]
impl ProjectSettingsStore for MemoryProjectSettings {
@ -132,7 +178,7 @@ mod tests {
theme_color: Some(ThemeColor::new("#123456").unwrap()),
icon: Some(ProjectIconRef::new(ProjectIconSet::Devicon, "rust").unwrap()),
})));
let update = UpdateProjectSettings::new(store);
let update = UpdateProjectSettings::new(store, Arc::new(MemoryIconAssets));
let project = project();
let icon = ProjectIconRef::new(ProjectIconSet::Lucide, "star").unwrap();
@ -170,4 +216,18 @@ mod tests {
Some("#ABCDEF")
);
}
#[tokio::test]
async fn project_settings_rejects_missing_custom_icon() {
let store = Arc::new(MemoryProjectSettings(
Mutex::new(ProjectSettings::default()),
));
let update = UpdateProjectSettings::new(store, Arc::new(MemoryIconAssets));
let icon = ProjectIconRef::new(ProjectIconSet::Custom, "missing").unwrap();
let error = update
.execute(&project(), None, Some(Some(icon)))
.await
.unwrap_err();
assert_eq!(error.code(), "INVALID");
}
}