Last active
July 21, 2026 01:03
-
-
Save samhenrigold/96233aff189f66ed6096820114a44bdd to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Triage a folder of iTunes .ipa files against legacystore.app. | |
| The key trick: FairPlay only encrypts the __TEXT segment of the main Mach-O. The zip | |
| container, Info.plist, and iTunesMetadata.plist are all PLAINTEXT even in an encrypted | |
| iTunes .ipa — so you can read every identifier out of your files WITHOUT decrypting or | |
| jailbreaking anything. | |
| For each .ipa it pulls whatever identifiers are present (iTunes downloads may have all, | |
| some, or none) and asks the archive whether a decrypted copy already exists, so you only | |
| spend decrypt/upload effort on the gaps. | |
| python3 coverage_triage.py /path/to/ipa/folder | |
| stdlib only except `requests` -> pip install requests | |
| """ | |
| import glob, sys, time, zipfile, plistlib | |
| import requests | |
| # Public open-data endpoint — no key needed. | |
| # Batch is one round trip per BATCH files; there's also a cacheable per-version resource | |
| # at GET /api/coverage/{bundle_id}/{version} and GET /api/coverage?external_id=… if you'd | |
| # rather query one at a time. | |
| API = "https://legacystore.app/api/coverage" | |
| BATCH = 500 | |
| def _load_plist(z, name): | |
| """Parse one plist member, tolerating a malformed/encrypted one (returns {}).""" | |
| try: | |
| return plistlib.loads(z.read(name)) | |
| except Exception: | |
| return {} | |
| def read_ids(ipa_path): | |
| """Return the identifier probe for one .ipa, or None if nothing usable is present.""" | |
| bundle_id = version = external_id = app_store_id = None | |
| with zipfile.ZipFile(ipa_path) as z: | |
| names = z.namelist() | |
| # iTunesMetadata.plist (zip root) — the richest source for iTunes downloads. | |
| meta_name = next((n for n in names if n.rsplit("/", 1)[-1] == "iTunesMetadata.plist"), None) | |
| if meta_name: | |
| m = _load_plist(z, meta_name) | |
| bundle_id = m.get("softwareVersionBundleId") or bundle_id | |
| version = m.get("bundleShortVersionString") or m.get("bundleVersion") or version | |
| external_id = m.get("softwareVersionExternalIdentifier") or external_id | |
| app_store_id = m.get("itemId") or app_store_id | |
| # Info.plist inside the .app — fill anything still missing. | |
| info_name = next( | |
| (n for n in names if n.startswith("Payload/") and n.endswith(".app/Info.plist") and n.count("/") == 2), | |
| None, | |
| ) | |
| if info_name and (bundle_id is None or version is None): | |
| pl = _load_plist(z, info_name) | |
| bundle_id = bundle_id or pl.get("CFBundleIdentifier") | |
| version = version or pl.get("CFBundleShortVersionString") or pl.get("CFBundleVersion") | |
| probe = {} | |
| if bundle_id: | |
| probe["bundle_id"] = str(bundle_id) | |
| if version: | |
| probe["version"] = str(version) | |
| if isinstance(external_id, int): | |
| probe["external_id"] = external_id | |
| if isinstance(app_store_id, int): | |
| probe["app_store_id"] = app_store_id | |
| # Locatable iff it has an external id, or a version plus an app key. | |
| if "external_id" in probe or ("version" in probe and ("bundle_id" in probe or "app_store_id" in probe)): | |
| return probe | |
| return None | |
| def post_batch(batch, tries=4): | |
| """POST one batch, retrying transient 5xx / network blips with backoff.""" | |
| for attempt in range(tries): | |
| try: | |
| r = requests.post( | |
| API, | |
| headers={"Content-Type": "application/json"}, | |
| json={"probes": batch}, | |
| timeout=60, | |
| ) | |
| if r.status_code < 500: | |
| r.raise_for_status() | |
| return r.json()["results"] | |
| last = f"HTTP {r.status_code}" | |
| except requests.RequestException as e: | |
| last = type(e).__name__ | |
| if attempt < tries - 1: | |
| time.sleep(2 ** attempt) # 1s, 2s, 4s | |
| raise RuntimeError(f"batch failed after {tries} tries ({last})") | |
| def main(root): | |
| index, probes = [], [] | |
| for ipa in sorted(glob.glob(f"{root}/**/*.ipa", recursive=True)): | |
| try: | |
| probe = read_ids(ipa) | |
| if probe: | |
| index.append(ipa) | |
| probes.append(probe) | |
| else: | |
| print(f" skip (no usable identifiers): {ipa}") | |
| except Exception as e: | |
| print(f" skip (unreadable): {ipa} {e}") | |
| rows = [] | |
| for i in range(0, len(probes), BATCH): | |
| rows.extend(post_batch(probes[i:i + BATCH])) | |
| have, encrypted_only, gap = [], [], [] | |
| for ipa, row in zip(index, rows): | |
| # `copies` maps the archive's install_status -> count (hidden/tampered excluded). | |
| copies = row.get("copies", {}) | |
| if copies.get("installable", 0) > 0: | |
| have.append(ipa) # a decrypted, runnable copy already exists | |
| elif copies.get("encrypted", 0) > 0: | |
| encrypted_only.append(ipa) # archived, but only still-encrypted — a decrypt still helps | |
| else: | |
| gap.append(ipa) # nothing here at all | |
| print(f"\n✅ {len(have)} already decrypted in the archive — skip") | |
| print(f"🔒 {len(encrypted_only)} in the archive but ENCRYPTED-only — your decrypt fills a gap") | |
| print(f"⬆️ {len(gap)} not in the archive at all — upload these\n") | |
| for ipa in encrypted_only + gap: | |
| print(f" {ipa}") | |
| if __name__ == "__main__": | |
| main(sys.argv[1] if len(sys.argv) > 1 else ".") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment