Files
factorio-ios/tools/build_native.py
T
SashaGoncharov19 30b961c923 Simple install guide and one-step install.sh
- README rewritten as a step-by-step guide for non-developers
- install.sh: build, install and copy data with plain error messages
- build_native.py: bundle identifier from the user's provisioning
  profile (Xcode project named factorio), version check on every
  build, friendly errors
2026-10-23 15:40:18 +04:00

290 lines
15 KiB
Python

#!/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/[email protected]", 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()