补齐桌面壳文件失败观测

桌面文件导入导出记录系统异常日志

Rust 单测覆盖稳定错误和日志边界

配置门禁和文档同步桌面文件观测约束
This commit is contained in:
2026-06-20 19:47:24 +08:00
parent bacb17506d
commit 679caf98f6
4 changed files with 135 additions and 16 deletions
+23 -2
View File
@@ -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"',
@@ -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"
));
}
}
@@ -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` 自身仍被排除在脚本清单和替身词扫描之外;这会让移动壳与桌面壳门禁结构不一致,也可能让后续门禁反查内容绕过生产替身词规则。
@@ -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 进程后留下空白容器。