加固移动壳隐私清单验收

移动壳 Expo 配置烟测反查 PrivacyInfo 插件消费路径

宿主壳方案和项目记忆同步隐私清单验收口径
This commit is contained in:
2026-06-20 06:30:08 +08:00
parent 67e781c886
commit a51ed00712
3 changed files with 87 additions and 3 deletions
@@ -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,
@@ -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 事件注入必须可执行覆盖
@@ -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 包。