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:
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
502
crates/application/src/project_icon_assets.rs
Normal file
502
crates/application/src/project_icon_assets.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user