138 lines
8.2 KiB
TypeScript
138 lines
8.2 KiB
TypeScript
// 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<string, string> {
|
|
const out = new Map<string, string>();
|
|
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<string, string> {
|
|
const at = body.search(/buildSettings\s*=\s*\{/);
|
|
if (at < 0) return {};
|
|
const inner = body.slice(body.indexOf('{', at) + 1);
|
|
const out: Record<string, string> = {};
|
|
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, string>): 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<string, string> = { ...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<string, unknown> | 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<string, unknown> | 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 }
|
|
|
|
/** The agent-first steps to tick the groups (the API cannot do it); the never-press list is part of the text. */
|
|
export function appGroupSteps(g: AppGroupGap): string[] {
|
|
const url = g.url ?? 'https://developer.apple.com/account/resources/identifiers/list';
|
|
return [
|
|
`${g.bundleId}: the profile lacks App Group(s) ${g.groups.join(', ')} (the 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).`,
|
|
'Capabilities list > App Groups: tick it if it is not ticked, press Configure, tick each group above (Continue if the group is new: register it first under Identifiers > App Groups), Continue, then Save at the top right (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.',
|
|
`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<string, unknown> | 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;
|
|
}
|