#!/usr/bin/env python3 """Build, package and optionally publish the Apple Silicon game with shared data.""" import argparse import gzip import hashlib import json import os from pathlib import Path import stat import subprocess import sys import tarfile from datetime import datetime, timezone def hash_file(path): with path.open('rb') as stream: return hashlib.file_digest(stream, 'sha256').hexdigest() def archive_bundle(bundle, output): """Write deterministic USTAR regular entries only, matching the launcher's reader.""" files = [] paths = sorted(bundle.rglob('*')) for path in paths: if path.is_symlink() or not (path.is_dir() or path.is_file()): raise ValueError(f'Unsupported bundle entry: {path}') if not path.is_file(): continue relative = path.relative_to(bundle.parent).as_posix() if any(c in relative for c in '\\:\r\n\0'): raise ValueError(f'Invalid bundle filename: {relative}') files.append({'path': relative, 'size': path.stat().st_size, 'sha256': hash_file(path), 'executable': bool(path.stat().st_mode & 0o111)}) if len(files) > 10000 or sum(f['size'] for f in files) > 2 * 1024**3: raise ValueError('Bundle exceeds launcher extraction limits') archive = output / 'MoonWell.app.tar.gz' with archive.open('xb') as raw: with gzip.GzipFile(filename='', fileobj=raw, mode='wb', mtime=0) as compressed: with tarfile.open(fileobj=compressed, mode='w', format=tarfile.USTAR_FORMAT) as tar: for file in files: entry = tarfile.TarInfo(file['path']) entry.size = file['size'] entry.mode = 0o755 if file['executable'] else 0o644 with (bundle.parent / file['path']).open('rb') as stream: tar.addfile(entry, stream) if archive.stat().st_size > 1024**3: raise ValueError('Bundle archive exceeds launcher download limit') descriptor = {'path': archive.name, 'size': archive.stat().st_size, 'sha256': hash_file(archive), 'files': files} (output / 'bundle.json').write_text(json.dumps(descriptor, indent=2) + '\n') return descriptor def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--icon', type=Path, required=True) parser.add_argument('--binary', type=Path, help='Use an existing arm64 player build') parser.add_argument('--output', type=Path) parser.add_argument('--env-file', type=Path) parser.add_argument('--shared-manifest-key', default='manifest-1.12.1-data.json') parser.add_argument('--manifest-key', default='manifest-1.12.1-macos-arm64.json') parser.add_argument('--prefix', default='World of Warcraft 1.12.1') mode = parser.add_mutually_exclusive_group() mode.add_argument('--publish', action='store_true') mode.add_argument('--dry-run', action='store_true') args = parser.parse_args() if sys.platform != 'darwin': parser.error('Build and package on an Apple Silicon Mac') if args.manifest_key in ('manifest.json', 'manifest-1.12.1.json', args.shared_manifest_key): parser.error('Mac manifest key must be separate from Windows and shared data') if (args.publish or args.dry_run) and not args.env_file: parser.error('--env-file is required for publication and dry-run') root = Path(__file__).resolve().parents[1] output = (args.output or root / 'dist' / ('moonwell-macos-' + datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S'))).resolve() output.mkdir(parents=True, exist_ok=False) if not args.binary: subprocess.run(['cargo', 'build', '-p', 'moonwell', '--release', '--no-default-features', '--locked', '--target', 'aarch64-apple-darwin'], cwd=root, check=True) binary = (args.binary or root / 'target/aarch64-apple-darwin/release/MoonWell').resolve() bundle = output / 'build' / 'MoonWell.app' subprocess.run([sys.executable, str(root / 'crates/moonwell/package_macos.py'), '--binary', str(binary), '--icon', str(args.icon.resolve()), '--output', str(bundle)], check=True) # A non-system library must be bundled and signed deliberately, not assumed present on player Macs. dependencies = subprocess.check_output(['otool', '-L', str(bundle / 'Contents/MacOS/MoonWell')], text=True).splitlines()[1:] for line in dependencies: library = line.strip().split(' (', 1)[0] if not library.startswith(('/System/Library/', '/usr/lib/')): raise RuntimeError(f'Unbundled runtime dependency: {library}') archive_bundle(bundle, output) if args.publish or args.dry_run: command = ['cargo', 'run', '-p', 'moonwell-deploy', '--release', '--locked', '--', 'publish-macos', '--dir', str(output), '--env-file', str(args.env_file.resolve()), '--shared-manifest-key', args.shared_manifest_key, '--manifest-key', args.manifest_key, '--prefix', args.prefix] if args.dry_run: command.append('--dry-run') subprocess.run(command, cwd=root, check=True) print(f'Mac package: {output}') if __name__ == '__main__': main()