use crate::common::image_utils::ImageUtils;
use crate::host::cache_level::CacheLevel;
use crate::host::error::HostApiError;
use crate::services::icon::icon_cache::IconCacheService;
use crate::services::icon_request::IconRequest;
use async_trait::async_trait;
#[async_trait]
pub trait IconExtractor: Send + Sync {
async fn extract_from_path(&self, path: &str) -> Result<Vec<u8>, HostApiError>;
async fn extract_from_url(&self, url: &str) -> Result<Vec<u8>, HostApiError>;
async fn extract_from_extension(&self, ext: &str) -> Result<Vec<u8>, HostApiError>;
fn default_app_icon_path(&self) -> &str;
fn default_web_icon_path(&self) -> &str;
fn is_network_available(&self) -> bool;
async fn extract(&self, request: &IconRequest) -> Result<Vec<u8>, HostApiError> {
match request {
IconRequest::Path(p) => self.extract_from_path(p).await,
IconRequest::Url(u) => self.extract_from_url(u).await,
IconRequest::Extension(e) => self.extract_from_extension(e).await,
IconRequest::Data(data) => decode_data_url(data),
}
}
async fn extract_and_process(&self, request: &IconRequest) -> Result<Vec<u8>, HostApiError> {
const MAX_ICON_SIZE: u32 = 128;
let data = self.extract(request).await?;
let data = if is_raster_icon(&data) {
data
} else {
ImageUtils::convert_image_to_png(data.clone())
.await
.unwrap_or(data)
};
let trimmed = ImageUtils::trim_transparent_white_border(data.clone()).unwrap_or(data);
let resized = ImageUtils::resize_image(trimmed.clone(), MAX_ICON_SIZE, MAX_ICON_SIZE)
.await
.unwrap_or(trimmed);
Ok(ImageUtils::to_webp(resized.clone()).unwrap_or(resized))
}
async fn load_default_icon(&self, request: &IconRequest) -> Vec<u8> {
let default_path = match request {
IconRequest::Url(_) => self.default_web_icon_path(),
_ => self.default_app_icon_path(),
};
let png = tokio::fs::read(default_path).await.unwrap_or_default();
if png.is_empty() {
return png;
}
ImageUtils::to_webp(png.clone()).unwrap_or(png)
}
async fn get_icon(
&self,
cache: &IconCacheService,
request: &IconRequest,
level: CacheLevel,
) -> Result<Vec<u8>, HostApiError> {
let hash_key = request.get_hash_string() + ".webp";
if level != CacheLevel::SkipAll {
if level == CacheLevel::Full {
if let Some(data) = cache.get_l1(&hash_key) {
if is_raster_icon(&data) {
return Ok(data);
}
}
}
if cache.contains_l2(&hash_key) {
if let Some(data) = cache.get_l2(&hash_key).await {
if !is_raster_icon(&data) {
return extract_and_cache(self, cache, request, level, &hash_key).await;
}
if level == CacheLevel::Full {
cache.set_l1(&hash_key, data.clone());
}
return Ok(data);
}
}
}
extract_and_cache(self, cache, request, level, &hash_key).await
}
async fn get_icon_and_update_cache(
&self,
cache: &IconCacheService,
request: &IconRequest,
level: CacheLevel,
) -> Result<Vec<u8>, HostApiError> {
let hash_key = request.get_hash_string() + ".webp";
let data = self.extract_and_process(request).await?;
write_back_cache(cache, &hash_key, &data, level).await;
Ok(data)
}
}
async fn write_back_cache(
cache: &IconCacheService,
hash_key: &str,
icon_data: &[u8],
level: CacheLevel,
) {
if level == CacheLevel::Full {
cache.set_l1(hash_key, icon_data.to_vec());
}
if level == CacheLevel::Full || level == CacheLevel::SkipMemory {
cache.set_l2(hash_key, icon_data.to_vec()).await;
}
}
async fn extract_and_cache<E: IconExtractor + ?Sized>(
extractor: &E,
cache: &IconCacheService,
request: &IconRequest,
level: CacheLevel,
hash_key: &str,
) -> Result<Vec<u8>, HostApiError> {
let data = match extractor.extract_and_process(request).await {
Ok(d) if !d.is_empty() => d,
_ => return Ok(extractor.load_default_icon(request).await),
};
write_back_cache(cache, hash_key, &data, level).await;
Ok(data)
}
fn is_raster_icon(data: &[u8]) -> bool {
image::guess_format(data).is_ok()
}
fn decode_data_url(data: &str) -> Result<Vec<u8>, HostApiError> {
let payload = data.rsplit_once(";base64,").map(|(_, p)| p).unwrap_or(data);
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, payload).map_err(|e| {
HostApiError::IconExtractionFailed {
request: "data".to_string(),
reason: format!("data URL 解码失败: {}", e),
}
})
}
#[cfg(test)]
mod tests {
use super::*;
const SVG_SOURCE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#2f7fd8"/></svg>"##;
fn svg_data_url() -> String {
use base64::Engine;
format!(
"data:image/svg+xml;base64,{}",
base64::engine::general_purpose::STANDARD.encode(SVG_SOURCE)
)
}
struct StubExtractor;
#[async_trait]
impl IconExtractor for StubExtractor {
async fn extract_from_path(&self, _path: &str) -> Result<Vec<u8>, HostApiError> {
Err(HostApiError::IconExtractionFailed {
request: "path".to_string(),
reason: "stub".to_string(),
})
}
async fn extract_from_url(&self, _url: &str) -> Result<Vec<u8>, HostApiError> {
Err(HostApiError::IconExtractionFailed {
request: "url".to_string(),
reason: "stub".to_string(),
})
}
async fn extract_from_extension(&self, _ext: &str) -> Result<Vec<u8>, HostApiError> {
Err(HostApiError::IconExtractionFailed {
request: "extension".to_string(),
reason: "stub".to_string(),
})
}
fn default_app_icon_path(&self) -> &str {
""
}
fn default_web_icon_path(&self) -> &str {
""
}
fn is_network_available(&self) -> bool {
false
}
}
#[tokio::test]
async fn svg_icon_is_rasterized_to_bitmap() {
let processed = StubExtractor
.extract_and_process(&IconRequest::Data(svg_data_url()))
.await
.expect("SVG 图标应能完成提取与后处理");
let format = image::guess_format(&processed).expect("处理结果必须是可识别位图");
assert!(
matches!(format, image::ImageFormat::WebP | image::ImageFormat::Png),
"SVG 应被光栅化,实际格式: {format:?}"
);
}
#[tokio::test]
async fn non_raster_cache_entry_is_re_extracted() {
let dir = tempfile::tempdir().expect("创建临时缓存目录");
let cache = IconCacheService::new(dir.path().to_string_lossy().into_owned());
cache.init();
let request = IconRequest::Data(svg_data_url());
cache
.set_l2(
&(request.get_hash_string() + ".webp"),
SVG_SOURCE.as_bytes().to_vec(),
)
.await;
let data = StubExtractor
.get_icon(&cache, &request, CacheLevel::Full)
.await
.expect("应忽略陈旧条目并重新提取");
assert!(
image::guess_format(&data).is_ok(),
"返回内容必须是可识别位图,而不是缓存中的 SVG 文本"
);
}
}