Conflicts resolved for both sides: - cli/lib/plist.ts, cli/lib/zip.ts: one implementation each. plist reads XML (text or bytes, attributes tolerated) and binary plists; ads scan now reads bytes, so a binary Info.plist works too. zip gains unzip() for the Play report exports on top of zipEntries / zipRead. - apple upload waits with testflight wait's loop (waitForBuild) instead of its own copy; BUILD_DONE removed. - config, credentials (profiles, keystore, ASC_VENDOR_NUMBER, now also a profile key), command table, MCP tools, credentials template, README, CHANGELOG: union. - MCP: --write stays forbidden; the three pull tools are the listed local-file exception (LOCAL_WRITE_TOOLS), so scan --write remains unreachable. - Adapters and API coverage tables regenerated.
128 lines
7.3 KiB
TypeScript
128 lines
7.3 KiB
TypeScript
// Property lists without dependencies: write XML plists (ExportOptions.plist), read XML and binary plists (a source or
|
|
// IPA Info.plist, the plist inside a .mobileprovision). Only the types those files use: dict, array, string, integer,
|
|
// real, true / false, date (as an ISO string) and data (as a Buffer). Pure.
|
|
export type PlistValue = string | number | boolean | Buffer | PlistValue[] | { [k: string]: PlistValue };
|
|
|
|
const esc = (s: string): string => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
|
|
function xmlValue(v: PlistValue, ind: string): string {
|
|
if (typeof v === 'string') return `${ind}<string>${esc(v)}</string>`;
|
|
if (typeof v === 'boolean') return `${ind}<${v}/>`;
|
|
if (typeof v === 'number') return Number.isInteger(v) ? `${ind}<integer>${v}</integer>` : `${ind}<real>${v}</real>`;
|
|
if (Buffer.isBuffer(v)) return `${ind}<data>${v.toString('base64')}</data>`;
|
|
if (Array.isArray(v)) return v.length ? `${ind}<array>\n${v.map(x => xmlValue(x, ind + '\t')).join('\n')}\n${ind}</array>` : `${ind}<array/>`;
|
|
const keys = Object.keys(v).filter(k => v[k] !== undefined);
|
|
if (!keys.length) return `${ind}<dict/>`;
|
|
return `${ind}<dict>\n${keys.map(k => `${ind}\t<key>${esc(k)}</key>\n${xmlValue(v[k]!, ind + '\t')}`).join('\n')}\n${ind}</dict>`;
|
|
}
|
|
|
|
/** XML plist text, the format Xcode writes. */
|
|
export function toXmlPlist(v: PlistValue): string {
|
|
return '<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
|
|
+ `<plist version="1.0">\n${xmlValue(v, '')}\n</plist>\n`;
|
|
}
|
|
|
|
const unesc = (s: string): string => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'")
|
|
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))).replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16))).replace(/&/g, '&');
|
|
|
|
/** Parses an XML plist (tolerant of comments and whitespace). Throws on anything it does not understand. */
|
|
export function parseXmlPlist(text: string): PlistValue {
|
|
if (text.startsWith('bplist')) throw new Error('binary plist: read the file as bytes (parsePlist(Buffer)) or convert it with `plutil -convert xml1`');
|
|
const body = /<plist[^>]*>([\s\S]*)<\/plist>/.exec(text)?.[1] ?? text.replace(/<\?xml[^>]*\?>|<!DOCTYPE[^>]*>/g, '');
|
|
if (!/<(dict|array|string|integer|real|true|false|date|data)\b/.test(body)) throw new Error('not an XML plist');
|
|
// tags may carry attributes or a space before "/>" (hand-edited files)
|
|
const toks = [...body.replace(/<!--[\s\S]*?-->/g, '').matchAll(/<(\/?)([a-zA-Z]+)(?:\s[^>]*?)?\s*(\/?)>|([^<]+)/g)];
|
|
let i = 0;
|
|
const skipWs = () => { while (i < toks.length && toks[i]![4] !== undefined && !toks[i]![4]!.trim()) i++; };
|
|
const text_ = (tag: string): string => {
|
|
let s = '';
|
|
while (i < toks.length && !(toks[i]![1] === '/' && toks[i]![2] === tag)) { s += toks[i]![4] ?? ''; i++; }
|
|
i++;
|
|
return unesc(s);
|
|
};
|
|
function value(): PlistValue {
|
|
skipWs();
|
|
const t = toks[i++];
|
|
if (!t || t[1]) throw new Error('plist: unexpected end or closing tag');
|
|
const tag = t[2]!, selfClose = t[3] === '/';
|
|
if (tag === 'true' || tag === 'false') { if (!selfClose) text_(tag); return tag === 'true'; }
|
|
if (selfClose) return tag === 'array' ? [] : tag === 'dict' ? {} : tag === 'data' ? Buffer.alloc(0) : '';
|
|
if (tag === 'string' || tag === 'date') return text_(tag);
|
|
if (tag === 'integer' || tag === 'real') return Number(text_(tag).trim());
|
|
if (tag === 'data') return Buffer.from(text_(tag).replace(/\s+/g, ''), 'base64');
|
|
if (tag === 'array') {
|
|
const out: PlistValue[] = [];
|
|
for (;;) { skipWs(); if (toks[i]?.[1] === '/' && toks[i]![2] === 'array') { i++; return out; } out.push(value()); }
|
|
}
|
|
if (tag === 'dict') {
|
|
const out: Record<string, PlistValue> = {};
|
|
for (;;) {
|
|
skipWs();
|
|
const k = toks[i];
|
|
if (k?.[1] === '/' && k[2] === 'dict') { i++; return out; }
|
|
if (k?.[2] !== 'key') throw new Error('plist: <key> expected in <dict>');
|
|
i++;
|
|
out[text_('key')] = value();
|
|
}
|
|
}
|
|
throw new Error(`plist: unsupported <${tag}>`);
|
|
}
|
|
return value();
|
|
}
|
|
|
|
/** Parses a binary plist ("bplist00"). */
|
|
export function parseBinaryPlist(b: Buffer): PlistValue {
|
|
if (b.subarray(0, 8).toString('latin1') !== 'bplist00') throw new Error('not a binary plist');
|
|
const t = b.subarray(b.length - 32);
|
|
const offSize = t[6]!, refSize = t[7]!;
|
|
const numObjects = Number(t.readBigUInt64BE(8)), top = Number(t.readBigUInt64BE(16)), tableOff = Number(t.readBigUInt64BE(24));
|
|
const uint = (at: number, n: number): number => { let v = 0; for (let k = 0; k < n; k++) v = v * 256 + b[at + k]!; return v; };
|
|
const offset = (ref: number): number => uint(tableOff + ref * offSize, offSize);
|
|
const depth = { n: 0 };
|
|
function obj(ref: number): PlistValue {
|
|
if (ref >= numObjects) throw new Error('bplist: bad object reference');
|
|
if (++depth.n > 512) throw new Error('bplist: too deep');
|
|
try {
|
|
const at = offset(ref);
|
|
const m = b[at]!, hi = m >> 4, lo = m & 15;
|
|
const len = (): [number, number] => {
|
|
if (lo !== 15) return [lo, at + 1];
|
|
const sz = 1 << (b[at + 1]! & 15);
|
|
return [uint(at + 2, sz), at + 2 + sz];
|
|
};
|
|
switch (hi) {
|
|
case 0: return lo === 9; // 0x08 false, 0x09 true (0x00 null -> false)
|
|
case 1: { const n = 1 << lo; return n === 8 ? Number(b.readBigInt64BE(at + 1)) : uint(at + 1, n); }
|
|
case 2: return lo === 2 ? b.readFloatBE(at + 1) : b.readDoubleBE(at + 1);
|
|
case 3: return new Date(Date.UTC(2001, 0, 1) + b.readDoubleBE(at + 1) * 1000).toISOString();
|
|
case 4: { const [n, s] = len(); return Buffer.from(b.subarray(s, s + n)); }
|
|
case 5: { const [n, s] = len(); return b.subarray(s, s + n).toString('latin1'); }
|
|
case 6: { const [n, s] = len(); const u = Buffer.from(b.subarray(s, s + n * 2)); u.swap16(); return u.toString('utf16le'); }
|
|
case 8: return uint(at + 1, lo + 1);
|
|
case 10: { const [n, s] = len(); return Array.from({ length: n }, (_, k) => obj(uint(s + k * refSize, refSize))); }
|
|
case 13: {
|
|
const [n, s] = len();
|
|
const out: Record<string, PlistValue> = {};
|
|
for (let k = 0; k < n; k++) out[String(obj(uint(s + k * refSize, refSize)))] = obj(uint(s + (n + k) * refSize, refSize));
|
|
return out;
|
|
}
|
|
default: throw new Error(`bplist: unsupported object type 0x${m.toString(16)}`);
|
|
}
|
|
} finally { depth.n--; }
|
|
}
|
|
return obj(top);
|
|
}
|
|
|
|
/** XML or binary plist bytes, or XML plist text. */
|
|
export function parsePlist(b: Buffer | string): PlistValue {
|
|
if (typeof b === 'string') return parseXmlPlist(b);
|
|
return b.subarray(0, 8).toString('latin1') === 'bplist00' ? parseBinaryPlist(b) : parseXmlPlist(b.toString('utf8'));
|
|
}
|
|
|
|
/** The plist embedded in a .mobileprovision (a CMS envelope around an XML plist; the signature is not checked). */
|
|
export function parseMobileprovision(b: Buffer): Record<string, PlistValue> {
|
|
const s = b.toString('latin1');
|
|
const start = s.indexOf('<?xml'), end = s.indexOf('</plist>');
|
|
if (start < 0 || end < 0) throw new Error('not a provisioning profile (no embedded plist)');
|
|
return parseXmlPlist(Buffer.from(s.slice(start, end + 8), 'latin1').toString('utf8')) as Record<string, PlistValue>;
|
|
}
|