From 7daa6b5635940b532ee9ca04a6d66f2205f6ea00 Mon Sep 17 00:00:00 2001 From: Aminark <27661225+Aminark@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:35:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20AltStore=20?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E6=BA=90=E5=8F=8A=E5=8F=91=E5=B8=83=E7=BB=B4?= =?UTF-8?q?=E6=8A=A4=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根据已发布 Release 和实际 IPA 生成应用源,包含版本、下载地址、权限和截图 - 校验包标识、版本标签、文件大小与 SHA-256,补充生成脚本测试 - 记录手动更新应用源的维护流程,完善本地自检 - 忽略仅供本机使用的 GitHub 上传说明 --- .gitignore | 1 + docs/DEVELOPMENT.md | 34 +++++++ docs/altstore.json | 63 +++++++++++++ scripts/self-check.sh | 4 +- scripts/tests/test_altstore_source.py | 131 ++++++++++++++++++++++++++ scripts/update-altstore-source.py | 120 +++++++++++++++++++++++ 6 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 docs/altstore.json create mode 100644 scripts/tests/test_altstore_source.py create mode 100644 scripts/update-altstore-source.py diff --git a/.gitignore b/.gitignore index da126dc..888acbf 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ DerivedData/ *.xcuserstate xcuserdata/ reference/ +/GITHUB_UPLOAD.local.md artifacts/ *.xcresult/ diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index d6a2177..e346830 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -75,3 +75,37 @@ python3 scripts/export-open-source.py /tmp/legado-ios-open-source ``` 目标目录必须尚不存在。导出后检查内容,再用独立仓库管理;`.gitignore` 不会自动清理已经跟踪的文件。 + +## 维护应用源 + +`docs/altstore.json` 是 AltStore Classic、SideStore 和 LiveContainer 共用的应用源,固定地址为: + +```text +https://raw.githubusercontent.com/Aminark/legado-ios/main/docs/altstore.json +``` + +源仅列出最新正式版,同时提供 `versions` 和旧客户端使用的顶层版本字段。版本号、构建号、最低 iOS 版本和隐私权限从实际 IPA 的 `Info.plist` 读取,下载地址、发布日期和更新说明来自 GitHub Release。目前由维护者在发布正式版后手动运行生成脚本并提交源文件。 + +1. 新版发布时,先在 Release 草稿中上传唯一的未签名 `.ipa`,再发布正式版。 +2. 下载 GitHub 标记的 latest 正式版元数据及 IPA,运行下方生成与测试命令,检查 `docs/altstore.json` 差异后提交到 `main`。 +3. 源文件发布后,客户端刷新源即可获取该版本的下载与更新入口。只提交源码或上传未发布的草稿不会更新应用源。 + +本地生成(需要 Python 3.9+;获取 Release 元数据时使用已登录的 GitHub CLI): + +```sh +source_update_dir=$(mktemp -d) +gh api repos/Aminark/legado-ios/releases/latest > "$source_update_dir/release.json" +source_release_tag=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["tag_name"])' "$source_update_dir/release.json") +gh release download "$source_release_tag" --repo Aminark/legado-ios \ + --pattern '*.ipa' --dir "$source_update_dir" +python3 scripts/update-altstore-source.py \ + --ipa "$source_update_dir/legado-ios-v0.1.10-build16-unsigned.ipa" \ + --release "$source_update_dir/release.json" +python3 -m unittest discover -s scripts/tests -v +``` + +请将示例 IPA 文件名换成实际下载的附件名。脚本会核对包标识、Release 标签与 IPA 版本、文件大小,以及 Release 提供的 SHA-256(如有),避免源指向与本地检查不一致的包。当前生成器针对本项目无扩展、无额外 entitlement 的未签名 IPA;改用签名包、增加扩展或 entitlement 时,需要同步完善权限提取,生成器会拒绝带签名资源、描述文件或扩展的 IPA。 + +发布后检查固定地址可匿名访问、图标和截图能正常加载,并下载源中 IPA 再核对大小、摘要和版本。客户端的添加源、安装、更新与签名流程需分别实测;源文件校验通过不代表已完成真机安装验证。 + +格式说明:[AltStore 源规范](https://faq.altstore.io/developers/make-a-source)、[SideStore 源字段](https://sidestore.io/sidestore-source-types/interfaces/Source.html)、[LiveContainer 源用法](https://livecontainer.github.io/docs/guides/sources)。 diff --git a/docs/altstore.json b/docs/altstore.json new file mode 100644 index 0000000..58c7158 --- /dev/null +++ b/docs/altstore.json @@ -0,0 +1,63 @@ +{ + "name": "小浣熊阅读", + "identifier": "com.epiboly.legado.source", + "subtitle": "小浣熊阅读官方应用源", + "description": "小浣熊阅读的最新正式版,供 AltStore Classic、SideStore 和 LiveContainer 添加。", + "sourceURL": "https://raw.githubusercontent.com/Aminark/legado-ios/main/docs/altstore.json", + "website": "https://github.com/Aminark/legado-ios", + "iconURL": "https://raw.githubusercontent.com/Aminark/legado-ios/main/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png", + "tintColor": "#315C50", + "apps": [ + { + "name": "小浣熊阅读", + "bundleIdentifier": "com.epiboly.legado", + "developerName": "Aminark", + "subtitle": "搜书快一点,读书舒服一点。", + "localizedDescription": "一款优先兼容阅读书源的开源 iOS 阅读器,支持书架、搜索、阅读排版、本地 TXT、语音朗读和 RSS。应用不提供书源或在线书库,书源需自行导入。", + "iconURL": "https://raw.githubusercontent.com/Aminark/legado-ios/main/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png", + "tintColor": "#315C50", + "category": "utilities", + "screenshots": [ + { + "imageURL": "https://raw.githubusercontent.com/Aminark/legado-ios/main/docs/images/bookshelf.png", + "width": 1206, + "height": 2622 + }, + { + "imageURL": "https://raw.githubusercontent.com/Aminark/legado-ios/main/docs/images/search.png", + "width": 1206, + "height": 2622 + }, + { + "imageURL": "https://raw.githubusercontent.com/Aminark/legado-ios/main/docs/images/reader.png", + "width": 1206, + "height": 2622 + } + ], + "versions": [ + { + "version": "0.1.10", + "buildVersion": "16", + "date": "2026-10-20T15:34:03Z", + "localizedDescription": "小浣熊阅读 **0.1.10**,构建号 **16**,支持 **iOS / iPadOS 15.0 及以上**。\n\n## 更新内容\n\n- 修复书源文件选择及导入流程:支持 JSON 多选,调整文件读取权限和导入预览展示时机(#1)。\n- 支持进入书源分组查看书源、打开详情及切换启用状态。\n- 修复阅读外观中的背景图片和字体选择;限制背景图片解码尺寸,降低内存占用。\n- 完善背景图片与预设主题切换,关闭背景图时保留已选图片。\n- 修复退出后重新进入阅读器的书签状态,避免重复添加书签。\n\n## 下载安装\n\n下载附件 **`legado-ios-v0.1.10-build16-unsigned.ipa`**,使用自己的签名工具安装。支持 TrollStore 的设备可自行安装;本项目暂不提供 App Store 或 TestFlight 分发。\n\n`Source code (zip / tar.gz)` 是源码,不能直接安装。附件 `SHA256SUMS` 可用于核对 IPA 完整性。\n\n## 验证与兼容\n\n本次通过 299 项核心及页面模型测试、12 项 iOS 阅读器测试、4 项文件导入与阅读操作 UI 回归,以及模拟器构建。IPA 由本次源码重新进行 iPhoneOS Release 构建。\n\niOS 15.7 真机尚未复测。若在该系统上仍遇到文件无法选择的问题,请继续在 [Issue #1](https://github.com/Aminark/legado-ios/issues/1) 反馈。\n\n**小浣熊阅读不提供、内置或分发网络书源,也不提供在线书库;书源需自行导入。**\n", + "downloadURL": "https://github.com/Aminark/legado-ios/releases/download/v0.1.10/legado-ios-v0.1.10-build16-unsigned.ipa", + "size": 12080513, + "minOSVersion": "15.0" + } + ], + "version": "0.1.10", + "versionDate": "2026-10-20T15:34:03Z", + "versionDescription": "小浣熊阅读 **0.1.10**,构建号 **16**,支持 **iOS / iPadOS 15.0 及以上**。\n\n## 更新内容\n\n- 修复书源文件选择及导入流程:支持 JSON 多选,调整文件读取权限和导入预览展示时机(#1)。\n- 支持进入书源分组查看书源、打开详情及切换启用状态。\n- 修复阅读外观中的背景图片和字体选择;限制背景图片解码尺寸,降低内存占用。\n- 完善背景图片与预设主题切换,关闭背景图时保留已选图片。\n- 修复退出后重新进入阅读器的书签状态,避免重复添加书签。\n\n## 下载安装\n\n下载附件 **`legado-ios-v0.1.10-build16-unsigned.ipa`**,使用自己的签名工具安装。支持 TrollStore 的设备可自行安装;本项目暂不提供 App Store 或 TestFlight 分发。\n\n`Source code (zip / tar.gz)` 是源码,不能直接安装。附件 `SHA256SUMS` 可用于核对 IPA 完整性。\n\n## 验证与兼容\n\n本次通过 299 项核心及页面模型测试、12 项 iOS 阅读器测试、4 项文件导入与阅读操作 UI 回归,以及模拟器构建。IPA 由本次源码重新进行 iPhoneOS Release 构建。\n\niOS 15.7 真机尚未复测。若在该系统上仍遇到文件无法选择的问题,请继续在 [Issue #1](https://github.com/Aminark/legado-ios/issues/1) 反馈。\n\n**小浣熊阅读不提供、内置或分发网络书源,也不提供在线书库;书源需自行导入。**\n", + "downloadURL": "https://github.com/Aminark/legado-ios/releases/download/v0.1.10/legado-ios-v0.1.10-build16-unsigned.ipa", + "size": 12080513, + "appPermissions": { + "entitlements": [], + "privacy": { + "NSCameraUsageDescription": "用于扫描书源和净化规则的导入二维码。", + "NSPhotoLibraryAddUsageDescription": "用于保存阅读页面中的图片到照片图库。" + } + } + } + ], + "news": [] +} diff --git a/scripts/self-check.sh b/scripts/self-check.sh index 26a7a03..6d0812a 100755 --- a/scripts/self-check.sh +++ b/scripts/self-check.sh @@ -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 diff --git a/scripts/tests/test_altstore_source.py b/scripts/tests/test_altstore_source.py new file mode 100644 index 0000000..30b9e35 --- /dev/null +++ b/scripts/tests/test_altstore_source.py @@ -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() diff --git a/scripts/update-altstore-source.py b/scripts/update-altstore-source.py new file mode 100644 index 0000000..60bd07d --- /dev/null +++ b/scripts/update-altstore-source.py @@ -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()