96 lines
3.7 KiB
Python
96 lines
3.7 KiB
Python
"""Package an .app bundle into an .ipa with a predictable, tool-friendly layout.
|
|
|
|
python3 scripts/package-ipa.py <path/to/App.app> <out.ipa>
|
|
|
|
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>.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>.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])
|