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

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

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

1
Cargo.lock generated
View File

@ -102,6 +102,7 @@ dependencies = [
"chrono-tz",
"dirs",
"domain",
"hex",
"serde",
"serde_json",
"sha2",

View File

@ -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<CustomProjectIconAssetDto, ErrorDto> {
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<Vec<CustomProjectIconAssetDto>, 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<CustomProjectIconAssetContentDto, ErrorDto> {
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<CustomProjectIconUsageDto, ErrorDto> {
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<CustomProjectIconUsageDto, ErrorDto> {
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

View File

@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<application::CustomProjectIconAssetView> 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<application::CustomProjectIconUsage> 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<domain::ProjectIconRef> for ProjectIconRefDto {
fn from(value: domain::ProjectIconRef) -> Self {
Self {

View File

@ -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<ReadProjectSettings>,
/// Updates settings owned by the active project.
pub update_project_settings: Arc<UpdateProjectSettings>,
/// Imports into the shared custom icon library.
pub import_custom_project_icon_asset: Arc<ImportCustomProjectIconAsset>,
/// Lists shared custom icons with live usage.
pub list_custom_project_icon_assets: Arc<ListCustomProjectIconAssets>,
/// Reads one custom icon and exact bytes.
pub read_custom_project_icon_asset: Arc<ReadCustomProjectIconAsset>,
/// Gets current project usage for one custom icon.
pub get_custom_project_icon_asset_usage: Arc<GetCustomProjectIconAssetUsage>,
/// Deletes an unused custom icon.
pub delete_custom_project_icon_asset: Arc<DeleteCustomProjectIconAsset>,
/// Read the project permission document.
pub get_project_permissions: Arc<GetProjectPermissions>,
/// 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<dyn ProjectSettingsStore>;
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<dyn CustomProjectIconAssetStore>;
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<dyn IdGenerator>,
Arc::clone(&clock) as Arc<dyn Clock>,
));
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<dyn OpenProjectRestoreStore>;
let window_state_port = Arc::clone(&window_state_store) as Arc<dyn WindowStateStore>;
@ -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,

View File

@ -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,

View File

@ -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<Vec<CustomProjectIconAsset>, CustomProjectIconStoreError>;
/// Reads metadata and bytes for one id.
async fn read(
&self,
asset_id: &str,
) -> Result<CustomProjectIconContent, CustomProjectIconStoreError>;
/// Returns whether an id exists in the canonical index.
async fn exists(&self, asset_id: &str) -> Result<bool, CustomProjectIconStoreError>;
/// 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.

View File

@ -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<u8>,
}
/// Maximum byte length of a stable project icon name.
pub const MAX_PROJECT_ICON_NAME_LEN: usize = 64;

View File

@ -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"),

View File

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

View File

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

View File

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

View File

@ -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<Bytes>;
@ -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<ServerState>,
origin: Option<&str>,
) -> Response<ResponseBody> {
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::<Vec<_>>(),
)
.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::<ImportCustomProjectIconAssetRequestDto>(&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<ResponseBody> {
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<ResponseBody>, 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"<svg xmlns='http://www.w3.org/2000/svg'/>")
}),
&[("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();

View File

@ -122,6 +122,11 @@ export class HttpInvoker {
return this.request<T>("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).

View File

@ -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(),

View File

@ -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");
});
});

View File

@ -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<CustomProjectIconAsset[]> {
return this.http.request("GET", "/api/project-icon-assets", undefined, "custom project icons");
}
async import(file: File): Promise<CustomProjectIconAsset> {
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<void> {
await this.http.request("DELETE", projectIconAssetPath(assetId), undefined, "custom project icon deletion");
}
async getUrl(assetId: string): Promise<string> {
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()}`;

View File

@ -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,

View File

@ -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<string, CustomProjectIconAsset>();
private readonly urls = new Map<string, string>();
private sequence = 0;
async list(): Promise<CustomProjectIconAsset[]> {
return [...this.assets.values()].map((asset) => ({ ...asset }));
}
async import(file: File): Promise<CustomProjectIconAsset> {
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<void> {
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<string> {
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<string, string>();
@ -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(),

View File

@ -35,6 +35,7 @@ describe("createMockGateways", () => {
"pluginWorkspace",
"profile",
"project",
"projectIconAsset",
"remote",
"scheduledTask",
"skill",

View File

@ -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" });
});
});

View File

@ -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<CustomProjectIconAsset[]> {
return invoke("list_custom_project_icon_assets");
}
async import(file: File): Promise<CustomProjectIconAsset> {
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<void> {
await invoke("delete_custom_project_icon_asset", { assetId });
}
getUrl(assetId: string): Promise<string> {
return invoke<CustomProjectIconAssetContent>("read_custom_project_icon_asset", { assetId })
.then((content) => `data:${content.mediaType};base64,${content.dataBase64}`);
}
}

View File

@ -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);
}

View File

@ -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<HTMLInputElement>(null);
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<ProjectIconRef | null>(() => 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<ProjectIconRef | null>(() => value?.set === "custom" ? value : toEmojiIconRef(value));
const [customAssets, setCustomAssets] = useState<CustomProjectIconAsset[]>([]);
const [loadingAssets, setLoadingAssets] = useState(true);
const [assetBusy, setAssetBusy] = useState(false);
const [assetError, setAssetError] = useState<string | null>(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<HTMLInputElement>) {
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 (
<div role="dialog" aria-modal="true" aria-label={t("project.icon.picker")} className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/55 p-4">
<div className="flex max-h-[80vh] w-full max-w-xl flex-col gap-3 rounded-lg border border-border bg-surface p-4 shadow-xl">
<h3 className="text-base font-semibold text-content">{t("project.icon.picker")}</h3>
<Input autoFocus value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("project.icon.search")} aria-label={t("project.icon.search")} />
<div role="listbox" aria-label={t("project.icon.results")} className="min-h-28 space-y-4 overflow-y-auto pr-1">
{groupedResults.map(({ group, entries }) => (
<div key={group} role="group" aria-labelledby={`project-icon-group-${group}`}>
<h4 id={`project-icon-group-${group}`} className="sticky top-0 z-10 mb-2 bg-surface py-1 text-xs font-semibold uppercase tracking-wide text-muted">
{t(GROUP_LABELS[group])}
</h4>
<div className="grid grid-cols-4 gap-2 sm:grid-cols-6">
{entries.map((entry) => {
const active = selected?.set === entry.set && selected.name === entry.name;
return <button key={`${entry.set}:${entry.name}`} type="button" role="option" aria-selected={active} title={entry.label} onClick={() => setSelected({ set: entry.set, name: entry.name })} className={`flex min-h-20 flex-col items-center justify-center gap-1 rounded border p-2 text-xs ${active ? "border-primary bg-primary/15 text-content" : "border-border bg-raised text-muted hover:text-content"}`}>
<ProjectIcon icon={entry} size={32} decorative />
<span className="line-clamp-2">{entry.label}</span>
</button>;
})}
</div>
<div role="listbox" aria-label={t("project.icon.results")} className="min-h-28 space-y-5 overflow-y-auto pr-1">
<section aria-labelledby="project-icon-custom-heading">
<div className="sticky top-0 z-20 mb-2 flex items-center justify-between gap-2 bg-surface py-1">
<h4 id="project-icon-custom-heading" className="text-xs font-semibold uppercase tracking-wide text-muted">{tr("Mes icônes", "My icons")}</h4>
<Button size="sm" variant="secondary" disabled={assetBusy} onClick={() => fileInput.current?.click()}>
{assetBusy ? <Spinner size={14} /> : null}{tr("Importer une icône", "Import an icon")}
</Button>
<input ref={fileInput} type="file" className="sr-only" accept="image/png,image/jpeg,image/webp" onChange={(event) => void importIcon(event)} aria-label={tr("Importer une icône", "Import an icon")} />
</div>
))}
<p className="mb-2 text-xs text-muted">{tr("PNG, JPEG ou WebP — 2 Mo maximum.", "PNG, JPEG or WebP — 2 MB maximum.")}</p>
{loadingAssets ? <div className="flex items-center gap-2 text-sm text-muted"><Spinner size={14} />{t("common.loading")}</div> : null}
{!loadingAssets && customAssets.length === 0 ? <p className="text-sm text-muted">{tr("Aucune icône importée.", "No imported icons yet.")}</p> : null}
{visibleCustomAssets.length > 0 ? <div className="grid grid-cols-4 gap-2 sm:grid-cols-6">
{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 <div key={asset.id} className="relative min-w-0">
<button type="button" role="option" aria-selected={active} aria-label={asset.originalFilename} onClick={() => setSelected({ set: "custom", name: asset.id })} className={`flex min-h-20 w-full flex-col items-center justify-center gap-1 rounded border p-2 text-xs ${active ? "border-primary bg-primary/15 text-content" : "border-border bg-raised text-muted hover:text-content"}`}>
<ProjectIcon icon={{ set: "custom", name: asset.id }} size={36} decorative />
<span className="line-clamp-2 break-all">{asset.originalFilename}</span>
</button>
<button type="button" disabled={assetBusy} aria-disabled={inUse || undefined} title={inUse ? usage : tr("Supprimer de la bibliothèque", "Delete from library")} aria-label={inUse ? `${tr("Suppression impossible", "Cannot delete")}: ${usage}` : `${tr("Supprimer", "Delete")} ${asset.originalFilename}`} onClick={() => inUse ? setAssetError(`${tr("Suppression impossible", "Cannot delete")}: ${usage}.`) : void deleteIcon(asset)} className="absolute right-1 top-1 rounded bg-surface/90 px-1.5 py-0.5 text-xs text-muted hover:text-content disabled:cursor-not-allowed disabled:opacity-50">×</button>
</div>;
})}
</div> : null}
</section>
<section aria-labelledby="project-icon-emoji-heading">
<h4 id="project-icon-emoji-heading" className="sticky top-0 z-10 mb-2 bg-surface py-1 text-xs font-semibold uppercase tracking-wide text-muted">Emojis</h4>
<div className="space-y-4">{groupedResults.map(({ group, entries }) => <div key={group} role="group" aria-labelledby={`project-icon-group-${group}`}>
<h5 id={`project-icon-group-${group}`} className="mb-2 text-xs font-semibold text-muted">{t(GROUP_LABELS[group])}</h5>
<div className="grid grid-cols-4 gap-2 sm:grid-cols-6">{entries.map((entry) => {
const active = selected?.set === entry.set && selected.name === entry.name;
return <button key={`${entry.set}:${entry.name}`} type="button" role="option" aria-selected={active} title={entry.label} onClick={() => setSelected({ set: entry.set, name: entry.name })} className={`flex min-h-20 flex-col items-center justify-center gap-1 rounded border p-2 text-xs ${active ? "border-primary bg-primary/15 text-content" : "border-border bg-raised text-muted hover:text-content"}`}>
<ProjectIcon icon={entry} size={32} decorative /><span className="line-clamp-2">{entry.label}</span>
</button>;
})}</div>
</div>)}</div>
</section>
</div>
{results.length === 0 && <p role="status" className="text-sm text-muted">{t("project.icon.noResults")}</p>}
{assetError ? <p role="alert" className="text-sm text-danger">{assetError}</p> : null}
{results.length === 0 && visibleCustomAssets.length === 0 ? <p role="status" className="text-sm text-muted">{t("project.icon.noResults")}</p> : null}
<div className="flex flex-wrap justify-between gap-2">
<Button variant="ghost" disabled={!selected} onClick={() => setSelected(null)}>{t("project.icon.remove")}</Button>
<div className="flex gap-2"><Button variant="ghost" onClick={onCancel}>{t("common.cancel")}</Button><Button onClick={() => onApply(selected)}>{t("common.apply")}</Button></div>
<div className="flex gap-2"><Button variant="ghost" onClick={onCancel}>{t("common.cancel")}</Button><Button disabled={assetBusy} onClick={() => onApply(selected)}>{t("common.apply")}</Button></div>
</div>
</div>
</div>

View File

@ -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(<DIProvider gateways={gateways}>{ui}</DIProvider>);
}
function renderPicker(value = null as Parameters<typeof ProjectIconPicker>[0]["value"]) {
render(<ProjectIconPicker value={value} onApply={vi.fn()} onCancel={vi.fn()} />);
renderWithGateways(<ProjectIconPicker value={value} onApply={vi.fn()} onCancel={vi.fn()} />);
}
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(<ProjectIconPicker value={null} onApply={onApply} onCancel={vi.fn()} />);
renderWithGateways(<ProjectIconPicker value={null} onApply={onApply} onCancel={vi.fn()} />);
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(<ProjectIconPicker value={{ set: "devicon", name: "python" }} onApply={onApply} onCancel={vi.fn()} />);
renderWithGateways(<ProjectIconPicker value={{ set: "devicon", name: "python" }} onApply={onApply} onCancel={vi.fn()} />);
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(<ProjectIconPicker value={null} onApply={onApply} onCancel={vi.fn()} />, 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(<ProjectIconPicker value={null} onApply={vi.fn()} onCancel={vi.fn()} />, 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(
<DIProvider gateways={gateways}><ProjectIcon icon={{ set: "custom", name: "asset-1" }} /></DIProvider>,
);
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(<DIProvider gateways={gateways}><ProjectIcon icon={{ set: "custom", name: "missing" }} fallbackLabel="Alpha" /></DIProvider>);
await waitFor(() => expect(getByRole("img").dataset.resolvedProjectIcon).toBe("initial"));
expect(getByRole("img").textContent).toBe("A");
});
});

View File

@ -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<HTMLAttributes<HTMLSpanElement>, "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<string | null>(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 ? (
<img
src={customUrl}
alt=""
aria-hidden="true"
onError={() => setCustomMissing(true)}
style={{ width: "100%", height: "100%", objectFit: "contain", display: "block" }}
/>
) : glyph}
</span>
);
}

View File

@ -495,13 +495,35 @@ export interface UserSettingsGateway {
updateUserSettings(settings: Partial<UserSettings>): Promise<UserSettings>;
}
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<CustomProjectIconAsset[]>;
import(file: File): Promise<CustomProjectIconAsset>;
delete(assetId: string): Promise<void>;
/** Returns a browser-loadable URL without exposing a local filesystem path. */
getUrl(assetId: string): Promise<string>;
}
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;