// Signable targets of an Xcode project (the app, app extensions such as a share extension or widget, the watch app, an // App Clip), read from project.pbxproj: product type, PRODUCT_BUNDLE_IDENTIFIER and CODE_SIGN_ENTITLEMENTS of the // Release configuration. Pure parsing; the fs side (entitlements files, config override) is cli/apple/signing.ts. // Also the App Group check: Apple's API can switch the APP_GROUPS capability on for a bundle id but cannot assign a // group container to it, so a profile carries `com.apple.security.application-groups` only when the group was ticked in // the developer portal; comparing the target's entitlements with the profile's entitlements finds the gap offline. export type TargetKind = 'app' | 'extension' | 'watch' | 'clip'; export interface PbxTarget { name: string; productType: string; kind: TargetKind; /** null: not resolvable (set in an xcconfig or a variable) */ bundleId: string | null; unresolved?: string; entitlements: string | null } /** Product types that are code signed with their own bundle id and provisioning profile. Frameworks, bundles, test * bundles, tools and libraries are not. */ export function targetKind(productType: string): TargetKind | null { const m = /^com\.apple\.product-type\.(.+)$/.exec(productType); const t = m?.[1]; if (!t) return null; if (t === 'application.on-demand-install-capable') return 'clip'; if (/^application\.watchapp/.test(t) || /^watchkit2?-extension$/.test(t)) return 'watch'; if (t === 'application') return 'app'; if (/^(app-extension(\..+)?|extensionkit-extension|tv-app-extension)$/.test(t)) return 'extension'; return null; } /** id -> body of every `<24 hex id> /* comment *\/ = { ... };` object (brace matched, quoted strings skipped). */ function pbxObjects(pbx: string): Map { const out = new Map(); const re = /^[ \t]*([0-9A-Fa-f]{24})(?:[ \t]*\/\*[\s\S]*?\*\/)?[ \t]*=[ \t]*\{/gm; for (let m = re.exec(pbx); m; m = re.exec(pbx)) { const open = m.index + m[0].length - 1; let depth = 0, q = false, i = open; for (; i < pbx.length; i++) { const ch = pbx[i]; if (q) { if (ch === '\\') i++; else if (ch === '"') q = false; continue; } if (ch === '"') q = true; else if (ch === '{') depth++; else if (ch === '}' && --depth === 0) break; } out.set(m[1]!, pbx.slice(open + 1, i)); re.lastIndex = i; } return out; } const unquote = (s: string): string => { const t = s.trim(); return t.startsWith('"') && t.endsWith('"') ? t.slice(1, -1).replace(/\\"/g, '"') : t; }; const field = (body: string, name: string): string | null => { const m = new RegExp(`(?:^|[;\\s])${name}\\s*=\\s*("(?:[^"\\\\]|\\\\.)*"|[^;\\s]+)\\s*(?:/\\*[\\s\\S]*?\\*/)?\\s*;`).exec(body); return m ? unquote(m[1]!) : null; }; function buildSettings(body: string): Record { const at = body.search(/buildSettings\s*=\s*\{/); if (at < 0) return {}; const inner = body.slice(body.indexOf('{', at) + 1); const out: Record = {}; for (const m of inner.matchAll(/^[ \t]*("?[A-Za-z_][A-Za-z0-9_\[\]=*]*"?)[ \t]*=[ \t]*("(?:[^"\\]|\\.)*"|\([^)]*\)|[^;]*);/gm)) out[unquote(m[1]!)] = unquote(m[2]!); return out; } /** $(VAR) / ${VAR} from the target's own settings, TARGET_NAME and PRODUCT_NAME; a modifier (:rfc1034identifier) * makes the value an identifier. Anything still containing $( afterwards is unresolved. */ export function resolveSetting(value: string, vars: Record): string { let v = value; for (let n = 0; n < 5 && /\$[({]/.test(v); n++) { v = v.replace(/\$[({]([A-Za-z0-9_]+)(?::([a-z0-9]+))?[)}]/g, (all, name: string, mod?: string) => { const r = vars[name]; if (r === undefined || r === `$(${name})`) return all; return mod === 'rfc1034identifier' || mod === 'identifier' ? r.replace(/[^A-Za-z0-9.-]/g, '-') : r; }); } return v; } /** The signable targets of one project.pbxproj, in file order. */ export function pbxTargets(pbx: string): PbxTarget[] { const objs = pbxObjects(pbx); const out: PbxTarget[] = []; for (const body of objs.values()) { if (field(body, 'isa') !== 'PBXNativeTarget') continue; const productType = field(body, 'productType') ?? ''; const kind = targetKind(productType); if (!kind) continue; const name = field(body, 'name') ?? field(body, 'productName') ?? '?'; const listBody = objs.get((field(body, 'buildConfigurationList') ?? '').split(/\s/)[0]!); const ids = [...(/buildConfigurations\s*=\s*\(([\s\S]*?)\)/.exec(listBody ?? '')?.[1] ?? '').matchAll(/([0-9A-Fa-f]{24})/g)].map(m => m[1]!); const cfgs = ids.map(id => objs.get(id)).filter((b): b is string => !!b); const cfg = cfgs.find(c => field(c, 'name') === 'Release') ?? cfgs[cfgs.length - 1]; const s = cfg ? buildSettings(cfg) : {}; const vars: Record = { ...s, TARGET_NAME: name, PRODUCT_NAME: resolveSetting(s.PRODUCT_NAME ?? name, { TARGET_NAME: name }) }; const raw = s.PRODUCT_BUNDLE_IDENTIFIER; const bundleId = raw ? resolveSetting(raw, vars) : null; const ent = s.CODE_SIGN_ENTITLEMENTS ? resolveSetting(s.CODE_SIGN_ENTITLEMENTS, { ...vars, SRCROOT: '' }).replace(/^\/+/, '') : null; out.push({ name, productType, kind, bundleId: bundleId && !/\$[({]/.test(bundleId) ? bundleId : null, ...(raw && (!bundleId || /\$[({]/.test(bundleId)) ? { unresolved: raw } : {}), entitlements: ent }); } return out; } /** App groups (`com.apple.security.application-groups`) a profile's Entitlements allow. */ export const profileAppGroups = (ent: Record | undefined): string[] => { const g = ent?.['com.apple.security.application-groups']; return Array.isArray(g) ? g.map(String) : []; }; /** Groups the target's entitlements need that the profile does not carry. `*` in a profile allows any group. */ export function appGroupGaps(want: string[], profileEnt: Record | undefined): string[] { const have = profileAppGroups(profileEnt); if (have.includes('*')) return []; return want.filter(g => !have.includes(g)); } /** The developer portal page of a bundle id (the id is the App Store Connect API resource id of the bundle id). */ export const bundleIdPortalUrl = (resourceId: string): string => `https://developer.apple.com/account/resources/identifiers/bundleId/edit/${encodeURIComponent(resourceId)}`; export interface AppGroupGap { bundleId: string; resourceId: string | null; groups: string[]; url: string | null; /** why it is a gap / not proven (one line) */ why?: string } export const PORTAL_IDENTIFIERS_URL = 'https://developer.apple.com/account/resources/identifiers/list'; /** The agent-first steps to tick the groups (the API cannot do it); the never-press list is part of the text. * The first line is the headline, the rest are the steps. */ export function appGroupSteps(g: AppGroupGap): string[] { const url = g.url ?? PORTAL_IDENTIFIERS_URL; return [ `${g.bundleId}: ${g.why ?? `the profile lacks App Group(s) ${g.groups.join(', ')}`} (the App Store Connect API can enable the App Groups capability but cannot assign a group to a bundle id).`, `Open ${url} in the signed-in browser (agent-first: you drive it; plan: storebound console plan apple-app-group --bundle-id ${g.bundleId}${g.url ? '' : ' --lookup'}).`, `Certificates, Identifiers & Profiles > Identifiers > ${g.bundleId} > App Groups (tick it if it is not ticked) > Configure > tick ${g.groups.join(', ')} (register a new group first under Identifiers > App Groups) > Continue > Save (and Confirm in the dialog that says the profiles get invalidated).`, 'Never press: Remove (the bundle id), Delete, Revoke, or any Certificates / Keys / Account button; change no other capability.', 'Then run: storebound apple signing ensure --refresh-profile (makes a new profile that carries the group), and storebound apple signing status to read it back.', ]; } /** Groups missing per bundle id -> one gap row each. `groupsOf` and `profileOf` are looked up by bundle id. */ export function appGroupGapsFor(targets: { bundleId: string; appGroups: string[] }[], profileEnt: (bundleId: string) => Record | undefined | null, resourceId: (bundleId: string) => string | null): AppGroupGap[] { const out: AppGroupGap[] = []; for (const t of targets) { if (!t.appGroups.length) continue; const ent = profileEnt(t.bundleId); if (ent === null) continue; // no readable profile: unknown, not a gap const groups = appGroupGaps(t.appGroups, ent); if (groups.length) { const rid = resourceId(t.bundleId); out.push({ bundleId: t.bundleId, resourceId: rid, groups, url: rid ? bundleIdPortalUrl(rid) : null }); } } return out; } /** What the API (or an installed profile) shows about one App ID's App Group association. */ export interface GroupEvidence { /** App Store Connect API resource id of the bundle id (the portal deep link); null: not registered / unknown */ resourceId: string | null; /** the APP_GROUPS capability is on the App ID (null: unknown) */ capabilityOn: boolean | null; /** the raw settings of the APP_GROUPS capability (any field that names a group proves it) */ capabilitySettings?: unknown; /** Entitlements of the newest readable profile of the bundle id (a managed one too); null: none readable */ profileEnt: Record | null; } /** Groups of `want` that the capability's settings name (the API has no documented App Group field; if a field ever * lists them, this proves the association without a profile). */ export const capabilityGroups = (settings: unknown, want: string[]): string[] => { if (settings === null || settings === undefined) return []; const text = JSON.stringify(settings); return want.filter(g => text.includes(g)); }; export interface GroupVerdicts { gaps: AppGroupGap[]; unproven: AppGroupGap[]; ok: string[] } /** * Every target whose entitlements list App Groups: proven (a readable profile carries each group, or the capability * settings name it), a gap (the evidence shows the group missing: a readable profile without it, or the capability * off) or unproven (nothing readable: the API cannot show the association). Pure. */ export function appGroupVerdicts(targets: { bundleId: string; appGroups: string[] }[], evidence: (bundleId: string) => GroupEvidence | null): GroupVerdicts { const out: GroupVerdicts = { gaps: [], unproven: [], ok: [] }; for (const t of targets) { if (!t.appGroups.length) continue; const ev = evidence(t.bundleId); const rid = ev?.resourceId ?? null, url = rid ? bundleIdPortalUrl(rid) : null; const viaCap = new Set(capabilityGroups(ev?.capabilitySettings, t.appGroups)); const missing = (ev?.profileEnt ? appGroupGaps(t.appGroups, ev.profileEnt) : t.appGroups).filter(g => !viaCap.has(g)); if (!missing.length) { out.ok.push(t.bundleId); continue; } if (ev?.capabilityOn === false) out.gaps.push({ bundleId: t.bundleId, resourceId: rid, groups: missing, url, why: `the App Groups capability is not enabled on the App ID, so ${missing.join(', ')} is not associated` }); else if (ev?.profileEnt) out.gaps.push({ bundleId: t.bundleId, resourceId: rid, groups: missing, url, why: `the newest profile lacks App Group(s) ${missing.join(', ')} (an empty application-groups list means the group is not ticked on the App ID)` }); else out.unproven.push({ bundleId: t.bundleId, resourceId: rid, groups: missing, url, why: ev ? `App Group(s) ${missing.join(', ')} cannot be proven: the API shows no group on the App ID's capability and no readable profile` : `App Group(s) ${missing.join(', ')} cannot be proven: the App ID is not registered or could not be read` }); } return out; }