From b1e2e18efa5f06207236cd49b896aac276bcba87 Mon Sep 17 00:00:00 2001 From: PageZ948 Date: Wed, 9 Sep 2026 22:42:06 +0000 Subject: [PATCH] fix: preserve macOS framework symlinks in portable ZIP archives --- docs/mac-test.md | 9 ++++++- scripts/package-mac-zip.py | 53 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 scripts/package-mac-zip.py diff --git a/docs/mac-test.md b/docs/mac-test.md index 5a78b1b..d183021 100644 --- a/docs/mac-test.md +++ b/docs/mac-test.md @@ -15,7 +15,14 @@ CSC_IDENTITY_AUTO_DISCOVERY=false npx electron-builder --mac zip --arm64 --x64 - Linux Electron launch smoke: `npm run smoke:headless`. This checks the shared application, not the macOS executable. -ZIP packaging works on Linux with this electron-builder version. Signing/notarization and a native macOS launch test remain separate gates. No claim is made that an unsigned app will pass Gatekeeper or Apple Silicon signature enforcement. +ZIP packaging works on Linux with this electron-builder version, but its default ZIP output dereferences framework symlinks. The first archives failed macOS Archive Utility with Error 94. Repackage the unpacked apps with Unix symlinks preserved and standard ZIP metadata: + +```sh +python scripts/package-mac-zip.py 'dist/mac-arm64/Alta Video Player.app' dist/Alta-Video-Player-0.1.1-mac.1-mac-arm64-repacked.zip +python scripts/package-mac-zip.py 'dist/mac/Alta Video Player.app' dist/Alta-Video-Player-0.1.1-mac.1-mac-x64-repacked.zip +``` + +The replacement archives pass CRC, file-byte/mode and symlink-target checks on Linux; macOS Archive Utility compatibility still requires a user test. Signing/notarization and a native macOS launch test remain separate gates. No claim is made that an unsigned app will pass Gatekeeper or Apple Silicon signature enforcement. ## Test on a Mac diff --git a/scripts/package-mac-zip.py b/scripts/package-mac-zip.py new file mode 100644 index 0000000..d2d13a3 --- /dev/null +++ b/scripts/package-mac-zip.py @@ -0,0 +1,53 @@ +"""Package an existing macOS app with Unix symlinks intact (stdlib only).""" +import argparse +import os +from pathlib import Path +import stat +import time +import zipfile + + +def package(app, output): + app = Path(app).resolve() + output = Path(output).resolve() + assert app.is_dir() and app.suffix == '.app' + with zipfile.ZipFile(output, 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive: + def add(path): + st = path.lstat() + name = path.relative_to(app.parent).as_posix() + is_dir = stat.S_ISDIR(st.st_mode) + info = zipfile.ZipInfo(name + ('/' if is_dir else ''), time.localtime(st.st_mtime)[:6]) + info.create_system = 3 + info.external_attr = st.st_mode << 16 + if is_dir: + info.external_attr |= 0x10 + info.compress_type = zipfile.ZIP_STORED if is_dir else zipfile.ZIP_DEFLATED + if stat.S_ISLNK(st.st_mode): + archive.writestr(info, os.readlink(path).encode('utf-8')) + elif is_dir: + archive.writestr(info, b'') + for child in sorted(path.iterdir()): + add(child) + elif stat.S_ISREG(st.st_mode): + with path.open('rb') as src, archive.open(info, 'w') as dst: + while chunk := src.read(1024 * 1024): + dst.write(chunk) + else: + raise ValueError(f'Unsupported file type: {path}') + add(app) + with zipfile.ZipFile(output) as archive: + assert archive.testzip() is None + for item in archive.infolist(): + source = app.parent / item.filename.rstrip('/') + assert (item.external_attr >> 16) == source.lstat().st_mode + if source.is_symlink(): + assert archive.read(item).decode('utf-8') == os.readlink(source) + print(output) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('app') + parser.add_argument('output') + args = parser.parse_args() + package(args.app, args.output)