1 Commits

Author SHA1 Message Date
peji b1e2e18efa fix: preserve macOS framework symlinks in portable ZIP archives 2026-09-09 22:42:06 +00:00
2 changed files with 61 additions and 1 deletions
+8 -1
View File
@@ -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
+53
View File
@@ -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)