field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

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())