ffi_build.py (13121B)
1 """Build and verify native candidates from the sole owned Rust workspace.""" 2 3 from __future__ import annotations 4 5 import argparse 6 import json 7 import os 8 import plistlib 9 import re 10 import shutil 11 import subprocess 12 import sys 13 import tempfile 14 from pathlib import Path 15 from typing import Any 16 17 import ffi_artifacts as artifacts 18 import ffi_installed as installed 19 import ffi_provenance as provenance 20 import ffi_source as source 21 import package_contract as contract 22 23 24 def build_roots(root: Path) -> tuple[Path, Path]: 25 if not os.environ.get("EXT_BUILD_RUN_ACTIVE"): 26 raise source.ProvenanceError("native artifact commands require extbuild") 27 project = Path(os.environ["EXT_BUILD_PROJECT_DIR"]).resolve() 28 target = Path(os.environ["CARGO_TARGET_DIR"]).resolve() 29 if not target.is_relative_to(project) or project.is_relative_to(root): 30 raise source.ProvenanceError("native outputs must use the external build root") 31 return project, target 32 33 34 def build_environment( 35 root: Path, project: Path, config: dict[str, Any] 36 ) -> dict[str, str]: 37 source.reject_build_overrides() 38 values = { 39 "producer_root": str(root), 40 "extbuild_root": str(project), 41 "cargo_home": os.environ.get("CARGO_HOME", str(Path.home() / ".cargo")), 42 } 43 environment = dict(os.environ) 44 environment.pop("TERA_CONSUMER_REVISION", None) 45 environment.update( 46 { 47 "CARGO_ENCODED_RUSTFLAGS": "\x1f".join( 48 flag.format(**values) for flag in config["build"]["rust_flags"] 49 ), 50 "IPHONEOS_DEPLOYMENT_TARGET": config["build"]["ios_deployment_target"], 51 "SOURCE_DATE_EPOCH": str(config["build"]["source_date_epoch"]), 52 "RADROOTS_LIB_REVISION": contract._read_toml( 53 root / config["foundation_lock"] 54 )["revision"], 55 } 56 ) 57 return environment 58 59 60 def run( 61 root: Path, logs: Path, name: str, argv: list[str], environment: dict[str, str] 62 ) -> Path: 63 print(f"native candidate: {name}", flush=True) 64 log = logs / f"{name}.txt" 65 with log.open("wb") as output: 66 try: 67 result = subprocess.run( 68 argv, 69 cwd=root, 70 env=environment, 71 stdout=output, 72 stderr=subprocess.STDOUT, 73 timeout=1800, 74 check=False, 75 ) 76 except (OSError, subprocess.TimeoutExpired) as error: 77 raise source.ProvenanceError( 78 f"native command unavailable or timed out: {name}" 79 ) from error 80 if result.returncode or log.stat().st_size > 64 * 1024 * 1024: 81 raise source.ProvenanceError( 82 f"native command failed; retained diagnostic: {name}" 83 ) 84 return log 85 86 87 def library_command(root: Path, target: str, project: Path) -> list[str]: 88 config = source.producer_contract(root) 89 flags = [ 90 flag.format(extbuild_root=project) 91 for flag in source.library_rust_flags(config["build"], target) 92 ] 93 command = [ 94 "cargo", 95 "rustc" if flags else "build", 96 "--manifest-path", 97 str(root / "Cargo.toml"), 98 "-p", 99 "tera_ffi", 100 "--lib", 101 "--release", 102 "--locked", 103 "--target", 104 target, 105 ] 106 return command + (["--", *flags] if flags else []) 107 108 109 def build_libraries( 110 root: Path, bundle: Path, target_root: Path, logs: Path, env: dict[str, str] 111 ) -> None: 112 for target in artifacts.TARGETS: 113 run( 114 root, 115 logs, 116 "build-" + target, 117 library_command(root, target, Path(env["EXT_BUILD_PROJECT_DIR"])), 118 env, 119 ) 120 extension = "dylib" if target == artifacts.TARGETS[-1] else "a" 121 destination = bundle / "native" / target 122 destination.mkdir(parents=True) 123 shutil.copyfile( 124 target_root / target / "release" / f"libtera_ffi.{extension}", 125 destination / f"libtera_ffi.{extension}", 126 ) 127 128 129 def generate_bindings( 130 root: Path, bundle: Path, logs: Path, env: dict[str, str] 131 ) -> None: 132 generated = bundle / "generated" 133 generated.mkdir() 134 run( 135 root, 136 logs, 137 "generate-swift", 138 [ 139 "cargo", 140 "run", 141 "--manifest-path", 142 str(root / "Cargo.toml"), 143 "-p", 144 "tera_bindgen", 145 "--locked", 146 "--", 147 "generate", 148 str(bundle / "native/aarch64-apple-darwin/libtera_ffi.dylib"), 149 "--library", 150 "--language", 151 "swift", 152 "--metadata-no-deps", 153 "--out-dir", 154 str(generated), 155 "--config", 156 str(root / "core/crates/tera_ffi/uniffi.toml"), 157 ], 158 env, 159 ) 160 headers = bundle / "headers" 161 headers.mkdir() 162 shutil.copyfile(generated / "TeraFFI.h", headers / "TeraFFI.h") 163 shutil.copyfile(generated / "TeraFFI.modulemap", headers / "module.modulemap") 164 165 166 def package_framework( 167 root: Path, bundle: Path, logs: Path, env: dict[str, str] 168 ) -> None: 169 argv = ["xcodebuild", "-create-xcframework"] 170 for target in artifacts.TARGETS[:2]: 171 argv += [ 172 "-library", 173 str(bundle / "native" / target / "libtera_ffi.a"), 174 "-headers", 175 str(bundle / "headers"), 176 ] 177 argv += ["-output", str(bundle / artifacts.FRAMEWORK)] 178 run(root, logs, "package-xcframework", argv, env) 179 canonicalize_framework_info(bundle / artifacts.FRAMEWORK / "Info.plist") 180 181 182 def canonicalize_framework_info(path: Path) -> None: 183 info = plistlib.loads(contract._read_regular(path)) 184 info["AvailableLibraries"].sort(key=lambda item: item["LibraryIdentifier"]) 185 path.write_bytes(plistlib.dumps(info, sort_keys=True)) 186 187 188 def generate_api( 189 root: Path, 190 bundle: Path, 191 work: Path, 192 logs: Path, 193 env: dict[str, str], 194 deployment: str, 195 ) -> None: 196 sdk = ( 197 source.command(root, ["xcrun", "--sdk", "iphonesimulator", "--show-sdk-path"]) 198 .decode() 199 .strip() 200 ) 201 module = work / "module" 202 symbols = work / "symbols" 203 module.mkdir() 204 symbols.mkdir() 205 common = [ 206 "-module-name", 207 artifacts.MODULE, 208 "-target", 209 f"arm64-apple-ios{deployment}-simulator", 210 "-sdk", 211 sdk, 212 "-I", 213 str(bundle / "headers"), 214 "-module-cache-path", 215 str(work / "module-cache"), 216 ] 217 run( 218 root, 219 logs, 220 "compile-swift-module", 221 [ 222 "xcrun", 223 "--sdk", 224 "iphonesimulator", 225 "swiftc", 226 "-emit-module", 227 "-parse-as-library", 228 *common, 229 "-emit-module-path", 230 str(module / f"{artifacts.MODULE}.swiftmodule"), 231 str(bundle / "generated/TeraKitBindings.swift"), 232 ], 233 env, 234 ) 235 run( 236 root, 237 logs, 238 "extract-swift-api", 239 [ 240 "xcrun", 241 "--sdk", 242 "iphonesimulator", 243 "swift-symbolgraph-extract", 244 *common, 245 "-I", 246 str(module), 247 "-minimum-access-level", 248 "public", 249 "-skip-inherited-docs", 250 "-skip-synthesized-members", 251 "-output-dir", 252 str(symbols), 253 ], 254 env, 255 ) 256 graph = json.loads((symbols / f"{artifacts.MODULE}.symbols.json").read_text()) 257 output = normalize_api(graph) 258 (bundle / "api").mkdir() 259 (bundle / "api/TeraKitBindings.symbols.json").write_bytes( 260 (json.dumps(output, sort_keys=True, separators=(",", ":")) + "\n").encode() 261 ) 262 263 264 def normalize_api(graph: dict[str, Any]) -> dict[str, Any]: 265 symbols = [ 266 { 267 "kind": item["kind"]["identifier"], 268 "precise": item["identifier"]["precise"], 269 "path": item["pathComponents"], 270 "access": item["accessLevel"], 271 "declaration": item.get("declarationFragments"), 272 } 273 for item in graph["symbols"] 274 ] 275 relationships = [ 276 {key: item[key] for key in ("kind", "source", "target")} 277 for item in graph["relationships"] 278 ] 279 return { 280 "schema": "radroots.swift-api-snapshot.v1", 281 "generator": graph["metadata"]["generator"], 282 "module": graph["module"], 283 "symbols": sorted(symbols, key=lambda item: item["precise"]), 284 "relationships": sorted( 285 relationships, 286 key=lambda item: (item["kind"], item["source"], item["target"]), 287 ), 288 } 289 290 291 def record_abi(root: Path, bundle: Path, logs: Path, env: dict[str, str]) -> None: 292 symbols = {} 293 reader = provenance.symbol_reader( 294 root, source.producer_contract(root)["build"]["host"] 295 ) 296 for target in artifacts.TARGETS: 297 extension = "dylib" if target == artifacts.TARGETS[-1] else "a" 298 log = run( 299 root, 300 logs, 301 "symbols-" + target, 302 [ 303 str(reader), 304 "--extern-only", 305 "--just-symbol-name", 306 "--defined-only", 307 str(bundle / "native" / target / f"libtera_ffi.{extension}"), 308 ], 309 env, 310 ) 311 symbols[target] = sorted( 312 set( 313 re.findall( 314 r"^_((?:ffi|uniffi)_tera_ffi_[A-Za-z0-9_]+)$", 315 log.read_text(), 316 re.MULTILINE, 317 ) 318 ) 319 ) 320 (bundle / "abi_symbols.json").write_bytes(provenance.encoded(symbols)) 321 322 323 def capture_sources(root: Path) -> dict[str, dict[str, Any]]: 324 return {target: provenance.capture(root, target) for target in artifacts.TARGETS} 325 326 327 def build( 328 root: Path, 329 project: Path, 330 target_root: Path, 331 records: dict[str, dict[str, Any]], 332 destination: Path, 333 ) -> dict[str, Any]: 334 config = source.producer_contract(root) 335 environment = build_environment(root, project, config) 336 destination.parent.mkdir(parents=True, exist_ok=True) 337 logs = Path(tempfile.mkdtemp(prefix="build-", dir=destination.parent)) 338 # Logs survive failure. Only this invocation's temporary workspace is cleaned. 339 with tempfile.TemporaryDirectory( 340 prefix="work-", dir=destination.parent 341 ) as temporary: 342 work = Path(temporary) 343 bundle = work / "bundle" 344 bundle.mkdir() 345 build_libraries(root, bundle, target_root, logs, environment) 346 generate_bindings(root, bundle, logs, environment) 347 package_framework(root, bundle, logs, environment) 348 generate_api( 349 root, 350 bundle, 351 work, 352 logs, 353 environment, 354 config["build"]["ios_deployment_target"], 355 ) 356 record_abi(root, bundle, logs, environment) 357 if capture_sources(root) != records: 358 raise source.ProvenanceError( 359 "producer changed during native artifact build" 360 ) 361 (bundle / "source").mkdir() 362 for target, record in records.items(): 363 (bundle / "source" / f"{target}.json").write_bytes( 364 provenance.encoded(record) 365 ) 366 result = artifacts.manifest(bundle, records) 367 (bundle / artifacts.MANIFEST).write_bytes(provenance.encoded(result)) 368 artifacts.check(bundle, records) 369 if destination.exists(): 370 raise source.ProvenanceError("native candidate destination already exists") 371 bundle.rename(destination) 372 return result 373 374 375 def main() -> int: 376 parser = argparse.ArgumentParser(description=__doc__) 377 parser.add_argument("mode", choices=("build", "check", "install", "clean")) 378 args = parser.parse_args() 379 root = Path(__file__).resolve().parent.parent 380 try: 381 project, target_root = build_roots(root) 382 if args.mode == "clean": 383 candidates = project / "target/tera_ffi/candidates" 384 if candidates.is_symlink() or candidates.resolve() != candidates: 385 raise source.ProvenanceError("candidate cache contains a symlink") 386 if candidates.exists(): 387 shutil.rmtree(candidates) 388 print("native candidate cache removed; Cargo incremental cache retained") 389 return 0 390 records = capture_sources(root) 391 tree = records[artifacts.TARGETS[0]]["source"]["tree"] 392 destination = project / "target/tera_ffi/candidates" / tree 393 if args.mode in ("build", "install") and not destination.exists(): 394 build(root, project, target_root, records, destination) 395 artifacts.check(destination, records) 396 if args.mode == "install": 397 installed.install(root, destination, records) 398 except ( 399 source.ProvenanceError, 400 contract.PackageContractError, 401 OSError, 402 ValueError, 403 KeyError, 404 ) as error: 405 print(f"native candidate: {error}", file=sys.stderr) 406 return 1 407 print(f"native candidate {args.mode}: tree={tree}; {destination}") 408 return 0 409 410 411 if __name__ == "__main__": 412 raise SystemExit(main())