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

@ -4,9 +4,11 @@
// storebound check everything above
// storebound check --aso keyword audit of both listing files (App Store keyword field, Play stuffing); exit 1 on errors
// --json structured result ({ ok, items, warnings, hints, problems } or { findings, errors }), exit 1 on problems
import { existsSync, readFileSync } from 'node:fs';
import { dirname, relative } from 'node:path';
import { appPrivacyMismatches } from './lib/app-privacy';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { appPrivacyMismatches, type PrivacyMismatch, trackingDeclaredIssues, type TrackingSources } from './lib/app-privacy';
import { type BundleManifest, bundleTrackingIssues, ipaManifests, projectSdkManifests, sdkOfProjectPath } from './lib/privacy-files';
import { trackingIssue } from './lib/privacy-manifest';
import { type AsoFinding, auditAso } from './lib/aso';
import { draftLocales } from './lib/aso-intel';
import { appleName, playCodes } from './lib/config';
@ -23,6 +25,11 @@ import { allSubscriptions } from './lib/subscriptions';
import { type CopyFile, validateCopy } from './shots/lib';
import { type CapProject, capacitorProject, prebuildFor, staleMessage, webState } from './lib/web-assets';
/** The newest .ipa in a folder (apple build's export), or null. */
function newestIpa(dir: string): string | null {
try { return readdirSync(dir).filter(f => f.endsWith('.ipa')).map(f => join(dir, f)).sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0] ?? null; } catch { return null; }
}
function aso(ctx: Ctx): void {
const { config, resolve } = ctx.loaded;
const appleMd = config.apple ? readFileSync(resolve(config.apple.listing ?? 'store/appstore.md'), 'utf8') : '';
@ -85,18 +92,39 @@ export async function run(ctx: Ctx): Promise<void> {
if (todo.length) note(`${relative(process.cwd(), f) || f}: not translated yet (status=translate): ${todo.join(', ')}`);
if (drafts.length) note(`${relative(process.cwd(), f) || f}: machine-translated drafts not reviewed: ${drafts.join(', ')} (storebound listing check, then listing reviewed --lang <ids>)`);
}
// App Privacy answers (appPrivacy.types) against the iOS project: ATT string, PrivacyInfo.xcprivacy tracking and types
// Privacy manifests (ITMS-91064: NSPrivacyTracking and NSPrivacyTrackingDomains agree in every manifest, the app's,
// the SDKs' and the last built IPA's) and the App Privacy answers against the iOS project: ATT string, a manifest
// that declares tracking (the app's or an SDK's), PrivacyInfo.xcprivacy types
const apTypes = config.appPrivacy?.types;
if (config.apple && apTypes && Object.keys(apTypes).length) {
const facts = detectProject(ctx.loaded.root);
if (!facts.ios) hints.push('appPrivacy.types: no iOS project found here, so it is not compared with Info.plist / PrivacyInfo.xcprivacy');
else {
const own = facts.privacyManifests.find(m => !/(^|\/)(Pods|node_modules|SourcePackages|Carthage|\.build)\//.test(m.path)) ?? null;
const found = appPrivacyMismatches(apTypes, { attString: facts.plist.has('NSUserTrackingUsageDescription'), manifest: own });
for (const f of found) if (f.level === 'error') problems.push(f.msg); else note(f.msg);
items.push({ what: 'app privacy', detail: `${Object.keys(apTypes).length} types answered, ${found.length} mismatch(es) with the iOS project` });
log('check', `app privacy: ${Object.keys(apTypes).length} types answered, ${found.length} mismatch(es) with the iOS project`);
const hasTypes = !!apTypes && Object.keys(apTypes).length > 0;
const facts = config.apple ? detectProject(ctx.loaded.root) : null;
if (hasTypes && facts && !facts.ios) hints.push('appPrivacy.types: no iOS project found here, so it is not compared with Info.plist / PrivacyInfo.xcprivacy');
if (facts?.ios) {
const own = facts.privacyManifests.find(m => !/(^|\/)(Pods|node_modules|SourcePackages|Carthage|\.build)\//.test(m.path)) ?? null;
const ownPaths = new Set(facts.privacyManifests.map(m => m.path));
const sdkMs = projectSdkManifests(ctx.loaded.root).filter(s => !ownPaths.has(s.manifest.path));
for (const m of facts.privacyManifests) { const i = trackingIssue(m, sdkOfProjectPath(m.path)); if (i) problems.push(i); }
for (const s of sdkMs) { const i = trackingIssue(s.manifest, s.sdk); if (i) problems.push(i); }
const ipa = newestIpa(resolve(join(config.apple!.build?.out ?? 'build/ios', 'export')));
let built: BundleManifest[] = [];
if (ipa) {
try { built = ipaManifests(ipa); } catch (e) { note(`${relative(process.cwd(), ipa)}: privacy manifests not readable (${(e as Error).message})`); }
for (const i of bundleTrackingIssues(built)) problems.push(`${relative(process.cwd(), ipa)}: ${i} Rebuild after the fix (storebound apple build).`);
}
const tracking: TrackingSources = {
sdkManifests: [...sdkMs.filter(s => s.manifest.tracking === true).map(s => s.sdk), ...built.filter(b => b.sdk && b.manifest.tracking === true).map(b => b.sdk!)],
trackerSdks: facts.sdks.filter(s => s.sdk.tracking).map(s => s.sdk.name),
};
const say = (f: PrivacyMismatch) => { if (f.level === 'error') problems.push(f.msg); else if (f.level === 'warn') note(f.msg); else { hints.push(f.msg); log('check', `hint: ${f.msg}`); } };
if (hasTypes) {
const found = appPrivacyMismatches(apTypes!, { attString: facts.plist.has('NSUserTrackingUsageDescription'), manifest: own, tracking });
found.forEach(say);
const n = found.filter(f => f.level !== 'info').length;
items.push({ what: 'app privacy', detail: `${Object.keys(apTypes!).length} types answered, ${n} mismatch(es) with the iOS project` });
log('check', `app privacy: ${Object.keys(apTypes!).length} types answered, ${n} mismatch(es) with the iOS project`);
}
const typesTrack = hasTypes && Object.values(apTypes!).some(a => a?.tracking === true);
if (config.appPrivacy?.tracking === true && !typesTrack && own) trackingDeclaredIssues('appPrivacy.tracking is true', own, tracking).forEach(say);
}
if (config.dataSafety) tryDo('data safety', () => {
const rows = dataSafetyRows(config.dataSafety!, config.accounts);