ITMS-91064: NSPrivacyTracking and NSPrivacyTrackingDomains must agree in every privacy manifest (true needs domains, domains need true); check, scan and privacy-manifest fail on either mismatch with the exact ITMS text; App Privacy tracking no longer demands the app manifest's true: SDK manifests that declare tracking (Pods, frameworks, SwiftPM, Carthage, plugins, the built IPA) are info, known tracking SDKs info, else a warning; privacy-manifest --propose writes true only with appPrivacy.trackingDomains; apple upload checks every manifest in the IPA (XML or binary) before any network call, apple build after the export. Pitfall 29

This commit is contained in:
CRTkafa 2026-10-12 10:27:21 +08:00
parent b7bfe89151
commit c8213be2b6
19 changed files with 631 additions and 44 deletions

View file

@ -109,21 +109,43 @@ export function validateAppPrivacyTypes(types: unknown): string[] {
return e;
}
export interface PrivacyMismatch { level: 'error' | 'warn'; msg: string }
export interface PrivacyMismatch { level: 'error' | 'warn' | 'info'; msg: string }
/** Where tracking is declared besides the app's own manifest. */
export interface TrackingSources {
/** SDKs whose own PrivacyInfo.xcprivacy declares NSPrivacyTracking true (Pods, frameworks, plugins, built app). */
sdkManifests?: string[];
/** Tracking-capable SDKs found in the project's dependencies (ads, mediation, attribution) whose manifest was not
* found locally (Pods not installed, binary only in the build). */
trackerSdks?: string[];
}
/** App Privacy "used for tracking" against the privacy manifests. The app's own manifest does NOT have to say
* NSPrivacyTracking true when the tracking comes from an SDK: the SDK's manifest declares it (and true with an empty
* NSPrivacyTrackingDomains is ITMS-91064). `what`: the App Privacy answer, e.g. "appPrivacy.tracking is true". */
export function trackingDeclaredIssues(what: string, appManifest: { path: string; tracking: boolean | null } | null, src: TrackingSources): PrivacyMismatch[] {
if (appManifest?.tracking === true) return [];
const own = appManifest ? `${appManifest.path} says NSPrivacyTracking ${appManifest.tracking === false ? 'false' : 'missing'}` : 'no app manifest';
const by = [...new Set(src.sdkManifests ?? [])];
if (by.length) return [{ level: 'info', msg: `${what}: tracking declared by the ${by.join(', ')} manifest${by.length > 1 ? 's' : ''} (${own}, right when only the SDK tracks)` }];
const known = [...new Set(src.trackerSdks ?? [])];
if (known.length) return [{ level: 'info', msg: `${what}: tracking comes from ${known.join(', ')}; its own privacy manifest declares it (not found in this folder: install Pods / resolve packages, or \`storebound apple upload --dry\` checks the built app). ${own}, right when only the SDK tracks` }];
return [{ level: 'warn', msg: `${what}, but no privacy manifest declares NSPrivacyTracking true (${own}, and no SDK manifest that declares tracking was found): check that the tracking SDK ships a manifest that declares it. Do not set the app's manifest to true without NSPrivacyTrackingDomains (ITMS-91064)` }];
}
/**
* `appPrivacy.types` against the iOS project (both ways): any type used for tracking <-> Info.plist
* NSUserTrackingUsageDescription and the app manifest's NSPrivacyTracking true; the app manifest's
* NSPrivacyCollectedDataTypes <-> the types marked collected. `manifest` null: no PrivacyInfo.xcprivacy found.
* NSUserTrackingUsageDescription and a manifest that declares tracking (the app's, or an SDK's: `tracking`); the app
* manifest's NSPrivacyCollectedDataTypes <-> the types marked collected. `manifest` null: no PrivacyInfo.xcprivacy found.
*/
export function appPrivacyMismatches(types: Partial<Record<AppleDataTypeId, AppleTypeAnswer>>, ios: { attString: boolean; manifest: { path: string; tracking: boolean | null; collectedTypes: string[] } | null }): PrivacyMismatch[] {
export function appPrivacyMismatches(types: Partial<Record<AppleDataTypeId, AppleTypeAnswer>>, ios: { attString: boolean; manifest: { path: string; tracking: boolean | null; collectedTypes: string[] } | null; tracking?: TrackingSources }): PrivacyMismatch[] {
const out: PrivacyMismatch[] = [];
const entries = Object.entries(types) as [AppleDataTypeId, AppleTypeAnswer][];
const tracked = entries.filter(([, a]) => a.tracking === true).map(([id]) => id);
const m = ios.manifest;
if (tracked.length) {
if (!ios.attString) out.push({ level: 'error', msg: `appPrivacy.types says ${tracked.join(', ')} ${tracked.length === 1 ? 'is' : 'are'} used for tracking, but Info.plist has no NSUserTrackingUsageDescription (App Tracking Transparency is required before tracking)` });
if (m && m.tracking !== true) out.push({ level: 'error', msg: `appPrivacy.types says ${tracked.join(', ')} ${tracked.length === 1 ? 'is' : 'are'} used for tracking, but ${m.path} has NSPrivacyTracking ${m.tracking === false ? 'false' : 'missing'}` });
if (m) out.push(...trackingDeclaredIssues(`appPrivacy.types says ${tracked.join(', ')} ${tracked.length === 1 ? 'is' : 'are'} used for tracking`, m, ios.tracking ?? {}));
} else if (entries.some(([, a]) => a.tracking === false) && entries.every(([, a]) => a.collected === false || a.tracking === false)) {
if (ios.attString) out.push({ level: 'warn', msg: 'Info.plist has NSUserTrackingUsageDescription, but appPrivacy.types marks no collected type as used for tracking' });
if (m?.tracking === true) out.push({ level: 'warn', msg: `${m.path} has NSPrivacyTracking true, but appPrivacy.types marks no collected type as used for tracking` });