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

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

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

View File

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