From ed63c38d7496d9666beb37a9a5e0bc86e8bce4cc Mon Sep 17 00:00:00 2001 From: Oliver Ames Date: Thu, 29 Oct 2026 13:34:51 -0400 Subject: [PATCH] iOS: a URL scheme per game build_ipa.sh registers two URL schemes in the app's Info.plist: the shared nxnative and this game's own nxnative-, so a launcher or a Shortcut can detect and open exactly this game. The keys are added to the Info.plist xcodegen just wrote, so a failure stops the build (die) like MetalCaptureEnabled's. test_ipa_unsigned.py runs the block on a fresh Info.plist and checks the single entry and the lowercased title ID, and that a plist which already names schemes stops the build instead of gaining a second entry. --- dm/ios/tests/test_ipa_unsigned.py | 44 ++++++++++++++++++++++++++++--- dm/ios/tools/build_ipa.sh | 8 ++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/dm/ios/tests/test_ipa_unsigned.py b/dm/ios/tests/test_ipa_unsigned.py index 028d506706..5110e3ad50 100644 --- a/dm/ios/tests/test_ipa_unsigned.py +++ b/dm/ios/tests/test_ipa_unsigned.py @@ -1,7 +1,8 @@ -"""The unsigned IPA of build_ipa.sh: the game files stay out with DM_COPY_ROMFS=NO, and the executable carries the entitlements in an ad-hoc signature. -Only the archive step and the signing step run (a stand-in app, no Xcode or game files).""" +"""The unsigned IPA of build_ipa.sh: the game files stay out with DM_COPY_ROMFS=NO, the executable carries the entitlements in an ad-hoc signature, and the Info.plist names the shared and the game's own URL scheme. +Only the archive step, the signing step and the Info.plist block run (a stand-in app, no Xcode or game files).""" import os from pathlib import Path +import plistlib import shutil import subprocess import sys @@ -19,13 +20,18 @@ SCRIPT = (REPO / 'dm/ios/tools/build_ipa.sh').read_text() SIGNING = SCRIPT[SCRIPT.index('# The entitlements of an unsigned IPA'):SCRIPT.index('\nP=Payload/DmIOS.app\n')] PACKING = SCRIPT[SCRIPT.index('\nP=Payload/DmIOS.app\n'):] +SCHEMES = SCRIPT[SCRIPT.index('# Game handoff: register the shared scheme'):SCRIPT.index('# DM_METAL_CAPTURE=1')] +PLIST = ('<?xml version="1.0" encoding="UTF-8"?>\n' + '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n' + '<plist version="1.0"><dict><key>CFBundleIdentifier</key><string>dev.nxnative.fixture</string></dict></plist>\n') PREAMBLE = 'set -u\nt() { :; }; die() { echo "$*" >&2; exit 1; }\ngp() { python3 "$PROFILE_TOOL" "$GAME" "$1"; }\n' CAN_SIGN = sys.platform == 'darwin' and shutil.which('codesign') and shutil.which('cc') def run_step(script, root, **extra): env = {k: v for k, v in os.environ.items() if not k.startswith('DM_') and k not in ('IOS_PULL', 'AOC', 'PSO')} - env.update(GAME=GAME, DM=str(root / 'dm'), PROFILE_TOOL=str(REPO / 'dm/games/game.py'), **extra) + env.update({'GAME': GAME, 'DM': str(root / 'dm'), 'PROFILE_TOOL': str(REPO / 'dm/games/game.py')}) + env.update(extra) return subprocess.run(['/bin/bash', '-c', PREAMBLE + script], cwd=root, env=env, capture_output=True, text=True) @@ -97,5 +103,37 @@ def test_executable_carries_the_entitlements(self): self.assertEqual('com.apple.developer.kernel.increased-memory-limit' in shown.stdout + shown.stderr, expected) +class UrlSchemeTests(unittest.TestCase): + """The Info.plist block build_ipa.sh runs after xcodegen: the shared scheme and this game's own.""" + + def plist(self, root, game_id=GAME): + (root / 'Gen').mkdir() + path = root / 'Gen/Info.plist' + path.write_text(PLIST) + return path, run_step(SCHEMES, root, GAME=game_id) + + def test_one_entry_names_the_shared_and_the_game_scheme(self): + for game_id in (GAME, '0100A6301214E000'): + with self.subTest(game=game_id), tempfile.TemporaryDirectory() as temporary: + path, result = self.plist(Path(temporary), game_id) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + plist = plistlib.loads(path.read_bytes()) + self.assertEqual(plist['CFBundleURLTypes'], [{ + 'CFBundleURLName': 'dev.nxnative', + 'CFBundleURLSchemes': ['nxnative', 'nxnative-' + game_id.lower()]}]) + self.assertEqual(plist['CFBundleIdentifier'], 'dev.nxnative.fixture') # nothing else is touched + + def test_a_plist_that_already_names_schemes_stops_the_build(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + path, result = self.plist(root) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + before = path.read_bytes() + result = run_step(SCHEMES, root) + self.assertNotEqual(result.returncode, 0) # die "URL schemes": no silent second entry for one bundle + self.assertIn('URL schemes', result.stderr) + self.assertEqual(path.read_bytes(), before) + + if __name__ == '__main__': unittest.main() diff --git a/dm/ios/tools/build_ipa.sh b/dm/ios/tools/build_ipa.sh index 57a16dbc23..3c27b24b7a 100755 --- a/dm/ios/tools/build_ipa.sh +++ b/dm/ios/tools/build_ipa.sh @@ -191,6 +191,14 @@ xcodegen generate -q > /dev/null || die "xcodegen" COMMIT=$(git -C "$DM" rev-parse --short=12 HEAD 2> /dev/null) && { [ -z "$(git -C "$DM" status --porcelain --untracked-files=no 2> /dev/null)" ] || COMMIT=$COMMIT+dirty /usr/libexec/PlistBuddy -c "Add :DMCommit string $COMMIT" Gen/Info.plist > /dev/null || die "DMCommit"; t "source: $COMMIT"; } case "$MSLDIR" in b-*) /usr/libexec/PlistBuddy -c "Add :DMChain string ${MSLDIR#b-}" Gen/Info.plist > /dev/null || die "DMChain";; esac +# Game handoff: register the shared scheme and this game's own scheme +# (nxnative-<title id>) so a launcher can detect and open exactly this game. +/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes array" Gen/Info.plist > /dev/null || die "URL schemes" +/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0 dict" Gen/Info.plist > /dev/null || die "URL schemes" +/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLName string dev.nxnative" Gen/Info.plist > /dev/null || die "URL schemes" +/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes array" Gen/Info.plist > /dev/null || die "URL schemes" +/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes:0 string nxnative" Gen/Info.plist > /dev/null || die "URL schemes" +/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes:1 string nxnative-$(echo "$GAME" | tr 'A-Z' 'a-z')" Gen/Info.plist > /dev/null || die "URL schemes" # DM_METAL_CAPTURE=1: a development build that may write GPU frame captures (MetalCaptureEnabled; the host's DM_GPU_CAPTURE switch writes them to Documents/gpu_captures) if [ "${DM_METAL_CAPTURE:-0}" = 1 ]; then /usr/libexec/PlistBuddy -c "Add :MetalCaptureEnabled bool true" Gen/Info.plist > /dev/null || die "MetalCaptureEnabled"; t "Metal frame capture: enabled (DM_METAL_CAPTURE=1)"; fi RP=; if [ -n "${PROGRAMS_TSV:-}" ]; then # a game of several programs: each program's files, which the Xcode build phase copies under p<index> as the IPA has them (stage_resources.sh)