storebound/cli/lib/device-families.ts

111 lines
7.4 KiB
TypeScript

// Device families of an iOS app: what the last live version of the app on the App Store supports, against what the
// project builds (TARGETED_DEVICE_FAMILY in the Xcode project) and what the config says (apple.ipad).
// Apple refuses an upload that drops a device family the previous version supported: ITMS-90101 "This bundle does not
// support one or more of the devices supported by the previous app version", after a full archive (seen live: an app
// whose live version ran on iPad, built with TARGETED_DEVICE_FAMILY = 1). So `validate` and `apple build` compare first.
// Pure helpers; the store reads are in cli/apple/device-families.ts.
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
export interface Families { iphone: boolean; ipad: boolean }
/** Families in TARGETED_DEVICE_FAMILY: 1 iPhone/iPod, 2 iPad (3 tvOS, 4 watchOS, 6 Mac Catalyst, 7 Vision). Pure. */
export function familiesOfList(list: number[]): Families { return { iphone: list.includes(1), ipad: list.includes(2) }; }
/** The families a store signal names: build bundle deviceProtocols, file-size device models, screenshot display types. Pure. */
export function familiesOfStrings(values: string[]): Families {
const t = values.join(' ');
return { iphone: /iphone|ipod/i.test(t), ipad: /ipad/i.test(t) };
}
export interface LiveFamilies { version: string; state: string; families: Families; evidence: string[] }
/** Merges the signals of one live version: any signal naming iPad means the version supports iPad. null when no signal
* names any family (unknown, never "iPhone only"). Pure. */
export function liveFamilies(version: string, state: string, signals: { name: string; values: string[] }[]): LiveFamilies | null {
const evidence: string[] = [];
const families: Families = { iphone: false, ipad: false };
for (const s of signals) {
const f = familiesOfStrings(s.values);
if (!f.iphone && !f.ipad) continue;
families.iphone ||= f.iphone;
families.ipad ||= f.ipad;
evidence.push(`${s.name}: ${[f.iphone && 'iPhone', f.ipad && 'iPad'].filter(Boolean).join(' + ')}`);
}
return evidence.length ? { version, state, families, evidence } : null;
}
export interface ProjectFamilies { file: string; list: number[]; families: Families; configurations: string[] }
/** The Release build configurations of a pbxproj with their TARGETED_DEVICE_FAMILY: the ones of the app's bundle id,
* else every Release configuration that is not an extension (bundle id below the app's). Pure. */
export function pbxFamilies(text: string, bundleId: string, file = 'project.pbxproj'): ProjectFamilies | null {
const blocks: { name: string; bundle: string | null; family: string | null; ext: boolean }[] = [];
const re = /isa = XCBuildConfiguration;[^{]*?buildSettings = \{([\s\S]*?)\};\s*name = ("[^"]*"|[\w.-]+);/g;
for (let m: RegExpExecArray | null; (m = re.exec(text));) {
const body = m[1]!;
const fam = /TARGETED_DEVICE_FAMILY = ("?)([\d, ]+)\1;/.exec(body)?.[2] ?? null;
const bid = /PRODUCT_BUNDLE_IDENTIFIER = "?([^";\s]+)"?;/.exec(body)?.[1] ?? null;
blocks.push({ name: m[2]!.replace(/"/g, ''), bundle: bid, family: fam, ext: /WRAPPER_EXTENSION = appex|APPLICATION_EXTENSION_API_ONLY = YES/.test(body) || (bid !== null && bid.startsWith(`${bundleId}.`)) });
}
const release = blocks.filter(b => b.name === 'Release' && b.family !== null);
let pick = release.filter(b => b.bundle === bundleId);
if (!pick.length) pick = release.filter(b => !b.ext);
if (!pick.length) return null;
const list = [...new Set(pick.flatMap(b => b.family!.split(',').map(x => Number(x.trim())).filter(n => Number.isFinite(n) && n > 0)))].sort((a, b) => a - b);
return { file, list, families: familiesOfList(list), configurations: pick.map(b => b.family!.replace(/\s/g, '')) };
}
const SKIP = new Set(['node_modules', 'Pods', '.git', 'build', 'DerivedData', 'Carthage', '.build', 'dist', 'vendor']);
/** Every project.pbxproj under root (depth <= 5), root-relative with forward slashes. */
export function findPbxprojs(root: string, depth = 5): string[] {
const out: string[] = [];
const walk = (dir: string, rel: string, d: number): void => {
if (d > depth || !existsSync(dir)) return;
let names: import('node:fs').Dirent[];
try { names = readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const e of names) {
if (!e.isDirectory() || SKIP.has(e.name)) continue;
const r = rel ? `${rel}/${e.name}` : e.name;
if (e.name.endsWith('.xcodeproj')) { if (existsSync(join(dir, e.name, 'project.pbxproj'))) out.push(`${r}/project.pbxproj`); continue; }
walk(join(dir, e.name), r, d + 1);
}
};
walk(root, '', 0);
return out;
}
/** The project's device families, read from the Xcode project (apple.build.project when set, else the first project
* that has the app's configurations). null when the project is not on this machine or sets no families. */
export function projectFamilies(root: string, bundleId: string, project?: string): ProjectFamilies | null {
const files = project && project.endsWith('.xcodeproj') ? [`${project.replace(/\\/g, '/')}/project.pbxproj`] : findPbxprojs(root);
for (const f of files) {
try {
const r = pbxFamilies(readFileSync(join(root, f), 'utf8'), bundleId, f);
if (r) return r;
} catch { /* unreadable: next */ }
}
return null;
}
export interface FamilyVerdict { level: 'ok' | 'blocker' | 'warn'; msg: string; fix?: string }
/** Compares the live version, the project and the config. Blocker: the live version supports iPad and the project does
* not (the upload is refused with ITMS-90101) or apple.ipad is not true (iPad screenshots and checks are off). Pure. */
export function familyVerdicts(i: { live: LiveFamilies | null; project: ProjectFamilies | null; ipad: boolean | undefined }): FamilyVerdict[] {
const out: FamilyVerdict[] = [];
const { live, project } = i;
const fixProject = 'set TARGETED_DEVICE_FAMILY to "1,2" (Xcode: target > General > Supported Destinations: add iPad)';
if (!live) {
out.push({ level: 'warn', msg: `device families of the live version not known${project ? '' : ' and no Xcode project found'}: if it supports iPad, an iPhone-only build is refused at upload (ITMS-90101)`, fix: 'storebound validate (reads the live version from App Store Connect)' });
return out;
}
const liveText = `${[live.families.iphone && 'iPhone', live.families.ipad && 'iPad'].filter(Boolean).join(' + ')} (version ${live.version} ${live.state}; ${live.evidence.join('; ')})`;
if (live.families.ipad) {
if (project && !project.families.ipad) out.push({ level: 'blocker', msg: `the live version supports ${liveText}, but ${project.file} builds TARGETED_DEVICE_FAMILY = ${project.configurations.join(' / ')} (no iPad): Apple refuses the upload after the archive with ITMS-90101`, fix: `${fixProject}; and apple.ipad: true in the config` });
if (i.ipad !== true) out.push({ level: 'blocker', msg: `the live version supports ${liveText}, but apple.ipad is not true in the config`, fix: 'set apple.ipad: true (iPad screenshots are then required) and TARGETED_DEVICE_FAMILY "1,2"' });
if (!out.length) out.push({ level: 'ok', msg: `device families: live ${liveText}; project ${project ? `TARGETED_DEVICE_FAMILY ${project.configurations.join(' / ')}` : 'not read'}; apple.ipad true` });
} else out.push({ level: 'ok', msg: `device families: live ${liveText}; nothing to keep` });
return out;
}