Files
nxnative-easy/scripts/build_ios.py
T
Erick Sanchez 7c33c508c9 Automate personal unsigned iOS builds and lightweight IPA export
Use the edited checkout for every game, persist identity, verify archives, preserve existing outputs, and reject concurrent builds. Include applicability triage for 57 open upstream PRs and 20 commits after the stable base.
2026-10-26 11:10:48 -06:00

193 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
"""Build from this checkout and export an unsigned lightweight IPA in one command."""
import argparse
from contextlib import contextmanager
import hashlib
import tempfile
import fcntl
from datetime import datetime
import importlib.util
import json
import os
from pathlib import Path
import plistlib
import re
import subprocess
import sys
import zipfile
REPO = Path(__file__).resolve().parents[1]
@contextmanager
def build_lock():
# Separate game folders still share the checkout's generated source files.
digest = hashlib.sha256(str(REPO).encode()).hexdigest()[:20]
with open(Path(tempfile.gettempdir()) / ('nxnative-ios-'+digest+'.lock'),'a') as lock:
try:
fcntl.flock(lock,fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise ValueError('Another build is using this checkout; wait for it to finish.')
try:
yield
finally:
fcntl.flock(lock,fcntl.LOCK_UN)
def load_builder():
spec = importlib.util.spec_from_file_location('nxnative_builder', REPO / 'scripts/build_ipa_from_nsp.py')
builder = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = builder
spec.loader.exec_module(builder)
# Preserve the venv executable path: resolving its symlink selects global Python.
builder.TOOL_PATHS.insert(0, str(Path(sys.executable).parent))
return builder
def verify_ipa(path):
from split_ipa import validate_entries, removed_signature
with zipfile.ZipFile(path) as archive:
entries = archive.infolist()
validate_entries(entries)
if any(removed_signature(e.filename) for e in entries):
raise ValueError('Expected an unsigned IPA, but signing resources were found.')
roots = [e.filename for e in entries if e.filename.startswith('Payload/')
and len(e.filename.split('/')) == 3 and e.filename.endswith('.app/Info.plist')]
if len(roots) != 1:
raise ValueError('Expected a single app Info.plist.')
info = plistlib.loads(archive.read(roots[0]))
executable = info.get('CFBundleExecutable')
prefix = roots[0][:-len('Info.plist')]
if not isinstance(executable, str) or '/' in executable or '\\' in executable or prefix+executable not in archive.namelist():
raise ValueError('Missing or invalid app executable.')
return info
def parser():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('game_dir', nargs='?', type=Path, help='folder containing one game and its updates')
p.add_argument('-k', '--keys', type=Path, help='prod.keys path; defaults to game_dir/prod.keys')
p.add_argument('-l', '--language', help='first build defaults to es-419; later builds retain identity settings')
p.add_argument('--bundle-id', help='use the SAME effective identifier when updating an installed app')
p.add_argument('--app-name')
p.add_argument('--package', help='explicit game update, such as v327680')
p.add_argument('--gpu', help='pipeline archive target; first build defaults to none')
p.add_argument('--show-fps', action=argparse.BooleanOptionalAction, default=None, help='retain FPS display choice across builds')
p.add_argument('--app-only', action='store_true', help='rebuild app from existing valid caches, without keys or re-extraction')
p.add_argument('--full-only', action='store_true', help='keep full IPA without exporting RomFS')
p.add_argument('--dry-run', action='store_true', help='show configuration without keys, builds or dependency installation')
return p
def main(argv=None):
args = parser().parse_args(argv)
if args.game_dir is None:
if sys.platform != 'darwin':
raise ValueError('Provide a game folder.')
result = subprocess.run(['/usr/bin/osascript', '-e',
'POSIX path of (choose folder with prompt "Select the folder containing one game")'],
capture_output=True, text=True, check=True)
args.game_dir = Path(result.stdout.rstrip('\n'))
game = args.game_dir.expanduser().resolve(strict=True)
if not game.is_dir():
raise ValueError('Game folder is not a directory.')
# Keep game data and credentials out of the source checkout.
if game == REPO or REPO in game.parents:
raise ValueError('Keep game folders outside the source repository.')
packages = [p for p in game.iterdir() if p.is_file() and p.suffix.lower() in {'.nsp','.xci','.nsz','.xcz'}]
if not packages:
raise ValueError('No NSP/XCI/NSZ/XCZ files directly in this game folder.')
previous = None
if args.app_only:
archives = sorted(game.glob('*.ipa'),key=lambda p:p.stat().st_mtime,reverse=True)
if archives: previous = verify_ipa(archives[0])
state_path = game / 'nxnative-ios.json'
state = json.loads(state_path.read_text()) if state_path.exists() else {}
if not isinstance(state, dict) or state.get('schema',1) != 1:
raise ValueError('Unsupported nxnative-ios.json; preserve it and check its format.')
if not state and previous:
state = {'bundle_id':previous['CFBundleIdentifier'],'app_name':previous.get('CFBundleDisplayName'),
'language':previous.get('DMLanguage','es-419'),'gpu':'none'}
settings = {}
for key, default in [('language','es-419'),('gpu','none'),('bundle_id',None),('app_name',None)]:
value = getattr(args,key) if getattr(args,key) is not None else state.get(key,default)
if value is not None and (not isinstance(value,str) or not value.strip()):
raise ValueError('Invalid saved setting: '+key)
settings[key] = value
settings['show_fps'] = args.show_fps if args.show_fps is not None else state.get('show_fps',str((previous or {}).get('DMShowFPS','YES')).lower() in {'yes','1','true'})
if not isinstance(settings['show_fps'],bool): raise ValueError('Invalid saved show_fps setting.')
old_id = state.get('bundle_id')
if old_id and settings['bundle_id'] != old_id:
raise ValueError('Bundle ID differs from the previous build. Use a separate game folder for a separate app.')
output = game / ('nxnative-ios-' + datetime.now().strftime('%Y%m%d-%H%M%S-%f') + '-unsigned.ipa')
command = [str(game), '--platform','ios','--team-id','0000000000',
'--gpu',settings['gpu'],'-l',settings['language'],'--progress','lines','-o',str(output)]
for key in ['bundle_id','app_name']:
if settings[key]: command += ['--'+key.replace('_','-'), settings[key]]
command += ['--show-fps' if settings['show_fps'] else '--no-show-fps']
if args.package: command += ['--package',args.package]
revision = subprocess.run(['git','-C',str(REPO),'rev-parse','HEAD'],capture_output=True,text=True,check=True).stdout.strip()
print('Source checkout:',REPO,flush=True)
print('Source revision:',revision,flush=True)
print('Output:',output,flush=True)
print('Settings:',json.dumps(settings),flush=True)
if args.dry_run:
print('Dry run: no build, no key access, no data changes.')
return 0
if sys.platform != 'darwin': raise ValueError('Builds require macOS and Xcode.')
with build_lock():
if args.app_only:
works = [p for p in game.glob('*_build') if re.fullmatch(r'[0-9a-fA-F]{16}_build',p.name)]
if len(works)!=1: raise ValueError('App-only needs exactly one existing title build directory.')
work=works[0]; title=work.name[:-6]
versions=[p for p in work.glob('patched_v*') if p.is_dir() and re.fullmatch(r'patched_v[0-9]+',p.name)]
package=args.package or ('v'+str(max(int(p.name[9:]) for p in versions)) if versions else None)
if not package or not re.fullmatch(r'v[0-9]+',package): raise ValueError('No valid cached package found.')
env=dict(os.environ, GAME=title, W=str(work/'ipa'), PATCHED=str(work/('patched_'+package)),
ROMFS=str(work/'extract'/package/'RomFS'), MSL_IOS=str(work/'shader/ios'),
FONT_PY=sys.executable, DM_TEAM_ID='0000000000',DM_PSO_GPUS=settings['gpu'],
DM_LANG=settings['language'],DM_SHOW_FPS='1' if settings['show_fps'] else '0',
PATH=os.pathsep.join([str(Path(sys.executable).parent),'/opt/homebrew/bin',os.environ.get('PATH','')]))
derived=work/'profile'/title
if derived.is_dir(): env['DM_GAMES_DIR']=str(derived.parent)
else: env.pop('DM_GAMES_DIR',None)
for setting,variable in [('bundle_id','DM_BUNDLE_ID'),('app_name','DM_DISPLAY_NAME')]:
if settings[setting]: env[variable]=settings[setting]
subprocess.run(['bash',str(REPO/'dm/ios/tools/build_ipa.sh'),str(output)],cwd=REPO,env=env,check=True)
else:
keys = (args.keys or game/'prod.keys').expanduser()
if not keys.is_file() or not keys.stat().st_size:
result = subprocess.run(['/usr/bin/osascript','-e','POSIX path of (choose file with prompt "Select prod.keys")'],capture_output=True,text=True,check=True)
keys = Path(result.stdout.rstrip('\n'))
if not keys.is_file() or not keys.stat().st_size: raise ValueError('Missing prod.keys.')
if keys.resolve() == REPO or REPO in keys.resolve().parents:
raise ValueError('Keep keys outside the source repository.')
if any(p.suffix.lower() in {'.nsz','.xcz'} for p in packages):
subprocess.run([sys.executable,'-m','pip','install','nsz'],check=True)
builder = load_builder()
code = builder.cli(command + ['-k',str(keys)])
if code: return code
info = verify_ipa(output)
actual_id = info['CFBundleIdentifier']
if old_id and actual_id != old_id: raise ValueError('Built bundle ID changed unexpectedly; do not install.')
settings['bundle_id'] = actual_id
from split_ipa import split_ipa
light_dir = None if args.full_only else split_ipa(output)
record = dict(schema=1, **settings, version=info.get('CFBundleShortVersionString'),
source_revision=revision, full_ipa=str(output), light_dir=str(light_dir) if light_dir else None)
temp = state_path.with_suffix('.json.tmp')
temp.write_text(json.dumps(record,indent=2)+'\n'); temp.replace(state_path)
print('Build complete. Install over the existing app using the same personal account and effective bundle ID.')
print('The game is not installed or signed by this script. Keep the existing app Documents and saves.')
return 0
if __name__ == '__main__':
try:
raise SystemExit(main())
except KeyboardInterrupt:
raise SystemExit(130)
except (OSError,ValueError,subprocess.SubprocessError,zipfile.BadZipFile) as error:
print('Error:',error,file=sys.stderr)
raise SystemExit(1)