zerolaunch_plugin_api/services/icon/
icon_extractor.rs1use crate::common::image_utils::ImageUtils;
2use crate::host::cache_level::CacheLevel;
3use crate::host::error::HostApiError;
4use crate::services::icon::icon_cache::IconCacheService;
5use crate::services::icon_request::IconRequest;
6use async_trait::async_trait;
7
8#[async_trait]
12pub trait IconExtractor: Send + Sync {
13 async fn extract_from_path(&self, path: &str) -> Result<Vec<u8>, HostApiError>;
19
20 async fn extract_from_url(&self, url: &str) -> Result<Vec<u8>, HostApiError>;
24
25 async fn extract_from_extension(&self, ext: &str) -> Result<Vec<u8>, HostApiError>;
29
30 fn default_app_icon_path(&self) -> &str;
34
35 fn default_web_icon_path(&self) -> &str;
39
40 fn is_network_available(&self) -> bool;
44
45 async fn extract(&self, request: &IconRequest) -> Result<Vec<u8>, HostApiError> {
52 match request {
53 IconRequest::Path(p) => self.extract_from_path(p).await,
54 IconRequest::Url(u) => self.extract_from_url(u).await,
55 IconRequest::Extension(e) => self.extract_from_extension(e).await,
56 IconRequest::Data(data) => decode_data_url(data),
57 }
58 }
59
60 async fn extract_and_process(&self, request: &IconRequest) -> Result<Vec<u8>, HostApiError> {
64 const MAX_ICON_SIZE: u32 = 128;
65 let data = self.extract(request).await?;
66 let data = if is_raster_icon(&data) {
70 data
71 } else {
72 ImageUtils::convert_image_to_png(data.clone())
73 .await
74 .unwrap_or(data)
75 };
76 let trimmed = ImageUtils::trim_transparent_white_border(data.clone()).unwrap_or(data);
78 let resized = ImageUtils::resize_image(trimmed.clone(), MAX_ICON_SIZE, MAX_ICON_SIZE)
80 .await
81 .unwrap_or(trimmed);
82 Ok(ImageUtils::to_webp(resized.clone()).unwrap_or(resized))
84 }
85
86 async fn load_default_icon(&self, request: &IconRequest) -> Vec<u8> {
91 let default_path = match request {
92 IconRequest::Url(_) => self.default_web_icon_path(),
93 _ => self.default_app_icon_path(),
94 };
95 let png = tokio::fs::read(default_path).await.unwrap_or_default();
96 if png.is_empty() {
97 return png;
98 }
99 ImageUtils::to_webp(png.clone()).unwrap_or(png)
101 }
102
103 async fn get_icon(
109 &self,
110 cache: &IconCacheService,
111 request: &IconRequest,
112 level: CacheLevel,
113 ) -> Result<Vec<u8>, HostApiError> {
114 let hash_key = request.get_hash_string() + ".webp";
115
116 if level != CacheLevel::SkipAll {
118 if level == CacheLevel::Full {
120 if let Some(data) = cache.get_l1(&hash_key) {
121 if is_raster_icon(&data) {
122 return Ok(data);
123 }
124 }
125 }
126
127 if cache.contains_l2(&hash_key) {
129 if let Some(data) = cache.get_l2(&hash_key).await {
130 if !is_raster_icon(&data) {
131 return extract_and_cache(self, cache, request, level, &hash_key).await;
133 }
134 if level == CacheLevel::Full {
136 cache.set_l1(&hash_key, data.clone());
137 }
138 return Ok(data);
139 }
140 }
141 }
142
143 extract_and_cache(self, cache, request, level, &hash_key).await
144 }
145
146 async fn get_icon_and_update_cache(
151 &self,
152 cache: &IconCacheService,
153 request: &IconRequest,
154 level: CacheLevel,
155 ) -> Result<Vec<u8>, HostApiError> {
156 let hash_key = request.get_hash_string() + ".webp";
157 let data = self.extract_and_process(request).await?;
158 write_back_cache(cache, &hash_key, &data, level).await;
159 Ok(data)
160 }
161}
162
163async fn write_back_cache(
166 cache: &IconCacheService,
167 hash_key: &str,
168 icon_data: &[u8],
169 level: CacheLevel,
170) {
171 if level == CacheLevel::Full {
172 cache.set_l1(hash_key, icon_data.to_vec());
173 }
174
175 if level == CacheLevel::Full || level == CacheLevel::SkipMemory {
176 cache.set_l2(hash_key, icon_data.to_vec()).await;
177 }
178}
179
180async fn extract_and_cache<E: IconExtractor + ?Sized>(
184 extractor: &E,
185 cache: &IconCacheService,
186 request: &IconRequest,
187 level: CacheLevel,
188 hash_key: &str,
189) -> Result<Vec<u8>, HostApiError> {
190 let data = match extractor.extract_and_process(request).await {
191 Ok(d) if !d.is_empty() => d,
192 _ => return Ok(extractor.load_default_icon(request).await),
193 };
194
195 write_back_cache(cache, hash_key, &data, level).await;
196
197 Ok(data)
198}
199
200fn is_raster_icon(data: &[u8]) -> bool {
206 image::guess_format(data).is_ok()
207}
208
209fn decode_data_url(data: &str) -> Result<Vec<u8>, HostApiError> {
213 let payload = data.rsplit_once(";base64,").map(|(_, p)| p).unwrap_or(data);
214 base64::Engine::decode(&base64::engine::general_purpose::STANDARD, payload).map_err(|e| {
215 HostApiError::IconExtractionFailed {
216 request: "data".to_string(),
217 reason: format!("data URL 解码失败: {}", e),
218 }
219 })
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 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>"##;
228
229 fn svg_data_url() -> String {
231 use base64::Engine;
232 format!(
233 "data:image/svg+xml;base64,{}",
234 base64::engine::general_purpose::STANDARD.encode(SVG_SOURCE)
235 )
236 }
237
238 struct StubExtractor;
240
241 #[async_trait]
242 impl IconExtractor for StubExtractor {
243 async fn extract_from_path(&self, _path: &str) -> Result<Vec<u8>, HostApiError> {
244 Err(HostApiError::IconExtractionFailed {
245 request: "path".to_string(),
246 reason: "stub".to_string(),
247 })
248 }
249
250 async fn extract_from_url(&self, _url: &str) -> Result<Vec<u8>, HostApiError> {
251 Err(HostApiError::IconExtractionFailed {
252 request: "url".to_string(),
253 reason: "stub".to_string(),
254 })
255 }
256
257 async fn extract_from_extension(&self, _ext: &str) -> Result<Vec<u8>, HostApiError> {
258 Err(HostApiError::IconExtractionFailed {
259 request: "extension".to_string(),
260 reason: "stub".to_string(),
261 })
262 }
263
264 fn default_app_icon_path(&self) -> &str {
265 ""
266 }
267
268 fn default_web_icon_path(&self) -> &str {
269 ""
270 }
271
272 fn is_network_available(&self) -> bool {
273 false
274 }
275 }
276
277 #[tokio::test]
279 async fn svg_icon_is_rasterized_to_bitmap() {
280 let processed = StubExtractor
281 .extract_and_process(&IconRequest::Data(svg_data_url()))
282 .await
283 .expect("SVG 图标应能完成提取与后处理");
284
285 let format = image::guess_format(&processed).expect("处理结果必须是可识别位图");
286 assert!(
287 matches!(format, image::ImageFormat::WebP | image::ImageFormat::Png),
288 "SVG 应被光栅化,实际格式: {format:?}"
289 );
290 }
291
292 #[tokio::test]
294 async fn non_raster_cache_entry_is_re_extracted() {
295 let dir = tempfile::tempdir().expect("创建临时缓存目录");
296 let cache = IconCacheService::new(dir.path().to_string_lossy().into_owned());
297 cache.init();
298
299 let request = IconRequest::Data(svg_data_url());
300 cache
301 .set_l2(
302 &(request.get_hash_string() + ".webp"),
303 SVG_SOURCE.as_bytes().to_vec(),
304 )
305 .await;
306
307 let data = StubExtractor
308 .get_icon(&cache, &request, CacheLevel::Full)
309 .await
310 .expect("应忽略陈旧条目并重新提取");
311
312 assert!(
313 image::guess_format(&data).is_ok(),
314 "返回内容必须是可识别位图,而不是缓存中的 SVG 文本"
315 );
316 }
317}