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.
193 lines
11 KiB
Python
Executable File
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)
|