// Ad-hoc / release-testing distribution: device list parsing, UDID checks and masking, the register plan, the // provisioning profile request, ExportOptions.plist and the xcodebuild argv. Pure (no IO), used by cli/apple/adhoc.ts. // Sources (checked October 2026): // App Store Connect API, Devices: https://developer.apple.com/documentation/appstoreconnectapi/devices // Device.Attributes: deviceClass APPLE_VISION_PRO | APPLE_WATCH | IPAD | IPHONE | IPOD | APPLE_TV | MAC, // status ENABLED | DISABLED, platform BundleIdPlatform (IOS | MAC_OS | UNIVERSAL), name, model, udid, addedDate. // DeviceCreateRequest.Data.Attributes: name, platform, udid (all required). Devices can only be removed on the // Apple Developer website. // Profiles: https://developer.apple.com/documentation/appstoreconnectapi/profiles // ProfileCreateRequest: attributes name + profileType (IOS_APP_ADHOC, ...), relationships bundleId + certificates // (required), devices. // Devices overview (Developer Account Help): up to 100 devices per product family per membership year; disabling a // device does not give the slot back. https://developer.apple.com/help/account/devices/devices-overview // `xcodebuild -help` (Xcode 27): -exportOptionsPlist keys method (app-store-connect, release-testing, enterprise, // debugging, developer-id, mac-application, validation; ad-hoc / development / app-store are the deprecated names), // teamID, signingStyle (manual | automatic), provisioningProfiles (bundle id -> profile name or UUID), destination // (export | upload), thinning ( ...). compileBitcode is no longer listed (bitcode was removed in Xcode 14), // so it is not written. // UDID shapes: Apple documents no format. Accepted here, from what devices report: the legacy 40 hex digits, the // 8-16 hex form of newer iPhones / iPads and Apple silicon Macs (the "Provisioning UDID"), and for MAC_OS also the // 8-4-4-4-12 hardware UUID of Intel Macs. This shape check is not from an Apple document. import { parseCsvRecords } from './csv'; export type DevicePlatform = 'IOS' | 'MAC_OS'; export const DEVICE_PLATFORMS: DevicePlatform[] = ['IOS', 'MAC_OS']; export const DEVICE_CLASSES = ['IPHONE', 'IPAD', 'IPOD', 'APPLE_WATCH', 'APPLE_TV', 'APPLE_VISION_PRO', 'MAC'] as const; /** Devices per product family per membership year (Devices overview). */ export const YEARLY_LIMIT = 100; export type ExportMethod = 'release-testing' | 'ad-hoc'; /** Deprecated ExportOptions method names -> the current ones (`xcodebuild -help`, Xcode 15.3+). */ export const MODERN_METHOD: Record = { 'ad-hoc': 'release-testing', development: 'debugging', 'app-store': 'app-store-connect' }; export interface WantedDevice { name: string; udid: string; platform: DevicePlatform } export interface RegisteredDevice { id: string; name: string; udid: string; platform: string; deviceClass?: string; model?: string | null; status: string } const LEGACY = /^[0-9a-f]{40}$/i; const MODERN = /^[0-9a-f]{8}-[0-9a-f]{16}$/i; const MAC_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; export function validUdid(udid: string, platform: DevicePlatform = 'IOS'): boolean { const u = udid.trim(); return LEGACY.test(u) || MODERN.test(u) || (platform === 'MAC_OS' && MAC_UUID.test(u)); } /** First 4 + "…" + last 4. Never print a full UDID anywhere (logs, JSON, errors). */ export function maskUdid(udid: string): string { const u = udid.trim(); return u.length <= 8 ? '…' : `${u.slice(0, 4)}…${u.slice(-4)}`; } /** Replaces every UDID-shaped string (and every given UDID) in a message with its masked form: API errors can echo them. */ export function scrubUdids(text: string, known: string[] = []): string { let s = text; for (const u of known) if (u.length > 8) s = s.split(u).join(maskUdid(u)); return s.replace(/\b[0-9a-fA-F]{40}\b|\b[0-9a-fA-F]{8}-[0-9a-fA-F]{16}\b|\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/g, m => maskUdid(m)); } export function parsePlatform(p: string | undefined): DevicePlatform | null { const v = (p ?? '').trim().toUpperCase().replace(/[\s-]/g, '_'); if (!v || v === 'IOS' || v === 'IPADOS') return 'IOS'; if (v === 'MAC_OS' || v === 'MACOS' || v === 'MAC') return 'MAC_OS'; return null; } /** devices.csv (name, udid, platform; platform optional, default IOS). Errors name the row and the masked UDID. */ export function parseDevicesCsv(text: string): { devices: WantedDevice[]; errors: string[] } { const devices: WantedDevice[] = []; const errors: string[] = []; const seen = new Set(); parseCsvRecords(text).forEach((r, i) => { const row = i + 2; const name = (r.name ?? r.device_name ?? '').trim(); const udid = (r.udid ?? r.device_id ?? r.identifier ?? '').trim(); const platform = parsePlatform(r.platform); if (!udid && !name) return; if (!name) errors.push(`row ${row}: name is empty`); if (!platform) errors.push(`row ${row}: platform must be IOS or MAC_OS`); if (!validUdid(udid, platform ?? 'IOS')) errors.push(`row ${row}: "${maskUdid(udid)}" is not a device UDID (40 hex digits, or 8 hex - 16 hex)`); const key = udid.toLowerCase(); if (seen.has(key)) errors.push(`row ${row}: ${maskUdid(udid)} is listed twice`); seen.add(key); if (name && platform && validUdid(udid, platform)) devices.push({ name, udid, platform }); }); return { devices, errors: [...new Set(errors)] }; } export interface RegisterPlan { add: WantedDevice[]; /** Already registered and enabled. */ present: WantedDevice[]; /** Registered but disabled: storebound never enables devices; the owner does it on the developer website. */ disabled: WantedDevice[]; } /** What a register run would POST: the wanted devices whose UDID is not registered yet (case-insensitive). */ export function planRegister(wanted: WantedDevice[], registered: RegisteredDevice[]): RegisterPlan { const by = new Map(registered.map(d => [d.udid.toLowerCase(), d] as const)); const plan: RegisterPlan = { add: [], present: [], disabled: [] }; for (const w of wanted) { const r = by.get(w.udid.toLowerCase()); if (!r) plan.add.push(w); else if (r.status === 'DISABLED') plan.disabled.push(w); else plan.present.push(w); } return plan; } /** Registered devices per device class (enabled and disabled both use a slot of the yearly 100). */ export function slotUsage(registered: RegisteredDevice[]): { deviceClass: string; registered: number; limit: number }[] { const n = new Map(); for (const d of registered) n.set(d.deviceClass ?? 'UNKNOWN', (n.get(d.deviceClass ?? 'UNKNOWN') ?? 0) + 1); return [...n].sort((a, b) => a[0].localeCompare(b[0])).map(([deviceClass, registered]) => ({ deviceClass, registered, limit: YEARLY_LIMIT })); } export function deviceFromApi(d: { id: string; attributes: Record }): RegisteredDevice { const a = d.attributes; return { id: d.id, name: String(a.name ?? ''), udid: String(a.udid ?? ''), platform: String(a.platform ?? ''), deviceClass: a.deviceClass ? String(a.deviceClass) : undefined, model: (a.model as string | null) ?? null, status: String(a.status ?? '') }; } /** One device as printed (table and --json): the UDID masked. */ export function maskedDevice(d: RegisteredDevice): { id: string; name: string; platform: string; deviceClass: string | null; model: string | null; status: string; udid: string } { return { id: d.id, name: d.name, platform: d.platform, deviceClass: d.deviceClass ?? null, model: d.model ?? null, status: d.status, udid: maskUdid(d.udid) }; } export const deviceCreateReq = (d: WantedDevice) => ({ data: { type: 'devices', attributes: { name: d.name, udid: d.udid, platform: d.platform } } }); /** Devices an iOS ad-hoc profile takes: enabled, platform IOS, not Apple TV (tvOS has its own TVOS_APP_ADHOC profiles). */ export const profileDevices = (registered: RegisteredDevice[]): RegisteredDevice[] => registered.filter(d => d.status === 'ENABLED' && d.platform === 'IOS' && d.deviceClass !== 'APPLE_TV'); export function profileCreateReq(name: string, bundleIdId: string, certificateIds: string[], deviceIds: string[]) { return { data: { type: 'profiles', attributes: { name, profileType: 'IOS_APP_ADHOC' }, relationships: { bundleId: { data: { type: 'bundleIds', id: bundleIdId } }, certificates: { data: certificateIds.map(id => ({ type: 'certificates', id })) }, devices: { data: deviceIds.map(id => ({ type: 'devices', id })) }, } } }; } /** Distribution certificates usable for an ad-hoc profile: Apple Distribution (DISTRIBUTION) or the older * iOS Distribution (IOS_DISTRIBUTION), not expired. */ export function usableCertificates(certs: { id: string; attributes: Record }[], now = new Date()): string[] { return certs.filter(c => ['DISTRIBUTION', 'IOS_DISTRIBUTION'].includes(String(c.attributes.certificateType)) && (!c.attributes.expirationDate || new Date(String(c.attributes.expirationDate)) > now)).map(c => c.id); } /** An existing profile is current when it is ACTIVE and holds every wanted device. */ export function profileCurrent(state: string | undefined, has: string[], want: string[]): { current: boolean; missing: number } { const s = new Set(has); const missing = want.filter(id => !s.has(id)).length; return { current: state === 'ACTIVE' && missing === 0, missing }; } export const escapeXml = (s: string): string => s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); export interface ExportOptions { method: string; teamId: string; bundleId?: string; profile?: string } /** ExportOptions.plist (XML plist, keys sorted like Xcode writes them). Manual signing only with a profile name. */ export function exportOptionsPlist(o: ExportOptions): string { const str = (k: string, v: string) => `\t${escapeXml(k)}\n\t${escapeXml(v)}\n`; let body = str('destination', 'export') + str('method', o.method); if (o.profile) { if (!o.bundleId) throw new Error('a manual-signing profile needs apple.bundleId'); body += `\tprovisioningProfiles\n\t\n\t\t${escapeXml(o.bundleId)}\n\t\t${escapeXml(o.profile)}\n\t\n`; } body += str('signingStyle', o.profile ? 'manual' : 'automatic') + str('teamID', o.teamId) + str('thinning', ''); return '\n' + '\n' + `\n\n${body}\n\n`; } export const exportArgv = (archive: string, plist: string, out: string): string[] => ['xcodebuild', '-exportArchive', '-archivePath', archive, '-exportOptionsPlist', plist, '-exportPath', out, '-allowProvisioningUpdates']; /** A command line to print: arguments with shell-special characters single-quoted. */ export const shellLine = (argv: string[]): string => argv.map(a => (/^[\w@%+=:,./-]+$/.test(a) ? a : `'${a.replace(/'/g, `'\\''`)}'`)).join(' ');