diff --git a/.github/changes/v1.0.3.md b/.github/changes/v1.0.3.md index d00db21..2214867 100644 --- a/.github/changes/v1.0.3.md +++ b/.github/changes/v1.0.3.md @@ -24,3 +24,4 @@ ### 修复 - 服务商返回结果无法识别时,失败原因显示为完整说明,而不是英文代码。 +- iOS 安装包的主程序信息(Info.plist)现在位于压缩包最前面,修复部分签名 / 安装工具提示「未得到可用的主包」、无法导入的问题。 diff --git a/.github/workflows/ios-unsigned.yml b/.github/workflows/ios-unsigned.yml index 2552107..79a9f1c 100644 --- a/.github/workflows/ios-unsigned.yml +++ b/.github/workflows/ios-unsigned.yml @@ -55,10 +55,10 @@ jobs: run: | ARCHIVE="$RUNNER_TEMP/App.xcarchive" VERSION=$(node -p "require('./app.json').expo.version") - cd "$RUNNER_TEMP" - rm -rf Payload && mkdir Payload - cp -R "$ARCHIVE"/Products/Applications/*.app Payload/ - zip -qry "AIUsage-$VERSION-unsigned.ipa" Payload + APP=$(ls -d "$ARCHIVE"/Products/Applications/*.app | head -1) + # main Info.plist and executable first: some sideloading tools only scan the head of + # the archive for the main bundle (see scripts/package-ipa.py) + python3 scripts/package-ipa.py "$APP" "$RUNNER_TEMP/AIUsage-$VERSION-unsigned.ipa" echo "IPA=$RUNNER_TEMP/AIUsage-$VERSION-unsigned.ipa" >> "$GITHUB_ENV" ls -lh "$RUNNER_TEMP"/*.ipa diff --git a/scripts/package-ipa.py b/scripts/package-ipa.py new file mode 100644 index 0000000..17fef6d --- /dev/null +++ b/scripts/package-ipa.py @@ -0,0 +1,95 @@ +"""Package an .app bundle into an .ipa with a predictable, tool-friendly layout. + + python3 scripts/package-ipa.py + +Some sideloading and re-signing tools find the main bundle by scanning the archive's local +headers from the start and give up after a while, so `Payload/.app/Info.plist` buried near +the end (where a plain `zip -r` tends to put it) reads as "no main bundle". This writes: + +1. `Payload/` and `Payload/.app/` +2. the main `Info.plist`, then the main executable (named by CFBundleExecutable) +3. everything else, directories before their contents, in sorted order + +Entries carry Unix permissions and no platform-specific extra fields; symlinks are stored as links. +""" + +import os +import plistlib +import stat +import sys +import zipfile + + +def add_dir(zf: zipfile.ZipFile, arcname: str, mode: int) -> None: + info = zipfile.ZipInfo(arcname.rstrip('/') + '/') + info.create_system = 3 + info.external_attr = (stat.S_IFDIR | (mode & 0o7777)) << 16 | 0x10 + zf.writestr(info, b'') + + +def add_path(zf: zipfile.ZipFile, path: str, arcname: str) -> None: + st = os.lstat(path) + if stat.S_ISLNK(st.st_mode): + info = zipfile.ZipInfo(arcname) + info.create_system = 3 + info.external_attr = (stat.S_IFLNK | 0o777) << 16 + zf.writestr(info, os.readlink(path)) + return + info = zipfile.ZipInfo.from_file(path, arcname) + info.create_system = 3 + info.external_attr = (stat.S_IFREG | (st.st_mode & 0o7777)) << 16 + info.compress_type = zipfile.ZIP_DEFLATED + with open(path, 'rb') as f: + zf.writestr(info, f.read(), compress_type=zipfile.ZIP_DEFLATED, compresslevel=9) + + +def main(app: str, out: str) -> None: + app = os.path.abspath(app.rstrip('/')) + name = os.path.basename(app) + if not name.endswith('.app') or not os.path.isfile(os.path.join(app, 'Info.plist')): + sys.exit(f'not an app bundle with an Info.plist: {app}') + with open(os.path.join(app, 'Info.plist'), 'rb') as f: + executable = plistlib.load(f).get('CFBundleExecutable') + + root = f'Payload/{name}' + first = ['Info.plist'] + ([executable] if executable else []) + written = set() + + with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED, allowZip64=False) as zf: + add_dir(zf, 'Payload/', 0o755) + add_dir(zf, root, os.stat(app).st_mode) + for rel in first: + path = os.path.join(app, rel) + if os.path.lexists(path): + add_path(zf, path, f'{root}/{rel}') + written.add(rel) + for dirpath, dirnames, filenames in os.walk(app): + dirnames.sort() + rel_dir = os.path.relpath(dirpath, app) + if rel_dir != '.': + add_dir(zf, f'{root}/{rel_dir.replace(os.sep, "/")}', os.stat(dirpath).st_mode) + for fn in sorted(filenames): + rel = fn if rel_dir == '.' else f'{rel_dir}/{fn}'.replace(os.sep, '/') + if rel in written: + continue + add_path(zf, os.path.join(dirpath, fn), f'{root}/{rel}') + # symlinked directories are walked as links, not followed + for d in list(dirnames): + full = os.path.join(dirpath, d) + if os.path.islink(full): + rel = d if rel_dir == '.' else f'{rel_dir}/{d}'.replace(os.sep, '/') + add_path(zf, full, f'{root}/{rel}') + dirnames.remove(d) + + with zipfile.ZipFile(out) as zf: + bad = zf.testzip() + if bad: + sys.exit(f'corrupt entry: {bad}') + names = zf.namelist() + print(f'{out}: {len(names)} entries; first: {names[:4]}') + + +if __name__ == '__main__': + if len(sys.argv) != 3: + sys.exit(__doc__) + main(sys.argv[1], sys.argv[2])