diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index db696605b..265965a33 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -2564,9 +2564,14 @@ const requiredRustHostSnippets = [ '"minimized"', 'WindowEvent::DragDrop', 'first_valid_desktop_image_drop_payload', - '.filter(|path| path.is_file() && import_image_mime_type(path).is_some())', - '.find_map(|path| import_image_file_payload(path.clone(), "dropped", Some(position)).ok())', + 'if !path.is_file() || import_image_mime_type(path).is_none()', + 'match import_image_file_payload(path.clone(), "dropped", Some(position))', + 'import_image_file_payload(path.clone(), "dropped", Some(position))', + 'log_desktop_image_drop_payload_failure(&error)', + 'file.imageDropped.payload', 'fn image_drop_without_valid_image_does_not_emit_payload()', + 'fn image_drop_logs_invalid_candidate_then_uses_next_valid_image()', + 'fn image_drop_payload_failure_reports_failure()', 'fs::create_dir_all(&directory_path)', 'assert_eq!(payload, None);', 'PageLoadEvent', @@ -3206,6 +3211,7 @@ for (const blockedLifecycleSnippet of [ 'let _ = emit_current_desktop_lifecycle_event(window)', 'let _ = emit_current_desktop_lifecycle_event(&lifecycle_window)', 'let _ = emit_desktop_image_drop_event(&drop_window', + '.find_map(|path| import_image_file_payload(path.clone(), "dropped", Some(position)).ok())', 'window.is_visible().unwrap_or(', 'window.is_minimized().unwrap_or(', 'window.is_focused().unwrap_or(', diff --git a/apps/desktop-shell/src-tauri/src/shell/file_drop.rs b/apps/desktop-shell/src-tauri/src/shell/file_drop.rs index 7bfe31c79..3075c7e80 100644 --- a/apps/desktop-shell/src-tauri/src/shell/file_drop.rs +++ b/apps/desktop-shell/src-tauri/src/shell/file_drop.rs @@ -9,10 +9,24 @@ fn first_valid_desktop_image_drop_payload( paths: &[PathBuf], position: (i32, i32), ) -> Option { - paths - .iter() - .filter(|path| path.is_file() && import_image_mime_type(path).is_some()) - .find_map(|path| import_image_file_payload(path.clone(), "dropped", Some(position)).ok()) + paths.iter().find_map(|path| { + if !path.is_file() || import_image_mime_type(path).is_none() { + return None; + } + + match import_image_file_payload(path.clone(), "dropped", Some(position)) { + Ok(payload) => Some(payload), + Err(error) => { + log_desktop_image_drop_payload_failure(&error); + None + } + } + }) +} + +fn log_desktop_image_drop_payload_failure(error: &str) -> bool { + eprintln!("desktop host event failed for file.imageDropped.payload: {error}"); + false } fn normalize_desktop_drop_position(x: f64, y: f64) -> (i32, i32) { @@ -84,6 +98,42 @@ mod tests { fs::remove_file(valid_path).expect("remove valid drop image"); } + #[test] + fn image_drop_logs_invalid_candidate_then_uses_next_valid_image() { + let disguised_path = std::env::temp_dir().join(format!( + "genarrative-desktop-drop-disguised-{}.png", + std::process::id() + )); + let valid_path = std::env::temp_dir().join(format!( + "genarrative-desktop-drop-valid-after-error-{}.png", + std::process::id() + )); + fs::write(&disguised_path, b"text").expect("write disguised drop image"); + fs::write(&valid_path, png_bytes()).expect("write valid drop image"); + + let payload = first_valid_desktop_image_drop_payload( + &[disguised_path.clone(), valid_path.clone()], + (11, 13), + ) + .expect("valid image drop payload after invalid candidate"); + + assert_eq!( + payload["fileName"], + valid_path.file_name().unwrap().to_str().unwrap() + ); + assert_eq!(payload["position"], serde_json::json!({ "x": 11, "y": 13 })); + + fs::remove_file(disguised_path).expect("remove disguised drop image"); + fs::remove_file(valid_path).expect("remove valid drop image"); + } + + #[test] + fn image_drop_payload_failure_reports_failure() { + assert!(!log_desktop_image_drop_payload_failure( + "image bytes do not match MIME", + )); + } + #[test] fn image_drop_without_valid_image_does_not_emit_payload() { let text_path = std::env::temp_dir().join(format!( diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 2134616aa..85268c508 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -32,6 +32,14 @@ - 验证方式:`npm run desktop-shell:test -- file_drop`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 - 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。 +## 2026-06-20 桌面图片拖拽候选读取失败不可静默 + +- 背景:桌面壳拖入图片时会按路径列表寻找第一个真实可导入图片;扩展名合法但内容损坏、超限或读取失败的文件如果被静默跳过,用户只会看到拖拽无反应,开发侧也缺少排障线索。 +- 决策:`first_valid_desktop_image_drop_payload(...)` 对扩展名符合图片候选但 payload 组装失败的路径必须记录 `desktop host event failed for file.imageDropped.payload`,然后继续尝试后续候选;目录、非图片扩展名或没有任何有效图片仍保持不派发 `file.imageDropped` payload。 +- 影响范围:`apps/desktop-shell/src-tauri/src/shell/file_drop.rs`、`apps/desktop-shell/scripts/check-config.mjs`、宿主壳方案文档。 +- 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml shell::file_drop`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。 + ## 2026-06-20 移动扫码权限异步取消边界 - 背景:Expo 移动壳 `scanner.scanQrCode` 会打开真实相机权限请求和扫码 overlay;如果用户在系统权限 Promise 返回前关闭扫码,旧权限结果不能重新激活 CameraView,也不能完成已经取消的 HostBridge 请求。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 455aab36a..122d315b5 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -411,7 +411,7 @@ GameBridge 禁止: - 实现 runtime、openExternalUrl、clipboard、share fallback、窗口标题同步。 - 验证 macOS / Windows / Linux 至少一条本地 smoke。 -当前状态:已新增 `apps/desktop-shell/`,Tauri dev 直接加载本地主站 Vite,release 打包根 `dist` 主站资产。Rust 侧只把 `host_bridge_request` command 授给主窗口,`appearance.getColorScheme` 由 Rust 内部读取主窗口 `theme()` 并返回 `light` / `dark` / `unknown`,不设置或覆盖系统主题;`app.lifecycle` 由主窗口 focus / blur、窗口 resize 后的状态读取、托盘隐藏 / 恢复和页面加载重放注入统一状态,不开放 Tauri event 插件给前端。桌面壳只向 H5 派发共享契约里的 `active` / `inactive` / `background`,隐藏到托盘和系统最小化都归一为 `background`,`hidden`、`minimized`、`focused`、`blurred` 只写入 `nativeState` 便于排障;H5 通过 `useHostLifecycleActive()` 统一归一窗口焦点和后台状态,WebAudio 背景音乐和拼图、抓大鹅等固定玩法 `