Files
ipa-archiver/src_mac/lib.py
2024-04-02 22:03:02 +02:00

270 lines
7.7 KiB
Python
Executable File

#!/usr/bin/env python3
from pathlib import Path
from subprocess import run
from typing import Dict, NamedTuple
from zipfile import ZipFile
import json
import os
import plistlib
import shutil
from cfg import CONFIG, Log
# ------------------------------
# Types
# ------------------------------
VersionMap = Dict[int, str]
class FlatVersion(NamedTuple):
verId: int
verOs: int
class InfoPlist(NamedTuple):
appId: int
verId: int
allVersions: 'list[int]'
bundleId: str
osVer: str
class LocalIpaFile(NamedTuple):
cracked: bool
path: Path
# ------------------------------
# IPA tool
# ------------------------------
def ipaTool(*args: 'str|Path') -> None:
# '--json'
run(['python3', Path(__file__).parent/'ipatool-py'/'main.py'] + list(args))
def ipaToolHistory(appId: int):
Log.info('history for appid=%d', appId)
ipaTool('historyver', '-s', CONFIG.itunes_server,
'-o', CONFIG.download_tmp, '-i', str(appId))
def ipaToolDownload(appId: int, verId: int):
Log.info('download appid=%d verid=%d', appId, verId)
ipaTool('download', '-s', CONFIG.itunes_server, '-o', CONFIG.download_tmp,
'-i', str(appId), '--appVerId', str(verId))
# ------------------------------
# Path handling
# ------------------------------
def pathForApp(appId: int) -> 'Path|None':
return next(CONFIG.completed.glob(f'* - {appId}/'), None)
def pathForIpa(appId: int, appVerId: int) -> 'Path|None':
app_path = pathForApp(appId)
if not app_path:
return None
return next(app_path.glob(f'* - {appVerId}.ipa'), None)
# ------------------------------
# IPA content reading
# ------------------------------
def ipaReadInfoPlist(fname: Path) -> InfoPlist:
with ZipFile(fname) as zip:
itunesPlist = plistlib.loads(zip.read('iTunesMetadata.plist'))
for entry in zip.filelist:
p = entry.filename.split('/')
if len(p) == 3 and p[0] == 'Payload' and p[2] == 'Info.plist':
infoPlist = plistlib.loads(zip.read(entry))
break
return InfoPlist(
itunesPlist['itemId'],
itunesPlist['softwareVersionExternalIdentifier'],
itunesPlist['softwareVersionExternalIdentifiers'],
# itunesPlist['softwareVersionBundleId']
infoPlist['CFBundleIdentifier'],
infoPlist.get('MinimumOSVersion', '1.0'),
)
# ------------------------------
# Version Map
# ------------------------------
def readVersionMap(appId: int) -> 'VersionMap|None':
app_dir = pathForApp(appId)
if app_dir:
ver_map_json = app_dir / '_versions.json'
if ver_map_json.exists():
with open(ver_map_json, 'rb') as fp:
data: dict[str, str] = json.load(fp)
return {int(k): v for k, v in data.items()}
return None
def readVersionMapFromTemp(appId: int) -> 'VersionMap|None':
hist_json = CONFIG.download_tmp / f'historyver_{appId}.json'
if hist_json.exists():
with open(hist_json, 'rb') as fp:
allVerIds: list[int] = json.load(fp)['appVerIds']
return {x: '' for x in allVerIds}
return None
def writeVersionMap(appId: int, data: VersionMap):
app_dir = pathForApp(appId)
assert app_dir, f'app dir must exist for {appId} before calling this.'
with open(app_dir / '_versions.json', 'w') as fp:
json.dump(data, fp, indent=2, sort_keys=True)
hist_json = CONFIG.download_tmp / f'historyver_{appId}.json'
if hist_json.exists():
os.remove(hist_json)
def updateVersionMap(fname: Path) -> InfoPlist:
''' Returns iOS version string '''
info = ipaReadInfoPlist(fname)
if not pathForApp(info.appId):
app_dir = CONFIG.completed / f'{info.bundleId} - {info.appId}'
app_dir.mkdir(parents=True, exist_ok=True)
data = readVersionMap(info.appId)
if not data:
data = readVersionMapFromTemp(info.appId)
if not data:
data: 'VersionMap|None' = {x: '' for x in info.allVersions}
assert data, f'by now, history json for {info.appId} should exist!'
if data.get(info.verId) != info.osVer:
for x in info.allVersions:
if x not in data:
data[x] = ''
data[info.verId] = info.osVer
Log.info('update version for %s (%s)', info.appId, info.bundleId)
writeVersionMap(info.appId, data)
return info
def flattenVersionMap(data: VersionMap) -> 'list[FlatVersion]':
return sorted(FlatVersion(k, versionToInt(v)) for k, v in data.items())
def loadFlatVersionMap(appId: int) -> 'list[FlatVersion]':
data = readVersionMap(appId)
if not data:
data = readVersionMapFromTemp(appId)
if not data: # needs download
ipaToolHistory(appId)
data = readVersionMapFromTemp(appId)
if not data:
raise RuntimeError(f'could not download version history for {appId}')
return flattenVersionMap(data)
# ------------------------------
# Helper
# ------------------------------
def versionToInt(ver: str) -> int:
if not ver:
return 0
major, minor, patch, *_ = ver.split('.') + [0, 0, 0]
return int(major) * 1_00_00 + int(minor) * 1_00 + int(patch)
def enumAppIds():
return sorted([int(x.parent.name.split(' ')[-1])
for x in CONFIG.completed.glob('*/_versions.json')])
def downloadPath(appId: int, verId: int):
return CONFIG.download_fix / f'{appId}.{verId}.ipa'
# ------------------------------
# Actual logic
# ------------------------------
def downloadSpecificVersion(appId: int, verId: int) -> LocalIpaFile:
ipa_path = pathForIpa(appId, verId)
if ipa_path:
return LocalIpaFile(True, ipa_path) # already cracked
download_path = downloadPath(appId, verId)
if download_path.exists():
return LocalIpaFile(False, download_path) # needs cracking
ipaToolDownload(appId, verId)
tmp_file = next(CONFIG.download_tmp.glob(f'*-{appId}-{verId}.ipa'), None)
if not tmp_file:
raise RuntimeError(f'Could not download ipa {appId} {verId}')
shutil.move(tmp_file.as_posix(), download_path)
updateVersionMap(download_path)
return LocalIpaFile(False, download_path)
def findLatestVersion(
appId: int, maxOS: str, *, rmIncompatible: bool
) -> 'int|None':
ver_map = loadFlatVersionMap(appId)
_maxOS = versionToInt(maxOS)
def proc_index(i: int) -> bool:
verId, osVer = ver_map[i]
if not osVer:
ipa_file = downloadSpecificVersion(appId, verId)
info = ipaReadInfoPlist(ipa_file.path)
osVer = versionToInt(info.osVer)
if rmIncompatible and osVer > _maxOS and not ipa_file.cracked:
os.remove(ipa_file.path)
Log.debug('app: %d ver: %d iOS: %s ...', appId, verId, osVer)
return osVer <= _maxOS
if not proc_index(0):
Log.warning(f'No compatible version for {appId}')
return None
imin, imax = 1, len(ver_map) - 1
best_i = 0
while imin <= imax:
i = imin + (imax - imin) // 2
if proc_index(i):
best_i = i
imin = i + 1
else:
best_i = i - 1
imax = i - 1
return ver_map[best_i].verId
def downloadAllUntil(
idx: int, appId: int, maxOS: str, *, rmIncompatible: bool
) -> 'Path|None':
ver_map = loadFlatVersionMap(appId)
_maxOS = versionToInt(maxOS)
if idx >= len(ver_map):
return None
if any(x.verOs > _maxOS for x in ver_map[:idx + 1]):
return None
verId = ver_map[idx].verId
ipa_file = downloadSpecificVersion(appId, verId)
if ipa_file.cracked:
return None
info = ipaReadInfoPlist(ipa_file.path)
osVer = versionToInt(info.osVer)
if osVer <= _maxOS:
return ipa_file.path
elif rmIncompatible:
os.remove(ipa_file.path)