Files
nxnative-easy/scripts/build_ios.py
T

215 lines
12 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 as error:
raise ValueError('Another build is using this checkout; wait for it to finish.') from error
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')
assert spec is not None and spec.loader is not None
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 choose_keys_folder():
result=subprocess.run(['/usr/bin/osascript','-e',
'POSIX path of (choose folder with prompt "Select your own folder containing prod.keys")'],
capture_output=True,text=True,check=True)
return Path(result.stdout.rstrip('\n'))
def select_keys(args):
# Never silently discover keys next to a game or in a bundled/default location.
folder=args.keys_folder
if args.keys is not None:
keys=args.keys.expanduser()
else:
folder=folder if folder is not None else choose_keys_folder()
keys=folder.expanduser()/'prod.keys'
resolved=keys.resolve()
if REPO==resolved or REPO in resolved.parents:
raise ValueError('Select your own keys outside the source repository.')
if not resolved.is_file() or not resolved.stat().st_size:
raise ValueError('The selected folder must contain your own readable prod.keys.')
return resolved
def parser():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('game_dir', nargs='?', type=Path, help='folder containing one game and its updates')
keys=p.add_mutually_exclusive_group()
keys.add_argument('--keys-folder',type=Path,help='explicit external folder containing your own prod.keys')
keys.add_argument('-k','--keys',type=Path,help='explicit path to your own prod.keys (legacy CLI option)')
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',os.environ.get('NXE_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).stdout.strip() or 'downloaded-source'
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 = select_keys(args)
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
if os.environ.get('NXE_PROGRESS')=='json' and not args.full_only:
print(json.dumps({'ev':'stage','name':'Export lightweight IPA','step':'Separating game data','k':0,'n':0}),flush=True)
light_dir = None if args.full_only else split_ipa(output)
record = dict(schema=1, **settings, version=info.get('CFBundleShortVersionString'),
source_revision=revision, packaging="full" if args.full_only else "split", 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) from None
except (OSError,ValueError,subprocess.SubprocessError,zipfile.BadZipFile) as error:
print('Error:',error,file=sys.stderr)
raise SystemExit(1) from error