补齐桌面壳插件失败观测

桌面剪贴板和通知插件异常记录日志

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

配置门禁和文档同步桌面插件观测约束
This commit is contained in:
2026-06-20 19:40:54 +08:00
parent 48bad28a96
commit bacb17506d
5 changed files with 83 additions and 5 deletions
@@ -2065,6 +2065,10 @@ for (const snippet of [
'write_desktop_clipboard_text(app, text)',
'read_desktop_clipboard_text(app)',
'normalize_clipboard_text(&text)',
'log_desktop_clipboard_failure',
'desktop clipboard failed for {label}: {error}',
'log_desktop_clipboard_failure("write.text", &error)',
'log_desktop_clipboard_failure("read.text", &error)',
'clipboard_write_unavailable_response',
'clipboard_read_unavailable_response',
'required_string_payload(request, "text")',
@@ -2073,6 +2077,7 @@ for (const snippet of [
'"clipboard write unavailable"',
'"clipboard read unavailable"',
'clipboard_unavailable_responses_are_stable',
'clipboard_failures_are_logged_without_exposing_native_detail',
]) {
if (!desktopHostBridgeClipboardSource.includes(snippet)) {
throw new Error(`desktop shell clipboard module is missing ${snippet}`);
@@ -2699,6 +2704,11 @@ for (const snippet of [
'desktop_notification_permission_action',
'notification_permission_unavailable_response',
'notification_delivery_unavailable_response',
'log_desktop_notification_failure',
'desktop notification failed for {label}: {error}',
'log_desktop_notification_failure("permission.state", &error.to_string())',
'log_desktop_notification_failure("permission.request", &error.to_string())',
'log_desktop_notification_failure("delivery.show", &error.to_string())',
'PermissionState::Granted',
'PermissionState::Denied',
'PermissionState::Prompt | PermissionState::PromptWithRationale',
@@ -2707,6 +2717,7 @@ for (const snippet of [
'local_notification_payload_truncates_to_shared_contract_limits',
'notification_permission_unavailable_response_is_stable',
'notification_delivery_unavailable_response_is_stable',
'notification_failures_are_logged_without_exposing_native_detail',
'"a".repeat(HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH)',
'"b".repeat(HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH)',
'"notification permission unavailable"',
@@ -27,6 +27,14 @@ pub(crate) fn read_desktop_clipboard_text(app: &tauri::AppHandle) -> Result<Stri
.map_err(|error| error.to_string())
}
fn log_desktop_clipboard_failure(label: &str, error: &str) -> bool {
if !error.is_empty() {
eprintln!("desktop clipboard failed for {label}: {error}");
}
false
}
pub(crate) fn clipboard_write_unavailable_response(
request: &HostBridgeRequest,
) -> HostBridgeResponse {
@@ -58,7 +66,10 @@ pub(crate) fn write_desktop_host_bridge_clipboard_text(
match write_desktop_clipboard_text(app, text) {
Ok(()) => ok(request.id.clone(), json!(true)),
Err(_) => clipboard_write_unavailable_response(request),
Err(error) => {
log_desktop_clipboard_failure("write.text", &error);
clipboard_write_unavailable_response(request)
}
}
}
@@ -73,7 +84,10 @@ pub(crate) fn read_desktop_host_bridge_clipboard_text(
"text": text,
}),
),
Err(_) => clipboard_read_unavailable_response(request),
Err(error) => {
log_desktop_clipboard_failure("read.text", &error);
clipboard_read_unavailable_response(request)
}
}
}
@@ -140,4 +154,16 @@ mod tests {
assert_eq!(read_error.code, "host_error");
assert_eq!(read_error.message, "clipboard read unavailable");
}
#[test]
fn clipboard_failures_are_logged_without_exposing_native_detail() {
assert!(!log_desktop_clipboard_failure(
"write.text",
"private clipboard detail"
));
assert!(!log_desktop_clipboard_failure(
"read.text",
"private clipboard detail"
));
}
}
@@ -105,6 +105,14 @@ fn notification_delivery_unavailable_response(request: &HostBridgeRequest) -> Ho
)
}
fn log_desktop_notification_failure(label: &str, error: &str) -> bool {
if !error.is_empty() {
eprintln!("desktop notification failed for {label}: {error}");
}
false
}
pub(crate) fn show_desktop_local_notification(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
@@ -116,7 +124,10 @@ pub(crate) fn show_desktop_local_notification(
let notification_manager = app.notification();
let permission_state = notification_manager
.permission_state()
.map_err(|_| notification_permission_unavailable_response(request));
.map_err(|error| {
log_desktop_notification_failure("permission.state", &error.to_string());
notification_permission_unavailable_response(request)
});
let permission_state = match permission_state {
Ok(permission_state) => permission_state,
Err(response) => return response,
@@ -125,7 +136,10 @@ pub(crate) fn show_desktop_local_notification(
if permission_action == DesktopNotificationPermissionAction::Request {
let requested_state = notification_manager
.request_permission()
.map_err(|_| notification_permission_unavailable_response(request));
.map_err(|error| {
log_desktop_notification_failure("permission.request", &error.to_string());
notification_permission_unavailable_response(request)
});
let requested_state = match requested_state {
Ok(requested_state) => requested_state,
Err(response) => return response,
@@ -150,7 +164,10 @@ pub(crate) fn show_desktop_local_notification(
request.id.clone(),
desktop_notification_delivered_to_system_result(),
),
Err(_) => notification_delivery_unavailable_response(request),
Err(error) => {
log_desktop_notification_failure("delivery.show", &error.to_string());
notification_delivery_unavailable_response(request)
}
}
}
@@ -219,6 +236,22 @@ mod tests {
assert_eq!(error.message, "notification delivery unavailable");
}
#[test]
fn notification_failures_are_logged_without_exposing_native_detail() {
assert!(!log_desktop_notification_failure(
"permission.state",
"private notification detail"
));
assert!(!log_desktop_notification_failure(
"permission.request",
"private notification detail"
));
assert!(!log_desktop_notification_failure(
"delivery.show",
"private notification detail"
));
}
#[test]
fn local_notification_payload_rejects_empty_and_control_text() {
let mut empty = request("notification.showLocal");
@@ -3118,6 +3118,12 @@
- 决策:`apps/mobile-shell/src/host-bridge/clipboard.ts` 必须在剪贴板写入 / 读取失败时记录 `mobile clipboard failed for ...` 日志;`apps/mobile-shell/src/host-bridge/haptics.ts` 必须在触觉派发失败时记录 `mobile haptics failed for ...` 日志;`apps/mobile-shell/src/host-bridge/network.ts` 必须在 HostBridge 网络状态查询失败时记录 `mobile network failed for ...` 日志。HostBridge 对 H5 仍只返回稳定错误语义;该约束不新增后台网络探测、任意系统能力或 H5 业务兜底路径。
- 验证方式:`npm run mobile-shell:test -- src/host-bridge/clipboard.test.ts src/host-bridge/haptics.test.ts src/host-bridge/network.test.ts``npm run mobile-shell:typecheck``npm run check:native-shells``npm run check:encoding``git diff --check`
## 2026-06-20 桌面壳剪贴板与通知异常必须可观测
- 背景:Tauri 桌面壳已声明剪贴板读写和即时本地通知;这些能力会触发 Tauri clipboard-manager 和 notification 插件。如果插件异常只被折叠成稳定 HostBridge 错误,H5 语义安全,但开发侧无法区分系统剪贴板不可用、通知权限查询异常或系统通知投递失败。
- 决策:`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 移动壳门禁脚本必须自登记自扫描
- 背景:Expo 移动壳单端检查已把 `apps/mobile-shell/scripts/` 纳入生产源码扫描入口,但 `check-config.mjs` 自身仍被排除在脚本清单和替身词扫描之外;这会让移动壳与桌面壳门禁结构不一致,也可能让后续门禁反查内容绕过生产替身词规则。
@@ -346,6 +346,8 @@ GameBridge 禁止:
2026-06-19 追加:移动壳 `haptics.impact` 的 HostBridge payload 解析、共享 style 归一、Expo style 映射和 `Haptics.impactAsync(...)` 真实调用统一收口在 `apps/mobile-shell/src/host-bridge/haptics.ts``dispatch.ts` 只按 method 委托 `runMobileHostBridgeHapticsImpact(request.payload)`
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-18 追加:H5 账号状态刷新开始消费 `app.reloadWebView`。用户登录成功、退出登录、其它身份边界变化或登录状态异常页点击重新尝试时,`AuthGate` 会优先请求 Expo 壳刷新当前 WebView;宿主未声明或刷新失败时再回退浏览器刷新,避免在移动壳内绕过受控容器刷新入口。
2026-06-18 追加:移动壳 WebView 内容 / 渲染进程终止时复用同一受控刷新路径。iOS `onContentProcessDidTerminate` 和 Android `onRenderProcessGone` 只调用当前 `react-native-webview``reload()`,不改写 H5 URL、不注入额外脚本、不新增宿主恢复页面,避免系统回收 WebView 进程后留下空白容器。