From a51ed007120a02da179833506fd81e4eaeee1b8e Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 20 Jun 2026 06:30:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A0=E5=9B=BA=E7=A7=BB=E5=8A=A8=E5=A3=B3?= =?UTF-8?q?=E9=9A=90=E7=A7=81=E6=B8=85=E5=8D=95=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移动壳 Expo 配置烟测反查 PrivacyInfo 插件消费路径 宿主壳方案和项目记忆同步隐私清单验收口径 --- .../scripts/check-expo-config.mjs | 84 +++++++++++++++++++ .../shared-memory/decision-log.md | 4 +- ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 2 +- 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/apps/mobile-shell/scripts/check-expo-config.mjs b/apps/mobile-shell/scripts/check-expo-config.mjs index 2375a55e5..5826b3deb 100644 --- a/apps/mobile-shell/scripts/check-expo-config.mjs +++ b/apps/mobile-shell/scripts/check-expo-config.mjs @@ -4,6 +4,14 @@ import fs from 'node:fs'; const appConfigPath = new URL('../app.json', import.meta.url); const packagePath = new URL('../package.json', import.meta.url); +const configPluginsPackagePath = new URL( + '../../../node_modules/@expo/config-plugins/package.json', + import.meta.url, +); +const expoPrivacyInfoPluginPath = new URL( + '../../../node_modules/@expo/config-plugins/build/ios/PrivacyInfo.js', + import.meta.url, +); const sharedContractPath = new URL( '../../../packages/shared/src/contracts/hostBridge.ts', import.meta.url, @@ -12,6 +20,13 @@ const shellRoot = new URL('../', import.meta.url); const appConfig = JSON.parse(fs.readFileSync(appConfigPath, 'utf8')).expo; const packageConfig = JSON.parse(fs.readFileSync(packagePath, 'utf8')); +const configPluginsPackageConfig = JSON.parse( + fs.readFileSync(configPluginsPackagePath, 'utf8'), +); +const expoPrivacyInfoPluginSource = fs.readFileSync( + expoPrivacyInfoPluginPath, + 'utf8', +); const sharedContractSource = fs.readFileSync(sharedContractPath, 'utf8'); const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; @@ -92,6 +107,56 @@ function assertSameSet(actual, expected, label) { } } +function assertPrivacyManifest(privacyManifests, label) { + if (!privacyManifests) { + throw new Error(`${label} missing iOS privacy manifest`); + } + + assertEqual( + privacyManifests.NSPrivacyTracking, + false, + `${label} privacy tracking flag`, + ); + assertSameList( + privacyManifests.NSPrivacyCollectedDataTypes ?? [], + [], + `${label} collected data types`, + ); + assertSameList( + privacyManifests.NSPrivacyTrackingDomains ?? [], + [], + `${label} tracking domains`, + ); + + const expectedAccessedApiTypes = new Map([ + [ + 'NSPrivacyAccessedAPICategoryFileTimestamp', + ['0A2A.1', '3B52.1', 'C617.1'], + ], + ['NSPrivacyAccessedAPICategoryDiskSpace', ['85F4.1', 'E174.1']], + ['NSPrivacyAccessedAPICategorySystemBootTime', ['35F9.1']], + ['NSPrivacyAccessedAPICategoryUserDefaults', ['CA92.1']], + ]); + const accessedApiTypes = privacyManifests.NSPrivacyAccessedAPITypes ?? []; + if (accessedApiTypes.length !== expectedAccessedApiTypes.size) { + throw new Error(`${label} accessed API type count drifted`); + } + + for (const [apiType, reasons] of expectedAccessedApiTypes) { + const entry = accessedApiTypes.find( + (candidate) => candidate.NSPrivacyAccessedAPIType === apiType, + ); + if (!entry) { + throw new Error(`${label} missing ${apiType}`); + } + assertSameList( + entry.NSPrivacyAccessedAPITypeReasons ?? [], + reasons, + `${label} reasons for ${apiType}`, + ); + } +} + function findPlugin(name) { return expoConfig.plugins?.find((plugin) => Array.isArray(plugin) ? plugin[0] === name : plugin === name, @@ -189,6 +254,25 @@ assertEqual( '允许 Genarrative 使用麦克风运行需要实时声音输入的玩法。', 'iOS microphone permission text', ); +assertPrivacyManifest(appConfig.ios?.privacyManifests, 'Expo source config'); + +const configPluginsMajor = Number( + String(configPluginsPackageConfig.version).split('.')[0], +); +if (!Number.isFinite(configPluginsMajor) || configPluginsMajor < 50) { + throw new Error('Expo config plugins must support built-in iOS privacy manifests'); +} +for (const snippet of [ + 'function withPrivacyInfo(config)', + 'config.ios?.privacyManifests', + 'setPrivacyInfo(projectConfig, privacyManifests)', + 'PrivacyInfo.xcprivacy', + 'mergePrivacyInfo(existing, privacyManifests)', +]) { + if (!expoPrivacyInfoPluginSource.includes(snippet)) { + throw new Error(`Expo config plugins PrivacyInfo support missing ${snippet}`); + } +} assertEqual( expoConfig.android?.package, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 26796ac92..deec11379 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2890,8 +2890,8 @@ ## 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`、原生壳方案文档。 +- 决策:`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/scripts/check-expo-config.mjs` 额外确认当前安装的 `@expo/config-plugins` 仍包含消费 `config.ios?.privacyManifests` 并写入 `PrivacyInfo.xcprivacy` 的 `withPrivacyInfo` 插件,避免该字段变成源配置里的死声明。 +- 影响范围:`apps/mobile-shell/app.json`、`apps/mobile-shell/scripts/check-config.mjs`、`apps/mobile-shell/scripts/check-expo-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 事件注入必须可执行覆盖 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 906465735..f44a4e840 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -175,7 +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 或新增原生模块后隐私声明漂移。 +- iOS 分发配置必须在 `expo.ios.privacyManifests` 中声明当前 RN / Expo 原生依赖实际使用的 required reason API,且不声明未使用的数据采集或追踪域;移动壳配置检查必须反查该清单,并确认当前 Expo config plugin 仍会消费该字段写入 `PrivacyInfo.xcprivacy`,避免升级 Expo SDK 或新增原生模块后隐私声明漂移。 - 登录首期优先复用 H5 账号体系;后续再逐项接入 Apple / Android / 微信等原生登录能力。 - 支付必须按上架渠道拆分:iOS / Android 虚拟内容优先评估 IAP / Google Play Billing 或国内渠道要求;H5 支付、小程序虚拟支付和桌面二维码支付不能直接照搬到 App Store 包。