zerolaunch-plugin-api 0.2.7

ZeroLaunch plugin SDK — traits, data types, host API surface.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
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;

/// 图标请求对应的缓存文件名(`<请求哈希>.webp`)。
/// 图标字节缓存与失败记录共用该键,`.webp` 后缀只在此处定义。
/// 参数:request - 图标请求。
/// 返回:缓存文件名。
pub(crate) fn cache_file_name(request: &IconRequest) -> String {
    request.get_hash_string() + ".webp"
}

/// 图标提取器 trait,定义平台原语与跨平台业务默认实现。
/// 平台实现者只需实现 6 个原语方法,业务逻辑由默认实现提供。
/// 默认实现可按需覆盖。
#[async_trait]
pub trait IconExtractor: Send + Sync {
    // ===== 平台原语(各平台必须实现)=====

    /// 从本地文件路径提取图标,返回 PNG 格式字节数据。
    /// 参数:path - 文件路径(exe, lnk, url, ico, png 等)。
    /// 返回:PNG 格式图标字节数据,失败返回 HostApiError。
    async fn extract_from_path(&self, path: &str) -> Result<Vec<u8>, HostApiError>;

    /// 从网址提取图标(favicon),返回 PNG 格式字节数据。
    /// 参数:url - 网址。
    /// 返回:PNG 格式图标字节数据,失败返回 HostApiError。
    async fn extract_from_url(&self, url: &str) -> Result<Vec<u8>, HostApiError>;

    /// 从文件扩展名提取系统关联图标,返回 PNG 格式字节数据。
    /// 参数:ext - 文件扩展名(如 ".txt", ".doc")。
    /// 返回:PNG 格式图标字节数据,失败返回 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;

    /// 检测当前平台网络是否可用。
    /// 参数:无。
    /// 返回:网络可用返回 true。
    fn is_network_available(&self) -> bool;

    // ===== 跨平台业务逻辑(默认实现)=====

    /// 根据 IconRequest 提取原始图标数据。
    /// 默认实现根据请求类型分发到对应的平台原语方法。
    /// 参数:request - 图标请求。
    /// 返回:PNG 格式图标字节数据,失败返回 HostApiError。
    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),
        }
    }

    /// 提取图标并应用后处理:非位图先光栅化 → 裁剪白边 → 等比缩放到 128×128 上限 → WebP 无损编码(VP8L)。
    /// 参数:request - 图标请求。
    /// 返回:处理后的 WebP 格式图标字节数据(失败回退位图原始字节,消费方按字节头嗅探),提取失败返回 HostApiError。
    async fn extract_and_process(&self, request: &IconRequest) -> Result<Vec<u8>, HostApiError> {
        const MAX_ICON_SIZE: u32 = 128;
        let data = self.extract(request).await?;
        // 0. 非位图输入(插件图标常以 SVG 矢量提供)先光栅化为 PNG
        //    后续裁剪/缩放/编码都是位图链路,矢量字节会在每一步失败并原样透出,
        //    最终按位图 MIME 编码的 data URL 消费方无法解码(图标显示为破图)。
        let data = if is_raster_icon(&data) {
            data
        } else {
            ImageUtils::convert_image_to_png(data.clone())
                .await
                .unwrap_or(data)
        };
        // 1. 裁剪透明/白边(失败回退原数据)
        let trimmed = ImageUtils::trim_transparent_white_border(data.clone()).unwrap_or(data);
        // 2. 超过 128×128 时等比缩放到 128(失败回退裁剪产物)
        let resized = ImageUtils::resize_image(trimmed.clone(), MAX_ICON_SIZE, MAX_ICON_SIZE)
            .await
            .unwrap_or(trimmed);
        // 3. WebP 无损编码(失败回退原始字节,MIME 侧按字节头嗅探兜底)
        Ok(ImageUtils::to_webp(resized.clone()).unwrap_or(resized))
    }

    /// 加载默认图标。
    /// 默认实现:URL 类型加载默认网址图标,其他类型加载默认应用图标。
    /// 参数:request - 图标请求(用于判断类型)。
    /// 返回:默认图标的 WebP 字节数据,读取失败返回空 Vec。
    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;
        }
        // 与提取路径一致编码为 WebP,统一返回字节格式
        ImageUtils::to_webp(png.clone()).unwrap_or(png)
    }

    /// 完整的图标获取流程,包含缓存策略。
    /// 默认实现按 CacheLevel 决定缓存读写与提取:
    /// Full → L1 → L2 → 提取 → 写回 L1+L2;SkipMemory → L2 → 提取 → 写回 L2;
    /// SkipAll → 直接提取、不读写缓存;CacheOnly → 只读 L1 → L2(命中回填 L1),不提取。
    /// 缓存命中会校验内容为可识别位图,历史版本写入的非位图条目按未命中处理。
    /// 参数:cache - 图标缓存服务;request - 图标请求;level - 缓存等级。
    /// 返回:WebP 格式图标字节数据(回退路径为原始字节,消费方按字节头嗅探 MIME)。
    ///       CacheOnly 未命中返回 HostApiError;提取链路失败回退默认图标(不报错)。
    async fn get_icon(
        &self,
        cache: &IconCacheService,
        request: &IconRequest,
        level: CacheLevel,
    ) -> Result<Vec<u8>, HostApiError> {
        let hash_key = cache_file_name(request);
        // 读 L1 与写回 L1 的等级(SkipMemory 只用文件缓存,SkipAll 完全不碰缓存)。
        let uses_l1 = matches!(level, CacheLevel::Full | CacheLevel::CacheOnly);

        // L1 查询
        if uses_l1 {
            if let Some(data) = cache.get_l1(&hash_key) {
                if is_raster_icon(&data) {
                    return Ok(data);
                }
            }
        }

        // L2 查询:命中回填 L1,供后续查询免磁盘读
        if level != CacheLevel::SkipAll && cache.contains_l2(&hash_key) {
            if let Some(data) = cache.get_l2(&hash_key).await {
                if is_raster_icon(&data) {
                    if uses_l1 {
                        cache.set_l1(&hash_key, data.clone());
                    }
                    return Ok(data);
                }
                // 非位图内容视为未命中;非只读等级走提取链路覆盖写回
                if level != CacheLevel::CacheOnly {
                    return extract_and_cache(self, cache, request, level, &hash_key).await;
                }
            }
        }

        // 只读等级到此即未命中:调用方据此回退占位图标并异步补齐
        if level == CacheLevel::CacheOnly {
            return Err(HostApiError::IconExtractionFailed {
                request: request.get_hash_string(),
                reason: "图标缓存未命中(CacheOnly 不触发提取)".to_string(),
            });
        }

        extract_and_cache(self, cache, request, level, &hash_key).await
    }

    /// 强制从磁盘提取图标并更新缓存(跳过缓存读取)。
    /// 默认实现:直接提取 → 写回缓存。
    /// 参数:cache - 图标缓存服务;request - 图标请求;level - 缓存等级。
    /// 返回:WebP 格式图标字节数据,提取失败返回 HostApiError。
    async fn get_icon_and_update_cache(
        &self,
        cache: &IconCacheService,
        request: &IconRequest,
        level: CacheLevel,
    ) -> Result<Vec<u8>, HostApiError> {
        let hash_key = cache_file_name(request);
        let data = self.extract_and_process(request).await?;
        cache.clear_failed(&hash_key);
        write_back_cache(cache, &hash_key, &data, level).await;
        Ok(data)
    }
}

/// 根据缓存等级将图标数据写回缓存。
/// Full: 写入 L1 + L2;SkipMemory: 只写 L2;SkipAll: 不写。
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;
    }
}

/// 记录一次图标提取失败;整机离线时不记录,避免离线启动把所有图标标成失败。
/// 参数:extractor - 图标提取器;cache - 图标缓存服务;hash_key - 缓存键。
/// 返回:无。
fn record_failure<E: IconExtractor + ?Sized>(
    extractor: &E,
    cache: &IconCacheService,
    hash_key: &str,
) {
    if extractor.is_network_available() {
        cache.mark_failed(hash_key);
    }
}

/// 提取并后处理图标,成功后按缓存等级写回;失败或结果为空时回退默认图标。
/// 参数:extractor - 图标提取器;cache - 图标缓存服务;request - 图标请求;level - 缓存等级;hash_key - 缓存键。
/// 返回:图标字节数据(WebP,回退路径为位图原始字节)。
async fn extract_and_cache<E: IconExtractor + ?Sized>(
    extractor: &E,
    cache: &IconCacheService,
    request: &IconRequest,
    level: CacheLevel,
    hash_key: &str,
) -> Result<Vec<u8>, HostApiError> {
    // 命中失败记录直接回退默认图标:不再尝试提取,由设置页「全部重试」清除记录。
    if cache.should_skip(hash_key) {
        return Ok(extractor.load_default_icon(request).await);
    }

    let data = match extractor.extract_and_process(request).await {
        Ok(data) if !data.is_empty() => data,
        Ok(_) => {
            record_failure(extractor, cache, hash_key);
            return Ok(extractor.load_default_icon(request).await);
        }
        Err(_) => {
            record_failure(extractor, cache, hash_key);
            return Ok(extractor.load_default_icon(request).await);
        }
    };

    cache.clear_failed(hash_key);
    write_back_cache(cache, hash_key, &data, level).await;

    Ok(data)
}

/// 判断图标字节是否为可识别位图(image crate 的字节头嗅探)。
/// 缓存键固定以 .webp 结尾,但历史版本可能写入未光栅化的 SVG 等非位图字节,
/// 命中此类条目必须按未命中处理并重新提取。
/// 参数:data - 图标字节数据。
/// 返回:true 表示是可识别位图。
fn is_raster_icon(data: &[u8]) -> bool {
    image::guess_format(data).is_ok()
}

/// 解码 data URL 或纯 base64 为原始字节(IconRequest::Data 直通路径)。
/// 参数:data - "data:<mime>;base64,<payload>" 形式或纯 base64 字符串。
/// 返回:解码后的字节;格式不合法返回 HostApiError。
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::*;
    use std::sync::atomic::{AtomicUsize, Ordering};

    /// 最小可渲染 SVG(第三方插件图标常见形态)。
    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>"##;

    /// 以 data URL 形式提供 SVG,与插件候选图标下发形态一致。
    fn svg_data_url() -> String {
        use base64::Engine;
        format!(
            "data:image/svg+xml;base64,{}",
            base64::engine::general_purpose::STANDARD.encode(SVG_SOURCE)
        )
    }

    /// 最小提取器:平台原语不可用,图标只经 IconRequest::Data 直通。
    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
        }
    }

    /// SVG 图标必须光栅化为位图,否则 data URL 只能按位图 MIME 标注 → 消费方解码失败(破图)。
    #[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:?}"
        );
    }

    /// 历史版本可能把非位图字节写进 .webp 缓存条目,命中后必须重新提取而非直接返回。
    #[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(&cache_file_name(&request), 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 文本"
        );
    }

    /// 计数提取器:提取固定失败,用于验证失败记录跳过与重试。
    ///
    /// 仅限本测试模块使用:`calls` 统计提取次数,`network` 控制失败是否记账。
    struct CountingExtractor {
        /// 提取调用次数。
        calls: AtomicUsize,
        /// 网络是否可用;false 时失败不写入失败记录。
        network: bool,
    }

    #[async_trait]
    impl IconExtractor for CountingExtractor {
        async fn extract_from_path(&self, path: &str) -> Result<Vec<u8>, HostApiError> {
            self.calls.fetch_add(1, Ordering::SeqCst);
            Err(HostApiError::IconExtractionFailed {
                request: path.to_string(),
                reason: "stub".to_string(),
            })
        }

        async fn extract_from_url(&self, url: &str) -> Result<Vec<u8>, HostApiError> {
            self.calls.fetch_add(1, Ordering::SeqCst);
            Err(HostApiError::IconExtractionFailed {
                request: url.to_string(),
                reason: "dns error: test".to_string(),
            })
        }

        async fn extract_from_extension(&self, ext: &str) -> Result<Vec<u8>, HostApiError> {
            Err(HostApiError::IconExtractionFailed {
                request: ext.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 {
            self.network
        }
    }

    /// 失败记录必须落盘:重启后同一请求直接跳过,不再调用提取。
    #[tokio::test]
    async fn failure_record_persists_and_skips_retry() {
        let dir = tempfile::tempdir().expect("创建临时缓存目录");
        let cache_dir = dir.path().to_string_lossy().into_owned();
        let request = IconRequest::Url("https://example.invalid/".to_string());
        let key = cache_file_name(&request);

        let cache = IconCacheService::new(cache_dir.clone());
        cache.init();
        let extractor = CountingExtractor {
            calls: AtomicUsize::new(0),
            network: true,
        };
        let data = extractor
            .get_icon(&cache, &request, CacheLevel::Full)
            .await
            .expect("提取失败应回退默认图标而不是返回错误");
        assert!(data.is_empty(), "默认图标路径为空时回退空数据");
        assert_eq!(extractor.calls.load(Ordering::SeqCst), 1);
        assert!(cache.should_skip(&key), "失败后应写入失败记录");

        // 模拟重启:新服务从同一目录读回失败记录,不再调用提取。
        let cache = IconCacheService::new(cache_dir);
        cache.init();
        let extractor = CountingExtractor {
            calls: AtomicUsize::new(0),
            network: true,
        };
        let _ = extractor
            .get_icon(&cache, &request, CacheLevel::Full)
            .await
            .expect("命中失败记录应直接回退默认图标");
        assert_eq!(
            extractor.calls.load(Ordering::SeqCst),
            0,
            "已记录失败的请求不应再次联网抓取"
        );
    }

    /// 清空失败记录后必须重新尝试,并再次记录失败。
    #[tokio::test]
    async fn clearing_failure_allows_retry() {
        let dir = tempfile::tempdir().expect("创建临时缓存目录");
        let cache = IconCacheService::new(dir.path().to_string_lossy().into_owned());
        cache.init();
        let request = IconRequest::Url("https://example.invalid/".to_string());
        let key = cache_file_name(&request);
        let extractor = CountingExtractor {
            calls: AtomicUsize::new(0),
            network: true,
        };

        let _ = extractor
            .get_icon(&cache, &request, CacheLevel::Full)
            .await
            .expect("首次尝试应回退默认图标");
        assert_eq!(extractor.calls.load(Ordering::SeqCst), 1);

        cache.clear_all_failed();
        let _ = extractor
            .get_icon(&cache, &request, CacheLevel::Full)
            .await
            .expect("清空记录后应重新尝试");
        assert_eq!(extractor.calls.load(Ordering::SeqCst), 2);
        assert!(cache.should_skip(&key), "重试失败后应重新记录");
    }

    /// 离线时的失败不记账:避免断网启动把所有图标永久标记为失败。
    #[tokio::test]
    async fn offline_failure_is_not_recorded() {
        let dir = tempfile::tempdir().expect("创建临时缓存目录");
        let cache = IconCacheService::new(dir.path().to_string_lossy().into_owned());
        cache.init();
        let request = IconRequest::Url("https://example.invalid/".to_string());
        let key = cache_file_name(&request);
        let extractor = CountingExtractor {
            calls: AtomicUsize::new(0),
            network: false,
        };

        let _ = extractor
            .get_icon(&cache, &request, CacheLevel::Full)
            .await
            .expect("离线失败应回退默认图标");
        assert!(!cache.should_skip(&key), "离线失败不应写入失败记录");
    }

    /// 非网址图标(文件路径)失败同样记账并跳过。
    #[tokio::test]
    async fn path_failure_is_recorded_and_skipped() {
        let dir = tempfile::tempdir().expect("创建临时缓存目录");
        let cache = IconCacheService::new(dir.path().to_string_lossy().into_owned());
        cache.init();
        let request = IconRequest::Path("C:/not-exists/app.exe".to_string());
        let key = cache_file_name(&request);
        let extractor = CountingExtractor {
            calls: AtomicUsize::new(0),
            network: true,
        };

        let _ = extractor
            .get_icon(&cache, &request, CacheLevel::Full)
            .await
            .expect("提取失败应回退默认图标");
        assert_eq!(extractor.calls.load(Ordering::SeqCst), 1);
        assert!(cache.should_skip(&key), "文件路径失败也应写入失败记录");

        let _ = extractor
            .get_icon(&cache, &request, CacheLevel::Full)
            .await
            .expect("命中失败记录应直接回退默认图标");
        assert_eq!(
            extractor.calls.load(Ordering::SeqCst),
            1,
            "已记录失败的路径不应再次提取"
        );
    }
}