// 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; }