Current section

Files

Jump to
mob_new priv templates mob.new ios build.zig.eex
Raw

priv/templates/mob.new/ios/build.zig.eex

// build.zig — Native build orchestration for <%= display_name %> (iOS sim).
//
// Phase 2 of the build-system migration. Owns the full native compile + link
// of the iOS sim binary:
//
// Plain C (zig cc):
// - driver_tab_ios.zig — the static-NIF table (per-app generated;
// legacy .c also supported — extension auto-detected at the
// compile step below)
// - enif_keepalive.c — auto-generated by build.sh from libbeam.a's
// erl_nif.o, then handed to this build for
// compilation
//
// ObjC (xcrun cc via system command — Apple's clang is required for
// -fmodules to find Foundation/WebKit/AVFoundation; zig's bundled clang
// can't build Apple framework module maps):
// - $MOB_DIR/ios/MobNode.m
// - $MOB_DIR/ios/mob_nif.m (-DSTATIC_ERLANG_NIF, needs swift hdr)
// - $MOB_DIR/ios/mob_beam.m
// - ios/AppDelegate.m (in-project, needs swift hdr)
// - ios/beam_main.m (in-project)
//
// Swift (xcrun swiftc via system command):
// - $MOB_DIR/ios/MobViewModel.swift
// - $MOB_DIR/ios/MobRootView.swift
// - optional project Swift sources from -Dproject_swift_sources
// produces swift_mob.o + MobApp-Swift.h.
//
// Link (xcrun swiftc via system command):
// all 8 .o files + OTP/crypto static libs + UIKit/Foundation/CoreGraphics
// /QuartzCore/SwiftUI frameworks + -dead_strip → final binary.
//
// Build steps:
//
// `zig build objects` — compile only, leave outputs in zig-out/
// `zig build binary` — full pipeline through the link, binary at
// zig-out/<%= display_name %>
//
// Subsequent Phase 2 commits will move the bundle/install glue out of
// build.sh into a Mix task.
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.resolveTargetQuery(.{
.cpu_arch = .aarch64,
.os_tag = .ios,
.abi = .simulator,
});
const optimize: std.builtin.OptimizeMode = .ReleaseSmall;
const mob_dir = required(b, "mob_dir", "Path to mob library");
const otp_root = required(b, "otp_root", "Path to iOS OTP runtime");
const erts_vsn = required(b, "erts_vsn", "ERTS version dir name (e.g. erts-17.0)");
const sdkroot = required(b, "sdkroot", "iOS simulator SDK path");
const driver_tab = required(b, "driver_tab", "Absolute path to driver_tab_ios.{zig,c}");
const enif_keepalive = required(b, "enif_keepalive", "Absolute path to generated enif_keepalive.c");
const project_ios_dir = required(b, "project_ios_dir", "Absolute path to the project's ios/ dir");
const module_name = required(b, "module_name", "Swift module name (the project's display name)");
// Project-side NIFs declared in `mob.exs :static_nifs` (see issue #18).
// Same shape as the device build — see `build_device.zig` for the
// companion. Empty if the project has no project-side NIFs.
const project_root = b.option([]const u8, "project_root", "Absolute path to the project root (for c_src/<name>.c lookups)") orelse "";
const project_c_nifs = b.option([]const u8, "project_c_nifs", "Comma-separated C NIF names (each at c_src/<name>.c); empty if none") orelse "";
const project_rust_libs = b.option([]const u8, "project_rust_libs", "Comma-separated absolute paths to Rust NIF .a files (pre-built by mob_dev)") orelse "";
const project_swift_sources = b.option([]const u8, "project_swift_sources", "Comma-separated absolute paths to extra project Swift sources; empty if none") orelse "";
// MLX + EMLX. See build_device.zig.eex for full rationale.
const mlx_static = b.option(bool, "mlx_static", "EMLX NIF statically linked + libmlx.a included (-DMOB_STATIC_EMLX_NIF on driver_tab)") orelse false;
const mlx_dir = b.option([]const u8, "mlx_dir", "Absolute path to extracted MLX bundle (libmlx.a + libemlx.a + include/)") orelse "";
// NxEigen (Eigen-backed Nx backend, C++ NIF). mob_dev cross-compiles
// libnx_eigen.a per arch and threads the output dir through nxeigen_dir.
const nxeigen_static = b.option(bool, "nxeigen_static", "NxEigen NIF statically linked (-DMOB_STATIC_NX_EIGEN_NIF on driver_tab)") orelse false;
const nxeigen_dir = b.option([]const u8, "nxeigen_dir", "Absolute path to dir containing libnx_eigen.a") orelse "";
const objects_step = b.step(
"objects",
"Compile C, ObjC, and Swift objects (no link)",
);
const binary_step = b.step(
"binary",
"Compile + link the iOS sim binary (default)",
);
b.default_step = binary_step;
// Collect every produced .o LazyPath so the link step can consume them.
var objs = std.ArrayList(std.Build.LazyPath).empty;
defer objs.deinit(b.allocator);
// --- Swift via xcrun swiftc ------------------------------------------------
// Produces swift_mob.o + MobApp-Swift.h. The .h is read by the ObjC steps
// for mob_nif.m and AppDelegate.m, so they declare a build-graph dependency
// on this run + use swift_h.dirname() as their -I include path.
const swift_run = b.addSystemCommand(&.{
"xcrun",
"-sdk",
"iphonesimulator",
"swiftc",
"-target",
"arm64-apple-ios17.0-simulator",
"-module-name",
module_name,
"-import-objc-header",
});
swift_run.addArg(b.fmt("{s}/ios/MobDemo-Bridging-Header.h", .{mob_dir}));
swift_run.addArg("-I");
swift_run.addArg(b.fmt("{s}/ios", .{mob_dir}));
swift_run.addArgs(&.{ "-parse-as-library", "-wmo" });
swift_run.addFileArg(.{ .cwd_relative = b.fmt("{s}/ios/MobViewModel.swift", .{mob_dir}) });
swift_run.addFileArg(.{ .cwd_relative = b.fmt("{s}/ios/MobRootView.swift", .{mob_dir}) });
swift_run.addFileArg(.{ .cwd_relative = b.fmt("{s}/ios/MobGpuView.swift", .{mob_dir}) });
if (project_swift_sources.len > 0) {
var swift_it = std.mem.splitScalar(u8, project_swift_sources, ',');
while (swift_it.next()) |source| {
if (source.len == 0) continue;
swift_run.addFileArg(.{ .cwd_relative = source });
}
}
swift_run.addArg("-c");
swift_run.addArg("-emit-objc-header");
swift_run.addArg("-emit-objc-header-path");
const swift_header = swift_run.addOutputFileArg("MobApp-Swift.h");
swift_run.addArg("-o");
const swift_obj = swift_run.addOutputFileArg("swift_mob.o");
installAndCollect(b, objects_step, &objs, swift_obj, "swift_mob.o");
// --- Plain C via zig cc ----------------------------------------------------
const c_flags = &[_][]const u8{
"-Os",
"-ffunction-sections",
"-fdata-sections",
"-mios-simulator-version-min=17.0",
};
// driver_tab is driver_tab_ios.zig by default (Phase 6a iter 4);
// .c is supported for projects that prefer hand-editable C dispatch
// tables (`mix mob.regen_driver_tab --format c`). The Zig path
// declares the C-ABI types it needs directly via `extern struct`,
// so it doesn't need any of the iOS SDK include paths the C path
// requires. Auto-detect by extension.
//
// The Zig file imports `build_options` for the sqlite_static
// comptime gate. Sim builds always set sqlite_static = false
// (exqlite loads dynamically as a .so on sim).
// Build the C-path driver_tab compile flags, threading any enabled
// -DMOB_STATIC_<X>_NIF guards through. The Zig path uses b.addOptions()
// instead and consumes the same booleans at comptime.
var driver_tab_flags_buf = std.ArrayList([]const u8).empty;
defer driver_tab_flags_buf.deinit(b.allocator);
for (c_flags) |f| driver_tab_flags_buf.append(b.allocator, f) catch unreachable;
if (mlx_static) driver_tab_flags_buf.append(b.allocator, "-DMOB_STATIC_EMLX_NIF") catch unreachable;
if (nxeigen_static) driver_tab_flags_buf.append(b.allocator, "-DMOB_STATIC_NX_EIGEN_NIF") catch unreachable;
const driver_tab_flags: []const []const u8 = driver_tab_flags_buf.items;
const driver_tab_lp = if (std.mem.endsWith(u8, driver_tab, ".zig")) blk: {
const opts = b.addOptions();
// Sim builds: exqlite loads dynamically as a .so, so sqlite_static
// is always false here. EMLX has no sim/device distinction — same
// static-link story regardless.
opts.addOption(bool, "sqlite_static", false);
opts.addOption(bool, "emlx_static", mlx_static);
opts.addOption(bool, "nx_eigen_static", nxeigen_static);
break :blk addZigObject(b, .{
.name = "driver_tab_ios",
.source = driver_tab,
.target = target,
.optimize = optimize,
.build_options = opts,
});
} else addCObject(b, .{
.name = "driver_tab_ios",
.source = driver_tab,
.target = target,
.optimize = optimize,
.c_flags = driver_tab_flags,
.mob_dir = mob_dir,
.otp_root = otp_root,
.erts_vsn = erts_vsn,
.sdkroot = sdkroot,
});
installAndCollect(b, objects_step, &objs, driver_tab_lp, "driver_tab_ios.o");
const enif_lp = addCObject(b, .{
.name = "enif_keepalive",
.source = enif_keepalive,
.target = target,
.optimize = optimize,
.c_flags = c_flags,
.mob_dir = mob_dir,
.otp_root = otp_root,
.erts_vsn = erts_vsn,
.sdkroot = sdkroot,
});
installAndCollect(b, objects_step, &objs, enif_lp, "enif_keepalive.o");
// --- ObjC via xcrun cc -----------------------------------------------------
const objc_specs = [_]ObjcSpec{
.{ .name = "MobNode", .source = b.fmt("{s}/ios/MobNode.m", .{mob_dir}) },
.{
.name = "mob_nif",
.source = b.fmt("{s}/ios/mob_nif.m", .{mob_dir}),
// -DSTATIC_ERLANG_NIF prevents fall-through to dlopen (which iOS
// rejects). swift_header dependency makes Swift run first so
// MobApp-Swift.h is available on the include path.
.extra_flags = &.{"-DSTATIC_ERLANG_NIF"},
.swift_header = swift_header,
},
.{ .name = "mob_beam", .source = b.fmt("{s}/ios/mob_beam.m", .{mob_dir}) },
.{
.name = "AppDelegate",
.source = b.fmt("{s}/AppDelegate.m", .{project_ios_dir}),
.swift_header = swift_header,
},
.{ .name = "beam_main", .source = b.fmt("{s}/beam_main.m", .{project_ios_dir}) },
};
for (objc_specs) |spec| {
const obj_lp = addObjcObject(b, .{
.name = spec.name,
.source = spec.source,
.extra_flags = spec.extra_flags,
.swift_header = spec.swift_header,
.mob_dir = mob_dir,
.otp_root = otp_root,
.erts_vsn = erts_vsn,
.sdkroot = sdkroot,
});
installAndCollect(b, objects_step, &objs, obj_lp, b.fmt("{s}.o", .{spec.name}));
}
// --- Project-side C NIFs (auto-wired from mob.exs :static_nifs) ───────────
// Same logic as build_device.zig. See that file for the full rationale.
if (project_c_nifs.len > 0) {
var c_it = std.mem.splitScalar(u8, project_c_nifs, ',');
while (c_it.next()) |nif_name| {
if (nif_name.len == 0) continue;
const flags = b.allocator.alloc([]const u8, c_flags.len + 2) catch unreachable;
@memcpy(flags[0..c_flags.len], c_flags);
flags[c_flags.len] = "-DSTATIC_ERLANG_NIF";
flags[c_flags.len + 1] = b.fmt("-DSTATIC_ERLANG_NIF_LIBNAME={s}", .{nif_name});
installAndCollect(b, objects_step, &objs, addCObject(b, .{
.name = nif_name,
.source = b.fmt("{s}/c_src/{s}.c", .{ project_root, nif_name }),
.target = target,
.optimize = optimize,
.c_flags = flags,
.mob_dir = mob_dir,
.otp_root = otp_root,
.erts_vsn = erts_vsn,
.sdkroot = sdkroot,
}), b.fmt("{s}.o", .{nif_name}));
}
}
// --- Link via xcrun swiftc -------------------------------------------------
addLink(b, binary_step, .{
.module_name = module_name,
.otp_root = otp_root,
.erts_vsn = erts_vsn,
.mlx_static = mlx_static,
.mlx_dir = mlx_dir,
.nxeigen_static = nxeigen_static,
.nxeigen_dir = nxeigen_dir,
.project_rust_libs = project_rust_libs,
.objects = objs.items,
});
}
const ObjcSpec = struct {
name: []const u8,
source: []const u8,
extra_flags: []const []const u8 = &.{},
swift_header: ?std.Build.LazyPath = null,
};
const CObjectOptions = struct {
name: []const u8,
source: []const u8,
target: std.Build.ResolvedTarget,
optimize: std.builtin.OptimizeMode,
c_flags: []const []const u8,
mob_dir: []const u8,
otp_root: []const u8,
erts_vsn: []const u8,
sdkroot: []const u8,
};
const ZigObjectOptions = struct {
name: []const u8,
source: []const u8,
target: std.Build.ResolvedTarget,
optimize: std.builtin.OptimizeMode,
// build_options is imported by the .zig module via
// `@import("build_options")`. driver_tab_ios.zig uses it to read
// `sqlite_static`. nil means no module is registered (driver_tab.zig
// imports it unconditionally so callers should pass an Options step
// — sim builds set sqlite_static=false, device builds derive from
// the real flag).
build_options: ?*std.Build.Step.Options = null,
};
// addZigObject compiles a single .zig source file into a relocatable
// object whose exports use the C ABI. Used by Phase 6a's driver_tab.zig
// path; matches addCObject's interface so the call site can pick one or
// the other based on the source file's extension.
fn addZigObject(b: *std.Build, opts: ZigObjectOptions) std.Build.LazyPath {
const mod = b.createModule(.{
.root_source_file = .{ .cwd_relative = opts.source },
.target = opts.target,
.optimize = opts.optimize,
});
if (opts.build_options) |build_opts| {
mod.addOptions("build_options", build_opts);
}
const obj = b.addObject(.{
.name = opts.name,
.root_module = mod,
});
return obj.getEmittedBin();
}
fn addCObject(b: *std.Build, opts: CObjectOptions) std.Build.LazyPath {
const mod = b.createModule(.{
.target = opts.target,
.optimize = opts.optimize,
});
mod.addCSourceFile(.{
.file = .{ .cwd_relative = opts.source },
.flags = opts.c_flags,
});
mod.addIncludePath(.{
.cwd_relative = b.fmt("{s}/{s}/include", .{ opts.otp_root, opts.erts_vsn }),
});
mod.addIncludePath(.{
.cwd_relative = b.fmt("{s}/{s}/include/aarch64-apple-iossimulator", .{ opts.otp_root, opts.erts_vsn }),
});
mod.addIncludePath(.{
.cwd_relative = b.fmt("{s}/ios", .{opts.mob_dir}),
});
mod.addSystemIncludePath(.{
.cwd_relative = b.fmt("{s}/usr/include", .{opts.sdkroot}),
});
mod.addFrameworkPath(.{
.cwd_relative = b.fmt("{s}/System/Library/Frameworks", .{opts.sdkroot}),
});
const obj = b.addObject(.{
.name = opts.name,
.root_module = mod,
});
return obj.getEmittedBin();
}
const ObjcObjectOptions = struct {
name: []const u8,
source: []const u8,
extra_flags: []const []const u8 = &.{},
swift_header: ?std.Build.LazyPath = null,
mob_dir: []const u8,
otp_root: []const u8,
erts_vsn: []const u8,
sdkroot: []const u8,
};
fn addObjcObject(b: *std.Build, opts: ObjcObjectOptions) std.Build.LazyPath {
const run = b.addSystemCommand(&.{
"xcrun",
"-sdk",
"iphonesimulator",
"cc",
"-arch",
"arm64",
"-mios-simulator-version-min=17.0",
"-Os",
"-ffunction-sections",
"-fdata-sections",
"-fobjc-arc",
"-fmodules",
});
run.addArg(b.fmt("-I{s}/{s}/include", .{ opts.otp_root, opts.erts_vsn }));
run.addArg(b.fmt("-I{s}/{s}/include/aarch64-apple-iossimulator", .{ opts.otp_root, opts.erts_vsn }));
run.addArg(b.fmt("-I{s}/ios", .{opts.mob_dir}));
run.addArg(b.fmt("-isysroot{s}", .{opts.sdkroot}));
if (opts.swift_header) |sh| {
// addPrefixedDirectoryArg both passes -I<dir> as one arg AND wires
// the build-graph dependency so the swift step runs before this objc
// step.
run.addPrefixedDirectoryArg("-I", sh.dirname());
}
for (opts.extra_flags) |flag| run.addArg(flag);
run.addArg("-c");
run.addFileArg(.{ .cwd_relative = opts.source });
run.addArg("-o");
return run.addOutputFileArg(b.fmt("{s}.o", .{opts.name}));
}
fn installAndCollect(
b: *std.Build,
step: *std.Build.Step,
objs: *std.ArrayList(std.Build.LazyPath),
lp: std.Build.LazyPath,
install_name: []const u8,
) void {
const install = b.addInstallFile(lp, install_name);
step.dependOn(&install.step);
objs.append(b.allocator, lp) catch @panic("OOM");
}
const LinkOptions = struct {
module_name: []const u8,
otp_root: []const u8,
erts_vsn: []const u8,
// MLX bundle. Empty mlx_dir / false mlx_static = MLX not in this build.
mlx_static: bool = false,
mlx_dir: []const u8 = "",
// NxEigen. Empty nxeigen_dir / false nxeigen_static = NxEigen not in this build.
nxeigen_static: bool = false,
nxeigen_dir: []const u8 = "",
// Comma-separated absolute paths to project-side Rust NIF .a files
// (pre-built by mob_dev with `cargo rustc --target
// aarch64-apple-ios-sim --crate-type staticlib`). Empty if none.
project_rust_libs: []const u8,
objects: []const std.Build.LazyPath,
};
fn addLink(b: *std.Build, step: *std.Build.Step, opts: LinkOptions) void {
const run = b.addSystemCommand(&.{
"xcrun",
"-sdk",
"iphonesimulator",
"swiftc",
"-target",
"arm64-apple-ios17.0-simulator",
});
// All compiled object files. addFileArg propagates the dependency so
// each .o's compile step is implicitly required by the link step.
for (opts.objects) |obj| run.addFileArg(obj);
// OTP + crypto static libs. Order matters — libbeam.a first so its
// erts_static_nif_tab[] override resolves against driver_tab_ios.o
// before the BEAM's own empty default. liberts_internal_r and libethread
// satisfy BEAM internals; libcrypto.a ships OpenSSL; libzstd / libepcre /
// libryu / asn1rt_nif / crypto.a are statically-linked NIF dependencies.
const lib_names = [_][]const u8{
"lib/libbeam.a",
"lib/internal/liberts_internal_r.a",
"lib/internal/libethread.a",
"lib/libzstd.a",
"lib/libepcre.a",
"lib/libryu.a",
"lib/asn1rt_nif.a",
"lib/crypto.a",
"lib/libcrypto.a",
};
for (lib_names) |name| {
run.addArg(b.fmt("{s}/{s}/{s}", .{ opts.otp_root, opts.erts_vsn, name }));
}
// MLX + EMLX (Apple's MLX numerics + the EMLX Nx backend), wired in
// when `mix mob.enable mlx` set mlx_static=true. libemlx.a holds the
// NIF (emlx_nif_nif_init); libmlx.a is the underlying MLX library.
if (opts.mlx_static and opts.mlx_dir.len > 0) {
run.addArg(b.fmt("{s}/lib/libemlx.a", .{opts.mlx_dir}));
run.addArg(b.fmt("{s}/lib/libmlx.a", .{opts.mlx_dir}));
}
// NxEigen (Eigen-backed Nx backend, C++ NIF). Wired in when
// `mix mob.enable nxeigen` set nxeigen_static=true and mob_dev passed
// the per-arch build dir through nxeigen_dir. libnx_eigen.a holds
// nx_eigen_nif_init (the static-NIF symbol); no separate runtime
// library (Eigen is header-only).
if (opts.nxeigen_static and opts.nxeigen_dir.len > 0) {
run.addArg(b.fmt("{s}/libnx_eigen.a", .{opts.nxeigen_dir}));
}
// Project-side Rust NIF static archives (auto-wired from mob.exs
// :static_nifs entries that have a native/<name>/Cargo.toml). mob_dev
// cross-compiles each with `cargo rustc --target aarch64-apple-ios-sim
// --crate-type staticlib` before invoking zig.
if (opts.project_rust_libs.len > 0) {
var rust_it = std.mem.splitScalar(u8, opts.project_rust_libs, ',');
while (rust_it.next()) |lib_path| {
if (lib_path.len == 0) continue;
// `addFileArg` (not `addArg`) so the cache key picks up the
// contents of the .a, not just its path. Otherwise rebuilding
// a project-side rust crate produces a new .a that the link
// step ignores as UP-TO-DATE.
const lp: std.Build.LazyPath = .{ .cwd_relative = lib_path };
run.addFileArg(lp);
}
}
run.addArgs(&.{ "-lz", "-lc++", "-lpthread" });
// -dead_strip + the per-function/data sections compile flags drop every
// function and data object that no live symbol references — the C-side
// analog of beam_lib:strip_release/1. Single biggest C-side win on
// bundle size per the GRiSP nano writeup.
run.addArgs(&.{ "-Xlinker", "-dead_strip" });
const frameworks_base = [_][]const u8{
"UIKit",
"Foundation",
"CoreGraphics",
"QuartzCore",
"SwiftUI",
};
for (frameworks_base) |fw| {
run.addArgs(&.{ "-Xlinker", "-framework", "-Xlinker", fw });
}
// Apple's Accelerate framework provides vectorized BLAS/LAPACK used by
// MLX-CPU. Available on iOS sim + device — only linked when MLX is on.
if (opts.mlx_static) {
run.addArgs(&.{ "-Xlinker", "-framework", "-Xlinker", "Accelerate" });
}
run.addArg("-o");
const binary = run.addOutputFileArg(opts.module_name);
const install = b.addInstallFile(binary, opts.module_name);
step.dependOn(&install.step);
}
fn required(b: *std.Build, name: []const u8, desc: []const u8) []const u8 {
return b.option([]const u8, name, desc) orelse {
std.debug.print(
"ERROR: -D{s}=... is required ({s})\n",
.{ name, desc },
);
std.process.exit(1);
};
}