270 lines
7.7 KiB
Python
Executable File
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)
|