215 lines
12 KiB
Python
Executable File
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
|