apple signing ensure with automatic signing only adds capabilities (no profiles or certificates; the dry run says so) and lists unused storebound profiles with the exact delete command, never run; every capability write is read back (lag retries, timeout) and fails with the API answers when one did not stick; apple status, signing status and validate compare the App ID, apple.signing.capabilities and the iOS project's entitlements (CODE_SIGN_ENTITLEMENTS too) and name what is missing where; api writes outside a terminal say TTY by design and name the dedicated command; pitfalls 64-66; tests
This commit is contained in:
parent
357319a775
commit
daae3715bf
12 changed files with 463 additions and 23 deletions
|
|
@ -199,3 +199,114 @@ export function exportOptions(i: ExportOptionsInput): Record<string, PlistValue>
|
|||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
// --- capabilities: App ID vs config vs the iOS project's entitlements
|
||||
|
||||
/** Entitlement keys (*.entitlements) -> the App ID capability they need. In-App Purchase has no entitlement. */
|
||||
export const ENTITLEMENT_CAPABILITIES: Record<string, CapabilityType> = {
|
||||
'aps-environment': 'PUSH_NOTIFICATIONS',
|
||||
'com.apple.developer.aps-environment': 'PUSH_NOTIFICATIONS',
|
||||
'com.apple.developer.game-center': 'GAME_CENTER',
|
||||
'com.apple.developer.associated-domains': 'ASSOCIATED_DOMAINS',
|
||||
'com.apple.developer.applesignin': 'APPLE_ID_AUTH',
|
||||
'com.apple.developer.icloud-container-identifiers': 'ICLOUD',
|
||||
'com.apple.developer.icloud-services': 'ICLOUD',
|
||||
'com.apple.developer.ubiquity-kvstore-identifier': 'ICLOUD',
|
||||
'com.apple.developer.ubiquity-container-identifiers': 'ICLOUD',
|
||||
'com.apple.security.application-groups': 'APP_GROUPS',
|
||||
'com.apple.developer.healthkit': 'HEALTHKIT',
|
||||
'com.apple.developer.homekit': 'HOMEKIT',
|
||||
'com.apple.developer.in-app-payments': 'APPLE_PAY',
|
||||
'com.apple.developer.pass-type-identifiers': 'WALLET',
|
||||
'com.apple.developer.siri': 'SIRIKIT',
|
||||
'com.apple.developer.networking.networkextension': 'NETWORK_EXTENSIONS',
|
||||
'com.apple.developer.networking.vpn.api': 'PERSONAL_VPN',
|
||||
'com.apple.developer.nfc.readersession.formats': 'NFC_TAG_READING',
|
||||
'com.apple.developer.networking.wifi-info': 'ACCESS_WIFI_INFORMATION',
|
||||
'com.apple.developer.default-data-protection': 'DATA_PROTECTION',
|
||||
'com.apple.developer.ClassKit-environment': 'CLASSKIT',
|
||||
'com.apple.developer.authentication-services.autofill-credential-provider': 'AUTOFILL_CREDENTIAL_PROVIDER',
|
||||
'com.apple.developer.networking.multipath': 'MULTIPATH',
|
||||
'com.apple.developer.networking.HotspotConfiguration': 'HOT_SPOT',
|
||||
'inter-app-audio': 'INTER_APP_AUDIO',
|
||||
'com.apple.external-accessory.wireless-configuration': 'WIRELESS_ACCESSORY_CONFIGURATION',
|
||||
'com.apple.developer.coremedia.hls.low-latency': 'COREMEDIA_HLS_LOW_LATENCY',
|
||||
'com.apple.developer.user-management': 'USER_MANAGEMENT',
|
||||
};
|
||||
|
||||
/** Top-level entitlement keys switched on in an .entitlements plist (a key whose value is <false/> is off). */
|
||||
export function entitlementKeys(xml: string): string[] {
|
||||
const body = xml.replace(/<!--[\s\S]*?-->/g, '');
|
||||
return [...body.matchAll(/<key>\s*([^<]+?)\s*<\/key>\s*(<false\s*\/>)?/g)].filter(m => !m[2]).map(m => m[1]!);
|
||||
}
|
||||
|
||||
export interface ProjectEntitlements { file: string; caps: { cap: CapabilityType; key: string }[]; referenced: boolean | null }
|
||||
|
||||
export function projectEntitlements(file: string, xml: string, referenced: boolean | null): ProjectEntitlements {
|
||||
const caps: { cap: CapabilityType; key: string }[] = [];
|
||||
for (const key of entitlementKeys(xml)) {
|
||||
const cap = ENTITLEMENT_CAPABILITIES[key];
|
||||
if (cap && !caps.some(c => c.cap === cap)) caps.push({ cap, key });
|
||||
}
|
||||
return { file, caps, referenced };
|
||||
}
|
||||
|
||||
export interface CapMismatch { cap: string; missingIn: 'app-id' | 'config' | 'project'; msg: string; fix: string }
|
||||
|
||||
/** Compares the three places a capability has to be: the App ID (Certificates, Identifiers & Profiles), the config's
|
||||
* apple.signing.capabilities and the iOS project's entitlements. `appId` null = not read; `project` null = no iOS
|
||||
* project found (not compared). Names exactly what is missing where. */
|
||||
export function compareCapabilities(i: { appId: string[] | null; config: string[]; project: ProjectEntitlements[] | null }): CapMismatch[] {
|
||||
const out: CapMismatch[] = [];
|
||||
const has = new Set(i.appId ?? []);
|
||||
const projCaps = new Map<string, { file: string; key: string }>();
|
||||
for (const p of i.project ?? []) for (const c of p.caps) if (!projCaps.has(c.cap)) projCaps.set(c.cap, { file: p.file, key: c.key });
|
||||
const needsEntitlement = new Set<string>(Object.values(ENTITLEMENT_CAPABILITIES));
|
||||
if (i.appId) {
|
||||
for (const c of i.config) if (!has.has(c))
|
||||
out.push({ cap: c, missingIn: 'app-id', msg: `${c} is in apple.signing.capabilities but not on the App ID`, fix: 'storebound apple signing ensure --dry, then without --dry' });
|
||||
for (const [c, w] of projCaps) if (!has.has(c) && !i.config.includes(c))
|
||||
out.push({ cap: c, missingIn: 'app-id', msg: `${c} is in ${w.file} (${w.key}) but not on the App ID: the archive's profile will not allow it`, fix: `add '${c}' to apple.signing.capabilities, then storebound apple signing ensure` });
|
||||
}
|
||||
for (const [c, w] of projCaps) if (!i.config.includes(c))
|
||||
out.push({ cap: c, missingIn: 'config', msg: `${c} is in ${w.file} (${w.key}) but not in apple.signing.capabilities`, fix: `add '${c}' to apple.signing.capabilities so signing ensure keeps the App ID in step` });
|
||||
if (i.project) {
|
||||
for (const c of i.config) if (needsEntitlement.has(c) && !projCaps.has(c)) {
|
||||
const key = Object.entries(ENTITLEMENT_CAPABILITIES).find(([, v]) => v === c)![0];
|
||||
out.push({ cap: c, missingIn: 'project', msg: `${c} is in apple.signing.capabilities but no .entitlements file of the iOS project has ${key}: builds ship without it`,
|
||||
fix: `add ${key} to the app target's .entitlements (Xcode > Signing & Capabilities > + ${c.toLowerCase().replace(/_/g, ' ')}) and set CODE_SIGN_ENTITLEMENTS` });
|
||||
}
|
||||
for (const p of i.project) if (p.referenced === false && p.caps.length)
|
||||
out.push({ cap: p.caps.map(c => c.cap).join(', '), missingIn: 'project', msg: `${p.file} is not referenced by CODE_SIGN_ENTITLEMENTS in the Xcode project: builds ignore it`, fix: `set CODE_SIGN_ENTITLEMENTS = ${p.file.split('/').slice(-2).join('/')} for the app target (Release and Debug)` });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Reads the App ID's capabilities until every wanted one is there (App Store Connect lags right after a write) or the
|
||||
* time is up. `missing` empty = all there. */
|
||||
export async function verifyCapabilities(read: () => Promise<string[]>, want: string[], o: { tries?: number; waitMs?: number; timeoutMs?: number; sleep?: (ms: number) => Promise<void>; onWait?: (missing: string[], n: number) => void } = {}): Promise<{ have: string[]; missing: string[]; reads: number }> {
|
||||
const tries = o.tries ?? 5, waitMs = o.waitMs ?? 3000, deadline = Date.now() + (o.timeoutMs ?? 45_000);
|
||||
const sleep = o.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
|
||||
let have: string[] = [], missing = want, reads = 0;
|
||||
for (let n = 0; n < tries; n++) {
|
||||
if (n) { o.onWait?.(missing, n); if (Date.now() + waitMs * n > deadline) break; await sleep(waitMs * n); }
|
||||
have = await read(); reads++;
|
||||
missing = want.filter(w => !have.includes(w));
|
||||
if (!missing.length) break;
|
||||
}
|
||||
return { have, missing, reads };
|
||||
}
|
||||
|
||||
/** Provisioning profiles storebound made ("storebound <bundle id> ..."), not in `inUse`: unused leftovers whose
|
||||
* deletion is the owner's call. */
|
||||
export const unusedStoreboundProfiles = (list: ApiProfile[], inUse: Set<string>): ApiProfile[] =>
|
||||
list.filter(p => /^storebound /.test(p.attributes.name ?? '') && !inUse.has(p.id));
|
||||
|
||||
export const deleteProfileCmd = (p: ApiProfile): string => `storebound api apple profiles_deleteInstance --param id=${p.id} --yes # "${p.attributes.name}" (in a terminal; asks you to type the operation id)`;
|
||||
|
||||
/** What `signing ensure` writes: automatic signing only adds capabilities (Xcode owns certificates and profiles). */
|
||||
export function ensureScope(style: 'automatic' | 'manual' | undefined): { certificates: boolean; profiles: boolean; why: string } {
|
||||
return (style ?? 'automatic') === 'automatic'
|
||||
? { certificates: false, profiles: false, why: 'automatic signing: ensure only keeps the App ID and its capabilities in step; certificates and provisioning profiles are Xcode\'s (xcodebuild -allowProvisioningUpdates with the API key makes them at archive time)' }
|
||||
: { certificates: true, profiles: true, why: 'manual signing: ensure also keeps the distribution certificate and an App Store profile per bundle id' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ export interface AppleSnapshot {
|
|||
iaps: { productId: string; state: string; reviewScreenshot: boolean }[];
|
||||
expectedProducts: string[];
|
||||
privacyUrl: UrlCheck;
|
||||
/** App ID capabilities vs apple.signing.capabilities vs the iOS project's entitlements (cli/lib/signing.ts compareCapabilities). */
|
||||
capabilityMismatches?: { msg: string; fix: string }[];
|
||||
/** Local renders (Apple sizes only), checked before upload. Optional: validate fills it when a renders dir exists. */
|
||||
renders?: RendersCheck;
|
||||
/** Auto-renewable subscriptions on App Store Connect (validate reads them when the config has subscriptions). */
|
||||
|
|
@ -218,6 +220,8 @@ export function appleRules(s: AppleSnapshot): Finding[] {
|
|||
if (s.iaps.length && !f.some(x => x.code === 'iap')) add('ok', 'iap', `${s.iaps.length} in-app purchases ready`);
|
||||
|
||||
f.push(...appleSubscriptionRules(s));
|
||||
for (const m of s.capabilityMismatches ?? []) add('warn', 'capabilities', m.msg, m.fix);
|
||||
if (s.capabilityMismatches && !s.capabilityMismatches.length) add('ok', 'capabilities', 'capabilities match (App ID, config, project entitlements)');
|
||||
add(urlOk(s.privacyUrl) ? 'ok' : 'blocker', 'privacy-url', `privacy policy ${s.privacyUrl.url}: ${urlMsg(s.privacyUrl)}`, 'privacy-policy skill');
|
||||
if (s.profile) f.push(...profileRules('apple', s.profile, { reviewDetail: s.reviewDetail, descriptions: s.locales.map(l => ({ locale: l.locale, text: l.description ?? '' })) }));
|
||||
f.push(...rendersFindings('apple', s.renders));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue