Initial
This commit is contained in:
269
src_mac/lib.py
Executable file
269
src_mac/lib.py
Executable file
@@ -0,0 +1,269 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user