diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index d764828f3..d3778dc8e 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -1111,11 +1111,18 @@ function assertDesktopDialogBoundary(method, functionName, filterLabel, expected } const unavailableResponse = action === 'save' - ? 'Err(_) => return file_export_unavailable_response(request)' - : 'Err(_) => return file_import_unavailable_response(request)'; + ? 'return file_export_unavailable_response(request)' + : 'return file_import_unavailable_response(request)'; if (!fileFunctionBody.includes(unavailableResponse)) { throw new Error(`desktop shell ${method} must hide native path conversion errors`); } + const failureLogger = + action === 'save' + ? 'log_desktop_file_export_failure("path.convert", &error.to_string())' + : 'log_desktop_file_import_failure("path.convert", &error.to_string())'; + if (!fileFunctionBody.includes(failureLogger)) { + throw new Error(`desktop shell ${method} must log native path conversion errors`); + } if ( fileFunctionBody.includes( 'return failed(request.id.clone(), "host_error", error.to_string())', @@ -2399,8 +2406,22 @@ const requiredRustHostSnippets = [ 'file_import_cancelled_response', 'file_export_unavailable_response', 'file_import_unavailable_response', + 'log_desktop_file_export_failure', + 'log_desktop_file_import_failure', + 'desktop file export failed for {label}: {error}', + 'desktop file import failed for {label}: {error}', + 'log_desktop_file_export_failure("path.convert", &error.to_string())', + 'log_desktop_file_import_failure("path.convert", &error.to_string())', + 'log_desktop_file_export_failure("write.text", &error.to_string())', + 'log_desktop_file_export_failure("write.image", &error.to_string())', + 'log_desktop_file_export_failure("write.audio", &error.to_string())', + 'log_desktop_file_import_failure("read.text.join", &error.to_string())', + 'log_desktop_file_import_failure("read.document.join", &error.to_string())', + 'log_desktop_file_import_failure("read.image.join", &error.to_string())', + 'log_desktop_file_import_failure("read.audio.join", &error.to_string())', 'desktop_file_cancelled_responses_are_stable', 'desktop_file_unavailable_responses_are_stable', + 'desktop_file_failures_are_logged_without_exposing_native_detail', '"file export cancelled"', '"file import cancelled"', '"file export unavailable"', diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/files.rs b/apps/desktop-shell/src-tauri/src/host_bridge/files.rs index a18ce3496..acdf5660a 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/files.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/files.rs @@ -31,6 +31,22 @@ fn file_import_unavailable_response(request: &HostBridgeRequest) -> HostBridgeRe ) } +fn log_desktop_file_export_failure(label: &str, error: &str) -> bool { + if !error.is_empty() { + eprintln!("desktop file export failed for {label}: {error}"); + } + + false +} + +fn log_desktop_file_import_failure(label: &str, error: &str) -> bool { + if !error.is_empty() { + eprintln!("desktop file import failed for {label}: {error}"); + } + + false +} + pub(crate) async fn export_desktop_host_bridge_text_file( app: &tauri::AppHandle, request: &HostBridgeRequest, @@ -50,13 +66,23 @@ pub(crate) async fn export_desktop_host_bridge_text_file( }; let path = match file_path.into_path() { Ok(path) => path, - Err(_) => return file_export_unavailable_response(request), + Err(error) => { + log_desktop_file_export_failure("path.convert", &error.to_string()); + return file_export_unavailable_response(request); + } }; let export_result = tauri::async_runtime::spawn_blocking(move || write_export_text_file(path, content)).await; let bytes = match export_result { Ok(Ok(bytes)) => bytes, - Ok(Err(_)) | Err(_) => return file_export_unavailable_response(request), + Ok(Err(error)) => { + log_desktop_file_export_failure("write.text", &error.to_string()); + return file_export_unavailable_response(request); + } + Err(error) => { + log_desktop_file_export_failure("write.text.join", &error.to_string()); + return file_export_unavailable_response(request); + } }; ok( @@ -83,14 +109,20 @@ pub(crate) async fn import_desktop_host_bridge_text_file( }; let path = match file_path.into_path() { Ok(path) => path, - Err(_) => return file_import_unavailable_response(request), + Err(error) => { + log_desktop_file_import_failure("path.convert", &error.to_string()); + return file_import_unavailable_response(request); + } }; let import_result = tauri::async_runtime::spawn_blocking(move || import_text_file_payload(path)).await; match import_result { Ok(Ok(payload)) => ok(request.id.clone(), payload), Ok(Err(error)) => failed(request.id.clone(), "invalid_request", error), - Err(_) => file_import_unavailable_response(request), + Err(error) => { + log_desktop_file_import_failure("read.text.join", &error.to_string()); + file_import_unavailable_response(request) + } } } @@ -111,14 +143,20 @@ pub(crate) async fn import_desktop_host_bridge_document_file( }; let path = match file_path.into_path() { Ok(path) => path, - Err(_) => return file_import_unavailable_response(request), + Err(error) => { + log_desktop_file_import_failure("path.convert", &error.to_string()); + return file_import_unavailable_response(request); + } }; let import_result = tauri::async_runtime::spawn_blocking(move || import_document_file_payload(path)).await; match import_result { Ok(Ok(payload)) => ok(request.id.clone(), payload), Ok(Err(error)) => failed(request.id.clone(), "invalid_request", error), - Err(_) => file_import_unavailable_response(request), + Err(error) => { + log_desktop_file_import_failure("read.document.join", &error.to_string()); + file_import_unavailable_response(request) + } } } @@ -141,13 +179,23 @@ pub(crate) async fn export_desktop_host_bridge_image_file( }; let path = match file_path.into_path() { Ok(path) => path, - Err(_) => return file_export_unavailable_response(request), + Err(error) => { + log_desktop_file_export_failure("path.convert", &error.to_string()); + return file_export_unavailable_response(request); + } }; let export_result = tauri::async_runtime::spawn_blocking(move || write_export_bytes_file(path, bytes)).await; let byte_count = match export_result { Ok(Ok(byte_count)) => byte_count, - Ok(Err(_)) | Err(_) => return file_export_unavailable_response(request), + Ok(Err(error)) => { + log_desktop_file_export_failure("write.image", &error.to_string()); + return file_export_unavailable_response(request); + } + Err(error) => { + log_desktop_file_export_failure("write.image.join", &error.to_string()); + return file_export_unavailable_response(request); + } }; ok( request.id.clone(), @@ -173,7 +221,10 @@ pub(crate) async fn import_desktop_host_bridge_image_file( }; let path = match file_path.into_path() { Ok(path) => path, - Err(_) => return file_import_unavailable_response(request), + Err(error) => { + log_desktop_file_import_failure("path.convert", &error.to_string()); + return file_import_unavailable_response(request); + } }; let import_result = tauri::async_runtime::spawn_blocking(move || { import_image_file_payload(path, "selected", None) @@ -182,7 +233,10 @@ pub(crate) async fn import_desktop_host_bridge_image_file( match import_result { Ok(Ok(payload)) => ok(request.id.clone(), payload), Ok(Err(error)) => failed(request.id.clone(), "invalid_request", error), - Err(_) => file_import_unavailable_response(request), + Err(error) => { + log_desktop_file_import_failure("read.image.join", &error.to_string()); + file_import_unavailable_response(request) + } } } @@ -200,14 +254,20 @@ pub(crate) async fn import_desktop_host_bridge_audio_file( }; let path = match file_path.into_path() { Ok(path) => path, - Err(_) => return file_import_unavailable_response(request), + Err(error) => { + log_desktop_file_import_failure("path.convert", &error.to_string()); + return file_import_unavailable_response(request); + } }; let import_result = tauri::async_runtime::spawn_blocking(move || import_audio_file_payload(path)).await; match import_result { Ok(Ok(payload)) => ok(request.id.clone(), payload), Ok(Err(error)) => failed(request.id.clone(), "invalid_request", error), - Err(_) => file_import_unavailable_response(request), + Err(error) => { + log_desktop_file_import_failure("read.audio.join", &error.to_string()); + file_import_unavailable_response(request) + } } } @@ -230,13 +290,23 @@ pub(crate) async fn export_desktop_host_bridge_audio_file( }; let path = match file_path.into_path() { Ok(path) => path, - Err(_) => return file_export_unavailable_response(request), + Err(error) => { + log_desktop_file_export_failure("path.convert", &error.to_string()); + return file_export_unavailable_response(request); + } }; let export_result = tauri::async_runtime::spawn_blocking(move || write_export_bytes_file(path, bytes)).await; let byte_count = match export_result { Ok(Ok(byte_count)) => byte_count, - Ok(Err(_)) | Err(_) => return file_export_unavailable_response(request), + Ok(Err(error)) => { + log_desktop_file_export_failure("write.audio", &error.to_string()); + return file_export_unavailable_response(request); + } + Err(error) => { + log_desktop_file_export_failure("write.audio.join", &error.to_string()); + return file_export_unavailable_response(request); + } }; ok( request.id.clone(), @@ -286,4 +356,24 @@ mod tests { assert_eq!(import_error.code, "host_error"); assert_eq!(import_error.message, "file import unavailable"); } + + #[test] + fn desktop_file_failures_are_logged_without_exposing_native_detail() { + assert!(!log_desktop_file_export_failure( + "path.convert", + "private export detail" + )); + assert!(!log_desktop_file_export_failure( + "write.text", + "private export detail" + )); + assert!(!log_desktop_file_import_failure( + "path.convert", + "private import detail" + )); + assert!(!log_desktop_file_import_failure( + "read.text.join", + "private import detail" + )); + } } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index b71b2c8b0..74c4b0a63 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3124,6 +3124,12 @@ - 决策:`apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs` 必须在剪贴板写入 / 读取失败时记录 `desktop clipboard failed for ...` 日志;`apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs` 必须在通知权限状态读取、权限请求和通知投递失败时记录 `desktop notification failed for ...` 日志。HostBridge 对 H5 仍只返回稳定错误语义;该约束不新增遥测 SDK、后台通知、自动更新或 H5 直连 Tauri JS 插件。 - 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml host_bridge::clipboard host_bridge::notifications`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 +## 2026-06-20 桌面壳文件能力系统异常必须可观测 + +- 背景:Tauri 桌面壳已声明文本 / 文档 / 图片 / 音频导入导出;这些能力会打开系统文件对话框、转换系统路径并在后台线程读写文件。如果系统路径转换、后台读写或任务 join 异常只被折叠成稳定 HostBridge 错误,H5 语义安全,但开发侧无法区分系统对话框路径异常、文件系统失败或后台任务失败。 +- 决策:`apps/desktop-shell/src-tauri/src/host_bridge/files.rs` 必须在导出路径转换、导出写入、导入路径转换和导入后台读取 join 失败时记录 `desktop file export failed for ...` 或 `desktop file import failed for ...` 日志。HostBridge 对 H5 仍只返回稳定错误语义,不透传本地路径、系统错误或线程细节;用户取消系统文件对话框仍返回 `cancelled`,不记录为异常。 +- 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml host_bridge::files`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + ## 2026-06-20 移动壳门禁脚本必须自登记自扫描 - 背景:Expo 移动壳单端检查已把 `apps/mobile-shell/scripts/` 纳入生产源码扫描入口,但 `check-config.mjs` 自身仍被排除在脚本清单和替身词扫描之外;这会让移动壳与桌面壳门禁结构不一致,也可能让后续门禁反查内容绕过生产替身词规则。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 0c4c79caa..7d226ee67 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -348,6 +348,8 @@ GameBridge 禁止: 2026-06-20 追加:桌面壳剪贴板和本地通知插件异常必须可观测。Tauri clipboard-manager 读写失败、notification permission state / request 失败、系统通知 show 失败时,桌面壳必须分别记录 `desktop clipboard failed for ...` 或 `desktop notification failed for ...` 日志;HostBridge 回包仍只暴露稳定 `clipboard write unavailable`、`clipboard read unavailable`、`notification permission unavailable` 或 `notification delivery unavailable` 语义。该约束不新增遥测 SDK、后台通知、自动更新或 H5 直连 Tauri JS 插件。 +2026-06-20 追加:桌面壳文件导入导出的系统异常必须可观测。Tauri dialog 路径转换失败、后台文件读写任务失败或任务 join 失败时,桌面壳必须分别记录 `desktop file export failed for ...` 或 `desktop file import failed for ...` 日志;HostBridge 回包仍只暴露稳定 `file export unavailable` 或 `file import unavailable` 语义,不透传本地路径、系统错误或线程细节。用户取消系统文件对话框仍返回 `cancelled`,不记录为异常。 + 2026-06-18 追加:H5 账号状态刷新开始消费 `app.reloadWebView`。用户登录成功、退出登录、其它身份边界变化或登录状态异常页点击重新尝试时,`AuthGate` 会优先请求 Expo 壳刷新当前 WebView;宿主未声明或刷新失败时再回退浏览器刷新,避免在移动壳内绕过受控容器刷新入口。 2026-06-18 追加:移动壳 WebView 内容 / 渲染进程终止时复用同一受控刷新路径。iOS `onContentProcessDidTerminate` 和 Android `onRenderProcessGone` 只调用当前 `react-native-webview` 的 `reload()`,不改写 H5 URL、不注入额外脚本、不新增宿主恢复页面,避免系统回收 WebView 进程后留下空白容器。