- assets/logo.png (transparent) and assets/logo-256.png for the site and README - tools/make_icon.m builds the full-bleed iOS icon from the logo
290 lines
15 KiB
Python
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 assets/logo.png; 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, ROOT / "assets/logo.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()
|