From 0f01d1f9b18488b3bf8217e795b53eb66af9a20b Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 15 Sep 2026 07:57:51 +0200 Subject: [PATCH] =?UTF-8?q?feat(domain,application,infrastructure,backend,?= =?UTF-8?q?web-server,app-tauri,frontend):=20biblioth=C3=A8que=20d'ic?= =?UTF-8?q?=C3=B4nes=20projet=20personnalis=C3=A9es=20import=C3=A9es=20par?= =?UTF-8?q?=20l'utilisateur=20=E2=80=94=20#358=20(QA=20verte)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Cargo.lock | 1 + crates/app-tauri/src/commands.rs | 94 +++- crates/app-tauri/src/lib.rs | 5 + crates/application/Cargo.toml | 1 + crates/application/src/error.rs | 10 + crates/application/src/lib.rs | 7 + crates/application/src/project_icon_assets.rs | 502 ++++++++++++++++++ crates/application/src/settings.rs | 72 ++- crates/backend/src/dto.rs | 82 +++ crates/backend/src/lib.rs | 154 ++++-- crates/domain/src/lib.rs | 32 +- crates/domain/src/ports.rs | 38 ++ crates/domain/src/settings.rs | 33 ++ crates/domain/tests/settings.rs | 1 + crates/infrastructure/src/lib.rs | 1 + crates/infrastructure/src/store/mod.rs | 2 + .../src/store/project_icon_asset.rs | 277 ++++++++++ crates/web-server/src/lib.rs | 332 +++++++++++- frontend/src/adapters/http/httpInvoker.ts | 5 + frontend/src/adapters/http/index.ts | 2 + .../http/projectIconAssetGateway.test.ts | 27 + .../adapters/http/requestResponseGateways.ts | 39 ++ frontend/src/adapters/index.ts | 3 + frontend/src/adapters/mock/index.ts | 56 ++ frontend/src/adapters/mock/mock.test.ts | 1 + .../src/adapters/projectIconAsset.test.ts | 38 ++ frontend/src/adapters/projectIconAsset.ts | 41 ++ frontend/src/app/di.tsx | 5 + .../features/projects/ProjectIconPicker.tsx | 164 ++++-- .../features/projects/projectIcons.test.tsx | 70 ++- .../src/features/projects/projectIcons.tsx | 33 +- frontend/src/ports/index.ts | 25 +- 32 files changed, 2002 insertions(+), 151 deletions(-) create mode 100644 crates/application/src/project_icon_assets.rs create mode 100644 crates/infrastructure/src/store/project_icon_asset.rs create mode 100644 frontend/src/adapters/http/projectIconAssetGateway.test.ts create mode 100644 frontend/src/adapters/projectIconAsset.test.ts create mode 100644 frontend/src/adapters/projectIconAsset.ts diff --git a/Cargo.lock b/Cargo.lock index dad55926..479006f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -102,6 +102,7 @@ dependencies = [ "chrono-tz", "dirs", "domain", + "hex", "serde", "serde_json", "sha2", diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 7613cdd3..7e5cd78c 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -54,21 +54,22 @@ use crate::dto::{ CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto, - CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto, + CreateTemplateRequestDto, CustomProjectIconAssetContentDto, CustomProjectIconAssetDto, + CustomProjectIconUsageDto, DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, ExecuteSlashCommandRequestDto, ExecuteSlashCommandResponseDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto, ImportChatAttachmentsRequestDto, - ImportChatAttachmentsResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto, - LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, - ManagedTempGcResponseDto, ManagedTempHandleRequestDto, ManagedTempLeaseDto, - ManagedTempLeaseResponseDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, - ModelServerConfigDto, ModelServerConfigListDto, OpenCodeProviderListDto, - OpenProjectRestoreStateDto, OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto, - ProfileListDto, ProfileModelCatalogDto, ProjectDto, ProjectListDto, - ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSettingsDto, + ImportChatAttachmentsResponseDto, ImportCustomProjectIconAssetRequestDto, + InspectConversationRequestDto, InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, + LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, ManagedTempGcResponseDto, + ManagedTempHandleRequestDto, ManagedTempLeaseDto, ManagedTempLeaseResponseDto, MemoryDto, + MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto, ModelServerConfigListDto, + OpenCodeProviderListDto, OpenProjectRestoreStateDto, OpenTerminalRequestDto, + PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto, + ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSettingsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto, ReadChatDraftResponseDto, ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, RemoveOpenProjectRestoreEntryRequestDto, RemoveProjectRequestDto, @@ -893,6 +894,81 @@ pub async fn update_project_settings( .map_err(Into::into) } +/// Imports a PNG, JPEG or WebP into the instance-local shared icon library. +#[tauri::command] +pub async fn import_custom_project_icon_asset( + request: ImportCustomProjectIconAssetRequestDto, + state: State<'_, AppState>, +) -> Result { + let bytes = STANDARD + .decode(request.data_base64.trim()) + .map_err(|error| ErrorDto::invalid(format!("invalid base64 image bytes: {error}")))?; + state + .import_custom_project_icon_asset + .execute(request.original_filename, request.media_type, bytes) + .await + .map(Into::into) + .map_err(Into::into) +} + +/// Lists the shared custom icon library. +#[tauri::command] +pub async fn list_custom_project_icon_assets( + state: State<'_, AppState>, +) -> Result, ErrorDto> { + state + .list_custom_project_icon_assets + .execute() + .await + .map(|assets| assets.into_iter().map(Into::into).collect()) + .map_err(Into::into) +} + +/// Reads one shared icon without exposing its filesystem path. +#[tauri::command] +pub async fn read_custom_project_icon_asset( + asset_id: String, + state: State<'_, AppState>, +) -> Result { + state + .read_custom_project_icon_asset + .execute(&asset_id) + .await + .map(|output| CustomProjectIconAssetContentDto { + metadata: output.metadata.into(), + data_base64: STANDARD.encode(output.bytes), + }) + .map_err(Into::into) +} + +/// Returns current registered-project usage for one shared icon. +#[tauri::command] +pub async fn get_custom_project_icon_asset_usage( + asset_id: String, + state: State<'_, AppState>, +) -> Result { + state + .get_custom_project_icon_asset_usage + .execute(&asset_id) + .await + .map(Into::into) + .map_err(Into::into) +} + +/// Deletes one shared icon when it is unused. +#[tauri::command] +pub async fn delete_custom_project_icon_asset( + asset_id: String, + state: State<'_, AppState>, +) -> Result { + state + .delete_custom_project_icon_asset + .execute(&asset_id) + .await + .map(Into::into) + .map_err(Into::into) +} + /// `get_project_permissions` — read `.ideai/permissions.json`. /// /// # Errors diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 4bd65f18..e9f3d080 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -267,6 +267,11 @@ pub fn run() { commands::update_user_settings, commands::read_project_settings, commands::update_project_settings, + commands::import_custom_project_icon_asset, + commands::list_custom_project_icon_assets, + commands::read_custom_project_icon_asset, + commands::get_custom_project_icon_asset_usage, + commands::delete_custom_project_icon_asset, commands::get_project_permissions, commands::update_project_permissions, commands::update_agent_permissions, diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml index c78ebea3..86f6b50d 100644 --- a/crates/application/Cargo.toml +++ b/crates/application/Cargo.toml @@ -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 diff --git a/crates/application/src/error.rs b/crates/application/src/error.rs index 5c20a2dc..42606216 100644 --- a/crates/application/src/error.rs +++ b/crates/application/src/error.rs @@ -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", diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 1bb05a88..4f6267d4 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -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, diff --git a/crates/application/src/project_icon_assets.rs b/crates/application/src/project_icon_assets.rs new file mode 100644 index 00000000..d6c0b755 --- /dev/null +++ b/crates/application/src/project_icon_assets.rs @@ -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, +} + +/// 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, + ids: Arc, + clock: Arc, +} + +impl ImportCustomProjectIconAsset { + /// Builds the use case. + #[must_use] + pub fn new( + store: Arc, + ids: Arc, + clock: Arc, + ) -> 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, + ) -> Result { + 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, + projects: Arc, + settings: Arc, +} + +impl ListCustomProjectIconAssets { + /// Builds the use case. + #[must_use] + pub fn new( + store: Arc, + projects: Arc, + settings: Arc, + ) -> Self { + Self { + store, + projects, + settings, + } + } + /// Lists metadata in stable order. + pub async fn execute(&self) -> Result, 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, + projects: Arc, + settings: Arc, +} + +impl ReadCustomProjectIconAsset { + /// Builds the use case. + #[must_use] + pub fn new( + store: Arc, + projects: Arc, + settings: Arc, + ) -> Self { + Self { + store, + projects, + settings, + } + } + /// Reads exact bytes after validating the opaque id shape. + pub async fn execute(&self, asset_id: &str) -> Result { + 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, + projects: Arc, + settings: Arc, +} + +impl GetCustomProjectIconAssetUsage { + /// Builds the use case. + #[must_use] + pub fn new( + store: Arc, + projects: Arc, + settings: Arc, + ) -> Self { + Self { + store, + projects, + settings, + } + } + /// Returns usage, rejecting unknown ids. + pub async fn execute(&self, asset_id: &str) -> Result { + 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, + projects: Arc, + settings: Arc, +} + +impl DeleteCustomProjectIconAsset { + /// Builds the use case. + #[must_use] + pub fn new( + store: Arc, + projects: Arc, + settings: Arc, + ) -> Self { + Self { + store, + projects, + settings, + } + } + /// Deletes an unused asset and returns its former usage. + pub async fn execute(&self, asset_id: &str) -> Result { + 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, + settings: &Arc, +) -> Result { + 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 { + 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 { + 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 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"").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" + ); + } +} diff --git a/crates/application/src/settings.rs b/crates/application/src/settings.rs index d6af6ee6..9af634c9 100644 --- a/crates/application/src/settings.rs +++ b/crates/application/src/settings.rs @@ -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, + icon_assets: Arc, } impl UpdateProjectSettings { /// Builds the use case. #[must_use] - pub fn new(store: Arc) -> Self { - Self { store } + pub fn new( + store: Arc, + icon_assets: Arc, + ) -> Self { + Self { store, icon_assets } } /// Applies the supplied fields and returns the accepted project settings. /// @@ -73,6 +77,17 @@ impl UpdateProjectSettings { theme_color: Option>, icon: Option>, ) -> Result { + 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); + struct MemoryIconAssets; + + #[async_trait] + impl CustomProjectIconAssetStore for MemoryIconAssets { + async fn list(&self) -> Result, CustomProjectIconStoreError> { + Ok(Vec::new()) + } + async fn read( + &self, + _asset_id: &str, + ) -> Result { + Err(CustomProjectIconStoreError::NotFound) + } + async fn exists(&self, asset_id: &str) -> Result { + 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"); + } } diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index a7d14ef3..6ee90a31 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -3358,6 +3358,88 @@ pub struct ProjectIconRefDto { pub name: String, } +/// Metadata for one user-imported icon, including live project usage. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CustomProjectIconAssetDto { + /// Opaque stable asset id. + pub id: String, + /// Validated raster media type. + pub media_type: String, + /// Stored encoded byte count. + pub byte_size: u64, + /// Pixel width. + pub width: u32, + /// Pixel height. + pub height: u32, + /// SHA-256 digest. + pub sha256: String, + /// Original user-facing basename. + pub original_filename: String, + /// Creation time as epoch milliseconds. + pub created_at: i64, + /// Current number of registered projects using the asset. + pub used_by_project_count: u64, +} + +impl From for CustomProjectIconAssetDto { + fn from(value: application::CustomProjectIconAssetView) -> Self { + Self { + id: value.asset.id, + media_type: value.asset.media_type, + byte_size: value.asset.byte_size, + width: value.asset.width, + height: value.asset.height, + sha256: value.asset.sha256, + original_filename: value.asset.original_filename, + created_at: value.asset.created_at, + used_by_project_count: value.used_by_project_count, + } + } +} + +/// Import payload shared by Tauri and HTTP JSON clients. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ImportCustomProjectIconAssetRequestDto { + /// User-facing source basename (paths are stripped server-side). + pub original_filename: String, + /// Claimed media type, verified against file bytes. + pub media_type: String, + /// Standard base64-encoded image bytes. + pub data_base64: String, +} + +/// Tauri-safe read response; HTTP serves these bytes directly instead. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CustomProjectIconAssetContentDto { + /// Metadata and usage. + #[serde(flatten)] + pub metadata: CustomProjectIconAssetDto, + /// Standard base64-encoded image bytes. + pub data_base64: String, +} + +/// Current usage for one custom icon. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CustomProjectIconUsageDto { + /// Stable asset id. + pub asset_id: String, + /// Number of registered projects using it. + pub used_by_project_count: u64, +} + +impl From for CustomProjectIconUsageDto { + fn from(value: application::CustomProjectIconUsage) -> Self { + Self { + asset_id: value.asset_id, + used_by_project_count: value.used_by_project_count, + } + } +} + impl From for ProjectIconRefDto { fn from(value: domain::ProjectIconRef) -> Self { Self { diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index d4b5389e..b11cabe8 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -21,30 +21,33 @@ use application::{ ClearChatDraft, CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal, CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, - CreateProject, CreateSkill, CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, - DeleteIssue, DeleteLayout, DeleteMemory, DeleteModelArtifact, DeleteModelServer, DeleteProfile, - DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, - DetectProfiles, DismissEmbedderSuggestion, EnsureLocalModelServer, ExecuteSlashCommand, - FirstRunState, GcManagedTemp, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory, - GetProjectPermissions, GetProjectSystemPermissions, GetProjectWorkState, GitBranches, - GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, - HarvestMemoryFromTurn, HealthUseCase, ImportChatAttachments, InspectConversation, - InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent, - LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels, - ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers, - ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, - ListProjectFiles, ListProjectPluginSettings, ListProjects, ListResumableAgents, ListSkills, - ListSlashCommands, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, - LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, - MarkIssueAttachmentSummarized, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow, - MutateAgentPluginSkill, MutateAgentPluginTool, MutateLayout, OnnxModelView, - OpenPluginLayoutWindow, OpenProject, OpenTerminal, OpenTicketAssistant, OrchestratorService, - PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, PluginCommandTasks, - PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess, - PluginToolchainDiagnostics, PluginWorkspaceAccess, PluginWorkspaceExecTool, - ProjectPluginResolver, ProposeContext, PurgeManagedTemp, QueryProjectStructure, - ReadAgentContext, ReadChatDraft, ReadContext, ReadConversationPage, ReadIssue, - ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, + CreateProject, CreateSkill, CreateSprint, CreateTemplate, DeleteAgent, + DeleteCustomProjectIconAsset, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory, + DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, + DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, + DismissEmbedderSuggestion, EnsureLocalModelServer, ExecuteSlashCommand, FirstRunState, + GcManagedTemp, GetAppExitWorkGuardState, GetCustomProjectIconAssetUsage, GetLiveStateLean, + GetMemory, GetProjectPermissions, GetProjectSystemPermissions, GetProjectWorkState, + GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, + GitUnstage, HarvestMemoryFromTurn, HealthUseCase, ImportChatAttachments, + ImportCustomProjectIconAsset, InspectConversation, InstallPluginFromArchive, + InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput, + LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels, + ListCustomProjectIconAssets, ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, + ListMemories, ListModelServers, ListOpenCodeProviders, ListPluginRuntimeContributions, + ListPlugins, ListProfiles, ListProjectFiles, ListProjectPluginSettings, ListProjects, + ListResumableAgents, ListSkills, ListSlashCommands, ListSprints, ListTemplates, + LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider, + LiveStateReadProvider, LoadLayout, MarkIssueAttachmentSummarized, McpRuntime, + McpToolPermissionCatalogue, MoveTabToNewWindow, MutateAgentPluginSkill, MutateAgentPluginTool, + MutateLayout, OnnxModelView, OpenPluginLayoutWindow, OpenProject, OpenTerminal, + OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, + PermissionProjectorRegistry, PluginCommandTasks, PluginConfigDocuments, + PluginEventSubscriptions, PluginStorageAccess, PluginToolchainDiagnostics, + PluginWorkspaceAccess, PluginWorkspaceExecTool, ProjectPluginResolver, ProposeContext, + PurgeManagedTemp, QueryProjectStructure, ReadAgentContext, ReadChatDraft, ReadContext, + ReadConversationPage, ReadCustomProjectIconAsset, ReadIssue, ReadIssueAttachment, + ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadOpenProjectRestoreState, ReadProjectContext, ReadProjectSettings, ReadSkill, ReadTemplate, ReadUserSettings, RecallMemory, ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordProjectClosedForRestore, RecordProjectOpenedForRestore, @@ -74,16 +77,16 @@ use domain::events::AgentRestartPhase; use domain::ports::{ AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentToolPolicyStore, AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner, - BackgroundTaskStore, ChatAttachmentStore, ChatDraftStore, Clock, DeviceSessionStore, Embedder, - EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EnvironmentReader, EventBus, - FileSystem, GitPort, IdGenerator, IssueNumberAllocator, IssueStore, ManagedTempStore, - McpToolPermissionStore, MemoryRecall, MemoryStore, ModelArtifactDownloader, - OpenProjectRestoreStore, PermissionStore, PluginManifestValidator, PluginMcpSupervisor, - PluginPackageStore, PluginRegistryStore, PluginStorageStore, ProcessSpawner, ProfileStore, - ProjectPluginSettingsStore, ProjectSettingsStore, ProjectStore, PtyHandle, PtyPort, - RuntimePermissionProbe, ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore, - StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, ToolInvoker, - UserSettingsStore, WakeError, WakeReason, WindowStateStore, + BackgroundTaskStore, ChatAttachmentStore, ChatDraftStore, Clock, CustomProjectIconAssetStore, + DeviceSessionStore, Embedder, EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, + EnvironmentReader, EventBus, FileSystem, GitPort, IdGenerator, IssueNumberAllocator, + IssueStore, ManagedTempStore, McpToolPermissionStore, MemoryRecall, MemoryStore, + ModelArtifactDownloader, OpenProjectRestoreStore, PermissionStore, PluginManifestValidator, + PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore, PluginStorageStore, + ProcessSpawner, ProfileStore, ProjectPluginSettingsStore, ProjectSettingsStore, ProjectStore, + PtyHandle, PtyPort, RuntimePermissionProbe, ScheduledTask, Scheduler, SecretStore, SkillStore, + SprintStore, StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, + ToolInvoker, UserSettingsStore, WakeError, WakeReason, WindowStateStore, }; use domain::profile::{ AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter, @@ -104,22 +107,22 @@ use infrastructure::{ CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, DiagnosticPluginWorkspaceExecAudit, EmbeddedCompatibilityMatrix, EmbedderEnvProbe, ExternalMcpPluginSupervisor, FsAssistantContextStore, FsBackgroundTaskStore, - FsChatAttachmentStore, FsChatDraftStore, FsConversationLog, FsDeviceSessionStore, - FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, - FsIssueStore, FsLiveStateStore, FsManagedTempStore, FsMcpToolPermissionStore, FsMemoryStore, - FsModelServerRegistry, FsOpenProjectRestoreStore, FsOrchestratorWatcher, FsPermissionStore, - FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore, FsProfileStore, - FsProjectPluginSettingsStore, FsProjectSettingsStore, FsProjectStore, FsProviderSessionStore, - FsScheduledProjectTaskStore, FsSecretStore, FsSkillStore, FsSprintStore, - FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore, FsWindowStateStore, - FsWorkspaceToolProvider, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader, - HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore, - InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime, - LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, - LocalWorkspaceToolRunner, ManagedTempToolProvider, McpServer, MediatedInbox, NaiveMemoryRecall, - OrchestratorWatchHandle, PortablePtyAdapter, ProcessCliVersionReader, - ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory, SystemClock, - SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer, + FsChatAttachmentStore, FsChatDraftStore, FsConversationLog, FsCustomProjectIconAssetStore, + FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, + FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsManagedTempStore, + FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOpenProjectRestoreStore, + FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore, + FsPluginStorageStore, FsProfileStore, FsProjectPluginSettingsStore, FsProjectSettingsStore, + FsProjectStore, FsProviderSessionStore, FsScheduledProjectTaskStore, FsSecretStore, + FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore, + FsWindowStateStore, FsWorkspaceToolProvider, Git2Repository, HeuristicHandoffSummarizer, + HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, + IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, + LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, + LocalProcessSpawner, LocalWorkspaceToolRunner, ManagedTempToolProvider, McpServer, + MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, + ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory, + SystemClock, SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, WorkspaceToolProvider, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, @@ -2267,6 +2270,16 @@ pub struct BackendCore { pub read_project_settings: Arc, /// Updates settings owned by the active project. pub update_project_settings: Arc, + /// Imports into the shared custom icon library. + pub import_custom_project_icon_asset: Arc, + /// Lists shared custom icons with live usage. + pub list_custom_project_icon_assets: Arc, + /// Reads one custom icon and exact bytes. + pub read_custom_project_icon_asset: Arc, + /// Gets current project usage for one custom icon. + pub get_custom_project_icon_asset_usage: Arc, + /// Deletes an unused custom icon. + pub delete_custom_project_icon_asset: Arc, /// Read the project permission document. pub get_project_permissions: Arc, /// Update project-level default permissions. @@ -2635,6 +2648,10 @@ impl BackendCore { let project_settings_store = Arc::new(FsProjectSettingsStore::new(Arc::clone(&fs_port))); let project_settings_store_port = Arc::clone(&project_settings_store) as Arc; + let custom_project_icon_asset_store = + Arc::new(FsCustomProjectIconAssetStore::new(&app_data_dir)); + let custom_project_icon_asset_store_port = + Arc::clone(&custom_project_icon_asset_store) as Arc; let read_user_settings = Arc::new(ReadUserSettings::new(Arc::clone(&user_settings_store_port))); let update_user_settings = Arc::new(UpdateUserSettings::new(Arc::clone( @@ -2643,9 +2660,35 @@ impl BackendCore { let read_project_settings = Arc::new(ReadProjectSettings::new(Arc::clone( &project_settings_store_port, ))); - let update_project_settings = Arc::new(UpdateProjectSettings::new(Arc::clone( - &project_settings_store_port, - ))); + let update_project_settings = Arc::new(UpdateProjectSettings::new( + Arc::clone(&project_settings_store_port), + Arc::clone(&custom_project_icon_asset_store_port), + )); + let import_custom_project_icon_asset = Arc::new(ImportCustomProjectIconAsset::new( + Arc::clone(&custom_project_icon_asset_store_port), + Arc::clone(&ids) as Arc, + Arc::clone(&clock) as Arc, + )); + let list_custom_project_icon_assets = Arc::new(ListCustomProjectIconAssets::new( + Arc::clone(&custom_project_icon_asset_store_port), + Arc::clone(&store_port), + Arc::clone(&project_settings_store_port), + )); + let read_custom_project_icon_asset = Arc::new(ReadCustomProjectIconAsset::new( + Arc::clone(&custom_project_icon_asset_store_port), + Arc::clone(&store_port), + Arc::clone(&project_settings_store_port), + )); + let get_custom_project_icon_asset_usage = Arc::new(GetCustomProjectIconAssetUsage::new( + Arc::clone(&custom_project_icon_asset_store_port), + Arc::clone(&store_port), + Arc::clone(&project_settings_store_port), + )); + let delete_custom_project_icon_asset = Arc::new(DeleteCustomProjectIconAsset::new( + custom_project_icon_asset_store_port, + Arc::clone(&store_port), + Arc::clone(&project_settings_store_port), + )); let open_project_restore_store_port = Arc::clone(&open_project_restore_store) as Arc; let window_state_port = Arc::clone(&window_state_store) as Arc; @@ -4514,6 +4557,11 @@ impl BackendCore { update_user_settings, read_project_settings, update_project_settings, + import_custom_project_icon_asset, + list_custom_project_icon_assets, + read_custom_project_icon_asset, + get_custom_project_icon_asset_usage, + delete_custom_project_icon_asset, get_project_permissions, update_project_permissions, update_agent_permissions, diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 8e4b9552..26bc9c6b 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -270,8 +270,9 @@ pub use scheduled_task::{ }; pub use settings::{ - DefaultAgentTerminalInterface, IdeaLanguage, IdeaUserSettings, ProjectIconRef, ProjectIconSet, - ProjectSettings, ThemeColor, MAX_PROJECT_ICON_NAME_LEN, + CustomProjectIconAsset, CustomProjectIconContent, DefaultAgentTerminalInterface, IdeaLanguage, + IdeaUserSettings, ProjectIconRef, ProjectIconSet, ProjectSettings, ThemeColor, + MAX_PROJECT_ICON_NAME_LEN, }; pub use orchestrator::{ @@ -283,19 +284,20 @@ pub use ports::{ AssistantContextProvider, BackgroundCompletionStream, BackgroundTaskCompletion, BackgroundTaskHandle, BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskSpec, BackgroundTaskStore, CliVersionReader, Clock, CompatibilityMatrixSource, ContextInjectionPlan, - DirEntry, Embedder, EmbedderEnvInspector, EmbedderEnvReport, EmbedderError, - EmbedderProfileStore, EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream, - ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, - IdGenerator, IssueAttachmentContent, IssueNumberAllocator, IssueStore, IssueStoreError, - LiveStateStore, LocalPath, McpToolPermissionStore, MemoryError, MemoryQuery, MemoryRecall, - MemoryStore, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, - ModelArtifactResolution, Output, OutputStream, PermissionStore, PluginManifestBytes, - PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor, - PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStorageError, - PluginStorageStore, PluginStoreError, PluginWorkspaceExecAuditSink, PreparedContext, - ProcessError, ProcessSpawner, ProfileStore, ProjectPluginSettingsStore, ProjectSettingsStore, - ProjectStore, ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, - RemotePath, RuntimeError, RuntimePermissionProbe, ScheduledProjectTaskStore, ScheduledTask, + CustomProjectIconAssetStore, CustomProjectIconStoreError, DirEntry, Embedder, + EmbedderEnvInspector, EmbedderEnvReport, EmbedderError, EmbedderProfileStore, + EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem, + FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator, + IssueAttachmentContent, IssueNumberAllocator, IssueStore, IssueStoreError, LiveStateStore, + LocalPath, McpToolPermissionStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, + ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactResolution, + Output, OutputStream, PermissionStore, PluginManifestBytes, PluginManifestError, + PluginManifestValidator, PluginMcpError, PluginMcpSupervisor, PluginPackageStore, + PluginRegistryError, PluginRegistryStore, PluginStorageError, PluginStorageStore, + PluginStoreError, PluginWorkspaceExecAuditSink, PreparedContext, ProcessError, ProcessSpawner, + ProfileStore, ProjectPluginSettingsStore, ProjectSettingsStore, ProjectStore, + ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, + RuntimeError, RuntimePermissionProbe, ScheduledProjectTaskStore, ScheduledTask, ScheduledTaskStoreError, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError, StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, UserSettingsStore, WindowStateStore, WorkspaceToolExecError, diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index c21e9dc3..759e4aa9 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -66,6 +66,7 @@ use crate::plugin::{ use crate::profile::{AgentProfile, EmbedderProfile, StructuredAdapter}; use crate::project::{Project, ProjectPath}; use crate::remote::RemoteKind; +use crate::settings::{CustomProjectIconAsset, CustomProjectIconContent}; use crate::skill::{Skill, SkillScope}; use crate::sprint::{Sprint, SprintIndexEntry, SprintVersion}; use crate::system_permissions::{ProjectSystemPermissions, RuntimePermissionSnapshot}; @@ -564,6 +565,43 @@ pub trait ProjectSettingsStore: Send + Sync { ) -> Result<(), StoreError>; } +/// Failures from the instance-local custom project icon library. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum CustomProjectIconStoreError { + /// Asset id is absent from the canonical index. + #[error("custom project icon asset not found")] + NotFound, + /// Persisted data is malformed. + #[error("custom project icon asset store format error: {0}")] + Format(String), + /// Filesystem operation failed. + #[error("custom project icon asset store I/O error: {0}")] + Io(String), +} + +/// Persistence for the workspace-wide user icon library. +#[async_trait] +pub trait CustomProjectIconAssetStore: Send + Sync { + /// Lists persisted metadata in stable creation order. + async fn list(&self) -> Result, CustomProjectIconStoreError>; + /// Reads metadata and bytes for one id. + async fn read( + &self, + asset_id: &str, + ) -> Result; + /// Returns whether an id exists in the canonical index. + async fn exists(&self, asset_id: &str) -> Result; + /// Atomically adds metadata and bytes under the validated extension. + async fn create( + &self, + asset: &CustomProjectIconAsset, + extension: &str, + bytes: &[u8], + ) -> Result<(), CustomProjectIconStoreError>; + /// Deletes one unused asset. + async fn delete(&self, asset_id: &str) -> Result<(), CustomProjectIconStoreError>; +} + /// Manifest parser and validator. pub trait PluginManifestValidator: Send + Sync { /// Validates raw manifest bytes. diff --git a/crates/domain/src/settings.rs b/crates/domain/src/settings.rs index 901a9450..56ef5ebc 100644 --- a/crates/domain/src/settings.rs +++ b/crates/domain/src/settings.rs @@ -38,6 +38,8 @@ pub struct IdeaUserSettings { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum ProjectIconSet { + /// User-imported icon from the instance-local shared library. + Custom, /// Unicode emoji catalogue. Emoji, /// Devicon language and platform catalogue. @@ -50,6 +52,37 @@ pub enum ProjectIconSet { Idea, } +/// Metadata for one user-imported project icon. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CustomProjectIconAsset { + /// Opaque stable ASCII id used by [`ProjectIconRef`]. + pub id: String, + /// Validated image media type. + pub media_type: String, + /// Stored byte count. + pub byte_size: u64, + /// Decoded pixel width. + pub width: u32, + /// Decoded pixel height. + pub height: u32, + /// Lowercase SHA-256 digest of the stored bytes. + pub sha256: String, + /// Basename supplied by the user; never a local path. + pub original_filename: String, + /// Creation time as epoch milliseconds. + pub created_at: i64, +} + +/// Stored custom icon together with its validated image bytes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CustomProjectIconContent { + /// Persisted metadata. + pub asset: CustomProjectIconAsset, + /// Exact raster bytes. + pub bytes: Vec, +} + /// Maximum byte length of a stable project icon name. pub const MAX_PROJECT_ICON_NAME_LEN: usize = 64; diff --git a/crates/domain/tests/settings.rs b/crates/domain/tests/settings.rs index 01a24132..c65b5c13 100644 --- a/crates/domain/tests/settings.rs +++ b/crates/domain/tests/settings.rs @@ -45,6 +45,7 @@ fn language_roundtrips_as_stable_codes_and_rejects_unknown_values() { #[test] fn project_icon_roundtrips_for_every_known_set() { for (set, wire) in [ + (ProjectIconSet::Custom, "custom"), (ProjectIconSet::Emoji, "emoji"), (ProjectIconSet::Devicon, "devicon"), (ProjectIconSet::SimpleIcons, "simple-icons"), diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index d88c41e1..f577c881 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -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; diff --git a/crates/infrastructure/src/store/mod.rs b/crates/infrastructure/src/store/mod.rs index fd620025..c7f3da6a 100644 --- a/crates/infrastructure/src/store/mod.rs +++ b/crates/infrastructure/src/store/mod.rs @@ -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; diff --git a/crates/infrastructure/src/store/project_icon_asset.rs b/crates/infrastructure/src/store/project_icon_asset.rs new file mode 100644 index 00000000..5d7f7469 --- /dev/null +++ b/crates/infrastructure/src/store/project_icon_asset.rs @@ -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, +} + +#[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) -> 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 { + 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, 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 { + 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 { + 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()); + } +} diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index 82caf0e7..b4403aff 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -86,17 +86,18 @@ use backend::dto::{ ConversationTranscriptUnavailableReasonDto, CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto, CreateScheduledProjectTaskRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto, - DeleteLayoutRequestDto, DeleteLayoutResultDto, DetectProfilesRequestDto, - DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, - ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, - GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, - GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, - LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, - MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto, - OpenProjectRestoreStateDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto, - ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto, - ProjectPermissionsDto, ProjectSettingsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto, - ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto, + CustomProjectIconAssetDto, CustomProjectIconUsageDto, DeleteLayoutRequestDto, + DeleteLayoutResultDto, DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, + EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, + GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, + GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto, + ImportCustomProjectIconAssetRequestDto, InspectConversationRequestDto, LaunchAgentRequestDto, + LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, + MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto, OpenProjectRestoreStateDto, + OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto, + ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSettingsDto, + ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto, + ReadConversationPageRequestDto, RecallMemoryRequestDto, RemoveOpenProjectRestoreEntryRequestDto, RemoveProjectRequestDto, RemoveProjectResponseDto, RenameLayoutRequestDto, ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto, ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto, @@ -138,6 +139,7 @@ const WS_PATH: &str = "/api/ws"; const WS_MAGIC: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; const WS_MAX_PAYLOAD: usize = 64 * 1024; const WS_OUTPUT_BUFFER: usize = 512; +const HTTP_MAX_REQUEST_BODY_BYTES: usize = 3 * 1024 * 1024; #[cfg(test)] type RequestBody = Full; @@ -1078,7 +1080,7 @@ async fn handle_tcp_connection( break; } buffer.extend_from_slice(&chunk[..read]); - if buffer.len() > 1024 * 1024 { + if buffer.len() > HTTP_MAX_REQUEST_BODY_BYTES { return Err("request too large".to_owned()); } } @@ -2299,7 +2301,7 @@ async fn dispatch_http( return cors_response(StatusCode::NO_CONTENT, origin.as_deref()); } - match (method, uri.path()) { + match (method.clone(), uri.path()) { (Method::POST, "/api/pair") => pair(body, state, origin.as_deref()).await, (Method::POST, "/api/pairing-code") => { let Some(token) = session_cookie(&headers) else { @@ -2366,6 +2368,25 @@ async fn dispatch_http( refresh_session_cookie(&mut response, &state, &token); response } + (_, path) + if path == "/api/project-icon-assets" + || path.starts_with("/api/project-icon-assets/") => + { + let (token, _device) = match authenticated_device(&headers, &state).await { + Ok(auth) => auth, + Err(response) => return response, + }; + let mut response = project_icon_asset_response( + &method, + path, + body, + Arc::clone(&state), + origin.as_deref(), + ) + .await; + refresh_session_cookie(&mut response, &state, &token); + response + } (Method::POST, "/api/devices/revoke-all") => { let (_token, _device) = match authenticated_device(&headers, &state).await { Ok(auth) => auth, @@ -2498,6 +2519,135 @@ async fn dispatch_http( } } +async fn project_icon_asset_response( + method: &Method, + path: &str, + body: Bytes, + state: Arc, + origin: Option<&str>, +) -> Response { + if method == Method::GET && path == "/api/project-icon-assets" { + return match state.app.list_custom_project_icon_assets.execute().await { + Ok(assets) => json_response( + StatusCode::OK, + &serde_json::to_value( + assets + .into_iter() + .map(CustomProjectIconAssetDto::from) + .collect::>(), + ) + .unwrap(), + origin, + ), + Err(error) => app_error_response(error, origin), + }; + } + if method == Method::POST && path == "/api/project-icon-assets" { + let request = match serde_json::from_slice::(&body) + { + Ok(request) => request, + Err(error) => { + return error_response( + StatusCode::BAD_REQUEST, + "INVALID", + format!("invalid import request: {error}"), + origin, + ) + } + }; + let bytes = match base64_decode(&request.data_base64) { + Ok(bytes) => bytes, + Err(error) => return error_dto_response(StatusCode::BAD_REQUEST, error, origin), + }; + return match state + .app + .import_custom_project_icon_asset + .execute(request.original_filename, request.media_type, bytes) + .await + { + Ok(asset) => json_response( + StatusCode::CREATED, + &serde_json::to_value(CustomProjectIconAssetDto::from(asset)).unwrap(), + origin, + ), + Err(error) => app_error_response(error, origin), + }; + } + let Some(asset_path) = path.strip_prefix("/api/project-icon-assets/") else { + return error_response( + StatusCode::METHOD_NOT_ALLOWED, + "METHOD_NOT_ALLOWED", + "method not allowed", + origin, + ); + }; + if method == Method::GET && asset_path.ends_with("/usage") { + let asset_id = asset_path.trim_end_matches("/usage").trim_end_matches('/'); + return match state + .app + .get_custom_project_icon_asset_usage + .execute(asset_id) + .await + { + Ok(usage) => json_response( + StatusCode::OK, + &serde_json::to_value(CustomProjectIconUsageDto::from(usage)).unwrap(), + origin, + ), + Err(error) => app_error_response(error, origin), + }; + } + if method == Method::GET { + return match state + .app + .read_custom_project_icon_asset + .execute(asset_path) + .await + { + Ok(output) => { + let mut response = Response::new(ResponseBody::bytes(Bytes::from(output.bytes))); + *response.status_mut() = StatusCode::OK; + if let Ok(value) = HeaderValue::from_str(&output.metadata.asset.media_type) { + response.headers_mut().insert(CONTENT_TYPE, value); + } + response.headers_mut().insert( + "x-content-type-options", + HeaderValue::from_static("nosniff"), + ); + add_cors_headers(&mut response, origin); + response + } + Err(error) => app_error_response(error, origin), + }; + } + if method == Method::DELETE { + return match state + .app + .delete_custom_project_icon_asset + .execute(asset_path) + .await + { + Ok(usage) => json_response( + StatusCode::OK, + &serde_json::to_value(CustomProjectIconUsageDto::from(usage)).unwrap(), + origin, + ), + Err(error) => app_error_response(error, origin), + }; + } + error_response( + StatusCode::METHOD_NOT_ALLOWED, + "METHOD_NOT_ALLOWED", + "method not allowed", + origin, + ) +} + +fn app_error_response(error: AppError, origin: Option<&str>) -> Response { + let error = ErrorDto::from(error); + error_dto_response(error_status(&error), error, origin) +} + fn is_api_path(path: &str) -> bool { path == "/api" || path.starts_with("/api/") } @@ -5886,6 +6036,9 @@ fn api_route_allows_missing_origin(method: &Method, path: &str) -> bool { | "/api/project-files/download-file" | "/api/project-files/download-directory" ) + || (method == Method::GET + && (path == "/api/project-icon-assets" + || path.starts_with("/api/project-icon-assets/"))) } fn validate_reverse_proxy( @@ -6098,7 +6251,7 @@ fn error_status(error: &ErrorDto) -> StatusCode { match error.code.as_str() { "NOT_FOUND" => StatusCode::NOT_FOUND, "UNAUTHORIZED" => StatusCode::UNAUTHORIZED, - "CONFLICT" => StatusCode::CONFLICT, + "CONFLICT" | "CUSTOM_ICON_IN_USE" => StatusCode::CONFLICT, "INVALID" => StatusCode::BAD_REQUEST, _ => StatusCode::INTERNAL_SERVER_ERROR, } @@ -6127,7 +6280,7 @@ fn add_cors_headers(response: &mut Response, origin: Option<&str>) ); response.headers_mut().insert( "access-control-allow-methods", - HeaderValue::from_static("POST, OPTIONS"), + HeaderValue::from_static("GET, POST, DELETE, OPTIONS"), ); } } @@ -6138,7 +6291,7 @@ fn status_for_error(error: &ErrorDto) -> StatusCode { "UNKNOWN_COMMAND" => StatusCode::BAD_REQUEST, "INVALID" => StatusCode::BAD_REQUEST, "NOT_FOUND" => StatusCode::NOT_FOUND, - "CONFLICT" => StatusCode::CONFLICT, + "CONFLICT" | "CUSTOM_ICON_IN_USE" => StatusCode::CONFLICT, "FORBIDDEN" => StatusCode::FORBIDDEN, "UNAUTHORIZED" => StatusCode::UNAUTHORIZED, _ => StatusCode::INTERNAL_SERVER_ERROR, @@ -6738,6 +6891,7 @@ mod tests { StructuredSessionDescriptor, }; use async_trait::async_trait; + use base64::engine::general_purpose::STANDARD; use domain::ports::{AgentSession, AgentSessionError, EventBus, ReplyStream}; use domain::{ AgentId, AgentProfile, BackgroundTask, BackgroundTaskKind, BackgroundTaskWakePolicy, @@ -10280,6 +10434,152 @@ mod tests { assert!(!entries.iter().any(|entry| entry["name"] == ".ideai")); } + #[tokio::test] + async fn custom_project_icon_routes_import_list_read_usage_and_delete() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + let png_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + + let (status, imported, _) = response_json( + request( + Arc::clone(&state), + Method::POST, + "/api/project-icon-assets", + json!({ + "originalFilename": "/home/user/avatar.png", + "mediaType": "image/png", + "dataBase64": png_base64 + }), + &[("cookie", &cookie)], + ) + .await, + ) + .await; + assert_eq!(status, StatusCode::CREATED); + assert_eq!(imported["mediaType"], "image/png"); + assert_eq!(imported["originalFilename"], "avatar.png"); + assert_eq!(imported["width"], 1); + assert_eq!(imported["height"], 1); + assert_eq!(imported["usedByProjectCount"], 0); + let asset_id = imported["id"].as_str().unwrap().to_owned(); + + let (status, listed, _) = response_json( + request( + Arc::clone(&state), + Method::GET, + "/api/project-icon-assets", + json!({}), + &[("cookie", &cookie)], + ) + .await, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(listed.as_array().unwrap().len(), 1); + + let (status, bytes, headers) = response_bytes( + request( + Arc::clone(&state), + Method::GET, + &format!("/api/project-icon-assets/{asset_id}"), + json!({}), + &[("cookie", &cookie)], + ) + .await, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(headers.get(CONTENT_TYPE).unwrap(), "image/png"); + assert_eq!(STANDARD.decode(png_base64).unwrap(), bytes); + + let project_id = create_project_for_test(&state, "Custom Icon User").await; + let (update_status, _) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "update_project_settings", + json!({ + "projectId": project_id, + "icon": { "set": "custom", "name": asset_id.clone() } + }), + ) + .await; + assert_eq!(update_status, StatusCode::OK); + + let (status, usage, _) = response_json( + request( + Arc::clone(&state), + Method::GET, + &format!("/api/project-icon-assets/{asset_id}/usage"), + json!({}), + &[("cookie", &cookie)], + ) + .await, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(usage["usedByProjectCount"], 1); + + let (status, conflict, _) = response_json( + request( + Arc::clone(&state), + Method::DELETE, + &format!("/api/project-icon-assets/{asset_id}"), + json!({}), + &[("cookie", &cookie)], + ) + .await, + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(conflict["code"], "CUSTOM_ICON_IN_USE"); + + let (clear_status, _) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "update_project_settings", + json!({ "projectId": project_id, "icon": null }), + ) + .await; + assert_eq!(clear_status, StatusCode::OK); + + let (status, deleted, _) = response_json( + request( + Arc::clone(&state), + Method::DELETE, + &format!("/api/project-icon-assets/{asset_id}"), + json!({}), + &[("cookie", &cookie)], + ) + .await, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(deleted["assetId"], asset_id); + } + + #[tokio::test] + async fn custom_project_icon_import_rejects_svg_content() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + let (status, error, _) = response_json( + request( + state, + Method::POST, + "/api/project-icon-assets", + json!({ + "originalFilename": "unsafe.svg", + "mediaType": "image/svg+xml", + "dataBase64": STANDARD.encode(b"") + }), + &[("cookie", &cookie)], + ) + .await, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(error["code"], "INVALID"); + } + #[tokio::test] async fn authorized_project_file_gets_allow_missing_origin_header() { let state = state(); diff --git a/frontend/src/adapters/http/httpInvoker.ts b/frontend/src/adapters/http/httpInvoker.ts index 8c7a9b3e..9d21afb4 100644 --- a/frontend/src/adapters/http/httpInvoker.ts +++ b/frontend/src/adapters/http/httpInvoker.ts @@ -122,6 +122,11 @@ export class HttpInvoker { return this.request("POST", "/api/invoke", { command, args }, `command '${command}'`); } + /** Resolves a same-server REST path for browser-owned resources such as images. */ + absoluteUrl(path: string): string { + return `${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`; + } + /** * Sends one authenticated REST request and maps the response exactly like * {@link invoke} (401 routing + `ErrorDto` preservation). diff --git a/frontend/src/adapters/http/index.ts b/frontend/src/adapters/http/index.ts index 40ffa9be..8335dea4 100644 --- a/frontend/src/adapters/http/index.ts +++ b/frontend/src/adapters/http/index.ts @@ -32,6 +32,7 @@ import { HttpPermissionGateway, HttpProfileGateway, HttpProjectGateway, + HttpProjectIconAssetGateway, HttpSkillGateway, HttpTemplateGateway, HttpWorkStateGateway, @@ -126,6 +127,7 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway input: new HttpInputGateway(http), terminal: new HttpTerminalGateway(ws), project: new HttpProjectGateway(http), + projectIconAsset: new HttpProjectIconAssetGateway(http), layout: new HttpLayoutGateway(http), git: new HttpGitGateway(http), remote: new WebRemoteGateway(), diff --git a/frontend/src/adapters/http/projectIconAssetGateway.test.ts b/frontend/src/adapters/http/projectIconAssetGateway.test.ts new file mode 100644 index 00000000..7edaa73a --- /dev/null +++ b/frontend/src/adapters/http/projectIconAssetGateway.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vitest"; + +import { HttpInvoker } from "./httpInvoker"; +import { HttpProjectIconAssetGateway } from "./requestResponseGateways"; + +describe("HttpProjectIconAssetGateway", () => { + it("maps list/import/delete/read URLs to the dedicated REST resource", async () => { + const http = new HttpInvoker({ baseUrl: "https://idea.test" }); + const request = vi.spyOn(http, "request").mockResolvedValue({ id: "asset-1" } as never); + const gateway = new HttpProjectIconAssetGateway(http); + + await gateway.list(); + const file = { + name: "icon.webp", type: "image/webp", + arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer, + } as File; + await gateway.import(file); + await gateway.delete("asset/with spaces"); + + expect(request).toHaveBeenNthCalledWith(1, "GET", "/api/project-icon-assets", undefined, "custom project icons"); + expect(request).toHaveBeenNthCalledWith(2, "POST", "/api/project-icon-assets", { + originalFilename: "icon.webp", mediaType: "image/webp", dataBase64: "AQID", + }, "custom project icon import"); + expect(request).toHaveBeenNthCalledWith(3, "DELETE", "/api/project-icon-assets/asset%2Fwith%20spaces", undefined, "custom project icon deletion"); + await expect(gateway.getUrl("asset-1")).resolves.toBe("https://idea.test/api/project-icon-assets/asset-1"); + }); +}); diff --git a/frontend/src/adapters/http/requestResponseGateways.ts b/frontend/src/adapters/http/requestResponseGateways.ts index 4fea0753..45ab8145 100644 --- a/frontend/src/adapters/http/requestResponseGateways.ts +++ b/frontend/src/adapters/http/requestResponseGateways.ts @@ -74,6 +74,8 @@ import type { PermissionGateway, ProfileGateway, ProjectGateway, + ProjectIconAssetGateway, + CustomProjectIconAsset, ProjectSettings, SaveOpenCodeProviderProfileInput, SkillGateway, @@ -170,6 +172,43 @@ export class HttpProjectGateway implements ProjectGateway { } } +export class HttpProjectIconAssetGateway implements ProjectIconAssetGateway { + constructor(private readonly http: HttpInvoker) {} + + list(): Promise { + return this.http.request("GET", "/api/project-icon-assets", undefined, "custom project icons"); + } + + async import(file: File): Promise { + const dataBase64 = bytesToBase64(new Uint8Array(await file.arrayBuffer())); + return this.http.request("POST", "/api/project-icon-assets", { + originalFilename: file.name, + mediaType: file.type, + dataBase64, + }, "custom project icon import"); + } + + async delete(assetId: string): Promise { + await this.http.request("DELETE", projectIconAssetPath(assetId), undefined, "custom project icon deletion"); + } + + async getUrl(assetId: string): Promise { + return this.http.absoluteUrl(projectIconAssetPath(assetId)); + } +} + +function projectIconAssetPath(assetId: string): string { + return `/api/project-icon-assets/${encodeURIComponent(assetId)}`; +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); + } + return btoa(binary); +} + function projectFilesPath(endpoint: string, projectId: string, path: string): string { const params = new URLSearchParams({ projectId, path }); return `${endpoint}?${params.toString()}`; diff --git a/frontend/src/adapters/index.ts b/frontend/src/adapters/index.ts index 0f26404a..830439c6 100644 --- a/frontend/src/adapters/index.ts +++ b/frontend/src/adapters/index.ts @@ -16,6 +16,7 @@ import { TauriSystemGateway } from "./system"; import { TauriAgentGateway } from "./agent"; import { TauriInputGateway } from "./input"; import { TauriProjectGateway } from "./project"; +import { TauriProjectIconAssetGateway } from "./projectIconAsset"; import { TauriTerminalGateway } from "./terminal"; import { TauriLayoutGateway } from "./layout"; import { TauriProfileGateway } from "./profile"; @@ -66,6 +67,7 @@ export function createTauriGateways(): Gateways { input: new TauriInputGateway(), terminal: new TauriTerminalGateway(), project: new TauriProjectGateway(), + projectIconAsset: new TauriProjectIconAssetGateway(), layout: new TauriLayoutGateway(), git: new TauriGitGateway(), remote: new TauriRemoteGateway(), @@ -101,6 +103,7 @@ export { TauriAgentGateway, TauriInputGateway, TauriProjectGateway, + TauriProjectIconAssetGateway, TauriTerminalGateway, TauriLayoutGateway, TauriProfileGateway, diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 767dc130..001ddbdf 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -138,6 +138,8 @@ import type { OpenTerminalOptions, ProfileGateway, ProjectGateway, + ProjectIconAssetGateway, + CustomProjectIconAsset, ProjectSettings, PermissionGateway, PluginConfigDocumentReadInput, @@ -1287,6 +1289,59 @@ export class MockTerminalGateway implements TerminalGateway { } } +export class MockProjectIconAssetGateway implements ProjectIconAssetGateway { + private readonly assets = new Map(); + private readonly urls = new Map(); + private sequence = 0; + + async list(): Promise { + return [...this.assets.values()].map((asset) => ({ ...asset })); + } + + async import(file: File): Promise { + if (!["image/png", "image/jpeg", "image/webp"].includes(file.type)) { + throw { code: "INVALID_ICON_MEDIA_TYPE", message: "PNG, JPEG or WebP required" } as GatewayError; + } + if (file.size > 2 * 1024 * 1024) { + throw { code: "ICON_TOO_LARGE", message: "Icon exceeds 2 MiB" } as GatewayError; + } + const id = `mock-icon-${++this.sequence}`; + const asset: CustomProjectIconAsset = { + id, + mediaType: file.type as CustomProjectIconAsset["mediaType"], + byteSize: file.size, + width: 0, + height: 0, + sha256: `mock-${id}`, + originalFilename: file.name, + createdAt: Date.now(), + usedByProjectCount: 0, + }; + this.assets.set(id, asset); + const createObjectURL = URL.createObjectURL?.bind(URL); + this.urls.set(id, createObjectURL ? createObjectURL(file) : `data:${file.type};base64,`); + return { ...asset }; + } + + async delete(assetId: string): Promise { + const asset = this.assets.get(assetId); + if (!asset) throw { code: "NOT_FOUND", message: `icon ${assetId} not found` } as GatewayError; + if (asset.usedByProjectCount > 0) { + throw { code: "CUSTOM_ICON_IN_USE", message: `icon is used by ${asset.usedByProjectCount} projects` } as GatewayError; + } + const url = this.urls.get(assetId); + if (url?.startsWith("blob:")) URL.revokeObjectURL?.(url); + this.assets.delete(assetId); + this.urls.delete(assetId); + } + + async getUrl(assetId: string): Promise { + const url = this.urls.get(assetId); + if (!url) throw { code: "NOT_FOUND", message: `icon ${assetId} not found` } as GatewayError; + return url; + } +} + export class MockProjectGateway implements ProjectGateway { private projects: Project[] = []; private contexts = new Map(); @@ -5002,6 +5057,7 @@ export function createMockGateways(): Gateways { input: new MockInputGateway(), terminal: new MockTerminalGateway(), project: new MockProjectGateway(), + projectIconAsset: new MockProjectIconAssetGateway(), layout: new MockLayoutGateway(), git: new MockGitGateway(), remote: new MockRemoteGateway(), diff --git a/frontend/src/adapters/mock/mock.test.ts b/frontend/src/adapters/mock/mock.test.ts index 61a24090..bb921b6e 100644 --- a/frontend/src/adapters/mock/mock.test.ts +++ b/frontend/src/adapters/mock/mock.test.ts @@ -35,6 +35,7 @@ describe("createMockGateways", () => { "pluginWorkspace", "profile", "project", + "projectIconAsset", "remote", "scheduledTask", "skill", diff --git a/frontend/src/adapters/projectIconAsset.test.ts b/frontend/src/adapters/projectIconAsset.test.ts new file mode 100644 index 00000000..4ee93dd8 --- /dev/null +++ b/frontend/src/adapters/projectIconAsset.test.ts @@ -0,0 +1,38 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const invoke = vi.fn(); +vi.mock("@tauri-apps/api/core", () => ({ invoke: (...args: unknown[]) => invoke(...args) })); + +import { TauriProjectIconAssetGateway } from "./projectIconAsset"; + +describe("TauriProjectIconAssetGateway", () => { + beforeEach(() => invoke.mockReset()); + + it("uses the shared backend commands and base64 import DTO", async () => { + const gateway = new TauriProjectIconAssetGateway(); + invoke.mockResolvedValueOnce([]); + await gateway.list(); + expect(invoke).toHaveBeenCalledWith("list_custom_project_icon_assets"); + + invoke.mockResolvedValueOnce({ id: "asset-1" }); + const file = { + name: "icon.png", type: "image/png", + arrayBuffer: async () => new Uint8Array([0, 1, 2]).buffer, + } as File; + await gateway.import(file); + expect(invoke).toHaveBeenCalledWith("import_custom_project_icon_asset", { + request: { originalFilename: "icon.png", mediaType: "image/png", dataBase64: "AAEC" }, + }); + + invoke.mockResolvedValueOnce(undefined); + await gateway.delete("asset-1"); + expect(invoke).toHaveBeenCalledWith("delete_custom_project_icon_asset", { assetId: "asset-1" }); + }); + + it("turns the Tauri read DTO into a browser-safe data URL", async () => { + invoke.mockResolvedValueOnce({ mediaType: "image/webp", dataBase64: "AQID" }); + await expect(new TauriProjectIconAssetGateway().getUrl("asset-1")) + .resolves.toBe("data:image/webp;base64,AQID"); + expect(invoke).toHaveBeenCalledWith("read_custom_project_icon_asset", { assetId: "asset-1" }); + }); +}); diff --git a/frontend/src/adapters/projectIconAsset.ts b/frontend/src/adapters/projectIconAsset.ts new file mode 100644 index 00000000..bb56d88f --- /dev/null +++ b/frontend/src/adapters/projectIconAsset.ts @@ -0,0 +1,41 @@ +import { invoke } from "@tauri-apps/api/core"; + +import type { CustomProjectIconAsset, ProjectIconAssetGateway } from "@/ports"; + +interface CustomProjectIconAssetContent extends CustomProjectIconAsset { + dataBase64: string; +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); + } + return btoa(binary); +} + +export class TauriProjectIconAssetGateway implements ProjectIconAssetGateway { + list(): Promise { + return invoke("list_custom_project_icon_assets"); + } + + async import(file: File): Promise { + const dataBase64 = bytesToBase64(new Uint8Array(await file.arrayBuffer())); + return invoke("import_custom_project_icon_asset", { + request: { + originalFilename: file.name, + mediaType: file.type, + dataBase64, + }, + }); + } + + async delete(assetId: string): Promise { + await invoke("delete_custom_project_icon_asset", { assetId }); + } + + getUrl(assetId: string): Promise { + return invoke("read_custom_project_icon_asset", { assetId }) + .then((content) => `data:${content.mediaType};base64,${content.dataBase64}`); + } +} diff --git a/frontend/src/app/di.tsx b/frontend/src/app/di.tsx index 9b5eab7a..b7aa9f68 100644 --- a/frontend/src/app/di.tsx +++ b/frontend/src/app/di.tsx @@ -80,3 +80,8 @@ export function useGateways(): Gateways { } return ctx; } + +/** Optional access for leaf renderers that retain a graceful no-provider fallback. */ +export function useOptionalGateways(): Gateways | null { + return useContext(GatewaysContext); +} diff --git a/frontend/src/features/projects/ProjectIconPicker.tsx b/frontend/src/features/projects/ProjectIconPicker.tsx index f70c3deb..91a662df 100644 --- a/frontend/src/features/projects/ProjectIconPicker.tsx +++ b/frontend/src/features/projects/ProjectIconPicker.tsx @@ -1,69 +1,147 @@ -import { useMemo, useState } from "react"; -import type { ProjectIconRef } from "@/ports"; -import { Button, Input } from "@/shared"; +import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; +import { useGateways } from "@/app/di"; +import type { CustomProjectIconAsset, ProjectIconRef } from "@/ports"; +import { Button, Input, Spinner } from "@/shared"; import { useI18n } from "@/i18n"; -import { - PROJECT_ICON_CATALOG, - ProjectIcon, - type ProjectIconGroup, - toEmojiIconRef, -} from "./projectIcons"; +import { PROJECT_ICON_CATALOG, ProjectIcon, type ProjectIconGroup, toEmojiIconRef } from "./projectIcons"; const GROUPS: readonly ProjectIconGroup[] = ["smileys", "nature", "food", "activities", "travel", "objects", "symbols"]; const GROUP_LABELS = { - smileys: "project.icon.group.smileys", - nature: "project.icon.group.nature", - food: "project.icon.group.food", - activities: "project.icon.group.activities", - travel: "project.icon.group.travel", - objects: "project.icon.group.objects", + smileys: "project.icon.group.smileys", nature: "project.icon.group.nature", food: "project.icon.group.food", + activities: "project.icon.group.activities", travel: "project.icon.group.travel", objects: "project.icon.group.objects", symbols: "project.icon.group.symbols", } as const; +const MAX_ICON_BYTES = 2 * 1024 * 1024; +const ACCEPTED_ICON_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]); + +function readableError(error: unknown, fallback: string): string { + if (error && typeof error === "object" && "message" in error && typeof error.message === "string") return error.message; + return fallback; +} export function ProjectIconPicker({ value, onApply, onCancel }: { value: ProjectIconRef | null; onApply(value: ProjectIconRef | null): void; onCancel(): void; }) { - const { t } = useI18n(); + const { projectIconAsset } = useGateways(); + const { t, tr } = useI18n(); + const fileInput = useRef(null); const [query, setQuery] = useState(""); - const [selected, setSelected] = useState(() => toEmojiIconRef(value)); - const results = useMemo(() => { - const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean); - return PROJECT_ICON_CATALOG.filter((entry) => words.every((word) => entry.keywords.some((key) => key.includes(word)))); - }, [query]); - const groupedResults = useMemo(() => GROUPS.map((group) => ({ - group, - entries: results.filter((entry) => entry.group === group), - })).filter(({ entries }) => entries.length > 0), [results]); + const [selected, setSelected] = useState(() => value?.set === "custom" ? value : toEmojiIconRef(value)); + const [customAssets, setCustomAssets] = useState([]); + const [loadingAssets, setLoadingAssets] = useState(true); + const [assetBusy, setAssetBusy] = useState(false); + const [assetError, setAssetError] = useState(null); + + useEffect(() => { + let cancelled = false; + setLoadingAssets(true); + void projectIconAsset.list().then((assets) => { + if (!cancelled) setCustomAssets(assets); + }).catch((error) => { + if (!cancelled) setAssetError(readableError(error, tr("Impossible de charger vos icônes.", "Unable to load your icons."))); + }).finally(() => { if (!cancelled) setLoadingAssets(false); }); + return () => { cancelled = true; }; + }, [projectIconAsset, tr]); + + const words = useMemo(() => query.trim().toLowerCase().split(/\s+/).filter(Boolean), [query]); + const results = useMemo(() => PROJECT_ICON_CATALOG.filter((entry) => + words.every((word) => entry.keywords.some((key) => key.includes(word)))), [words]); + const visibleCustomAssets = useMemo(() => customAssets.filter((asset) => { + const searchable = `${asset.originalFilename} ${asset.id}`.toLowerCase(); + return words.every((word) => searchable.includes(word)); + }), [customAssets, words]); + const groupedResults = useMemo(() => GROUPS.map((group) => ({ group, entries: results.filter((entry) => entry.group === group) })) + .filter(({ entries }) => entries.length > 0), [results]); + + async function importIcon(event: ChangeEvent) { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + if (!ACCEPTED_ICON_TYPES.has(file.type)) { + setAssetError(tr("Format non pris en charge. Choisissez un fichier PNG, JPEG ou WebP.", "Unsupported format. Choose a PNG, JPEG or WebP file.")); + return; + } + if (file.size > MAX_ICON_BYTES) { + setAssetError(tr("Cette icône dépasse la limite de 2 Mo.", "This icon exceeds the 2 MB limit.")); + return; + } + setAssetBusy(true); + setAssetError(null); + try { + const imported = await projectIconAsset.import(file); + setCustomAssets((current) => [imported, ...current.filter(({ id }) => id !== imported.id)]); + setSelected({ set: "custom", name: imported.id }); + } catch (error) { + setAssetError(readableError(error, tr("Impossible d’importer cette icône.", "Unable to import this icon."))); + } finally { setAssetBusy(false); } + } + + async function deleteIcon(asset: CustomProjectIconAsset) { + if (asset.usedByProjectCount > 0) return; + setAssetBusy(true); + setAssetError(null); + try { + await projectIconAsset.delete(asset.id); + setCustomAssets((current) => current.filter(({ id }) => id !== asset.id)); + if (selected?.set === "custom" && selected.name === asset.id) setSelected(null); + } catch (error) { + setAssetError(readableError(error, tr("Impossible de supprimer cette icône.", "Unable to delete this icon."))); + } finally { setAssetBusy(false); } + } return (

{t("project.icon.picker")}

setQuery(event.target.value)} placeholder={t("project.icon.search")} aria-label={t("project.icon.search")} /> -
- {groupedResults.map(({ group, entries }) => ( -
-

- {t(GROUP_LABELS[group])} -

-
- {entries.map((entry) => { - const active = selected?.set === entry.set && selected.name === entry.name; - return ; - })} -
+
+
+
+

{tr("Mes icônes", "My icons")}

+ + void importIcon(event)} aria-label={tr("Importer une icône", "Import an icon")} />
- ))} +

{tr("PNG, JPEG ou WebP — 2 Mo maximum.", "PNG, JPEG or WebP — 2 MB maximum.")}

+ {loadingAssets ?
{t("common.loading")}
: null} + {!loadingAssets && customAssets.length === 0 ?

{tr("Aucune icône importée.", "No imported icons yet.")}

: null} + {visibleCustomAssets.length > 0 ?
+ {visibleCustomAssets.map((asset) => { + const active = selected?.set === "custom" && selected.name === asset.id; + const inUse = asset.usedByProjectCount > 0; + const usage = tr(`Utilisée par ${asset.usedByProjectCount} projet${asset.usedByProjectCount > 1 ? "s" : ""}`, `Used by ${asset.usedByProjectCount} project${asset.usedByProjectCount === 1 ? "" : "s"}`); + return
+ + +
; + })} +
: null} +
+ +
+

Emojis

+
{groupedResults.map(({ group, entries }) =>
+
{t(GROUP_LABELS[group])}
+
{entries.map((entry) => { + const active = selected?.set === entry.set && selected.name === entry.name; + return ; + })}
+
)}
+
- {results.length === 0 &&

{t("project.icon.noResults")}

} + {assetError ?

{assetError}

: null} + {results.length === 0 && visibleCustomAssets.length === 0 ?

{t("project.icon.noResults")}

: null}
-
+
diff --git a/frontend/src/features/projects/projectIcons.test.tsx b/frontend/src/features/projects/projectIcons.test.tsx index c69985b7..35b5e22a 100644 --- a/frontend/src/features/projects/projectIcons.test.tsx +++ b/frontend/src/features/projects/projectIcons.test.tsx @@ -1,6 +1,10 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; +import { createMockGateways } from "@/adapters/mock"; +import { DIProvider } from "@/app/di"; +import type { CustomProjectIconAsset } from "@/ports"; import { ProjectIconPicker } from "./ProjectIconPicker"; import { PROJECT_ICON_CATALOG, @@ -63,8 +67,12 @@ describe("project emoji catalogue", () => { }); describe("ProjectIconPicker", () => { + function renderWithGateways(ui: ReactNode, gateways = createMockGateways()) { + return render({ui}); + } + function renderPicker(value = null as Parameters[0]["value"]) { - render(); + renderWithGateways(); } it("shows phone-like emoji categories only", () => { @@ -89,7 +97,7 @@ describe("ProjectIconPicker", () => { it("selects and persists an emoji reference", () => { const onApply = vi.fn(); - render(); + renderWithGateways(); fireEvent.change(screen.getByLabelText("Search icons in English"), { target: { value: "rocket" } }); fireEvent.click(screen.getByRole("option", { name: "Rocket" })); fireEvent.click(screen.getByRole("button", { name: "Appliquer" })); @@ -98,8 +106,62 @@ describe("ProjectIconPicker", () => { it("normalizes a known legacy selection before applying it", () => { const onApply = vi.fn(); - render(); + renderWithGateways(); fireEvent.click(screen.getByRole("button", { name: "Appliquer" })); expect(onApply).toHaveBeenCalledWith({ set: "emoji", name: "snake" }); }); + + it("imports a supported image into Mes icônes and selects its opaque id", async () => { + const gateways = createMockGateways(); + const imported: CustomProjectIconAsset = { + id: "asset-opaque-1", mediaType: "image/png", byteSize: 12, width: 32, height: 32, + sha256: "abc", originalFilename: "équipe.png", createdAt: 1, usedByProjectCount: 0, + }; + vi.spyOn(gateways.projectIconAsset, "import").mockResolvedValue(imported); + vi.spyOn(gateways.projectIconAsset, "getUrl").mockResolvedValue("data:image/png;base64,AA=="); + const onApply = vi.fn(); + const { container } = renderWithGateways(, gateways); + + expect(screen.getByText("Mes icônes")).toBeTruthy(); + expect(screen.getByText("PNG, JPEG ou WebP — 2 Mo maximum.")).toBeTruthy(); + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(input, { target: { files: [new File(["png"], "équipe.png", { type: "image/png" })] } }); + + await waitFor(() => expect(screen.getByRole("option", { name: "équipe.png" })).toBeTruthy()); + fireEvent.click(screen.getByRole("button", { name: "Appliquer" })); + expect(onApply).toHaveBeenCalledWith({ set: "custom", name: "asset-opaque-1" }); + }); + + it("blocks deletion while a custom icon is used by projects", async () => { + const gateways = createMockGateways(); + vi.spyOn(gateways.projectIconAsset, "list").mockResolvedValue([{ + id: "used-icon", mediaType: "image/webp", byteSize: 10, width: 16, height: 16, + sha256: "def", originalFilename: "used.webp", createdAt: 1, usedByProjectCount: 2, + }]); + const deleteSpy = vi.spyOn(gateways.projectIconAsset, "delete"); + renderWithGateways(, gateways); + + const remove = await screen.findByRole("button", { name: /Suppression impossible: Utilisée par 2 projets/ }); + expect(remove.getAttribute("aria-disabled")).toBe("true"); + fireEvent.click(remove); + expect(screen.getByRole("alert").textContent).toContain("Utilisée par 2 projets"); + expect(deleteSpy).not.toHaveBeenCalled(); + }); +}); + +describe("custom ProjectIcon", () => { + it("renders a contained image and falls back cleanly when the asset is missing", async () => { + const gateways = createMockGateways(); + vi.spyOn(gateways.projectIconAsset, "getUrl").mockResolvedValue("data:image/png;base64,AA=="); + const { getByRole, rerender } = render( + , + ); + await waitFor(() => expect(getByRole("img").querySelector("img")?.style.objectFit).toBe("contain")); + expect(getByRole("img").dataset.resolvedProjectIcon).toBe("custom:asset-1"); + + vi.spyOn(gateways.projectIconAsset, "getUrl").mockRejectedValueOnce({ code: "NOT_FOUND" }); + rerender(); + await waitFor(() => expect(getByRole("img").dataset.resolvedProjectIcon).toBe("initial")); + expect(getByRole("img").textContent).toBe("A"); + }); }); diff --git a/frontend/src/features/projects/projectIcons.tsx b/frontend/src/features/projects/projectIcons.tsx index 5f065dae..53b982c4 100644 --- a/frontend/src/features/projects/projectIcons.tsx +++ b/frontend/src/features/projects/projectIcons.tsx @@ -1,4 +1,5 @@ -import type { CSSProperties, HTMLAttributes } from "react"; +import { useEffect, useState, type CSSProperties, type HTMLAttributes } from "react"; +import { useOptionalGateways } from "@/app/di"; import type { ProjectIconRef } from "@/ports"; export type ProjectIconGroup = @@ -206,8 +207,24 @@ export function ProjectIcon({ icon: ref, fallbackLabel, size = 32, className, st decorative?: boolean; surface?: ProjectIconSurface; } & Omit, "children" | "style" | "className">) { + const gateways = useOptionalGateways(); const entry = resolveProjectIcon(ref, surface); - const label = entry?.label ?? fallbackInitial(ref, fallbackLabel); + const customAssetId = ref?.set === "custom" ? ref.name : null; + const [customUrl, setCustomUrl] = useState(null); + const [customMissing, setCustomMissing] = useState(false); + useEffect(() => { + let cancelled = false; + setCustomUrl(null); + setCustomMissing(false); + if (!customAssetId || !gateways) return () => { cancelled = true; }; + void gateways.projectIconAsset.getUrl(customAssetId).then((url) => { + if (!cancelled) setCustomUrl(url); + }).catch(() => { + if (!cancelled) setCustomMissing(true); + }); + return () => { cancelled = true; }; + }, [customAssetId, gateways]); + const label = customAssetId ? (fallbackLabel ? `${fallbackLabel} — custom icon` : "Custom icon") : entry?.label ?? fallbackInitial(ref, fallbackLabel); const numericSize = typeof size === "number" ? size : null; const glyph = entry?.glyph ?? fallbackInitial(ref, fallbackLabel); return ( @@ -219,7 +236,7 @@ export function ProjectIcon({ icon: ref, fallbackLabel, size = 32, className, st aria-label={decorative ? undefined : label} title={decorative ? undefined : label} data-project-icon={ref ? `${ref.set}:${ref.name}` : "none"} - data-resolved-project-icon={entry ? `emoji:${entry.name}` : "initial"} + data-resolved-project-icon={customUrl && !customMissing ? `custom:${customAssetId}` : entry ? `emoji:${entry.name}` : "initial"} style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: size, height: size, fontSize: numericSize ? Math.max(16, numericSize * 0.72) : "62%", @@ -227,7 +244,15 @@ export function ProjectIcon({ icon: ref, fallbackLabel, size = 32, className, st fontWeight: entry ? 400 : 700, lineHeight: 1, flexShrink: 0, ...style, }} > - {glyph} + {customUrl && !customMissing ? ( + setCustomMissing(true)} + style={{ width: "100%", height: "100%", objectFit: "contain", display: "block" }} + /> + ) : glyph} ); } diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 31a26388..729fe8e3 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -495,13 +495,35 @@ export interface UserSettingsGateway { updateUserSettings(settings: Partial): Promise; } -export type ProjectIconSet = "emoji" | "devicon" | "simple-icons" | "lucide" | "idea"; +export type ProjectIconSet = "emoji" | "devicon" | "simple-icons" | "lucide" | "idea" | "custom"; export interface ProjectIconRef { set: ProjectIconSet; name: string; } +/** One user-imported raster icon in the instance-wide project icon library. */ +export interface CustomProjectIconAsset { + id: string; + mediaType: "image/png" | "image/jpeg" | "image/webp"; + byteSize: number; + width: number; + height: number; + sha256: string; + originalFilename: string; + createdAt: number | string; + usedByProjectCount: number; +} + +/** Shared custom-project-icon library, independent from any single project. */ +export interface ProjectIconAssetGateway { + list(): Promise; + import(file: File): Promise; + delete(assetId: string): Promise; + /** Returns a browser-loadable URL without exposing a local filesystem path. */ + getUrl(assetId: string): Promise; +} + export interface ProjectSettings { themeColor: string | null; /** Absent only when talking to a pre-#325 backend. */ @@ -1892,6 +1914,7 @@ export interface Gateways { input: InputGateway; terminal: TerminalGateway; project: ProjectGateway; + projectIconAsset: ProjectIconAssetGateway; layout: LayoutGateway; git: GitGateway; remote: RemoteGateway;