mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-21 11:41:22 +08:00
- 根据已发布 Release 和实际 IPA 生成应用源,包含版本、下载地址、权限和截图 - 校验包标识、版本标签、文件大小与 SHA-256,补充生成脚本测试 - 记录手动更新应用源的维护流程,完善本地自检 - 忽略仅供本机使用的 GitHub 上传说明
121 lines
5.9 KiB
Python
121 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate the AltStore/SideStore source from a published release's unsigned IPA."""
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import plistlib
|
|
import re
|
|
import zipfile
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from urllib.parse import quote
|
|
|
|
|
|
def make_source(ipa: Path, release: dict, repository: str, branch: str) -> dict:
|
|
if release.get("draft") or release.get("prerelease"):
|
|
raise ValueError("Only published stable releases belong in this source")
|
|
date = release["published_at"]
|
|
if datetime.fromisoformat(date.replace("Z", "+00:00")).tzinfo is None:
|
|
raise ValueError("Release publication date must include a timezone")
|
|
assets = [asset for asset in release["assets"] if asset["name"] == ipa.name]
|
|
if len(assets) != 1 or assets[0].get("state") != "uploaded":
|
|
raise ValueError("IPA must match exactly one uploaded release asset")
|
|
asset = assets[0]
|
|
if ipa.stat().st_size != asset["size"]:
|
|
raise ValueError("IPA size differs from the published release asset")
|
|
digest = asset.get("digest")
|
|
if digest and digest != "sha256:" + hashlib.sha256(ipa.read_bytes()).hexdigest():
|
|
raise ValueError("IPA checksum differs from the published release asset")
|
|
|
|
with zipfile.ZipFile(ipa) as archive:
|
|
names = archive.namelist()
|
|
info_paths = [name for name in names if re.fullmatch(r"Payload/[^/]+\.app/Info\.plist", name)]
|
|
if len(info_paths) != 1:
|
|
raise ValueError("Expected exactly one app in Payload")
|
|
if any("/_CodeSignature/" in name or name.endswith("/embedded.mobileprovision")
|
|
or ".appex/" in name for name in names):
|
|
raise ValueError("Use an unsigned IPA without extensions; signed apps need an entitlement audit")
|
|
info = plistlib.loads(archive.read(info_paths[0]))
|
|
for key in ("CFBundleIdentifier", "CFBundleShortVersionString", "CFBundleVersion", "MinimumOSVersion"):
|
|
if not isinstance(info.get(key), str) or not info[key] or "$(" in info[key]:
|
|
raise ValueError(f"IPA has an invalid or unresolved {key}")
|
|
if info["CFBundleIdentifier"] != "com.epiboly.legado":
|
|
raise ValueError("IPA bundle identifier does not belong to this app")
|
|
if release["tag_name"] != "v" + info["CFBundleShortVersionString"]:
|
|
raise ValueError("Release tag does not match the IPA version")
|
|
|
|
website = f"https://github.com/{repository}"
|
|
download_url = f"{website}/releases/download/{quote(release['tag_name'], safe='')}/{quote(ipa.name, safe='')}"
|
|
if asset["browser_download_url"] != download_url:
|
|
raise ValueError("Release asset URL does not match the repository, tag and IPA name")
|
|
raw_url = f"https://raw.githubusercontent.com/{repository}/{quote(branch, safe='')}"
|
|
icon_url = f"{raw_url}/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png"
|
|
version = {
|
|
"version": info["CFBundleShortVersionString"],
|
|
"buildVersion": info["CFBundleVersion"],
|
|
"date": date,
|
|
"localizedDescription": release.get("body") or release.get("name") or release["tag_name"],
|
|
"downloadURL": download_url,
|
|
"size": asset["size"],
|
|
"minOSVersion": info["MinimumOSVersion"],
|
|
}
|
|
return {
|
|
"name": "小浣熊阅读",
|
|
"identifier": "com.epiboly.legado.source",
|
|
"subtitle": "小浣熊阅读官方应用源",
|
|
"description": "小浣熊阅读的最新正式版,供 AltStore Classic、SideStore 和 LiveContainer 添加。",
|
|
"sourceURL": f"{raw_url}/docs/altstore.json",
|
|
"website": website,
|
|
"iconURL": icon_url,
|
|
"tintColor": "#315C50",
|
|
"apps": [{
|
|
"name": info.get("CFBundleDisplayName") or info["CFBundleName"],
|
|
"bundleIdentifier": info["CFBundleIdentifier"],
|
|
"developerName": repository.split("/")[0],
|
|
"subtitle": "搜书快一点,读书舒服一点。",
|
|
"localizedDescription": "一款优先兼容阅读书源的开源 iOS 阅读器,支持书架、搜索、阅读排版、本地 TXT、语音朗读和 RSS。应用不提供书源或在线书库,书源需自行导入。",
|
|
"iconURL": icon_url,
|
|
"tintColor": "#315C50",
|
|
"category": "utilities",
|
|
"screenshots": [
|
|
{"imageURL": f"{raw_url}/docs/images/{name}.png", "width": 1206, "height": 2622}
|
|
for name in ("bookshelf", "search", "reader")
|
|
],
|
|
"versions": [version],
|
|
"version": version["version"],
|
|
"versionDate": date,
|
|
"versionDescription": version["localizedDescription"],
|
|
"downloadURL": download_url,
|
|
"size": asset["size"],
|
|
"appPermissions": {
|
|
"entitlements": [],
|
|
"privacy": {key: value for key, value in info.items()
|
|
if key.startswith("NS") and "UsageDescription" in key},
|
|
},
|
|
}],
|
|
"news": [],
|
|
}
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--ipa", required=True, type=Path)
|
|
parser.add_argument("--release", required=True, type=Path, help="GitHub REST release JSON")
|
|
parser.add_argument("--repository", default="Aminark/legado-ios")
|
|
parser.add_argument("--branch", default="main", help="Branch hosting the source and images")
|
|
parser.add_argument("--output", type=Path, default=Path("docs/altstore.json"))
|
|
args = parser.parse_args()
|
|
try:
|
|
source = make_source(args.ipa, json.loads(args.release.read_text(encoding="utf-8")),
|
|
args.repository, args.branch)
|
|
except (ValueError, KeyError, OSError, zipfile.BadZipFile) as error:
|
|
parser.error(str(error))
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output.write_text(json.dumps(source, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
print(f"Generated {args.output} from {args.ipa.name}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|