#!/usr/bin/env python3 """Build, sign and optionally install the native iOS host for the retargeted Factorio binary. Usage: python3 tools/build_native.py [--install] [--push-data] [--launch] [options] --install install build/native/Factorio.app on the connected device --push-data copy the installed game's data/ folder into the app's Documents (once, ~1.5 GB) --launch launch with the console attached; output is also saved to build/native/console.log --no-build skip building (install/launch the existing bundle) Options (each also readable from the environment variable shown): --game PATH factorio.app/Contents of a local install (FX_GAME; default: Steam location) --team ID Apple developer team ID (FX_TEAM; default: from your provisioning profiles) --bundle-id ID app bundle identifier (FX_BUNDLE_ID; default: the App ID of a provisioning profile containing "factorio", else local.factorio.native with a wildcard profile) --device ID devicectl device identifier or name (FX_DEVICE; default: first paired device) The game executable is the unmodified ARM64 slice with only Mach-O header changes (see tools/retarget.py). Shims supply the macOS APIs iOS lacks. No game data is put in the bundle. """ import argparse import hashlib import json import os import plistlib import subprocess import time import sys import urllib.request from pathlib import Path ROOT = Path(__file__).resolve().parent.parent STEAM_GAME = Path.home() / "Library/Application Support/Steam/steamapps/common/Factorio/factorio.app/Contents" OUT = ROOT / "build/native" APP = OUT / "Factorio.app" SRC = ROOT / "platform/native" MIN_IOS = "17.0" # Set from command-line options / environment in main(). GAME = STEAM_GAME BUNDLE_ID = None # resolved from the provisioning profile unless given DEFAULT_BUNDLE_ID = "local.factorio.native" SLICE_SHA256 = "2cb4de127b50d54c6c5666acfef2263fae16d11a3361ad39353ecf8a66a4d9c7" # 2.0.77 Steam, arm64 TEAM = None SLICE = ROOT / "build/factorio.arm64" WUFFS = ROOT / "third_party/wuffs/wuffs-v0.3.c" WUFFS_URL = "https://raw.githubusercontent.com/google/wuffs/v0.3.4/release/c/wuffs-v0.3.c" WUFFS_SHA256 = "a3db4bd979663423de00309d1ba07d7fa8576845223d3e02764181bd6da23f90" SDK = subprocess.check_output(["xcrun", "--sdk", "iphoneos", "--show-sdk-path"], text=True).strip() CC = ["xcrun", "--sdk", "iphoneos", "clang", "-target", f"arm64-apple-ios{MIN_IOS}", "-isysroot", SDK, "-O2", "-g", "-Wall", "-Wno-unused-function", "-Wno-objc-property-no-attribute", f"-I{SRC / 'shim'}"] # shim name -> (sources, defines, re-exported iOS framework or None, links fx_AppKit) SHIMS = { "fx_CoreGraphics": (["coregraphics.c"], [], "CoreGraphics", True), "fx_CoreVideo": (["small.c"], ["FX_LIB_COREVIDEO"], "CoreVideo", True), "fx_CoreServices": (["small.c"], ["FX_LIB_CORESERVICES"], "CoreServices", True), "fx_Security": (["small.c"], ["FX_LIB_SECURITY"], "Security", True), "fx_IOKit": (["small.c"], ["FX_LIB_IOKIT"], "IOKit", True), "fx_Foundation": (["frameworks.m"], ["FX_LIB_FOUNDATION"], "Foundation", True), "fx_Metal": (["frameworks.m"], ["FX_LIB_METAL"], "Metal", True), "fx_CoreAudio": (["audio.c"], ["FX_LIB_COREAUDIO"], "CoreAudio", True), "fx_AudioToolbox": (["audio.c"], ["FX_LIB_AUDIOTOOLBOX"], "AudioToolbox", True), "fx_Carbon": (["small.c"], ["FX_LIB_CARBON"], None, True), "fx_OpenCL": (["small.c"], ["FX_LIB_OPENCL"], None, True), "fx_ForceFeedback": (["small.c"], ["FX_LIB_FORCEFEEDBACK"], None, True), "fx_LDAP": (["small.c"], ["FX_LIB_LDAP"], None, True), "fx_ApplicationServices": (["small.c"], ["FX_LIB_APPLICATIONSERVICES"], None, True), "fx_Cocoa": (["small.c"], ["FX_LIB_EMPTY"], None, False), "fx_OpenGL": (["small.c"], ["FX_LIB_EMPTY"], None, False), "libsteam_api": (["small.c"], ["FX_LIB_STEAM"], None, True), } def run(cmd, **kw): print("+", " ".join(str(c) for c in cmd), flush=True) return subprocess.run([str(c) for c in cmd], check=True, **kw) def dylib(name, sources, defines=(), reexport=None, link_appkit=True, extra=()): out = APP / "Frameworks" / f"{name}.dylib" cmd = CC + ["-dynamiclib", "-o", out, "-install_name", f"@rpath/{name}.dylib", "-compatibility_version", "1000", "-current_version", "1000", "-Wl,-rpath,@loader_path", *[f"-D{d}" for d in defines], *[SRC / "shim" / s for s in sources], "-framework", "CoreFoundation", *extra] if any(s.endswith(".m") for s in sources): cmd += ["-fobjc-arc", "-framework", "Foundation"] if reexport: cmd += [f"-Wl,-reexport_framework,{reexport}"] if link_appkit: cmd += [APP / "Frameworks/fx_AppKit.dylib"] run(cmd) def build_icon(): """Compile an asset catalog with the app icon, made from the game's own icon; returns plist keys.""" work = OUT / "icon" iconset = work / "Assets.xcassets/AppIcon.appiconset" iconset.mkdir(parents=True, exist_ok=True) tool = work / "make_icon" run(["xcrun", "clang", "-O2", "-fobjc-arc", ROOT / "tools/make_icon.m", "-framework", "Foundation", "-framework", "CoreGraphics", "-framework", "ImageIO", "-o", tool]) run([tool, GAME / "data/core/graphics/factorio-icon@2x.png", iconset / "AppIcon-1024.png"]) (iconset / "Contents.json").write_text(json.dumps({"images": [{"filename": "AppIcon-1024.png", "idiom": "universal", "platform": "ios", "size": "1024x1024"}], "info": {"author": "xcode", "version": 1}})) (work / "Assets.xcassets/Contents.json").write_text(json.dumps({"info": {"author": "xcode", "version": 1}})) partial = work / "partial-info.plist" run(["xcrun", "actool", work / "Assets.xcassets", "--compile", APP, "--platform", "iphoneos", "--minimum-deployment-target", MIN_IOS, "--app-icon", "AppIcon", "--target-device", "iphone", "--target-device", "ipad", "--output-partial-info-plist", partial], stdout=subprocess.DEVNULL) with open(partial, "rb") as f: return plistlib.load(f) def prepare_inputs(): """Extract the ARM64 slice from the local install and fetch the pinned Wuffs source.""" binary = GAME / "MacOS/factorio" if not binary.exists(): raise SystemExit(f"Factorio not found at {GAME}.\nInstall Factorio from Steam on this Mac, or pass --game PATH/factorio.app/Contents.") SLICE.parent.mkdir(parents=True, exist_ok=True) # Re-extract every build so a Steam update is noticed. run(["lipo", "-thin", "arm64", binary, "-output", SLICE]) if hashlib.sha256(SLICE.read_bytes()).hexdigest() != SLICE_SHA256: raise SystemExit("wrong Factorio version: this port needs Factorio 2.0.77 for macOS from Steam.") if not WUFFS.exists() or hashlib.sha256(WUFFS.read_bytes()).hexdigest() != WUFFS_SHA256: WUFFS.parent.mkdir(parents=True, exist_ok=True) print("+ fetch", WUFFS_URL, flush=True) data = urllib.request.urlopen(WUFFS_URL, timeout=120).read() if hashlib.sha256(data).hexdigest() != WUFFS_SHA256: raise SystemExit("downloaded Wuffs source does not match the pinned SHA-256") WUFFS.write_bytes(data) def build(): prepare_inputs() run([sys.executable, ROOT / "tools/retarget.py", SLICE, OUT / "Factorio.dylib"]) (APP / "Frameworks").mkdir(parents=True, exist_ok=True) dylib("fx_AppKit", ["appkit.m", "boot.m", "crash.c", "keyboard.m", "metal_compat.m", "pngdecode.c", "stack.s"], link_appkit=False, extra=["-fobjc-arc", "-framework", "Foundation", "-framework", "UIKit", "-framework", "QuartzCore", "-framework", "CoreGraphics", "-framework", "Metal", "-framework", "ImageIO", "-framework", "AVFAudio", "-framework", "Accelerate", # UIKitCore already has a private NSColor; export ours under the AppKit symbol name. "-Wl,-alias,_OBJC_CLASS_$_FXNSColor,_OBJC_CLASS_$_NSColor"]) for name, (sources, defines, reexport, link_appkit) in SHIMS.items(): extra = ["-framework", "Metal"] if name == "fx_Metal" else [] dylib(name, sources, defines, reexport, link_appkit, extra) run(["cp", OUT / "Factorio.dylib", APP / "Frameworks/Factorio.dylib"]) run(CC + ["-o", APP / "FactorioNative", SRC / "host/main.c", APP / "Frameworks/fx_AppKit.dylib", "-Wl,-rpath,@executable_path/Frameworks"]) info = { "CFBundleExecutable": "FactorioNative", "CFBundleIdentifier": BUNDLE_ID, "CFBundleName": "Factorio", "CFBundleDisplayName": "Factorio", "CFBundlePackageType": "APPL", "CFBundleVersion": "1", "CFBundleShortVersionString": "2.0.77", "CFBundleInfoDictionaryVersion": "6.0", "CFBundleSupportedPlatforms": ["iPhoneOS"], "MinimumOSVersion": MIN_IOS, "UIDeviceFamily": [1, 2], "DTPlatformName": "iphoneos", "UIRequiresFullScreen": True, "UIStatusBarHidden": True, "UISupportedInterfaceOrientations": ["UIInterfaceOrientationLandscapeLeft", "UIInterfaceOrientationLandscapeRight"], "UILaunchScreen": {"UIColorName": ""}, "UIFileSharingEnabled": True, "LSSupportsOpeningDocumentsInPlace": True, "GCSupportsControllerUserInteraction": True, } info.update(build_icon()) with open(APP / "Info.plist", "wb") as f: plistlib.dump(info, f, fmt=plistlib.FMT_BINARY) symbols = OUT / "dSYM" run(["rm", "-rf", symbols]) symbols.mkdir() for d in list(APP.rglob("*.dSYM")): d.rename(symbols / d.name) sign() def load_profiles(): folder = Path.home() / "Library/Developer/Xcode/UserData/Provisioning Profiles" profiles = [] for path in sorted(folder.glob("*.mobileprovision")): plist = plistlib.loads(subprocess.check_output(["security", "cms", "-D", "-i", str(path)])) team = plist["TeamIdentifier"][0] if TEAM and team != TEAM: continue if plist.get("ExpirationDate") and plist["ExpirationDate"].timestamp() < time.time(): continue bundle = plist["Entitlements"]["application-identifier"].split(".", 1)[1] profiles.append((path, plist, team, bundle)) return profiles def find_profile(): """Choose the signing profile and the bundle identifier it allows. Preference: an explicit App ID equal to --bundle-id; otherwise an explicit App ID containing "factorio" (what the README's one-time Xcode project creates); otherwise a wildcard profile. """ global BUNDLE_ID profiles = load_profiles() if BUNDLE_ID: matches = [p for p in profiles if p[3] == BUNDLE_ID] + [p for p in profiles if p[3] == "*"] else: named = sorted((p for p in profiles if "factorio" in p[3].lower() and p[3] != "*"), key=lambda p: p[1].get("CreationDate"), reverse=True) matches = named + [p for p in profiles if p[3] == "*"] if not matches: raise SystemExit("no provisioning profile found for the app.\n" "Do README step 4 once (an Xcode project named \"factorio\" run on your iPhone).") path, plist, team, bundle = matches[0] if not BUNDLE_ID: BUNDLE_ID = bundle if bundle != "*" else DEFAULT_BUNDLE_ID return path, plist, team def signing_identity(profile): """The keychain identity whose certificate is embedded in the profile.""" allowed = {hashlib.sha1(cert).hexdigest().upper() for cert in profile.get("DeveloperCertificates", [])} listing = subprocess.check_output(["security", "find-identity", "-v", "-p", "codesigning"], text=True) for line in listing.splitlines(): parts = line.split() if len(parts) > 2 and parts[1] in allowed: return parts[1] raise SystemExit("no signing identity in the keychain matches the provisioning profile") def sign(): profile, plist, team = find_profile() run(["cp", profile, APP / "embedded.mobileprovision"]) identity = signing_identity(plist) entitlements = {"application-identifier": f"{team}.{BUNDLE_ID}", "com.apple.developer.team-identifier": team, "get-task-allow": True} if plist["Entitlements"].get("com.apple.developer.kernel.increased-memory-limit"): entitlements["com.apple.developer.kernel.increased-memory-limit"] = True ent = OUT / "entitlements.plist" with open(ent, "wb") as f: plistlib.dump(entitlements, f) for lib in sorted((APP / "Frameworks").glob("*.dylib")): run(["codesign", "-f", "-s", identity, "--timestamp=none", lib], stdout=subprocess.DEVNULL) run(["codesign", "-f", "-s", identity, "--timestamp=none", "--entitlements", ent, APP]) print(json.dumps({"app": str(APP), "profile": plist["Name"], "entitlements": entitlements}, indent=2)) def device(requested=None): if requested: return requested listing = OUT / "devices.json" subprocess.run(["xcrun", "devicectl", "list", "devices", "--json-output", listing], check=True, stdout=subprocess.DEVNULL) data = json.loads(listing.read_text()) for d in data["result"]["devices"]: if d.get("connectionProperties", {}).get("pairingState") == "paired": return d["identifier"] raise SystemExit("no iPhone found: plug it in, unlock it, tap Trust, and turn on Developer Mode.") def main(): global GAME, BUNDLE_ID, TEAM parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--install", action="store_true") parser.add_argument("--push-data", action="store_true") parser.add_argument("--launch", action="store_true") parser.add_argument("--no-build", action="store_true") parser.add_argument("--game", default=os.environ.get("FX_GAME", str(STEAM_GAME))) parser.add_argument("--team", default=os.environ.get("FX_TEAM")) parser.add_argument("--bundle-id", default=os.environ.get("FX_BUNDLE_ID")) parser.add_argument("--device", default=os.environ.get("FX_DEVICE")) args = parser.parse_args() GAME, BUNDLE_ID, TEAM = Path(args.game), args.bundle_id, args.team find_profile() print(f"+ bundle identifier {BUNDLE_ID}", flush=True) if not args.no_build: build() if args.install or args.push_data or args.launch: dev = device(args.device) if args.install: run(["xcrun", "devicectl", "device", "install", "app", "--device", dev, APP]) if args.push_data: run(["xcrun", "devicectl", "device", "copy", "to", "--device", dev, "--domain-type", "appDataContainer", "--domain-identifier", BUNDLE_ID, "--source", GAME / "data", "--destination", "Documents/factorio/data"]) if args.launch: with open(OUT / "console.log", "w") as log: proc = subprocess.Popen(["xcrun", "devicectl", "device", "process", "launch", "--device", dev, "--terminate-existing", "--console", BUNDLE_ID], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) for line in proc.stdout: sys.stdout.write(line) log.write(line) log.flush() proc.wait() if __name__ == "__main__": main()