加固原生壳分发失败门禁

桌面壳外链和新窗口外链失败改为统一日志记录

桌面壳托盘关闭生命周期和隐藏失败改为统一日志记录

移动壳补齐 iOS Privacy Manifest 与配置门禁

宿主壳方案和项目记忆同步分发约束
This commit is contained in:
2026-06-20 06:23:40 +08:00
parent c4193e27db
commit 67e781c886
8 changed files with 125 additions and 9 deletions
@@ -2116,6 +2116,10 @@ const requiredRustHostSnippets = [
'log_desktop_host_event_result("tray.show", show_main_window(app))',
'log_desktop_host_event_result("tray.reload", reload_main_window(app))',
'log_desktop_host_event_result("single_instance.show", show_main_window(app))',
'"navigation.external"',
'"webview.new_window.external"',
'"tray.close.lifecycle"',
'log_desktop_host_event_result("tray.close.hide", close_window.hide())',
'DESKTOP_DEEP_LINK_HOSTS',
'resolve_desktop_single_instance_action',
'tauri_plugin_clipboard_manager::init()',
@@ -2727,6 +2731,9 @@ for (const blockedLifecycleSnippet of [
'let _ = eval_main_window_history_back(app)',
'let _ = eval_main_window_history_forward(app)',
'let _ = reload_main_window(app)',
'let _ = open_normalized_desktop_external_url(',
'let _ = emit_desktop_lifecycle_event(&close_window',
'let _ = close_window.hide()',
]) {
if (rustHostSource.includes(blockedLifecycleSnippet)) {
throw new Error(
+6 -3
View File
@@ -87,9 +87,12 @@ pub(crate) fn run() {
})
.on_new_window(move |url, _features| {
if let Some(external_url) = desktop_new_window_external_url(&url) {
let _ = open_normalized_desktop_external_url(
&new_window_app_handle,
external_url,
log_desktop_host_event_result(
"webview.new_window.external",
open_normalized_desktop_external_url(
&new_window_app_handle,
external_url,
),
);
}
desktop_new_window_response::<tauri::Wry>()
@@ -1,4 +1,5 @@
use crate::shell::events::host_bridge_event_script;
use crate::shell::lifecycle::log_desktop_host_event_result;
use crate::shell::url::{desktop_h5_url_with_host_context, WEB_APP_ORIGIN};
use serde_json::{json, Value};
use tauri::webview::DownloadEvent;
@@ -152,17 +153,20 @@ pub(crate) fn desktop_external_navigation_url(url: &Url) -> Option<String> {
pub(crate) fn open_normalized_desktop_external_url(
app: &tauri::AppHandle,
external_url: String,
) -> Result<(), String> {
) -> tauri::Result<()> {
app.opener()
.open_url(external_url, None::<&str>)
.map_err(|error| error.to_string())
.map_err(|error| tauri::Error::Anyhow(error.into()))
}
pub(crate) fn open_desktop_external_navigation(app: &tauri::AppHandle, url: &Url) {
let Some(external_url) = desktop_external_navigation_url(url) else {
return;
};
let _ = open_normalized_desktop_external_url(app, external_url);
log_desktop_host_event_result(
"navigation.external",
open_normalized_desktop_external_url(app, external_url),
);
}
pub(crate) fn should_allow_desktop_webview_download(event: &DownloadEvent<'_>) -> bool {
@@ -138,8 +138,11 @@ pub(crate) fn register_desktop_window_close_events(window: &WebviewWindow, tray_
{
api.prevent_close();
// 中文注释:隐藏 WebView 前先通知 H5 暂停游戏循环和音频,避免隐藏后脚本执行被平台挂起。
let _ = emit_desktop_lifecycle_event(&close_window, "background", false, "hidden");
let _ = close_window.hide();
log_desktop_host_event_result(
"tray.close.lifecycle",
emit_desktop_lifecycle_event(&close_window, "background", false, "hidden"),
);
log_desktop_host_event_result("tray.close.hide", close_window.hide());
}
}
});
+35 -1
View File
@@ -55,7 +55,41 @@
},
"associatedDomains": [
"applinks:app.genarrative.world"
]
],
"privacyManifests": {
"NSPrivacyCollectedDataTypes": [],
"NSPrivacyTracking": false,
"NSPrivacyTrackingDomains": [],
"NSPrivacyAccessedAPITypes": [
{
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryFileTimestamp",
"NSPrivacyAccessedAPITypeReasons": [
"0A2A.1",
"3B52.1",
"C617.1"
]
},
{
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryDiskSpace",
"NSPrivacyAccessedAPITypeReasons": [
"85F4.1",
"E174.1"
]
},
{
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategorySystemBootTime",
"NSPrivacyAccessedAPITypeReasons": [
"35F9.1"
]
},
{
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryUserDefaults",
"NSPrivacyAccessedAPITypeReasons": [
"CA92.1"
]
}
]
}
},
"android": {
"package": "world.genarrative.mobile",
@@ -1065,6 +1065,55 @@ if (
throw new Error('mobile shell iOS microphone permission text must describe same-origin H5 gameplay input');
}
const iosPrivacyManifests = appConfig.ios?.privacyManifests;
if (!iosPrivacyManifests) {
throw new Error('mobile shell iOS privacy manifests must be configured');
}
if (iosPrivacyManifests.NSPrivacyTracking !== false) {
throw new Error('mobile shell iOS privacy manifest must not enable tracking');
}
assertSameList(
iosPrivacyManifests.NSPrivacyCollectedDataTypes ?? [],
[],
'mobile shell iOS privacy collected data types',
);
assertSameList(
iosPrivacyManifests.NSPrivacyTrackingDomains ?? [],
[],
'mobile shell iOS privacy tracking domains',
);
const requiredPrivacyAccessedApiTypes = new Map([
[
'NSPrivacyAccessedAPICategoryFileTimestamp',
['0A2A.1', '3B52.1', 'C617.1'],
],
['NSPrivacyAccessedAPICategoryDiskSpace', ['85F4.1', 'E174.1']],
['NSPrivacyAccessedAPICategorySystemBootTime', ['35F9.1']],
['NSPrivacyAccessedAPICategoryUserDefaults', ['CA92.1']],
]);
const privacyAccessedApiTypes =
iosPrivacyManifests.NSPrivacyAccessedAPITypes ?? [];
if (privacyAccessedApiTypes.length !== requiredPrivacyAccessedApiTypes.size) {
throw new Error('mobile shell iOS privacy manifest accessed API type count drifted');
}
for (const [apiType, reasons] of requiredPrivacyAccessedApiTypes) {
const entry = privacyAccessedApiTypes.find(
(candidate) => candidate.NSPrivacyAccessedAPIType === apiType,
);
if (!entry) {
throw new Error(`mobile shell iOS privacy manifest missing ${apiType}`);
}
assertSameList(
entry.NSPrivacyAccessedAPITypeReasons ?? [],
reasons,
`mobile shell iOS privacy reasons for ${apiType}`,
);
}
if (appConfig.android?.package !== 'world.genarrative.mobile') {
throw new Error('mobile shell Android package must be world.genarrative.mobile');
}
@@ -2873,6 +2873,13 @@
- 影响范围:`apps/desktop-shell/src-tauri/src/app.rs``apps/desktop-shell/src-tauri/src/shell/menu.rs``apps/desktop-shell/src-tauri/src/shell/tray.rs``apps/desktop-shell/scripts/check-config.mjs`、宿主壳方案文档。
- 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml``npm run desktop-shell:typecheck``npm run check:native-shells``npm run check:encoding``git diff --check`
## 2026-06-20 桌面壳外链与托盘关闭失败不可静默
- 背景:桌面壳 HostBridge 外链、WebView 新窗口外链接管和托盘关闭隐藏都会改变用户当前窗口状态或离开主 WebView;如果 opener、生命周期注入或窗口隐藏失败仍被静默忽略,用户会看到外链、关闭或托盘行为无反应且没有可排查日志。
- 决策:`open_normalized_desktop_external_url(...)` 返回 `tauri::Result<()>`HostBridge 外链打开和 WebView 新窗口外链接管失败都必须通过统一桌面宿主事件日志记录;托盘关闭主窗口前的 `app.lifecycle` 注入和 `hide()` 失败也必须记录日志。配置检查拒绝这些路径继续使用 `let _ = ...` 静默吞错。
- 影响范围:`apps/desktop-shell/src-tauri/src/app.rs``apps/desktop-shell/src-tauri/src/shell/navigation.rs``apps/desktop-shell/src-tauri/src/shell/tray.rs``apps/desktop-shell/scripts/check-config.mjs`、宿主壳方案文档。
- 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml``npm run desktop-shell:typecheck``npm run check:native-shells``npm run check:encoding``git diff --check`
## 2026-06-20 移动壳 EAS 原生包构建 profile
- 背景:移动壳已能通过 Expo managed config 和 Metro production bundle smoke,但缺少原生安装包构建 profile;如果只保留 `expo export`,无法证明 Android / iOS 壳有进入原生分发链路的配置。
@@ -2880,6 +2887,13 @@
- 影响范围:`apps/mobile-shell/eas.json``apps/mobile-shell/package.json``apps/mobile-shell/scripts/check-eas-build-config.mjs``apps/mobile-shell/scripts/check-config.mjs``scripts/check-native-shells.mjs`、原生壳方案和验收文档。
- 验证方式:`npm run mobile-shell:build-config``npm run mobile-shell:typecheck``npm run check:native-shells``npm run check:encoding``git diff --check`
## 2026-06-20 移动壳 iOS Privacy Manifest 门禁
- 背景:移动壳使用 React Native、Expo FileSystem、Notifications 等原生依赖,这些依赖包含 required reason API 的隐私清单;如果 `app.json` 不显式声明并由配置检查反查,iOS 分发时可能因为合并缺失或依赖升级导致隐私声明漂移。
- 决策:`apps/mobile-shell/app.json``expo.ios.privacyManifests` 声明当前依赖需要的 `FileTimestamp``DiskSpace``SystemBootTime``UserDefaults` required reason API;不声明数据采集和 tracking domain。`apps/mobile-shell/scripts/check-config.mjs` 必须精确反查 API category、reason、空 collected data 和 tracking=false。
- 影响范围:`apps/mobile-shell/app.json``apps/mobile-shell/scripts/check-config.mjs`、原生壳方案文档。
- 验证方式:`npm run mobile-shell:typecheck``npm run mobile-shell:config``npm run check:native-shells``npm run check:encoding``git diff --check`
## 2026-06-20 移动壳 ShellApp HostBridge 事件注入必须可执行覆盖
- 背景:Expo 移动壳声明 `host.events``app.lifecycle``network.statusChanged``navigation.canGoBack`,但 ShellApp 真实 AppState、Network 和 WebView 返回栈注入链路需要和扫码链路一样有可执行测试覆盖,不能只靠字符串门禁。
@@ -175,6 +175,7 @@ Expo 壳只负责 App 外壳和原生能力,不承接玩法业务。
- RN 到 H5:通过 WebView ref 注入脚本,向 H5 派发统一 bridge response / event。
- 使用 development build,不依赖 Expo Go 作为真实集成环境;需要自定义原生配置时用 config plugin / prebuild 管理。
- App 壳维护启动页、深链、系统分享、即时本地通知、权限和 App 版本;远程推送、崩溃日志、支付 SDK 等能力必须等真实端点、渠道合同、发布流程和隐私口径确定后逐项接入。
- iOS 分发配置必须在 `expo.ios.privacyManifests` 中声明当前 RN / Expo 原生依赖实际使用的 required reason API,且不声明未使用的数据采集或追踪域;移动壳配置检查必须反查该清单,避免升级 Expo SDK 或新增原生模块后隐私声明漂移。
- 登录首期优先复用 H5 账号体系;后续再逐项接入 Apple / Android / 微信等原生登录能力。
- 支付必须按上架渠道拆分:iOS / Android 虚拟内容优先评估 IAP / Google Play Billing 或国内渠道要求;H5 支付、小程序虚拟支付和桌面二维码支付不能直接照搬到 App Store 包。
@@ -212,6 +213,7 @@ Tauri 壳同样只负责桌面宿主能力,不承接玩法业务。
- 桌面壳和根 H5 包不安装 `@tauri-apps/api``@tauri-apps/plugin-*` JS guest 包;生产 H5 只通过 Tauri 注入的 `window.__TAURI__.core.invoke('host_bridge_request', request)` 进入 HostBridge。opener、clipboard、dialog、notification 等能力只保留 Rust Cargo 插件,由 Rust 内部分发并受 capability 白名单约束。
- 桌面深链只作为宿主启动 / 唤醒入口处理,不进入 HostBridge capability,也不把 deep-link 插件 command 授权给 H5。Tauri 只注册 `genarrative` scheme,并接受同源 `https://app.genarrative.world` URL;壳层会把目标路径归一为带 `native_app``tauri_desktop` 和真实 capability 清单的同源 H5 URL,外域、明文协议和危险协议直接丢弃。`navigation.openNativePage` 的同源主动跳转也必须复用同一宿主上下文补写逻辑,避免新页面按普通浏览器运行态启动。
- 桌面窗口状态持久化属于宿主壳自有体验,不进入 HostBridge capability,也不开放窗口状态插件 command 给 H5。Tauri 壳只保存主窗口大小、位置和最大化状态,不保存可见性、全屏或装饰状态,避免托盘隐藏窗口后下次启动被恢复成隐藏状态。
- 桌面壳外链打开、WebView 新窗口外链接管、托盘关闭前生命周期注入和窗口隐藏都属于用户可见宿主动作;这些动作失败必须走统一桌面宿主事件日志,配置检查拒绝 `let _ = ...` 静默吞错。
桌面 release 和 dev 模式: