feat: 添加 AltStore 应用源及发布维护流程

- 根据已发布 Release 和实际 IPA 生成应用源,包含版本、下载地址、权限和截图
- 校验包标识、版本标签、文件大小与 SHA-256,补充生成脚本测试
- 记录手动更新应用源的维护流程,完善本地自检
- 忽略仅供本机使用的 GitHub 上传说明
This commit is contained in:
Aminark
2026-10-20 23:35:00 +08:00
parent 0915a144a7
commit 7daa6b5635
6 changed files with 352 additions and 1 deletions
+3 -1
View File
@@ -7,9 +7,11 @@ cd "$workspace_dir"
derived_data_path=${LEGADO_DERIVED_DATA_PATH:-"$workspace_dir/DerivedData"}
source_packages_path=${LEGADO_SOURCE_PACKAGES_PATH:-"$derived_data_path/SourcePackages"}
echo "[1/5] Validate bundled resources"
echo "[1/5] Validate bundled resources and application source"
python3 -m json.tool App/Resources/txtTocRule.json >/dev/null
python3 -m json.tool App/Resources/httpTTS.json >/dev/null
python3 -m json.tool docs/altstore.json >/dev/null
python3 -m unittest discover -s scripts/tests -v
echo "[2/5] Check project and Swift formatting hazards"
plutil -lint Legado.xcodeproj/project.pbxproj >/dev/null
+131
View File
@@ -0,0 +1,131 @@
import hashlib
import importlib.util
import plistlib
import tempfile
import unittest
import zipfile
from pathlib import Path
spec = importlib.util.spec_from_file_location("altstore_source", Path(__file__).parents[1] / "update-altstore-source.py")
altstore_source = importlib.util.module_from_spec(spec)
spec.loader.exec_module(altstore_source)
class AltStoreSourceTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.ipa = Path(temporary.name) / "reader-unsigned.ipa"
self.info = {
"CFBundleIdentifier": "com.epiboly.legado",
"CFBundleName": "Reader",
"CFBundleDisplayName": "小浣熊阅读",
"CFBundleShortVersionString": "1.2.3",
"CFBundleVersion": "42",
"MinimumOSVersion": "15.0",
"NSCameraUsageDescription": "扫描导入二维码",
"NSPhotoLibraryAddUsageDescription": "保存图片",
"NSAppTransportSecurity": {"NSAllowsArbitraryLoads": True},
}
self.release = {
"tag_name": "v1.2.3",
"published_at": "2026-10-19T01:02:03Z",
"draft": False,
"prerelease": False,
"body": "更新阅读体验。",
}
self.write_ipa()
def write_ipa(self, extra=None):
with zipfile.ZipFile(self.ipa, "w") as archive:
archive.writestr("Payload/Reader.app/Info.plist", plistlib.dumps(self.info, fmt=plistlib.FMT_BINARY))
if extra:
archive.writestr(extra, b"fixture")
self.release["assets"] = [{
"name": self.ipa.name,
"state": "uploaded",
"size": self.ipa.stat().st_size,
"digest": "sha256:" + hashlib.sha256(self.ipa.read_bytes()).hexdigest(),
"browser_download_url": "https://github.com/Aminark/legado-ios/releases/download/v1.2.3/reader-unsigned.ipa",
}]
def generate(self):
return altstore_source.make_source(self.ipa, self.release, "Aminark/legado-ios", "main")
def test_metadata_permissions_and_legacy_fields_match_ipa(self):
source = self.generate()
self.assertEqual(source["sourceURL"], "https://raw.githubusercontent.com/Aminark/legado-ios/main/docs/altstore.json")
app = source["apps"][0]
version = app["versions"][0]
self.assertEqual(app["bundleIdentifier"], self.info["CFBundleIdentifier"])
self.assertEqual(version["version"], "1.2.3")
self.assertEqual(version["buildVersion"], "42")
self.assertEqual(version["minOSVersion"], "15.0")
self.assertEqual(version["date"], self.release["published_at"])
for key in ("version", "size", "downloadURL"):
self.assertEqual(app[key], version[key])
self.assertEqual(app["appPermissions"], {
"entitlements": [],
"privacy": {
"NSCameraUsageDescription": "扫描导入二维码",
"NSPhotoLibraryAddUsageDescription": "保存图片",
},
})
def test_rejects_drafts_and_prereleases(self):
for key in ("draft", "prerelease"):
with self.subTest(key=key):
self.release[key] = True
with self.assertRaisesRegex(ValueError, "published stable"):
self.generate()
self.release[key] = False
def test_rejects_wrong_release_asset(self):
for key, value in (("size", 1), ("digest", "sha256:wrong"),
("browser_download_url", "https://example.com/wrong.ipa"),
("name", "another.ipa"), ("state", "new")):
with self.subTest(key=key):
self.write_ipa()
self.release["assets"][0][key] = value
with self.assertRaises(ValueError):
self.generate()
def test_older_github_metadata_without_digest_is_supported(self):
del self.release["assets"][0]["digest"]
self.assertEqual(self.generate()["apps"][0]["version"], "1.2.3")
def test_rejects_packages_needing_an_entitlement_audit(self):
for extra in ("Payload/Reader.app/_CodeSignature/CodeResources",
"Payload/Reader.app/embedded.mobileprovision",
"Payload/Reader.app/PlugIns/Widget.appex/Info.plist"):
with self.subTest(extra=extra):
self.write_ipa(extra)
with self.assertRaisesRegex(ValueError, "entitlement audit"):
self.generate()
def test_rejects_unresolved_build_metadata(self):
self.info["CFBundleVersion"] = "$(CURRENT_PROJECT_VERSION)"
self.write_ipa()
with self.assertRaisesRegex(ValueError, "CFBundleVersion"):
self.generate()
def test_rejects_release_and_package_mismatches(self):
self.info["CFBundleIdentifier"] = "com.example.another-app"
self.write_ipa()
with self.assertRaisesRegex(ValueError, "bundle identifier"):
self.generate()
self.info["CFBundleIdentifier"] = "com.epiboly.legado"
self.info["CFBundleShortVersionString"] = "1.2.4"
self.write_ipa()
with self.assertRaisesRegex(ValueError, "Release tag"):
self.generate()
def test_rejects_publication_dates_without_a_timezone(self):
self.release["published_at"] = "2026-10-19T01:02:03"
with self.assertRaisesRegex(ValueError, "timezone"):
self.generate()
if __name__ == "__main__":
unittest.main()
+120
View File
@@ -0,0 +1,120 @@
#!/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()