Skip to content

Instantly share code, notes, and snippets.

@samhenrigold
Last active July 21, 2026 01:03
Show Gist options
  • Select an option

  • Save samhenrigold/96233aff189f66ed6096820114a44bdd to your computer and use it in GitHub Desktop.

Select an option

Save samhenrigold/96233aff189f66ed6096820114a44bdd to your computer and use it in GitHub Desktop.
#!/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