Files
Chris Tate 4e015e925f feat(updater): add native macOS app updates (#398)
* feat(updater): add native macOS app updates

- Add Ed25519-signed feeds, verified downloads, atomic replacement, rollback, and relaunch.
- Add manifest support plus key generation, feed signing, and updater ZIP packaging.
- Document and test the end-to-end macOS update workflow.

* fix(updater): harden macOS update lifecycle

* fix(updater): address review findings

* fix(updater): close review gaps

* fix(updater): bind installed identity and versions
2026-08-24 08:41:32 -05:00

2896 lines
151 KiB
Zig

//! Framework build helper: `addApp` gives a markup/builder app a complete
//! build (exe, run, test) from a ~5-line build.zig. The app supplies
//! src/main.zig, app.zon, and assets; the runner and all framework modules
//! come from the native-sdk dependency.
const std = @import("std");
const builtin = @import("builtin");
const json_to_zon = @import("../src/tooling/json_to_zon.zig");
/// Canonicalize a generated file by its CONTENT before another build step
/// consumes it. `std.Build.Step.Run` normally places outputs under a cache
/// directory keyed by every declared input. That is correct for the producer,
/// but it means an unrelated input can change the output PATH even when the
/// file's bytes are identical; downstream Run steps hash that path and miss
/// their own caches. TypeScript's combined frontend is exactly that shape:
/// service implementation edits re-run the checker while often leaving the
/// core ABI contract byte-identical.
///
/// This narrow adapter gives equal bytes one immutable cache path. Consumers
/// still invalidate whenever the bytes change, while producer-only churn
/// stops here. It is public so the repository's fixture graph can exercise
/// the same boundary as app builds.
pub fn stabilizeGeneratedFile(b: *std.Build, source: std.Build.LazyPath, basename: []const u8, trace: bool) std.Build.LazyPath {
return StableGeneratedFile.create(b, source, basename, trace).lazyPath();
}
const StableGeneratedFile = struct {
step: std.Build.Step,
source: std.Build.LazyPath,
basename: []const u8,
trace: bool,
generated: std.Build.GeneratedFile,
fn create(b: *std.Build, source: std.Build.LazyPath, basename: []const u8, trace: bool) *StableGeneratedFile {
const stable = b.allocator.create(StableGeneratedFile) catch @panic("OOM");
stable.* = .{
.step = std.Build.Step.init(.{
.id = .custom,
.name = b.fmt("stabilize generated {s}", .{basename}),
.owner = b,
.makeFn = make,
}),
.source = source.dupe(b),
.basename = b.dupePath(basename),
.trace = trace,
.generated = undefined,
};
stable.generated = .{ .step = &stable.step };
source.addStepDependencies(&stable.step);
return stable;
}
fn lazyPath(self: *StableGeneratedFile) std.Build.LazyPath {
return .{ .generated = .{ .file = &self.generated } };
}
fn make(step: *std.Build.Step, options: std.Build.Step.MakeOptions) !void {
_ = options;
const self: *StableGeneratedFile = @fieldParentPtr("step", step);
const b = step.owner;
const io = b.graph.io;
const arena = b.allocator;
const source_path = self.source.getPath3(b, step);
const bytes = source_path.root_dir.handle.readFileAlloc(io, source_path.sub_path, arena, .limited(64 * 1024 * 1024)) catch |err|
return step.fail("cannot stabilize generated {s}: {t}", .{ self.basename, err });
var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
std.crypto.hash.sha2.Sha256.hash(bytes, &digest, .{});
const hex = std.fmt.bytesToHex(digest, .lower);
const output_dir = b.pathJoin(&.{ "o", "native-stable", &hex });
const output_path = b.pathJoin(&.{ output_dir, self.basename });
self.generated.path = try b.cache_root.join(arena, &.{output_path});
var reused = false;
if (b.cache_root.handle.readFileAlloc(io, output_path, arena, .limited(64 * 1024 * 1024))) |existing| {
reused = std.mem.eql(u8, existing, bytes);
} else |_| {}
if (!reused) {
b.cache_root.handle.createDirPath(io, output_dir) catch |err|
return step.fail("cannot create the stable generated-output directory for {s}: {t}", .{ self.basename, err });
var atomic = b.cache_root.handle.createFileAtomic(io, output_path, .{ .replace = true }) catch |err|
return step.fail("cannot stage stable generated {s}: {t}", .{ self.basename, err });
defer atomic.deinit(io);
atomic.file.writeStreamingAll(io, bytes) catch |err|
return step.fail("cannot write stable generated {s}: {t}", .{ self.basename, err });
atomic.replace(io) catch |err|
return step.fail("cannot publish stable generated {s}: {t}", .{ self.basename, err });
}
step.result_cached = reused;
if (self.trace) std.debug.print("native build trace: {s} content {s} {s}\n", .{
self.basename,
&hex,
if (reused) "reused" else "changed",
});
}
};
/// The shared web-layer inference contract: this build graph is one thin
/// adapter over it (the CLI's manifest tooling and the app runner are the
/// others), feeding it the inputs only this boundary sees — the
/// `-Dweb-engine` and `-Dweb-layer` flags resolved against app.zon.
const web_layer_contract = @import("../src/primitives/app_manifest/web_layer.zig");
const PlatformOption = enum {
auto,
null,
macos,
linux,
windows,
};
const TraceOption = enum {
off,
events,
runtime,
all,
};
const WebEngineOption = web_layer_contract.WebEngine;
const WebLayerOption = web_layer_contract.WebViewLayer;
pub const AppOptions = struct {
name: []const u8,
/// Explicit manifest path. Null auto-detects app.json first, then app.zon,
/// so older owned build.zig files can adopt JSON without a build edit.
manifest: ?[]const u8 = null,
/// App entry point; defaults to src/main.zig (relative to `app_root`).
main: []const u8 = "src/main.zig",
/// Root of the app source tree, relative to the build root. "." for a
/// build.zig that lives in the app directory (every ejected app). The
/// CLI's generated build graph under `<app>/.native/build/` passes
/// "../.." so `src/`, `app.zon`, and `assets/` keep resolving in the
/// app directory rather than the cache directory.
app_root: []const u8 = ".",
/// Live `<terminal>` sessions: set true AND pin ghostty as a LAZY
/// dependency in the app's OWN build.zig.zon (the examples/workbench
/// pin) — the framework then resolves `ghostty-vt` with the app
/// module's target/optimize and the safe flags (simd off, no macOS
/// app or xcframework artifacts), and the runtime owns the emulator
/// behind every `<terminal pty={key}>` binding. Default false wires
/// the stub: `<terminal>` renders the honest empty surface, and the
/// build never traverses ghostty's dependency graph (whose configure
/// step walks wuffs/translate_c and whose full build pulls harfbuzz)
/// — the load-bearing property for scaffolded apps.
terminal_sessions: bool = false,
};
/// Which core the app tree carries. No flag and no config anywhere: the
/// tree IS the truth — `src/core.ts` is a TypeScript core (compiled to
/// native code at build time, run through generated wiring),
/// `src/main.zig` a Zig one, and both at once is a teaching error
/// naming the two files.
const CoreTree = enum { zig, ts, both, neither };
fn detectCoreTree(b: *std.Build, app_root: []const u8) CoreTree {
const has_ts = appFileExists(b, app_root, "src/core.ts");
const has_zig = appFileExists(b, app_root, "src/main.zig");
if (has_ts and has_zig) return .both;
if (has_ts) return .ts;
if (has_zig) return .zig;
return .neither;
}
fn appFileExists(b: *std.Build, app_root: []const u8, sub_path: []const u8) bool {
b.build_root.handle.access(b.graph.io, appPath(b, app_root, sub_path), .{}) catch return false;
return true;
}
fn appManifestName(b: *std.Build, app_root: []const u8, requested: ?[]const u8) []const u8 {
if (requested) |name| return name;
if (appFileExists(b, app_root, "app.json")) return "app.json";
return "app.zon";
}
fn appManifestPath(b: *std.Build, app_root: []const u8, manifest_name: []const u8) []const u8 {
return appPath(b, app_root, manifest_name);
}
/// Produce the Zig module consumed by the existing comptime manifest wiring.
/// ZON manifests are already modules; JSON manifests are converted losslessly
/// into a generated module, keeping one runtime feature path for both formats.
fn appManifestModule(b: *std.Build, app_root: []const u8, manifest_name: []const u8) *std.Build.Module {
const path = appManifestPath(b, app_root, manifest_name);
if (!json_to_zon.isJsonPath(path)) {
return b.createModule(.{ .root_source_file = b.path(path) });
}
const source = b.build_root.handle.readFileAlloc(b.graph.io, path, b.allocator, .limited(1024 * 1024)) catch
@panic("cannot read app.json");
const zon = json_to_zon.convertAlloc(b.allocator, source) catch |err| switch (err) {
error.NullNotAllowed => @panic("app.json cannot contain null values; omit optional fields instead"),
else => @panic("cannot convert app.json into the build-time manifest module; run `native check` for a precise diagnostic"),
};
const generated = b.addWriteFiles().add("app_manifest.zon", zon);
return b.createModule(.{ .root_source_file = generated });
}
const TsWindowView = struct {
label: []const u8,
source_path: []const u8,
staged_path: []const u8,
};
const TsWindowSource = struct {
set_path: []const u8,
source_path: []const u8,
staged_path: []const u8,
};
const TsWindowViews = struct {
views: []const TsWindowView,
sources: []const TsWindowSource,
};
/// Default TypeScript secondary-window views are statically discovered under
/// `src/windows/`: `settings.native` serves descriptor label `settings`.
/// Direct files form the generated launcher's closed, comptime-compiled view
/// set while nested `.native` files are the shared import source set and
/// `windows(model)` owns dynamic liveness.
fn collectTsWindowViews(b: *std.Build, app_root: []const u8) TsWindowViews {
const windows_path = appPath(b, app_root, "src/windows");
var dir = b.build_root.handle.openDir(b.graph.io, windows_path, .{ .iterate = true }) catch return .{ .views = &.{}, .sources = &.{} };
defer dir.close(b.graph.io);
var walker = dir.walk(b.allocator) catch return .{ .views = &.{}, .sources = &.{} };
defer walker.deinit();
var views: std.ArrayList(TsWindowView) = .empty;
var sources: std.ArrayList(TsWindowSource) = .empty;
while (walker.next(b.graph.io) catch null) |entry| {
if (entry.kind != .file or !std.mem.endsWith(u8, entry.path, ".native")) continue;
const normalized_path = b.dupe(entry.path);
for (normalized_path) |*char| {
if (char.* == '\\') char.* = '/';
}
sources.append(b.allocator, .{
.set_path = normalized_path,
.source_path = b.fmt("src/windows/{s}", .{normalized_path}),
.staged_path = b.fmt("windows/{s}", .{normalized_path}),
}) catch @panic("OOM");
// Direct children are label-addressed window roots. Nested files
// are component sources those roots may import transitively.
if (std.mem.indexOfScalar(u8, entry.path, '/') != null or std.mem.indexOfScalar(u8, entry.path, '\\') != null) continue;
const label = entry.path[0 .. entry.path.len - ".native".len];
if (label.len == 0 or label.len > 64 or std.mem.eql(u8, label, ".") or std.mem.eql(u8, label, "..")) {
@panic("\ninvalid TypeScript secondary-window view filename: use src/windows/<label>.native with a non-empty label of at most 64 bytes\n");
}
for (label) |ch| {
if (ch == 0 or ch == '/' or ch == '\\') {
@panic("\ninvalid TypeScript secondary-window view filename: the filename stem must be a valid window label\n");
}
}
views.append(b.allocator, .{
.label = b.dupe(label),
.source_path = b.fmt("src/windows/{s}", .{entry.path}),
.staged_path = b.fmt("windows/{s}", .{entry.path}),
}) catch @panic("OOM");
}
const less = struct {
fn than(_: void, a: TsWindowView, z: TsWindowView) bool {
return std.mem.order(u8, a.label, z.label) == .lt;
}
}.than;
std.mem.sort(TsWindowView, views.items, {}, less);
const source_less = struct {
fn than(_: void, a: TsWindowSource, z: TsWindowSource) bool {
return std.mem.order(u8, a.set_path, z.set_path) == .lt;
}
}.than;
std.mem.sort(TsWindowSource, sources.items, {}, source_less);
return .{ .views = views.items, .sources = sources.items };
}
fn tsWindowRegistrySource(b: *std.Build, registry: TsWindowViews) []const u8 {
const views = registry.views;
var out: std.ArrayList(u8) = .empty;
out.appendSlice(b.allocator,
\\//! Generated by build/app.zig from src/windows/<label>.native.
\\const std = @import("std");
\\const native_sdk = @import("native_sdk");
\\const core = @import("core.zig");
\\const canvas = native_sdk.canvas;
\\const Adapter = native_sdk.TsUiApp(core);
\\const App = Adapter.App;
\\
) catch @panic("OOM");
if (registry.sources.len > 0) {
out.appendSlice(b.allocator,
\\const window_sources = [_]canvas.ui_markup.SourceFile{
\\
) catch @panic("OOM");
for (registry.sources) |source| {
const line = std.fmt.allocPrint(
b.allocator,
" .{{ .path = \"{f}\", .source = @embedFile(\"{f}\") }},\n",
.{ std.zig.fmtString(source.set_path), std.zig.fmtString(source.staged_path) },
) catch @panic("OOM");
out.appendSlice(b.allocator, line) catch @panic("OOM");
}
out.appendSlice(b.allocator, "};\n") catch @panic("OOM");
}
for (views, 0..) |view, index| {
const line = std.fmt.allocPrint(
b.allocator,
"const WindowView{d} = canvas.CompiledMarkupImports(core.Model, core.Msg, \"{f}\", &window_sources);\n",
.{ index, std.zig.fmtString(std.fs.path.basename(view.staged_path)) },
) catch @panic("OOM");
out.appendSlice(b.allocator, line) catch @panic("OOM");
}
if (views.len == 0) {
out.appendSlice(b.allocator,
\\pub fn build(_: *App.Ui, _: *const core.Model, _: []const u8) App.Ui.Node {
\\ @compileError("this TypeScript core exports windows(model), but the app has no src/windows/<label>.native views");
\\}
\\pub const fragments = [_]canvas.MarkupFragment{};
\\
) catch @panic("OOM");
return out.items;
}
out.appendSlice(b.allocator,
\\pub fn build(ui: *App.Ui, model: *const core.Model, label: []const u8) App.Ui.Node {
\\
) catch @panic("OOM");
for (views, 0..) |view, index| {
const line = std.fmt.allocPrint(
b.allocator,
" if (std.mem.eql(u8, label, \"{f}\")) return WindowView{d}.build(ui, model);\n",
.{ std.zig.fmtString(view.label), index },
) catch @panic("OOM");
out.appendSlice(b.allocator, line) catch @panic("OOM");
}
out.appendSlice(b.allocator,
\\ @panic("windows(model) declared a label with no src/windows/<label>.native view");
\\}
\\pub const fragments = [_]canvas.MarkupFragment{
\\
) catch @panic("OOM");
for (views, 0..) |view, index| {
const line = std.fmt.allocPrint(
b.allocator,
" WindowView{d}.fragment(\"{f}\"),\n",
.{ index, std.zig.fmtString(view.source_path) },
) catch @panic("OOM");
out.appendSlice(b.allocator, line) catch @panic("OOM");
}
out.appendSlice(b.allocator, "};\n") catch @panic("OOM");
return out.items;
}
/// How a TypeScript core compiles: through the external core compiler,
/// always. The frontend checks the core and emits its contract sidecar,
/// corewire projects the compile entry and profile, the exact-pinned
/// external toolchain builds the archive, and the app links corewire's
/// generated mirror over it. `.core_compiler` in app.zon (and the
/// `-Dcore-compiler` flag) accept only "external" — the value exists so
/// a stated choice stays stateable; the removed transpiled lane's
/// spelling is refused with a teaching (see resolveCoreCompiler).
const core_compiler_teaching =
"\ncore_compiler = \"transpiler\" names the removed TS-to-Zig transpiled lane (v0.7.0" ++
" removed it): a TypeScript core compiles through the external core compiler now, and" ++
" that is the default.\nDelete the setting (or spell it \"external\").\n";
/// The staged TypeScript-core wiring: one generated directory holding the
/// core module (the generated mirror with its staged shim runtime) and the
/// SDK's generated-wiring entry (ts_core_main.zig as main.zig) — plus the
/// compiled-core archive and isolated markup data object the app module
/// links. Built once per app build and shared by the exe and test modules.
const TsCoreStage = struct {
main_root: std.Build.LazyPath,
/// The staged mobile wiring (ts_core_mobile.zig beside the same
/// mirror/registry files): the embed static library's `app`
/// module roots here on iOS/Android targets.
mobile_root: std.Build.LazyPath,
/// The compiled-core archive: the app module links it (with libc,
/// for the toolchain's runtime) beside the staged mirror.
archive: std.Build.LazyPath,
/// Plain-scriptc service host compiled from src/services/, when that
/// class exists and the child carrier is selected. It is installed/
/// packaged beside the app executable.
service_exe: ?std.Build.LazyPath = null,
/// The in-process carrier's service archive (thread-instanced,
/// runtime-localized), when that class exists and the in-process
/// carrier is selected. The app module links it beside the core's
/// archive.
service_archive: ?std.Build.LazyPath = null,
/// C source containing the primary markup bytes under stable external
/// symbols. Compiled as a tiny data object by the final app artifact.
markup_c: std.Build.LazyPath,
migrations: std.Build.LazyPath,
};
/// Which carrier runs src/services/ operations. Auto preserves the isolated,
/// single-instance child carrier; apps can explicitly opt into the parallel
/// in-process pool wherever the pinned service compiler can build the
/// runtime-localized service archive for the target (see
/// serviceArchiveSupported). `.service_carrier` in app.zon and the
/// `-Dservice-carrier` flag state the choice.
const ServiceCarrierOption = enum { auto, in_process, child };
const ServiceCarrier = enum { none, child, in_process };
/// Whether the pinned compiler can build TypeScript archives/executables for
/// this host/target pairing. Native desktop targets use their host toolchain,
/// including native Windows/MSVC. Cross-Windows builds use Zig's bundled GNU
/// sysroot; an MSVC cross target has no CRT headers or libraries to compile
/// the ScriptC runtime against. Mobile targets are aarch64 only and
/// archive-only (library mode — the app links the archive; no standalone
/// executable exists there): iOS device/simulator archives build on a macOS
/// host against the selected Apple SDK, Android archives build on any
/// desktop host against a discovered NDK sysroot.
pub fn scriptcCompileSupported(host: std.Target, target: std.Build.ResolvedTarget) bool {
const desktop_host = switch (host.os.tag) {
.macos, .linux, .windows => true,
else => false,
};
if (!desktop_host) return false;
const cross = scriptcTargetIsCross(host, target);
return switch (target.result.os.tag) {
.linux => if (target.result.abi.isAndroid()) target.result.cpu.arch == .aarch64 else true,
.windows => !cross or target.result.abi == .gnu,
.macos => host.os.tag == .macos,
.ios => host.os.tag == .macos and target.result.cpu.arch == .aarch64,
else => false,
};
}
/// Whether the pinned service compiler can build the in-process carrier's
/// archive (runtime-localized, thread-instanced) on this build host for
/// this target. Its object localizers are deliberately architecture-aware:
/// native Linux uses host binutils, cross-ELF accepts x86_64/aarch64
/// (Android's aarch64 archives ride this lane), COFF accepts x86_64 (GNU
/// when cross-compiled; the host ABI when native), and Mach-O — macOS, iOS
/// device, and iOS simulator — needs a macOS host (Apple linking rides the
/// host toolchain's SDK).
pub fn serviceArchiveSupported(host: std.Target, target: std.Build.ResolvedTarget) bool {
if (!scriptcCompileSupported(host, target)) return false;
const cross = scriptcTargetIsCross(host, target);
return switch (target.result.os.tag) {
.linux => !cross or target.result.cpu.arch == .x86_64 or target.result.cpu.arch == .aarch64,
.windows => target.result.cpu.arch == .x86_64,
.macos, .ios => host.os.tag == .macos,
else => false,
};
}
/// Whether a Linux target's spelling lands on Zig's default glibc floor,
/// which predates `arc4random_buf` — a symbol the compiled service
/// runtime references, so the link fails late and opaquely without the
/// configure-time teaching. A native target (no `-Dtarget` os) resolves
/// the system's own glibc and is exempt; an explicitly spelled `-gnu`
/// target needs a stated glibc of 2.36 or later (or `-musl`, whose libc
/// always has the symbol).
pub fn linuxGlibcSpellingHitsDefaultFloor(target: std.Build.ResolvedTarget) bool {
if (target.result.os.tag != .linux or !target.result.abi.isGnu()) return false;
if (target.query.os_tag == null) return false;
const glibc = target.query.glibc_version orelse return true;
return glibc.order(.{ .major = 2, .minor = 36, .patch = 0 }) == .lt;
}
/// Whether ScriptC needs its zig-cc target lane rather than the native host
/// compiler. Keep this identical to the driver scripts' platform-triple
/// comparison: a stated glibc version makes an otherwise same-triple Linux
/// target cross because that floor must reach Zig's `-target` argument.
pub fn scriptcTargetIsCross(host: std.Target, target: std.Build.ResolvedTarget) bool {
if (target.result.os.tag != host.os.tag or target.result.cpu.arch != host.cpu.arch) return true;
// Zig's Windows host triple defaults to GNU even when native clang uses
// the installed Windows toolchain. A same-architecture MSVC target is
// therefore still native compiler work. Preserve an exact native GNU
// triple too; every other ABI change needs the zig-cc cross lane.
if (target.result.os.tag == .windows) {
return target.result.abi != host.abi and target.result.abi != .msvc;
}
return target.result.abi != host.abi or target.query.glibc_version != null;
}
/// The glibc-floor teaching for explicitly targeted ScriptC builds. Both the
/// core archive and either service carrier carry the compiled TypeScript
/// runtime, so all of them hit the same missing symbol. This is independent of
/// whether the target triple happens to match the build host.
pub fn scriptcLinuxGlibcSpellingTeaching(b: *std.Build, target: std.Build.ResolvedTarget) []const u8 {
return b.fmt(
"\nTypeScript target {t}-linux-gnu uses Zig's default glibc floor, which" ++
" predates arc4random_buf — a symbol the compiled runtime needs (glibc" ++
" 2.36+).\nSpell the target as \"{t}-linux-gnu.2.36\" (or later), or" ++
" \"{t}-linux-musl\".\n",
.{ target.result.cpu.arch, target.result.cpu.arch, target.result.cpu.arch },
);
}
pub fn panicScriptcLinuxGlibcSpelling(b: *std.Build, target: std.Build.ResolvedTarget) noreturn {
@panic(scriptcLinuxGlibcSpellingTeaching(b, target));
}
pub fn panicUnsupportedScriptcTarget(b: *std.Build, host: std.Target, target: std.Build.ResolvedTarget) noreturn {
if (target.result.os.tag == .windows and scriptcTargetIsCross(host, target) and target.result.abi != .gnu) {
@panic(b.fmt(
"\nTypeScript cross-target Windows builds require the GNU ABI: Zig supplies" ++
" that target's CRT and system libraries, while an MSVC target needs a native" ++
" Windows toolchain.\nBuild {t}-windows-msvc on a matching Windows host, or" ++
" cross-compile as \"{t}-windows-gnu\".\n",
.{ target.result.cpu.arch, target.result.cpu.arch },
));
}
if (target.result.os.tag == .ios and host.os.tag != .macos) {
@panic(
"\nTypeScript iOS builds run on a macOS build host only: the Apple SDK sysroot" ++
" and Mach-O symbol localization live there.\nBuild iOS apps on a Mac.\n",
);
}
@panic(
"\nTypeScript builds support native host targets, Linux and Windows GNU cross" ++
" targets from macOS/Linux/Windows, macOS targets from macOS, and the mobile" ++
" targets aarch64 iOS/iOS-simulator (from macOS) and aarch64 Android." ++
"\nChoose a supported target/host pairing.\n",
);
}
fn resolveServiceCarrier(
choice: ServiceCarrierOption,
has_services: bool,
b: *std.Build,
target: std.Build.ResolvedTarget,
) ServiceCarrier {
if (!has_services) return .none;
const host = b.graph.host.result;
const supported = serviceArchiveSupported(host, target);
// Mobile has no child processes, so the in-process pool is the only
// carrier there: auto resolves to it, and an explicit "child" is a
// stated impossibility, taught rather than quietly rewritten.
if (target.result.os.tag == .ios or target.result.abi.isAndroid()) {
return switch (choice) {
.auto, .in_process => if (supported) .in_process else panicUnsupportedScriptcTarget(b, host, target),
.child => @panic(
"\nservice_carrier = \"child\" is unavailable on mobile targets: iOS and Android" ++
" apps cannot spawn a sibling service process, so src/services operations run" ++
" on the in-process pool there.\nUse \"auto\" or \"in_process\" (desktop" ++
" builds of the same app keep the child carrier under auto).\n",
),
};
}
const carrier: ServiceCarrier = switch (choice) {
.auto, .child => .child,
.in_process => if (supported) .in_process else @panic(
"\nservice_carrier = \"in_process\" requires a target the pinned service compiler" ++
" can produce a runtime-localized archive for: native Linux, cross-Linux" ++
" x86_64/aarch64, native Windows x86_64, cross-Windows x86_64 GNU," ++
" macOS from a macOS build host, or a mobile target." ++
"\nUse \"child\" (or drop the setting — auto selects the child carrier)" ++
" for this target.\n",
),
};
return carrier;
}
/// The `arch-os-abi` platform spelling every ScriptC compile lane receives.
/// A stated glibc version rides the abi (`gnu.2.36`) so the compiler's
/// cross lane builds at the spelled floor rather than the default one.
pub fn scriptcPlatformTriple(b: *std.Build, target: std.Build.ResolvedTarget) []const u8 {
const triple = b.fmt("{t}-{t}-{t}", .{ target.result.cpu.arch, target.result.os.tag, target.result.abi });
if (target.result.os.tag != .linux or !target.result.abi.isGnu()) return triple;
const glibc = target.query.glibc_version orelse return triple;
return if (glibc.patch == 0)
b.fmt("{s}.{d}.{d}", .{ triple, glibc.major, glibc.minor })
else
b.fmt("{s}.{d}.{d}.{d}", .{ triple, glibc.major, glibc.minor, glibc.patch });
}
fn parseServiceCarrierOption(raw: []const u8) ServiceCarrierOption {
if (std.mem.eql(u8, raw, "auto")) return .auto;
if (std.mem.eql(u8, raw, "in_process")) return .in_process;
if (std.mem.eql(u8, raw, "child")) return .child;
@panic("\n.service_carrier must be \"auto\", \"in_process\", or \"child\"\n");
}
/// The frontend's own sources — the staleness set of every build step
/// that runs it (a frontend edit re-checks every core).
const frontend_sources = [_][]const u8{
"checker.ts", "cli.ts", "contract.ts", "diagnostics.ts", "frontend.ts", "infer.ts", "modules.ts", "ownership.ts", "service_contract.ts", "sqlite_codegen.ts", "sqlite_cli.ts", "sqlite_runtime_policy.ts", "typed_ast.ts", "types.ts", "wyhash.ts",
};
/// Whether the frontend's TypeScript compiler (@typescript/old, the
/// exactly pinned npm alias of the real `typescript` package) RESOLVES
/// from the SDK's packages/core, by node's ancestor node_modules walk —
/// at the SDK's exactly pinned VERSION. The same semantics the CLI gates
/// on (src/tooling/ts_core.zig
/// transpilerResolution, this predicate's deliberate twin: keep the two
/// in lockstep).
///
/// Validation tracks ONLY what runtime loads, from the same origin
/// runtime resolves from: typed_ast.ts imports "@typescript/old" directly
/// and build/ts_run.mjs's load hook requires it from the target
/// packages/core/src module — src/ never carries a node_modules, so
/// packages/core is the walk origin that mirrors both. Covers every
/// layout: a repo checkout's packages/core/node_modules (nearest wins),
/// and the npm-installed CLI whose own `dependencies` carry the alias
/// (nested under the package on global prefixes, hoisted to the project
/// root on local ones, pnpm's sibling node_modules).
///
/// A stray `@typescript/typescript6` compat wrapper in a consumer tree
/// (a former dependency of this package, or the consumer's own) is
/// deliberately NOT probed: nothing imports it at run time, and holding
/// the alias's version as seen FROM a wrapper's origin against the pin
/// can only FALSE-REJECT healthy trees — a consumer's hoisted conflicting
/// `@typescript/old` wins the walk from there while the copy runtime
/// actually loads sits correctly pinned under packages/core.
///
/// Resolvable means the alias's manifest AND its entrypoint are present
/// (see tsAliasedCompilerVersion for node's error shape and the
/// mid-extraction slivers) and its installed version equals the pin —
/// read from the SDK dependency's own packages/core/package.json (the
/// `npm:typescript@X.Y.Z` alias suffix in devDependencies —
/// tsParseAliasedCompilerPin, mirroring the twin's
/// parseAliasedCompilerPin), never hardcoded, so version bumps stay a
/// one-file change.
const TsToolchainResolution = union(enum) {
resolved,
unresolved,
/// The aliased compiler resolves, but at a version other than the
/// SDK's exact pin (strings allocated on b.allocator, freed never —
/// this is configure-time teaching data).
version_mismatch: struct { resolved: []const u8, pinned: []const u8 },
};
fn tsToolchainResolution(b: *std.Build, dep: *std.Build.Dependency) TsToolchainResolution {
const sdk_root = tsSdkRoot(b.allocator, b.graph.io, dep);
// The pin first: an SDK tree whose packages/core/package.json is
// missing or pinless is not one this gate can vouch for (the file
// ships in every layout), so it concludes unresolved and the teaching
// path acts.
const pinned = tsPinnedCompilerVersion(b, sdk_root) orelse return .unresolved;
const resolved = tsAliasedCompilerVersion(b, b.pathJoin(&.{ sdk_root, "packages", "core" })) orelse return .unresolved;
if (std.mem.eql(u8, resolved, pinned)) return .resolved;
return .{ .version_mismatch = .{ .resolved = resolved, .pinned = pinned } };
}
/// How runtime's `import "@typescript/old"` resolves, by node's walk FROM
/// `origin_dir` upward: the nearest ancestor
/// `node_modules/@typescript/old` wins (skipping ancestors that are
/// themselves a node_modules directory, as node does). Callers pass the
/// SDK's packages/core — the origin typed_ast.ts and ts_run.mjs resolve
/// the alias from. The walk must be a real ancestor walk, not a
/// fixed-sibling probe: npm hoists the alias to the install root on flat
/// layouts and nests it under the CLI on version conflicts. Resolvable
/// means the alias's manifest AND its entrypoint — the alias is the real
/// `typescript` package, whose `"main"` is ./lib/typescript.js (no
/// `"exports"`): a bare directory (an interrupted install, a pruned
/// node_modules) is MODULE_NOT_FOUND at run time, and a manifest without
/// its entrypoint (npm extraction is not atomic; package.json rides
/// first in the tarball) fails just as opaquely. Hardcoding it is safe
/// because the alias is exactly pinned (`npm:typescript@X.Y.Z` in both
/// manifests plus the lockfile) and drift-checked by check-version-sync.
/// A manifest without its entrypoint THROWS in node rather than
/// consulting a deeper ancestor, so it concludes unresolvable here too
/// (this predicate's deliberate twin: src/tooling/ts_core.zig's
/// aliasedCompilerVersion — keep the two in lockstep).
///
/// Returns the resolved alias's installed VERSION so the caller can hold
/// it against the SDK's pin; a resolvable-looking alias whose manifest
/// carries no version field returns null (every real npm install writes
/// one — its absence is a corrupt sliver, not a compiler to vouch for).
fn tsAliasedCompilerVersion(b: *std.Build, origin_dir: []const u8) ?[]const u8 {
const io = b.graph.io;
var dir: []const u8 = origin_dir;
while (true) {
if (!std.mem.eql(u8, std.fs.path.basename(dir), "node_modules")) {
found: {
const manifest_path = b.pathJoin(&.{ dir, "node_modules", "@typescript", "old", "package.json" });
std.Io.Dir.cwd().access(io, manifest_path, .{}) catch break :found;
std.Io.Dir.cwd().access(io, b.pathJoin(&.{ dir, "node_modules", "@typescript", "old", "lib", "typescript.js" }), .{}) catch return null;
const manifest = std.Io.Dir.cwd().readFileAlloc(io, manifest_path, b.allocator, .limited(64 * 1024)) catch return null;
return tsParseQuotedManifestValue(manifest, "\"version\"", "");
}
}
dir = std.fs.path.dirname(dir) orelse return null;
}
}
/// The version the SDK pins its aliased compiler to, read from the SDK
/// dependency's own packages/core/package.json — the same manifest the
/// CLI's gate reads, so both surfaces learn a version bump from one file.
fn tsPinnedCompilerVersion(b: *std.Build, sdk_root: []const u8) ?[]const u8 {
const manifest = std.Io.Dir.cwd().readFileAlloc(b.graph.io, b.pathJoin(&.{ sdk_root, "packages", "core", "package.json" }), b.allocator, .limited(64 * 1024)) catch return null;
const pin = tsParseQuotedManifestValue(manifest, "\"@typescript/old\"", "npm:typescript@") orelse return null;
// Exact X.Y.Z only (check-version-sync's pin shape), mirroring the
// twin's parseAliasedCompilerPin: a range is not a pin.
if (pin.len == 0) return null;
for (pin) |c| {
if (!std.ascii.isDigit(c) and c != '.') return null;
}
if (std.mem.count(u8, pin, ".") != 2) return null;
return pin;
}
/// Targeted scan for a manifest key's string value (the twin of
/// ts_core.zig's parsePackageVersion/parseAliasedCompilerPin scanners):
/// find `key`, expect `: "<required_prefix><value>"`, return `value`.
fn tsParseQuotedManifestValue(manifest_json: []const u8, comptime key: []const u8, comptime required_prefix: []const u8) ?[]const u8 {
const key_at = std.mem.indexOf(u8, manifest_json, key) orelse return null;
var rest = manifest_json[key_at + key.len ..];
rest = std.mem.trimStart(u8, rest, " \t\r\n");
if (rest.len == 0 or rest[0] != ':') return null;
rest = std.mem.trimStart(u8, rest[1..], " \t\r\n");
if (rest.len == 0 or rest[0] != '"') return null;
rest = rest[1..];
const end = std.mem.indexOfScalar(u8, rest, '"') orelse return null;
const value = rest[0..end];
if (!std.mem.startsWith(u8, value, required_prefix)) return null;
const suffix = value[required_prefix.len..];
if (suffix.len == 0) return null;
return suffix;
}
/// PR 166 adds both the published compile-cache bootstrap and the optional
/// library-profile optimization field. The installed bootstrap is therefore
/// the capability marker: keep older exact pins byte-for-byte compatible;
/// once that release is installed, Debug/native-dev gets `dev` and
/// release/package artifacts get `release` automatically.
fn scriptcProfileOptimization(b: *std.Build, dep: *std.Build.Dependency, optimize: std.builtin.OptimizeMode) ?[]const u8 {
const sdk_root = tsSdkRoot(b.allocator, b.graph.io, dep);
var dir: []const u8 = b.pathJoin(&.{ sdk_root, "packages", "core" });
while (true) {
if (!std.mem.eql(u8, std.fs.path.basename(dir), "node_modules")) {
const marker = b.pathJoin(&.{ dir, "node_modules", "scriptc", "dist", "bootstrap.js" });
std.Io.Dir.cwd().access(b.graph.io, marker, .{}) catch {
dir = std.fs.path.dirname(dir) orelse return null;
continue;
};
return if (optimize == .Debug) "dev" else "release";
}
dir = std.fs.path.dirname(dir) orelse return null;
}
}
/// The SDK dependency's real root, resolved the way both the toolchain
/// check and its teaching name it.
fn tsSdkRoot(allocator: std.mem.Allocator, io: std.Io, dep: *std.Build.Dependency) []const u8 {
const raw_root = dep.builder.build_root.path orelse ".";
return std.Io.Dir.cwd().realPathFileAlloc(io, raw_root, allocator) catch raw_root;
}
const TsToolingConsumer = enum { app_core, sqlite_schema };
/// Resolve the node + pinned TypeScript loader shared by the app-core
/// frontend and relational schema generator. Keep app-shape assertions out
/// of this helper: a Zig core may use SQLite without carrying a markup view.
fn tsToolingPreflight(b: *std.Build, dep: *std.Build.Dependency, consumer: TsToolingConsumer) []const u8 {
const node = b.findProgram(&.{"node"}, &.{}) catch switch (consumer) {
.app_core => @panic("\nbuilding a TypeScript app core needs node on PATH (the @native-sdk/core frontend checks the" ++
" core at build time; the binary you ship carries no JS runtime).\nInstall Node.js 24+" ++
" — https://nodejs.org or `brew install node` — and re-run.\n"),
.sqlite_schema => @panic("\nbuilding relational SQLite migrations needs node on PATH (the schema checker and" ++
" migration generator run at build time; the binary you ship carries no JS runtime).\nInstall Node.js" ++
" 24+ — https://nodejs.org or `brew install node` — and re-run.\n"),
};
switch (tsToolchainResolution(b, dep)) {
.resolved => {},
.unresolved => {
// Safety net for direct `zig build` users: the `native` CLI
// gates this itself, so reaching here means zig was invoked by
// hand against an SDK whose toolchain resolves nowhere. Name
// the SDK dependency's real location as a RESOLVED path and
// fail the configure phase cleanly — a teaching message, never
// a panic stack trace.
const sdk_root = tsSdkRoot(dep.builder.allocator, dep.builder.graph.io, dep);
switch (consumer) {
.app_core => std.debug.print(
\\
\\error: the @native-sdk/core frontend cannot resolve its TypeScript toolchain
\\(its compiler, @typescript/old). On a repo checkout, install it once with:
\\ cd {s}/packages/core && npm ci --include=dev
\\(An npm-installed @native-sdk/cli carries the toolchain automatically; if it
\\is missing there, the install is broken - reinstall @native-sdk/cli.)
\\
\\
, .{sdk_root}),
.sqlite_schema => std.debug.print(
\\
\\error: the relational SQLite schema generator cannot resolve its TypeScript toolchain
\\(its compiler, @typescript/old). On a repo checkout, install it once with:
\\ cd {s}/packages/core && npm ci --include=dev
\\(An npm-installed @native-sdk/cli carries the toolchain automatically; if it
\\is missing there, the install is broken - reinstall @native-sdk/cli.)
\\
\\
, .{sdk_root}),
}
std.process.exit(1);
},
.version_mismatch => |mismatch| {
// The wrong-VERSION shape gets its own teaching (the CLI's
// gate mirrors it): a conflicting consumer-tree
// @typescript/old shadows the SDK's exact pin, and no install
// command fixes what is already installed — the conflict has
// to move.
switch (consumer) {
.app_core => std.debug.print(
\\
\\error: the @native-sdk/core frontend's TypeScript compiler resolves at the
\\wrong version: @typescript/old resolves to typescript {s}, but the SDK pins
\\npm:typescript@{s}. Another package in this tree pins a conflicting
\\@typescript/old - align it with the SDK's pin (or remove it) and reinstall,
\\so the SDK's exact pin is the copy that resolves.
\\
\\
, .{ mismatch.resolved, mismatch.pinned }),
.sqlite_schema => std.debug.print(
\\
\\error: the relational SQLite schema generator's TypeScript compiler resolves at the
\\wrong version: @typescript/old resolves to typescript {s}, but the SDK pins
\\npm:typescript@{s}. Another package in this tree pins a conflicting
\\@typescript/old - align it with the SDK's pin (or remove it) and reinstall,
\\so the SDK's exact pin is the copy that resolves.
\\
\\
, .{ mismatch.resolved, mismatch.pinned }),
}
std.process.exit(1);
},
}
return node;
}
/// The TS-core-only shape gate plus the shared tooling preflight. The
/// frontend (the subset checker and contract-sidecar emitter) runs under
/// node; the compile itself is the external toolchain's.
fn tsCorePreflight(b: *std.Build, dep: *std.Build.Dependency, app_root: []const u8) []const u8 {
if (!appFileExists(b, app_root, "src/app.native")) {
@panic("\nthis app has a TypeScript core (src/core.ts) but no view: TS apps render markup," ++
" so add src/app.native (the whole view tier binds the core's model)\n");
}
return tsToolingPreflight(b, dep, .app_core);
}
/// The TypeScript-core compile lane: the frontend checks the core and
/// emits its contract sidecar, corewire projects the generated compile
/// entry and library-mode profile, the stager assembles the compile
/// tree (author sources with the mechanical staging transforms, the
/// staged SDK modules, the static compile surface), the exact-pinned
/// external toolchain builds the archive AND co-emits the archive's own
/// contract sidecar, and corewire generates the mirror module from THAT
/// document — so the boot identity fence always pairs the mirror with
/// its own compile. The staged module directory carries core.zig (the
/// mirror) + its staged runtime + main.zig, so the
/// generated wiring imports one fixed shape.
fn tsCoreStage(
b: *std.Build,
dep: *std.Build.Dependency,
target: std.Build.ResolvedTarget,
app_root: []const u8,
app_name: []const u8,
persist_capability: bool,
store_capability: bool,
relational_capability: bool,
credentials_capability: bool,
credentials_permission: bool,
persist_version: ?u64,
service_packages: []const ServicePackageConfig,
service_carrier_choice: ServiceCarrierOption,
service_pool_workers: ?u8,
build_trace: bool,
scriptc_optimization: ?[]const u8,
) TsCoreStage {
const node = tsCorePreflight(b, dep, app_root);
const window_views = collectTsWindowViews(b, app_root);
const has_services = appHasServiceFiles(b, app_root);
if (!scriptcCompileSupported(b.graph.host.result, target)) {
panicUnsupportedScriptcTarget(b, b.graph.host.result, target);
}
// Every TypeScript app compiles its core archive for the target. Any
// explicitly spelled Linux GNU target without a sufficient glibc version
// lands on Zig's too-old default floor, even when its triple matches the
// build host; reject it before either compile lane reaches the linker.
if (linuxGlibcSpellingHitsDefaultFloor(target)) {
panicScriptcLinuxGlibcSpelling(b, target);
}
const service_carrier = resolveServiceCarrier(service_carrier_choice, has_services, b, target);
// Relational schema analysis runs the real SQLite parser in memory before
// the ordinary core checker. Its generated SDK/static surfaces carry the
// named Cmd.q/Sub.q members, while the Zig projection embeds the exact
// append-only migration chain the checker accepted.
var checked_sdk_core: std.Build.LazyPath = dep.path("packages/core/sdk/core.ts");
var checked_static_core: std.Build.LazyPath = dep.path("packages/core/compile-surface/core.ts");
var migrations_zig: std.Build.LazyPath = dep.path("src/app_runner/no_migrations.zig");
if (relational_capability) {
const sqlite_check = b.addSystemCommand(&.{node});
sqlite_check.addFileArg(dep.path("build/ts_run.mjs"));
sqlite_check.addFileArg(dep.path("packages/core/src/sqlite_cli.ts"));
sqlite_check.addArg("--src");
sqlite_check.addDirectoryArg(b.path(appPath(b, app_root, "src")));
sqlite_check.addArg("--sdk-in");
sqlite_check.addFileArg(dep.path("packages/core/sdk/core.ts"));
sqlite_check.addArg("--static-in");
sqlite_check.addFileArg(dep.path("packages/core/compile-surface/core.ts"));
sqlite_check.addArg("--sdk-out");
checked_sdk_core = sqlite_check.addOutputFileArg("core.ts");
sqlite_check.addArg("--sdk-events-out");
_ = sqlite_check.addOutputFileArg("events.ts");
sqlite_check.addArg("--sdk-text-out");
_ = sqlite_check.addOutputFileArg("text.ts");
sqlite_check.addArg("--sdk-bytes-text-methods-out");
_ = sqlite_check.addOutputFileArg("bytes_text_methods.d.ts");
sqlite_check.addArg("--sdk-events-dts-out");
_ = sqlite_check.addOutputFileArg("events.d.ts");
sqlite_check.addArg("--sdk-text-dts-out");
_ = sqlite_check.addOutputFileArg("text.d.ts");
sqlite_check.addArg("--static-out");
checked_static_core = sqlite_check.addOutputFileArg("core_static.ts");
sqlite_check.addArg("--zig-out");
migrations_zig = sqlite_check.addOutputFileArg("migrations.zig");
sqlite_check.addArg("--metadata-out");
_ = sqlite_check.addOutputFileArg("sqlite.meta.json");
sqlite_check.addArgs(&.{ "--state", b.pathFromRoot(appPath(b, app_root, "src/schema/migrations.lock.json")) });
if (appFileExists(b, app_root, "src/schema/migrations.lock.json")) {
sqlite_check.addFileInput(b.path(appPath(b, app_root, "src/schema/migrations.lock.json")));
}
sqlite_check.addFileInput(dep.path("packages/core/src/sqlite_codegen.ts"));
sqlite_check.addFileInput(dep.path("packages/core/src/sqlite_runtime_policy.ts"));
for ([_][]const u8{ "events.ts", "text.ts", "bytes_text_methods.d.ts", "events.d.ts", "text.d.ts" }) |source| {
sqlite_check.addFileInput(dep.path(b.fmt("packages/core/sdk/{s}", .{source})));
}
addAppSqlDirInputs(b, sqlite_check, appPath(b, app_root, "src"));
}
// The frontend, in check-only mode: the subset checker and the
// contract sidecar, no emission. Every check-time teaching gates the
// compile here — the checker's NS diagnostics stream to stderr
// verbatim; nothing wraps them. The frontend runs through
// build/ts_run.mjs, not as `node cli.ts`: on the npm-installed
// layout the frontend's .ts sources live inside node_modules, where
// node refuses its builtin type stripping — the runner strips those
// modules with the frontend's own installed TypeScript and is a
// pass-through on a repo checkout. The frontend reads its own
// sources, the SDK modules, and the core's WHOLE import graph at
// run time; declare them all so an edit to ANY module of a
// multi-file core re-checks it (every .ts under src/ is a superset
// of the reachable imports: over-approximation only re-runs the
// check, never misses a stale input).
const check = b.addSystemCommand(&.{node});
check.setName("native ts frontend (core + service contracts)");
check.addFileArg(dep.path("build/ts_run.mjs"));
check.addFileArg(dep.path("packages/core/src/cli.ts"));
check.addFileArg(b.path(appPath(b, app_root, "src/core.ts")));
check.addArg("--contract");
const contract_raw = check.addOutputFileArg("core.contract.json");
const contract = stabilizeGeneratedFile(b, contract_raw, "core.contract.json", build_trace);
// The document's entry spelling is app-relative (the sidecar/facade
// contract carries no machine paths).
check.addArgs(&.{ "--contract-entry", "src/core.ts" });
const services_contract: ?std.Build.LazyPath = if (has_services) services_contract: {
check.addArg("--services-contract");
const raw = check.addOutputFileArg("services.contract.json");
break :services_contract stabilizeGeneratedFile(b, raw, "services.contract.json", build_trace);
} else null;
if (persist_capability) check.addArgs(&.{ "--capability", "persist" });
for (service_packages) |package_entry| {
check.addArgs(&.{
"--service-package",
b.fmt("{s}|{s}|{s}", .{ package_entry.name, package_entry.version, package_entry.content_hash }),
});
}
if (store_capability) check.addArgs(&.{ "--capability", "store" });
if (relational_capability) check.addArgs(&.{ "--capability", "sqlite" });
if (credentials_capability) check.addArgs(&.{ "--capability", "credentials" });
if (credentials_permission) check.addArgs(&.{ "--permission", "credentials" });
// Activate the cross-tier registry check even when discovery found no
// roots: the empty set must reject a core that declares a window.
check.addArg("--window-views");
for (window_views.views) |view| check.addArgs(&.{ "--window-view", view.label });
if (relational_capability) {
check.addArg("--sdk-core");
check.addFileArg(checked_sdk_core);
}
if (persist_version) |version| {
check.addArgs(&.{ "--persist-version", b.fmt("{d}", .{version}) });
check.addArgs(&.{ "--persist-state", appPath(b, app_root, ".native/cache/persist-schema.json") });
}
addTsDirInputs(b, dep.builder, check, "packages/core/sdk");
addAppTsDirInputs(b, check, appPath(b, app_root, "src"));
for (frontend_sources) |source| {
check.addFileInput(dep.path(b.fmt("packages/core/src/{s}", .{source})));
}
// Service contracts echo the exact scriptc pin read from this manifest.
// Declare that runtime read explicitly so a compiler bump invalidates a
// warm build cache before the compile lane checks the echoed version.
if (has_services) check.addFileInput(dep.path("packages/core/package.json"));
// corewire, compiled from the SDK dependency for the build host: one
// invocation projects the generated compile entry and its profile,
// so the profile's entry spelling and the facade file can never skew.
const corewire_exe = corewireExe(b, dep);
const project = b.addRunArtifact(corewire_exe);
project.setName("native corewire (core facade + profile)");
project.addArg("--sidecar");
project.addFileArg(contract);
project.addArg("--facade");
const facade = project.addOutputFileArg("core_facade.ts");
project.addArg("--profile");
const profile = project.addOutputFileArg("core_profile.json");
if (scriptc_optimization) |value| project.addArgs(&.{ "--optimization", value });
// The fourth corewire projection is intentionally driven only by the
// service sidecar: the generated TypeScript dispatch loop and Zig
// name/index registry never rediscover facts from author source.
var service_registry: std.Build.LazyPath = dep.path("src/app_runner/no_services.zig");
var service_exe: ?std.Build.LazyPath = null;
var service_archive: ?std.Build.LazyPath = null;
var service_client: ?std.Build.LazyPath = null;
if (services_contract) |service_contract| {
const service_project = b.addRunArtifact(corewire_exe);
service_project.setName("native corewire (service host + ABI projections)");
service_project.addArg("--services-sidecar");
service_project.addFileArg(service_contract);
service_project.addArg("--service-host-main");
const service_host_main = service_project.addOutputFileArg("service_host_main.ts");
service_project.addArg("--service-registry");
const service_registry_raw = service_project.addOutputFileArg("services.zig");
service_registry = stabilizeGeneratedFile(b, service_registry_raw, "services.zig", build_trace);
service_project.addArg("--service-client");
const service_client_raw = service_project.addOutputFileArg("services.gen.ts");
service_client = stabilizeGeneratedFile(b, service_client_raw, "services.gen.ts", build_trace);
const service_inproc_main: ?std.Build.LazyPath = if (service_carrier == .in_process) inproc: {
service_project.addArg("--service-inproc-main");
break :inproc service_project.addOutputFileArg("service_inproc_main.ts");
} else null;
const service_inproc_profile: ?std.Build.LazyPath = if (service_carrier == .in_process) inproc: {
service_project.addArg("--service-inproc-profile");
break :inproc service_project.addOutputFileArg("service_profile.json");
} else null;
// Ordinary service TypeScript is staged without core-subset rewrites.
// The one service-boundary lowering turns NS1067's `{ kind, message }`
// throw into the tagged Error shape the pinned compiler can catch from an
// imported op; no deterministic profile fences participate here.
const service_stage_run = b.addSystemCommand(&.{node});
service_stage_run.setName("native stage TypeScript services");
service_stage_run.addFileArg(dep.path("packages/core/scripts/stage_external_services.mjs"));
service_stage_run.addArg("--src");
service_stage_run.addDirectoryArg(b.path(appPath(b, app_root, "src")));
service_stage_run.addArg("--host-main");
service_stage_run.addFileArg(service_host_main);
if (service_inproc_main) |inproc_main| {
service_stage_run.addArg("--inproc-main");
service_stage_run.addFileArg(inproc_main);
}
if (service_inproc_profile) |inproc_profile| {
service_stage_run.addArg("--inproc-profile");
service_stage_run.addFileArg(inproc_profile);
}
if (scriptc_optimization) |value| service_project.addArgs(&.{ "--optimization", value });
service_stage_run.addArg("--contract");
service_stage_run.addFileArg(service_contract);
service_stage_run.addArg("--out");
const service_stage_dir = service_stage_run.addOutputDirectoryArg("services-stage");
// The staging script discovers nested service modules recursively;
// a directory LazyPath alone does not hash those discovered files.
service_stage_run.has_side_effects = true;
const service_compile = b.addSystemCommand(&.{node});
service_compile.setName("native scriptc service compile");
if (build_trace) service_compile.setEnvironmentVariable("SCRIPTC_TIMING", "1");
service_compile.addFileArg(dep.path("packages/core/scripts/run_external_service_compiler.mjs"));
service_compile.addFileInput(dep.path("packages/core/scripts/compiler_command.mjs"));
service_compile.addArg("--stage");
service_compile.addDirectoryArg(service_stage_dir);
service_compile.addArg("--manifest");
service_compile.addFileArg(dep.path("packages/core/package.json"));
service_compile.addArg("--contract");
service_compile.addFileArg(service_contract);
if (service_carrier == .in_process) {
// The in-process carrier links the service class into the app
// binary; no sibling executable is built or packaged.
service_compile.addArg("--out-archive");
service_archive = service_compile.addOutputFileArg(b.fmt("lib{s}_services.a", .{externalCoreSymbolName(b, app_name)}));
} else {
service_compile.addArg("--out-exe");
const service_suffix = if (target.result.os.tag == .windows) ".exe" else "";
service_exe = service_compile.addOutputFileArg(b.fmt("{s}_services{s}", .{ app_name, service_suffix }));
}
service_compile.addArgs(&.{
"--host-platform",
b.fmt("{t}-{t}-{t}", .{ b.graph.host.result.cpu.arch, b.graph.host.result.os.tag, b.graph.host.result.abi }),
"--target-platform",
scriptcPlatformTriple(b, target),
// Cross service compiles run the pinned compiler's zig-cc
// lane; hand over this build's own zig so the lane never
// depends on a PATH zig (native compiles ignore it).
"--zig-exe",
b.graph.zig_exe,
});
addScriptcAndroidNdk(b, service_compile, target);
if (b.graph.environ_map.get("NATIVE_SDK_CORE_COMPILER")) |override| {
service_compile.addArgs(&.{ "--compiler", override });
} else {
// Let Node resolve the dependency from the SDK package origin:
// this is npm's own nested/hoisted/global-sibling walk, and the
// driver follows scriptc's published `bin` declaration.
service_compile.addArg("--compiler-package-origin");
service_compile.addFileArg(dep.path("packages/core/package.json"));
}
}
// The compile stage: author sources + staged SDK + static surface +
// generated entry/profile, one scratch tree.
const stage_run = b.addSystemCommand(&.{node});
stage_run.setName("native stage TypeScript core");
stage_run.addFileArg(dep.path("packages/core/scripts/stage_external_core.mjs"));
stage_run.addArg("--src");
stage_run.addDirectoryArg(b.path(appPath(b, app_root, "src")));
stage_run.addArg("--sdk");
stage_run.addDirectoryArg(dep.path("packages/core/sdk"));
stage_run.addArg("--static");
stage_run.addFileArg(checked_static_core);
stage_run.addArg("--facade");
stage_run.addFileArg(facade);
stage_run.addArg("--profile");
stage_run.addFileArg(profile);
if (service_client) |client| {
stage_run.addArg("--services-client");
stage_run.addFileArg(client);
}
// `addDirectoryArg` orders the stager after a generated directory but
// does not hash a source directory's discovered contents. The old graph
// accidentally got core-source invalidation from changing producer cache
// paths. Now that generated ABI files are stabilized by content, state
// every source the stager copies explicitly: authored core files plus the
// two SDK implementation modules. Services are excluded because they have
// their own compile lane.
addAppCoreTsDirInputs(b, stage_run, appPath(b, app_root, "src"));
addStagedCoreSdkInputs(b, dep.builder, stage_run);
stage_run.addArg("--out");
const stage_dir = stage_run.addOutputDirectoryArg("stage");
// The external compile itself: driver-verified against the SDK's
// exact pin, archive normalized, the co-emitted sidecar captured.
const symbol_name = externalCoreSymbolName(b, app_name);
const compile = b.addSystemCommand(&.{node});
compile.setName("native scriptc core compile");
if (build_trace) compile.setEnvironmentVariable("SCRIPTC_TIMING", "1");
compile.addFileArg(dep.path("packages/core/scripts/run_external_core_compiler.mjs"));
compile.addFileInput(dep.path("packages/core/scripts/compiler_command.mjs"));
compile.addArg("--stage");
compile.addDirectoryArg(stage_dir);
compile.addArgs(&.{ "--name", symbol_name });
compile.addArg("--manifest");
compile.addFileArg(dep.path("packages/core/package.json"));
compile.addArg("--frontend-sidecar");
compile.addFileArg(contract);
compile.addArg("--out-archive");
const archive = compile.addOutputFileArg(b.fmt("lib{s}.a", .{symbol_name}));
compile.addArg("--out-sidecar");
const compiled_sidecar = compile.addOutputFileArg("core.contract.json");
compile.addArgs(&.{
"--host-platform",
b.fmt("{t}-{t}-{t}", .{ b.graph.host.result.cpu.arch, b.graph.host.result.os.tag, b.graph.host.result.abi }),
"--target-platform",
scriptcPlatformTriple(b, target),
// Keep the core and service archives on the same cross compiler and
// target. Native compiles ignore the supplied Zig path.
"--zig-exe",
b.graph.zig_exe,
});
addScriptcAndroidNdk(b, compile, target);
if (b.graph.environ_map.get("NATIVE_SDK_CORE_COMPILER")) |override| {
// The development override: point at any toolchain command; the
// driver still refuses a release other than the SDK's pin.
compile.addArgs(&.{ "--compiler", override });
} else {
compile.addArg("--compiler-package-origin");
compile.addFileArg(dep.path("packages/core/package.json"));
}
// The mirror, generated from the archive's OWN co-emitted contract.
const mirror = b.addRunArtifact(corewire_exe);
mirror.setName("native corewire (compiled-core mirror)");
mirror.addArg("--sidecar");
mirror.addFileArg(compiled_sidecar);
mirror.addArg("--out");
const shim = mirror.addOutputFileArg("core_shim.zig");
// The staged module directory: the mirror is the app's core.zig,
// and it imports its staged shim runtime relatively.
const staged = b.addWriteFiles();
_ = staged.addCopyFile(shim, "core.zig");
_ = staged.addCopyFile(dep.path("tools/corewire/shim_rt.zig"), "shim_rt.zig");
_ = staged.addCopyFile(dep.path("tools/corewire/core_abi.zig"), "core_abi.zig");
_ = staged.addCopyFile(service_registry, "services.zig");
// The carrier selection, as one staged constant module: the generated
// wiring comptime-switches its service transport on it.
_ = staged.add("service_carrier.zig", b.fmt(
\\//! Generated by the app build: the resolved service-carrier selection.
\\pub const Kind = enum {{ none, child, in_process }};
\\pub const kind: Kind = .{t};
\\/// Pool width for the in-process carrier; null resolves the
\\/// runtime default (min(4, cores)).
\\pub const pool_workers: ?usize = {?d};
\\
, .{ service_carrier, service_pool_workers }));
_ = staged.addCopyFile(migrations_zig, "migrations.zig");
// Primary markup bytes live in their own tiny C translation unit. They
// no longer participate in Zig source analysis, so a markup edit reuses
// the compiled app/SDK graph and performs only C data compile + relink.
const markup_embed = b.addSystemCommand(&.{node});
markup_embed.setName("native embed primary markup data");
markup_embed.addFileArg(dep.path("packages/core/scripts/embed_markup_c.mjs"));
markup_embed.addFileArg(b.path(appPath(b, app_root, "src/app.native")));
const markup_c = markup_embed.addOutputFileArg("app_markup.c");
for (window_views.sources) |source| {
_ = staged.addCopyFile(b.path(appPath(b, app_root, source.source_path)), source.staged_path);
}
_ = staged.add("window_views.zig", tsWindowRegistrySource(b, window_views));
const main_root = staged.addCopyFile(dep.path("src/app_runner/ts_core_main.zig"), "main.zig");
// The mobile wiring stages beside the desktop entry: same mirror, same
// registry, same carrier constant — only the shell differs (the embed
// host's AppDef contract instead of a process `main`).
const mobile_root = staged.addCopyFile(dep.path("src/app_runner/ts_core_mobile.zig"), "mobile.zig");
return .{
.main_root = main_root,
.mobile_root = mobile_root,
.archive = archive,
.service_exe = service_exe,
.service_archive = service_archive,
.markup_c = markup_c,
.migrations = migrations_zig,
};
}
fn sqliteMigrationsStage(b: *std.Build, dep: *std.Build.Dependency, app_root: []const u8) std.Build.LazyPath {
const node = tsToolingPreflight(b, dep, .sqlite_schema);
const generate = b.addSystemCommand(&.{node});
generate.addFileArg(dep.path("build/ts_run.mjs"));
generate.addFileArg(dep.path("packages/core/src/sqlite_cli.ts"));
generate.addArg("--src");
generate.addDirectoryArg(b.path(appPath(b, app_root, "src")));
generate.addArg("--zig-out");
const migrations = generate.addOutputFileArg("migrations.zig");
generate.addArgs(&.{ "--state", b.pathFromRoot(appPath(b, app_root, "src/schema/migrations.lock.json")) });
if (appFileExists(b, app_root, "src/schema/migrations.lock.json")) {
generate.addFileInput(b.path(appPath(b, app_root, "src/schema/migrations.lock.json")));
}
generate.addFileInput(dep.path("packages/core/src/sqlite_codegen.ts"));
generate.addFileInput(dep.path("packages/core/src/sqlite_runtime_policy.ts"));
addAppSqlDirInputs(b, generate, appPath(b, app_root, "src"));
return migrations;
}
fn addAppSqlDirInputs(b: *std.Build, run: *std.Build.Step.Run, src_path: []const u8) void {
var dir = b.build_root.handle.openDir(b.graph.io, src_path, .{ .iterate = true }) catch return;
defer dir.close(b.graph.io);
var walker = dir.walk(b.allocator) catch return;
defer walker.deinit();
while (walker.next(b.graph.io) catch null) |entry| {
if (entry.kind != .file or !std.mem.endsWith(u8, entry.basename, ".sql")) continue;
run.addFileInput(b.path(b.fmt("{s}/{s}", .{ src_path, entry.path })));
}
}
/// corewire (the contract-sidecar shim generator), compiled from the SDK
/// dependency's sources for the build host.
fn corewireExe(b: *std.Build, dep: *std.Build.Dependency) *std.Build.Step.Compile {
const mod = b.createModule(.{
.root_source_file = dep.path("tools/corewire/main.zig"),
.target = b.graph.host,
.optimize = .Debug,
});
return b.addExecutable(.{
.name = "corewire",
.root_module = mod,
.use_llvm = useLlvmWorkaround(b.graph.host),
});
}
/// The archive's symbol-safe stem (`<app>_core` with every non-identifier
/// byte folded to '_'), the -o name the external compile builds under.
fn externalCoreSymbolName(b: *std.Build, app_name: []const u8) []const u8 {
const stem = b.fmt("{s}_core", .{app_name});
const sanitized = b.dupe(stem);
for (sanitized) |*char| {
const ok = (char.* >= 'a' and char.* <= 'z') or (char.* >= 'A' and char.* <= 'Z') or
(char.* >= '0' and char.* <= '9') or char.* == '_';
if (!ok) char.* = '_';
}
return sanitized;
}
/// Declare every .ts file in an SDK-relative directory as a file input of
/// the transpile step (the SDK library modules an app may import).
fn addTsDirInputs(b: *std.Build, sdk_builder: *std.Build, transpile: *std.Build.Step.Run, dir_path: []const u8) void {
var dir = sdk_builder.build_root.handle.openDir(b.graph.io, dir_path, .{ .iterate = true }) catch return;
defer dir.close(b.graph.io);
var it = dir.iterate();
while (it.next(b.graph.io) catch null) |entry| {
if (entry.kind != .file) continue;
if (!std.mem.endsWith(u8, entry.name, ".ts")) continue;
transpile.addFileInput(sdk_builder.path(b.fmt("{s}/{s}", .{ dir_path, entry.name })));
}
}
/// Declare every .ts file under the app's src/ (recursively — a core may
/// split into subdirectories) as a file input of the transpile step.
fn addAppTsDirInputs(b: *std.Build, transpile: *std.Build.Step.Run, src_path: []const u8) void {
var dir = b.build_root.handle.openDir(b.graph.io, src_path, .{ .iterate = true }) catch return;
defer dir.close(b.graph.io);
var walker = dir.walk(b.allocator) catch return;
defer walker.deinit();
while (walker.next(b.graph.io) catch null) |entry| {
if (entry.kind != .file) continue;
if (!std.mem.endsWith(u8, entry.basename, ".ts")) continue;
transpile.addFileInput(b.path(b.fmt("{s}/{s}", .{ src_path, entry.path })));
}
}
/// Declare exactly the author TypeScript files copied into the core compiler's
/// scratch tree. `src/services/` is a separate compiler class and `.d.ts`
/// files are declarations for editor/provider use, not scriptc source inputs.
fn addAppCoreTsDirInputs(b: *std.Build, stage: *std.Build.Step.Run, src_path: []const u8) void {
var dir = b.build_root.handle.openDir(b.graph.io, src_path, .{ .iterate = true }) catch return;
defer dir.close(b.graph.io);
var walker = dir.walk(b.allocator) catch return;
defer walker.deinit();
while (walker.next(b.graph.io) catch null) |entry| {
if (entry.kind != .file or !std.mem.endsWith(u8, entry.basename, ".ts") or std.mem.endsWith(u8, entry.basename, ".d.ts")) continue;
const normalized = b.dupe(entry.path);
for (normalized) |*char| if (char.* == '\\') {
char.* = '/';
};
if (std.mem.startsWith(u8, normalized, "services/")) continue;
stage.addFileInput(b.path(b.fmt("{s}/{s}", .{ src_path, entry.path })));
}
}
/// Declare the SDK implementation modules copied by
/// stage_external_core.mjs. A directory LazyPath supplies ordering only for
/// this source tree; these file inputs make implementation-only SDK edits
/// invalidate the staged tree and compiled core archive even when the public
/// contract remains byte-identical. Public for the repository fixture graph,
/// which exercises the same compiler boundary as app builds.
pub fn addStagedCoreSdkInputs(b: *std.Build, sdk_builder: *std.Build, stage: *std.Build.Step.Run) void {
for ([_][]const u8{ "text.ts", "events.ts" }) |source| {
stage.addFileInput(sdk_builder.path(b.fmt("packages/core/sdk/{s}", .{source})));
}
}
fn appHasServiceFiles(b: *std.Build, app_root: []const u8) bool {
const services_path = appPath(b, app_root, "src/services");
var dir = b.build_root.handle.openDir(b.graph.io, services_path, .{ .iterate = true }) catch return false;
defer dir.close(b.graph.io);
var walker = dir.walk(b.allocator) catch return false;
defer walker.deinit();
while (walker.next(b.graph.io) catch null) |entry| {
if (entry.kind == .file and std.mem.endsWith(u8, entry.basename, ".ts") and
!std.mem.endsWith(u8, entry.basename, ".d.ts")) return true;
}
return false;
}
/// The `native_sdk_app_*` C ABI every embed static library exports.
pub const mobile_export_symbol_names = [_][]const u8{
"native_sdk_app_create",
"native_sdk_app_destroy",
"native_sdk_app_destroy_with_status",
"native_sdk_app_start",
"native_sdk_app_activate",
"native_sdk_app_deactivate",
"native_sdk_app_stop",
"native_sdk_app_resize",
"native_sdk_app_viewport",
"native_sdk_app_viewport_state",
"native_sdk_app_gpu_frame_state",
"native_sdk_app_text_input_state",
"native_sdk_app_set_text_measure",
"native_sdk_app_set_audio_service",
"native_sdk_app_set_credential_service",
"native_sdk_app_audio_event",
"native_sdk_app_set_image_service",
"native_sdk_app_set_automation_dir",
"native_sdk_app_set_data_root",
"native_sdk_app_touch",
"native_sdk_app_scroll",
"native_sdk_app_key",
"native_sdk_app_text",
"native_sdk_app_ime",
"native_sdk_app_command",
"native_sdk_app_frame",
"native_sdk_app_chrome_tab_count",
"native_sdk_app_chrome_tab_at",
"native_sdk_app_chrome_primary_action",
"native_sdk_app_chrome_selected_tab",
"native_sdk_app_chrome_navigation_depth",
"native_sdk_app_chrome_navigation_back_command",
"native_sdk_app_chrome_icon_pixels",
"native_sdk_app_set_form_factor",
"native_sdk_app_set_chrome_tabs_projected",
"native_sdk_app_set_asset_root",
"native_sdk_app_set_asset_entry",
"native_sdk_app_last_command_count",
"native_sdk_app_last_command_name",
"native_sdk_app_last_error_name",
"native_sdk_app_widget_semantics_count",
"native_sdk_app_widget_semantics_at",
"native_sdk_app_widget_semantics_by_id",
"native_sdk_app_widget_text_geometry",
"native_sdk_app_widget_action",
"native_sdk_app_render_pixel_size",
"native_sdk_app_render_pixels",
"native_sdk_app_render_pixels_damage",
};
pub const MobileSceneOption = enum {
/// The user app's UiApp on a gpu_surface view (window 1,
/// "mobile-surface"), pumped by the host's frame callback.
canvas,
/// The fixed WebView shell the ios/android/mobile-shell examples embed
/// today; the app module is not compiled in.
webview,
};
pub const MobileLibOptions = struct {
name: []const u8,
/// Mobile app entry (the `"app"` module the embed host drives); must
/// declare `Model`, `Msg`, `initModel`, and `mobileOptions` — see
/// `src/embed/ui_host.zig`. Ignored for `.scene = .webview`.
main: []const u8 = "src/main.zig",
scene: MobileSceneOption = .canvas,
/// Link and install the engine-owned Tier-2 record store. Standard
/// `addApp` builds infer this from app.zon; direct `addMobileLib`
/// callers state it here because that lower-level API has no manifest.
store_capability: bool = false,
/// Link and install the engine-owned Tier-3 relational database.
relational_capability: bool = false,
/// Generated append-only migration module. Direct low-level callers may
/// omit it and open an empty version-0 database.
relational_migrations: ?std.Build.LazyPath = null,
/// Compile the Tier-4 core credential effects into the mobile host.
credentials_capability: bool = false,
/// Grant those effects access to the registered OS credential service.
credentials_permission: bool = false,
/// Grant raw file effects access outside the OS-owned app data root.
filesystem_permission: bool = false,
/// Stable app identity used as the Keychain/Keystore service namespace.
credentials_service: []const u8 = "dev.native_sdk.app",
/// Frozen registered-image pixel budget. Standard app builds infer it
/// from app.zon; low-level embedders default to the SDK's 1 MiB tier.
max_image_pixel_bytes: usize = 1024 * 1024,
/// A TypeScript core's staged mobile wiring: set by `addAppArtifacts`
/// when the tree carries src/core.ts. The `app` module roots at the
/// staged mobile entry instead of `main`, and the compiled core (and
/// in-process service) archives merge into the embed static library —
/// the host tiers keep linking exactly one archive.
ts_core: ?MobileTsCore = null,
};
/// The TypeScript pieces a mobile embed library consumes (see
/// `MobileLibOptions.ts_core`).
pub const MobileTsCore = struct {
/// The staged mobile wiring (mobile.zig beside the generated mirror).
main_root: std.Build.LazyPath,
/// The compiled-core archive; merged into the embed library.
archive: std.Build.LazyPath,
/// The in-process service archive, when src/services exists.
service_archive: ?std.Build.LazyPath,
markup_c: std.Build.LazyPath,
/// The app.zon module the mobile wiring reads scene chrome, identity,
/// and theme from (`app_manifest_zon`).
manifest_mod: *std.Build.Module,
};
/// Mobile counterpart of `addApp`: produce the embed static library
/// (`native_sdk_app_*` C ABI) compiled with the user's UiApp. Call it from
/// a standalone build.zig (it registers the standard `target`/`optimize`
/// options itself).
pub fn addMobileLib(b: *std.Build, dep: *std.Build.Dependency, options: MobileLibOptions) void {
const target = nativeSdkTarget(b);
const optimize_request = b.option(std.builtin.OptimizeMode, "optimize", "Prioritize performance, safety, or binary size");
const optimize = exampleOptimizeMode(b, optimize_request, .Debug);
addMobileLibWithTarget(b, dep, target, optimize, options);
}
/// The mobile-lib wiring behind `addMobileLib`, for builds that already
/// resolved `target`/`optimize` (`addAppArtifacts` registers the `lib`
/// step through this for iOS/Android targets, so every standard app —
/// generated graph or ejected `addApp` — can produce the embed library
/// with nothing but `-Dtarget`).
fn addMobileLibWithTarget(b: *std.Build, dep: *std.Build.Dependency, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, options: MobileLibOptions) void {
const native_sdk_mod = nativeSdkModule(b, dep, target, optimize);
// Android hosts load the embed lib inside a shared object
// (System.loadLibrary / NativeActivity), so every object must be PIC —
// without it Zig emits local-exec TLS relocations (R_AARCH64_TLSLE_*)
// that the NDK linker rejects when producing the shim .so. Imported
// modules leave `pic` null and inherit this from the root module.
const pic: ?bool = if (target.result.abi.isAndroid()) true else null;
const exports_mod = b.createModule(.{
.root_source_file = dep.path(switch (options.scene) {
.canvas => "src/embed/app_exports.zig",
.webview => "src/embed/c_exports.zig",
}),
.target = target,
.optimize = optimize,
.pic = pic,
});
exports_mod.addImport("native_sdk", native_sdk_mod);
if (options.scene == .canvas) {
const mobile_options = b.addOptions();
mobile_options.addOption(bool, "store_capability", options.store_capability);
mobile_options.addOption(bool, "relational_capability", options.relational_capability);
mobile_options.addOption(bool, "credentials_capability", options.credentials_capability);
mobile_options.addOption(bool, "credentials_permission", options.credentials_permission);
mobile_options.addOption(bool, "filesystem_permission", options.filesystem_permission);
mobile_options.addOption([]const u8, "credentials_service", options.credentials_service);
mobile_options.addOption(usize, "max_image_pixel_bytes", options.max_image_pixel_bytes);
exports_mod.addImport("mobile_build_options", mobile_options.createModule());
const migration_path = options.relational_migrations orelse dep.path("src/app_runner/no_migrations.zig");
const migration_mod = b.createModule(.{ .root_source_file = migration_path, .target = target, .optimize = optimize });
migration_mod.addImport("native_sdk", native_sdk_mod);
exports_mod.addImport("relational_migrations", migration_mod);
// A TypeScript core's app module roots at the staged mobile wiring;
// a Zig core's at the app's own mobile entry. Either way the embed
// host sees the same AppDef contract (Model/Msg/initModel/
// mobileOptions).
const app_mod = if (options.ts_core) |ts| ts_app: {
const mod = b.createModule(.{
.root_source_file = ts.main_root,
.target = target,
.optimize = optimize,
});
mod.addImport("app_manifest_zon", ts.manifest_mod);
break :ts_app mod;
} else localModule(b, target, optimize, options.main);
app_mod.addImport("native_sdk", native_sdk_mod);
exports_mod.addImport("app", app_mod);
}
if (options.ts_core) |ts| {
// The compiled TypeScript archives merge into the embed static
// library (Zig's static-lib emission bundles archive inputs), so
// the iOS/Android host tiers keep linking the one archive they
// already stage. The toolchain's runtime needs libc; the host
// link supplies it (plus -lm/-ldl on Android, which the Android
// host link already passes).
exports_mod.link_libc = true;
exports_mod.addObjectFile(ts.archive);
exports_mod.addObjectFile(markupDataObject(b, target, optimize, ts.markup_c).getEmittedBin());
if (ts.service_archive) |service_archive| exports_mod.addObjectFile(service_archive);
}
if (options.store_capability or options.relational_capability) {
exports_mod.addIncludePath(dep.path("third_party/sqlite"));
exports_mod.addCSourceFile(.{
.file = dep.path("third_party/sqlite/sqlite3.c"),
.flags = sqliteCFlags(b, target),
});
exports_mod.link_libc = true;
}
exports_mod.export_symbol_names = &mobile_export_symbol_names;
const lib = b.addLibrary(.{
.linkage = .static,
.name = options.name,
.root_module = exports_mod,
// The embed C ABI (`native_sdk_app_viewport`) is exactly the
// f32-heavy SysV signature Zig 0.16.0's self-hosted x86_64 backend
// miscompiles (see useLlvmWorkaround in the framework build.zig):
// clang-compiled hosts calling a self-hosted Debug lib receive
// corrupted inset/keyboard floats on x86_64 (Android emulators,
// Intel simulators). Force LLVM there; Release already uses it.
.use_llvm = useLlvmWorkaround(target),
});
const lib_step = b.step("lib", "Build the mobile embed static library");
if (options.ts_core != null and target.result.abi.isAndroid()) {
// Zig's ELF static-library emission stores the compiled TypeScript
// archives as nested members instead of merging their objects (the
// Mach-O emission merges), and the NDK's -shared host link would
// skip those blobs with only a warning. Flatten to one plain
// object archive so the Android host tier keeps linking exactly
// the archive it already stages.
const merged = mergeMobileArchive(b, dep, lib, options.name);
const lib_name = b.fmt("lib{s}.a", .{options.name});
b.getInstallStep().dependOn(&b.addInstallFileWithDir(merged, .lib, lib_name).step);
lib_step.dependOn(&b.addInstallFileWithDir(merged, .lib, lib_name).step);
} else {
b.installArtifact(lib);
lib_step.dependOn(&b.addInstallArtifact(lib, .{}).step);
}
}
/// Flatten an Android embed library whose members include the compiled
/// TypeScript archives (see the call site above). Runs under node like the
/// rest of the TypeScript lane's drivers.
fn mergeMobileArchive(b: *std.Build, dep: *std.Build.Dependency, lib: *std.Build.Step.Compile, name: []const u8) std.Build.LazyPath {
const node = b.findProgram(&.{"node"}, &.{}) catch
@panic("\nmerging the mobile TypeScript archives needs node on PATH (the TypeScript core lane already requires it).\n");
const merge = b.addSystemCommand(&.{node});
merge.addFileArg(dep.path("packages/core/scripts/merge_static_archives.mjs"));
merge.addArgs(&.{ "--zig", b.graph.zig_exe, "--format", "gnu" });
merge.addArg("--out");
const merged = merge.addOutputFileArg(b.fmt("lib{s}.a", .{name}));
merge.addArg("--in");
merge.addFileArg(lib.getEmittedBin());
return merged;
}
/// The pieces `addApp` wires, for callers that extend the standard app
/// build (extra native sources, frameworks, post-build steps such as
/// entitlement signing). `install` is the artifact-install step behind the
/// default `zig build`; append dependencies to it and to `run` to order
/// work between the emitted binary and its consumers.
pub const AppArtifacts = struct {
exe: *std.Build.Step.Compile,
tests: *std.Build.Step.Compile,
install: *std.Build.Step.InstallArtifact,
run: *std.Build.Step.Run,
};
pub fn addApp(b: *std.Build, dep: *std.Build.Dependency, app_options: AppOptions) void {
_ = addAppArtifacts(b, dep, app_options);
}
pub fn addAppArtifacts(b: *std.Build, dep: *std.Build.Dependency, app_options: AppOptions) AppArtifacts {
const target = nativeSdkTarget(b);
const optimize_request = b.option(std.builtin.OptimizeMode, "optimize", "Prioritize performance, safety, or binary size");
const optimize = exampleOptimizeMode(b, optimize_request, .Debug);
const app_optimize = exampleOptimizeMode(b, optimize_request, .ReleaseFast);
const build_trace = b.option(bool, "build-trace", "Trace generated TypeScript ABI artifacts and cache reuse") orelse false;
const scriptc_optimization = scriptcProfileOptimization(b, dep, app_optimize);
// The core role is detected from the tree (never a flag or config):
// builds with a custom `main` entry declared their core explicitly and
// skip detection.
const core_tree: CoreTree = if (std.mem.eql(u8, app_options.main, "src/main.zig"))
detectCoreTree(b, app_options.app_root)
else
.zig;
if (core_tree == .both) {
@panic("\nthis app declares two cores: src/core.ts (TypeScript) and src/main.zig (Zig)." ++
"\nAn app has exactly one core - the tree is the truth. Keep src/core.ts and delete" ++
" src/main.zig,\nor keep src/main.zig and delete src/core.ts. (Other Zig files under" ++
" src/ are fine either way.)\n");
}
const manifest_name = appManifestName(b, app_options.app_root, app_options.manifest);
const app_config = appManifestBuildConfig(b, app_options.app_root, manifest_name);
// The core-compiler setting names the one lane there is; the flag
// overrides app.zon's `.core_compiler` and both exist so a stated
// choice stays stateable (and so the removed lane's spelling teaches
// instead of failing opaquely).
if (b.option([]const u8, "core-compiler", "How a TypeScript core compiles: external (the default and only lane)")) |flag| {
if (std.mem.eql(u8, flag, "transpiler")) @panic(core_compiler_teaching);
if (!std.mem.eql(u8, flag, "external")) @panic("\n-Dcore-compiler must be \"external\" (the default and only lane)\n");
if (core_tree != .ts) {
@panic("\n-Dcore-compiler applies to TypeScript cores (src/core.ts) only; this app has" ++
" a Zig core.\nDrop the flag or port the core to TypeScript.\n");
}
}
// The service-carrier selection: `-Dservice-carrier` overrides app.zon's
// `.service_carrier`; both default to auto (the child carrier).
const service_carrier_choice: ServiceCarrierOption = choice: {
if (b.option([]const u8, "service-carrier", "How src/services operations run: auto (default), in_process, child")) |flag| {
break :choice parseServiceCarrierOption(flag);
}
break :choice parseServiceCarrierOption(app_config.service_carrier);
};
const service_pool_workers: ?u8 = workers: {
const configured = b.option(u8, "service-pool-size", "In-process service carrier worker threads (1-16; default min(4, cores))") orelse
app_config.service_pool_size;
if (configured) |count| {
if (count < 1 or count > 16) @panic("\nservice pool size must be between 1 and 16\n");
}
break :workers configured;
};
const ts_stage: ?TsCoreStage = if (core_tree == .ts)
tsCoreStage(
b,
dep,
target,
app_options.app_root,
app_options.name,
app_config.persist_capability,
app_config.store_capability,
app_config.relational_capability,
app_config.credentials_capability,
app_config.credentials_permission,
app_config.persist_version,
app_config.service_packages,
service_carrier_choice,
service_pool_workers,
build_trace,
scriptc_optimization,
)
else
null;
const relational_migrations = if (ts_stage) |stage|
stage.migrations
else if (app_config.relational_capability)
sqliteMigrationsStage(b, dep, app_options.app_root)
else
dep.path("src/app_runner/no_migrations.zig");
// Mobile targets get the embed static library as a `lib` step: the
// artifact the toolkit-owned iOS host (and any hand-written shim)
// links, so `native dev|package --target ios` works against every
// standard app build — generated graph or ejected — with nothing but
// `-Dtarget`. Desktop targets keep the step absent. A TypeScript core
// roots the library's app module at the staged mobile wiring and
// merges the compiled archives into it.
if (target.result.os.tag == .ios or target.result.abi.isAndroid()) {
addMobileLibWithTarget(b, dep, target, optimize, .{
.name = app_options.name,
.main = appPath(b, app_options.app_root, app_options.main),
.store_capability = app_config.store_capability,
.relational_capability = app_config.relational_capability,
.relational_migrations = relational_migrations,
.credentials_capability = app_config.credentials_capability,
.credentials_permission = app_config.credentials_permission,
.filesystem_permission = app_config.filesystem_permission,
.credentials_service = app_config.app_id,
.max_image_pixel_bytes = app_config.max_image_pixel_bytes,
.ts_core = if (ts_stage) |stage| .{
.main_root = stage.mobile_root,
.archive = stage.archive,
.service_archive = stage.service_archive,
.markup_c = stage.markup_c,
.manifest_mod = appManifestModule(b, app_options.app_root, manifest_name),
} else null,
});
}
const platform_option = b.option(PlatformOption, "platform", "Desktop backend: auto, null, macos, linux, windows") orelse .auto;
const trace_option = b.option(TraceOption, "trace", "Trace output: off, events, runtime, all") orelse .events;
const debug_overlay = b.option(bool, "debug-overlay", "Enable debug overlay output") orelse false;
const automation_enabled = b.option(bool, "automation", "Enable Native SDK automation artifacts") orelse false;
const js_bridge_enabled = b.option(bool, "js-bridge", "Enable optional JavaScript bridge stubs") orelse false;
const web_engine_override = b.option(WebEngineOption, "web-engine", "Override app.zon web engine: system, chromium");
const web_layer_override = b.option(WebLayerOption, "web-layer", "Override app.zon webview_layer: auto, include, exclude");
const cef_dir_override = b.option([]const u8, "cef-dir", "Override CEF root directory for Chromium builds");
const cef_auto_install_override = b.option(bool, "cef-auto-install", "Override app.zon CEF auto-install setting");
const selected_platform: PlatformOption = switch (platform_option) {
.auto => if (target.result.os.tag == .macos) .macos else if (target.result.os.tag == .linux) .linux else if (target.result.os.tag == .windows) .windows else .null,
else => platform_option,
};
if (selected_platform == .macos and target.result.os.tag != .macos) {
@panic("-Dplatform=macos requires a macOS target");
}
if (selected_platform == .linux and target.result.os.tag != .linux) {
@panic("-Dplatform=linux requires a Linux target");
}
if (selected_platform == .windows and target.result.os.tag != .windows) {
@panic("-Dplatform=windows requires a Windows target");
}
const web_engine = web_engine_override orelse app_config.web_engine;
if (app_config.updates_enabled and selected_platform == .macos and web_engine == .chromium) {
@panic("\nnative updates currently require the system macOS host; use web_engine = \"system\" or remove the updates block\n");
}
const cef_dir = cef_dir_override orelse defaultCefDir(selected_platform, app_config.cef_dir);
const cef_auto_install = cef_auto_install_override orelse app_config.cef_auto_install;
if (web_engine == .chromium and selected_platform != .macos) {
@panic("-Dweb-engine=chromium currently requires -Dplatform=macos");
}
const web_layer = resolveWebLayer(app_config, web_engine, web_layer_override);
const options = b.addOptions();
options.addOption([]const u8, "platform", switch (selected_platform) {
.auto => unreachable,
.null => "null",
.macos => "macos",
.linux => "linux",
.windows => "windows",
});
options.addOption([]const u8, "trace", @tagName(trace_option));
options.addOption([]const u8, "web_engine", @tagName(web_engine));
options.addOption(bool, "debug_overlay", debug_overlay);
options.addOption(bool, "automation", automation_enabled);
options.addOption(bool, "js_bridge", js_bridge_enabled);
options.addOption(bool, "web_layer", web_layer);
const options_mod = options.createModule();
const app_mod = appModule(b, dep, target, app_optimize, app_options, manifest_name, options_mod, ts_stage, relational_migrations, app_config);
// TypeScript app code and platform hosts are expensive Zig/Clang semantic
// work but do not depend on primary markup bytes. Compile them once into
// an object, then make the executable a link-only artifact over that
// cached object plus the tiny generated markup-data object. A markup edit
// therefore avoids re-analyzing the SDK and app runner entirely.
const exe_root = if (ts_stage) |stage| root: {
const app_code = b.addObject(.{
.name = b.fmt("{s}-app-code", .{app_options.name}),
.root_module = app_mod,
.use_llvm = useLlvmWorkaround(target),
});
const link_mod = b.createModule(.{ .target = target, .optimize = app_optimize });
link_mod.addObject(app_code);
link_mod.addObject(markupDataObject(b, target, app_optimize, stage.markup_c));
// Object dependencies propagate framework/system-library NAMES, but
// Zig does not propagate the search paths or rpaths recorded on the
// module that produced an object. Restate those path-only facts on
// the final link module so the cached app-code split remains link-
// equivalent to compiling the app module directly.
addPlatformLinkSearchPaths(b, selected_platform, web_engine, cef_dir, link_mod);
break :root link_mod;
} else app_mod;
const exe = b.addExecutable(.{
.name = app_options.name,
.root_module = exe_root,
// The app executable crosses the platform C seam on every host
// call (the GTK host's `native_sdk_gtk_create_view` is a
// 22-parameter mix of pointers, sizes, ints, and doubles), and
// Zig 0.16.0's self-hosted x86_64 backend miscompiles exactly
// that calling-convention shape (see useLlvmWorkaround below):
// a Debug `native dev` on x86_64 Linux placed the stack-passed
// string arguments one slot off, so the host read a garbage
// `role` pointer and crashed in `native_sdk_strndup` while the
// register-passed `label` arrived intact. Every other artifact
// in this graph already forces LLVM on x86_64; the app exe —
// the one binary users actually run — must too.
.use_llvm = useLlvmWorkaround(target),
});
// Windows subsystem posture: release-shaped exes (`native build`,
// and therefore everything `native package --target windows` wraps)
// are GUI-subsystem, so launching the app never flashes a console
// window behind it. Debug exes keep the console subsystem — the dev
// loop's logs live there, and a double-clicked Debug binary opening
// its own log console is a feature. Redirected logging still works
// on GUI exes (handles inherit; only console AUTO-allocation is
// gated by the subsystem), so automation harnesses that pipe
// `app.exe > log 2>&1` keep their logs either way.
if (target.result.os.tag == .windows and app_optimize != .Debug) {
exe.subsystem = .windows;
}
linkPlatform(b, dep, target, app_mod, exe, selected_platform, web_engine, web_layer, cef_dir, cef_auto_install);
const install = b.addInstallArtifact(exe, .{});
b.getInstallStep().dependOn(&install.step);
const service_install: ?*std.Build.Step.InstallFile = if (ts_stage) |stage| if (stage.service_exe) |service_exe| service_install: {
const suffix = if (target.result.os.tag == .windows) ".exe" else "";
const value = b.addInstallFileWithDir(service_exe, .bin, b.fmt("{s}_services{s}", .{ app_options.name, suffix }));
b.getInstallStep().dependOn(&value.step);
break :service_install value;
} else null else null;
// A service-bearing app runs from the installed layout so the host is
// a real sibling exactly as it is in a package. Service-free apps keep
// the direct cached-artifact fast path.
const run = if (service_install != null) run: {
const suffix = if (target.result.os.tag == .windows) ".exe" else "";
const value = b.addSystemCommand(&.{b.getInstallPath(.bin, b.fmt("{s}{s}", .{ app_options.name, suffix }))});
value.step.dependOn(&install.step);
value.step.dependOn(&service_install.?.step);
break :run value;
} else b.addRunArtifact(exe);
addCefRuntimeRunFiles(b, target, run, exe, web_engine, cef_dir);
addWebView2RuntimeRunFiles(dep, target, run, web_engine, web_layer);
const run_step = b.step("run", "Run the app");
run_step.dependOn(&run.step);
// TypeScript tests need their own module even when optimize modes match:
// the production app module feeds the cached app-code object and must not
// absorb the separately linked markup data object.
const test_app_mod = if (ts_stage != null or app_optimize != optimize)
appModule(b, dep, target, optimize, app_options, manifest_name, options_mod, ts_stage, relational_migrations, app_config)
else
app_mod;
if (ts_stage) |stage| test_app_mod.addObject(markupDataObject(b, target, optimize, stage.markup_c));
const tests = b.addTest(.{ .root_module = test_app_mod, .use_llvm = useLlvmWorkaround(target) });
const test_step = b.step("test", "Run tests");
test_step.dependOn(&b.addRunArtifact(tests).step);
// `native test` must surface the app's compile-time teaching errors,
// not just its test failures. Test builds never analyze `main` (the
// test runner replaces the entry point), so rules that fire inside it
// — UiApp.create's Model-defaults rule above all — used to ambush at
// `native build`, the LAST step in the loop. Compiling this object
// forces full semantic analysis of the app module, entry point
// included; nothing links or runs.
const analysis_root = b.addWriteFiles().add("app_analysis.zig",
\\//! Generated by the app build: force semantic analysis of the
\\//! app's entry point at test time. Exactly `main`, transitively —
\\//! the same surface `native build` analyzes — so test-time can
\\//! never be stricter than the build it fronts.
\\comptime {
\\ const app = @import("app");
\\ if (@hasDecl(app, "main")) _ = &app.main;
\\}
\\
);
const analysis_mod = b.createModule(.{
.root_source_file = analysis_root,
.target = target,
.optimize = optimize,
});
analysis_mod.addImport("app", test_app_mod);
const analysis_obj = b.addObject(.{
.name = b.fmt("{s}-analysis", .{app_options.name}),
.root_module = analysis_mod,
.use_llvm = useLlvmWorkaround(target),
});
test_step.dependOn(&analysis_obj.step);
// `zig build model-contract`: reflect the app's Model/Msg into
// zig-out/model-contract.zon so `native check` can verify markup
// bindings against the app's real surface without compiling the app.
// The artifact carries a hash over the app's Zig sources; the checker
// degrades to structural checking when it goes stale. Apps without a
// pub Model/Msg pair make this a silent no-op. The test step refreshes
// the artifact too, so CI-checked apps always hold a fresh one.
const contract_root = b.addWriteFiles().add("model_contract_emit.zig",
\\//! Generated by the app build: emits the model contract artifact
\\//! (see the toolkit's ui_markup_contract.zig).
\\const std = @import("std");
\\const native_sdk = @import("native_sdk");
\\const app = @import("app");
\\
\\pub fn main(init: std.process.Init) !void {
\\ try native_sdk.canvas.emitModelContractMain(app, init);
\\}
\\
);
// The emit root must share the app module's native_sdk instance so
// the Msg payload types it classifies are the same types the app
// declares its variants with.
const contract_mod = b.createModule(.{
.root_source_file = contract_root,
.target = target,
.optimize = optimize,
});
contract_mod.addImport("app", test_app_mod);
if (test_app_mod.import_table.get("native_sdk")) |sdk_mod| {
contract_mod.addImport("native_sdk", sdk_mod);
}
const contract_exe = b.addExecutable(.{
.name = b.fmt("{s}-model-contract", .{app_options.name}),
.root_module = contract_mod,
.use_llvm = useLlvmWorkaround(target),
});
const contract_run = b.addRunArtifact(contract_exe);
contract_run.setCwd(b.path(app_options.app_root));
contract_run.addArgs(&.{ "--src", "src", "--out", "zig-out/model-contract.zon" });
contract_run.has_side_effects = true;
const contract_step = b.step("model-contract", "Emit zig-out/model-contract.zon for `native check`");
contract_step.dependOn(&contract_run.step);
test_step.dependOn(&contract_run.step);
// `zig build package`: bundle the built binary through the `native`
// CLI (built from the native_sdk dependency), so a scaffolded app can
// package itself without locating the CLI by hand.
const host_os = b.graph.host.result.os.tag;
const package_target: ?[]const u8 = switch (host_os) {
.macos => "macos",
.linux => "linux",
.windows => "windows",
else => null,
};
if (package_target) |package_target_name| {
const package_run = b.addRunArtifact(dep.artifact("native"));
// The CLI resolves SDK-owned package inputs (the vendored
// WebView2 loader) from the framework root; the cached artifact's
// own location cannot derive it, so hand it over explicitly.
package_run.setEnvironmentVariable("NATIVE_SDK_PATH", dep.builder.pathFromRoot("."));
package_run.addArgs(&.{ "package", "--target", package_target_name, "--manifest", manifest_name, "--output" });
package_run.addArg(if (host_os == .macos)
b.fmt("zig-out/package/{s}.app", .{app_options.name})
else
b.fmt("zig-out/package/{s}", .{package_target_name}));
package_run.addArg("--binary");
package_run.addFileArg(exe.getEmittedBin());
if (ts_stage) |stage| if (stage.service_exe) |service_exe| {
package_run.addArg("--service-binary");
package_run.addFileArg(service_exe);
};
// The archive and report names carry an optimize label; this
// build graph knows the packaged binary's REAL mode, so forward
// it instead of letting the CLI assume one.
package_run.addArgs(&.{ "--optimize", @tagName(app_optimize) });
// Forward the RESOLVED web-layer decision, never the raw inputs:
// this graph already decided web vs native-only for the exe it is
// packaging (app.zon declarations plus -Dweb-layer/-Dweb-engine),
// and the CLI re-inferring from app.zon alone would miss a
// flag-driven override — a WebView2-referencing exe packaged
// without its loader. Handing over the decision itself makes
// exe/package agreement structural.
package_run.addArgs(&.{ "--web-layer", if (web_layer) "include" else "exclude" });
// Same reasoning for the web engine: the CLI defaults to system, so
// a Chromium exe packaged without these flags would ship no CEF
// runtime (the generated build graph already forwards them).
package_run.addArgs(&.{ "--web-engine", @tagName(web_engine), "--cef-dir", cef_dir });
if (cef_auto_install) package_run.addArg("--cef-auto-install");
if (app_config.updates_enabled and host_os == .macos) package_run.addArg("--update-archive");
package_run.has_side_effects = true;
const package_step = b.step("package", "Create a distributable package via the native CLI");
package_step.dependOn(&package_run.step);
}
return .{ .exe = exe, .tests = tests, .install = install, .run = run };
}
/// Zig 0.16.0's self-hosted x86_64 backend miscompiles the SysV C calling
/// convention for f32-heavy signatures with interleaved pointer arguments
/// (`native_sdk_app_viewport`: 11 f32s + 2 pointers): both the caller and
/// the callee place/read the wrong registers and stack slots, so safe-area
/// insets arrive as garbage on x86_64 Debug builds while every LLVM-backed
/// build is correct. Minimal repro (fails under `zig test`, passes with
/// `-fllvm` on x86_64-linux):
///
/// fn take(a: ?*anyopaque, w: f32, h: f32, s: f32, p: ?*anyopaque,
/// t: f32, r: f32, bo: f32, l: f32, kt: f32, kr: f32, kb: f32,
/// kl: f32) callconv(.c) void { ... }
///
/// The same backend also mis-places STACK-passed integer/pointer arguments
/// in long mixed signatures with interleaved doubles: calling the GTK
/// host's `native_sdk_gtk_create_view` (22 params: 6 register ints, 4
/// doubles, 12 stack ints/pointers/sizes) from self-hosted Debug code
/// hands the clang-compiled callee arguments shifted by one stack slot
/// from `visible` onward — the callee's `role` pointer reads as the
/// caller's `enabled` value (a 4-byte 1 under 0xAA undefined fill,
/// faulting at 0xaaaaaaaa00000001) and `role_len` reads as the role
/// pointer. Register-passed arguments (`label`) arrive intact, which is
/// why the crash appears only at the first stack-passed string. Verified
/// against zig 0.16.0 on x86_64-linux with a standalone caller/callee
/// pair: self-hosted Debug corrupts, `-fllvm` is correct.
///
/// Force the LLVM backend on x86_64 until the upstream backend is fixed;
/// Release modes already default to LLVM, so this only changes Debug.
pub fn useLlvmWorkaround(target: std.Build.ResolvedTarget) ?bool {
return if (target.result.cpu.arch == .x86_64) true else null;
}
fn exampleOptimizeMode(b: *std.Build, requested: ?std.builtin.OptimizeMode, default_mode: std.builtin.OptimizeMode) std.builtin.OptimizeMode {
if (requested) |mode| return mode;
return switch (b.release_mode) {
.off => default_mode,
.any, .fast => .ReleaseFast,
.safe => .ReleaseSafe,
.small => .ReleaseSmall,
};
}
fn appModule(b: *std.Build, dep: *std.Build.Dependency, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, app_options: AppOptions, manifest_name: []const u8, options_mod: *std.Build.Module, ts_stage: ?TsCoreStage, relational_migrations: std.Build.LazyPath, app_config: AppManifestBuildConfig) *std.Build.Module {
const native_sdk_mod = nativeSdkModuleWithTerminal(b, dep, target, optimize, app_options.terminal_sessions);
const runner_mod = b.createModule(.{
.root_source_file = dep.path("src/app_runner/root.zig"),
.target = target,
.optimize = optimize,
});
const manifest_mod = appManifestModule(b, app_options.app_root, manifest_name);
runner_mod.addImport("native_sdk", native_sdk_mod);
runner_mod.addImport("build_options", options_mod);
runner_mod.addImport("app_manifest_zon", manifest_mod);
const migration_mod = b.createModule(.{ .root_source_file = relational_migrations, .target = target, .optimize = optimize });
migration_mod.addImport("native_sdk", native_sdk_mod);
runner_mod.addImport("relational_migrations", migration_mod);
const app_mod = if (ts_stage) |stage|
// TypeScript core: the app module roots at the staged generated
// wiring (ts_core_main.zig beside the mirror core.zig, its shim
// runtime; primary markup bytes link as a separate data object).
b.createModule(.{
.root_source_file = stage.main_root,
.target = target,
.optimize = optimize,
})
else
localModule(b, target, optimize, appPath(b, app_options.app_root, app_options.main));
app_mod.addImport("native_sdk", native_sdk_mod);
app_mod.addImport("runner", runner_mod);
if (ts_stage != null) {
// The wiring derives scene/identity/security from app.zon itself.
app_mod.addImport("app_manifest_zon", manifest_mod);
}
if (ts_stage) |stage| {
// The compiled-core archive links behind the staged mirror; the
// toolchain's runtime needs libc.
app_mod.link_libc = true;
app_mod.addObjectFile(stage.archive);
// The in-process service archive links beside it (distinct symbol
// prefix; its runtime internals are localized).
if (stage.service_archive) |service_archive| app_mod.addObjectFile(service_archive);
}
if (app_config.sqlite_capability) {
// `store` and the relational `sqlite` tier share this exact object.
// The source is absent from every artifact declaring neither
// capability, which keeps capability inference a real binary-size
// boundary rather than a runtime flag.
app_mod.addIncludePath(dep.path("third_party/sqlite"));
app_mod.addCSourceFile(.{
.file = dep.path("third_party/sqlite/sqlite3.c"),
.flags = &sqlite_c_defines,
});
app_mod.link_libc = true;
}
addMacosInfoPlist(b, app_mod, target, app_config);
return app_mod;
}
/// Compile primary markup bytes independently from the Zig app graph. The
/// final artifact sees this as an object-file input, so a markup edit dirties
/// only this tiny C compile and the final link.
fn markupDataObject(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, source: std.Build.LazyPath) *std.Build.Step.Compile {
const mod = b.createModule(.{ .target = target, .optimize = optimize });
mod.addCSourceFile(.{ .file = source, .flags = &.{} });
return b.addObject(.{ .name = "native-app-markup-data", .root_module = mod });
}
/// Bare Mach-O executables launched by the dev loop do not have an app
/// bundle's external Info.plist. Embed launch policy plus capture usage
/// strings in the conventional section so LaunchServices starts accessory
/// apps without a transient Dock tile and capture consent can be presented.
/// Packaged apps retain their richer external plist generated by package.zig.
fn addMacosInfoPlist(b: *std.Build, app_mod: *std.Build.Module, target: std.Build.ResolvedTarget, config: AppManifestBuildConfig) void {
if (target.result.os.tag != .macos) return;
if (config.dock_visible and !config.microphone_permission and !config.system_audio_permission) return;
const launch_policy = if (!config.dock_visible)
" <key>LSUIElement</key>\\n <true/>\\n"
else
"";
const microphone = if (config.microphone_permission)
" <key>NSMicrophoneUsageDescription</key>\\n <string>This app captures microphone audio when you start recording.</string>\\n"
else
"";
const system_audio = if (config.system_audio_permission)
" <key>NSAudioCaptureUsageDescription</key>\\n <string>This app captures system audio when you start recording.</string>\\n" ++
" <key>NSScreenCaptureUsageDescription</key>\\n <string>This app captures system audio when you start recording.</string>\\n"
else
"";
const source = b.fmt(
\\#define NATIVE_SDK_INFO_PLIST "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n" "<plist version=\"1.0\">\n<dict>\n{s}{s}{s}</dict>\n</plist>\n"
\\__attribute__((used, section("__TEXT,__info_plist")))
\\static const unsigned char native_sdk_info_plist[sizeof(NATIVE_SDK_INFO_PLIST) - 1] = NATIVE_SDK_INFO_PLIST;
\\
, .{ launch_policy, microphone, system_audio });
const generated = b.addWriteFiles().add("native_sdk_macos_info_plist.c", source);
app_mod.addCSourceFile(.{ .file = generated, .flags = &.{} });
}
fn nativeSdkTarget(b: *std.Build) std.Build.ResolvedTarget {
const target = b.standardTargetOptions(.{});
if (target.result.os.tag != .macos) return target;
if (b.sysroot == null) {
b.sysroot = macosSdkPath(b) orelse b.sysroot;
}
var query = target.query;
query.os_tag = .macos;
query.os_version_min = .{ .semver = .{ .major = 11, .minor = 0, .patch = 0 } };
return b.resolveTargetQuery(query);
}
const sqlite_c_defines = [_][]const u8{
"-DSQLITE_THREADSAFE=2",
"-DSQLITE_OMIT_LOAD_EXTENSION",
"-DSQLITE_DQS=0",
"-DSQLITE_ENABLE_FTS5",
"-DSQLITE_ENABLE_JSON1",
"-DSQLITE_ENABLE_UPDATE_HOOK",
"-DSQLITE_DEFAULT_WAL_SYNCHRONOUS=1",
"-DSQLITE_DEFAULT_MEMSTATUS=0",
};
/// Zig deliberately supplies no libc headers for Apple/Android cross targets.
/// Store-capable mobile libraries therefore compile the vendored amalgamation
/// against the same platform SDK the host tier will use to link the archive.
/// Desktop targets keep Zig's ordinary libc discovery. Pub because the SDK's
/// own build graph compiles the same amalgamation into modules that also
/// configure under a mobile -Dtarget (the mobile e2e battery).
pub fn sqliteCFlags(b: *std.Build, target: std.Build.ResolvedTarget) []const []const u8 {
if (target.result.os.tag == .ios) {
const sysroot = b.sysroot orelse iosSdkPath(b, target.result.abi == .simulator) orelse
std.debug.panic("a store-capable iOS library needs the Apple SDK; install Xcode or pass --sysroot <iphone SDK path>", .{});
return b.dupeStrings(&.{
sqlite_c_defines[0],
sqlite_c_defines[1],
sqlite_c_defines[2],
sqlite_c_defines[3],
sqlite_c_defines[4],
sqlite_c_defines[5],
sqlite_c_defines[6],
sqlite_c_defines[7],
"-isysroot",
sysroot,
b.fmt("-isystem{s}/usr/include", .{sysroot}),
});
}
if (target.result.abi.isAndroid()) {
const sysroot = b.sysroot orelse androidNdkSysrootPath(b) orelse
std.debug.panic("a store-capable Android library needs the NDK; set ANDROID_NDK_ROOT or ANDROID_HOME, or pass --sysroot <NDK sysroot>", .{});
const triple = target.result.linuxTriple(b.allocator) catch @panic("out of memory");
return b.dupeStrings(&.{
sqlite_c_defines[0],
sqlite_c_defines[1],
sqlite_c_defines[2],
sqlite_c_defines[3],
sqlite_c_defines[4],
sqlite_c_defines[5],
sqlite_c_defines[6],
sqlite_c_defines[7],
b.fmt("-isystem{s}/usr/include/{s}", .{ sysroot, triple }),
b.fmt("-isystem{s}/usr/include", .{sysroot}),
});
}
return &sqlite_c_defines;
}
fn iosSdkPath(b: *std.Build, simulator: bool) ?[]const u8 {
const result = std.process.run(b.allocator, b.graph.io, .{
.argv = &.{ "xcrun", "--sdk", if (simulator) "iphonesimulator" else "iphoneos", "--show-sdk-path" },
.stdout_limit = .limited(4096),
.stderr_limit = .limited(4096),
}) catch return null;
defer b.allocator.free(result.stderr);
if (result.term != .exited or result.term.exited != 0) {
b.allocator.free(result.stdout);
return null;
}
return std.mem.trimEnd(u8, result.stdout, "\r\n");
}
/// Thread the Android NDK location into a ScriptC driver invocation the
/// way `--zig-exe` threads this build's zig: resolved here, at the one
/// boundary that already knows how to discover it, so the compiler's own
/// discovery never depends on the ambient environment. A missing NDK stays
/// quiet — the driver and compiler own the teaching when an Android
/// compile actually needs one.
pub fn addScriptcAndroidNdk(b: *std.Build, run: *std.Build.Step.Run, target: std.Build.ResolvedTarget) void {
if (!target.result.abi.isAndroid()) return;
const ndk_root = androidNdkRootPath(b) orelse return;
run.addArgs(&.{ "--android-ndk", ndk_root });
}
/// The NDK's root directory (the directory holding toolchains/llvm):
/// ANDROID_NDK_ROOT/ANDROID_NDK_HOME wins, else the newest ndk/<version>
/// under the platform SDK location — the same order the compiler's own
/// discovery uses, so threading it changes nothing but the authority.
fn androidNdkRootPath(b: *std.Build) ?[]const u8 {
for ([_][]const u8{ "ANDROID_NDK_ROOT", "ANDROID_NDK_HOME", "ANDROID_NDK_LATEST_HOME" }) |name| {
if (b.graph.environ_map.get(name)) |root| {
if (root.len > 0 and buildDirExists(b, root)) return root;
}
}
const sdk_root = androidSdkRoot(b) orelse return null;
return latestVersionSubdir(b, sdk_root, "ndk") orelse blk: {
const legacy = b.pathJoin(&.{ sdk_root, "ndk-bundle" });
break :blk if (buildDirExists(b, legacy)) legacy else null;
};
}
fn androidNdkSysrootPath(b: *std.Build) ?[]const u8 {
for ([_][]const u8{ "ANDROID_NDK_ROOT", "ANDROID_NDK_HOME", "ANDROID_NDK_LATEST_HOME" }) |name| {
if (b.graph.environ_map.get(name)) |root| {
if (root.len > 0) {
if (ndkSysrootUnder(b, root)) |sysroot| return sysroot;
}
}
}
const sdk_root = androidSdkRoot(b) orelse return null;
const ndk_root = latestVersionSubdir(b, sdk_root, "ndk") orelse blk: {
const legacy = b.pathJoin(&.{ sdk_root, "ndk-bundle" });
break :blk if (buildDirExists(b, legacy)) legacy else return null;
};
return ndkSysrootUnder(b, ndk_root);
}
fn androidSdkRoot(b: *std.Build) ?[]const u8 {
for ([_][]const u8{ "ANDROID_HOME", "ANDROID_SDK_ROOT" }) |name| {
if (b.graph.environ_map.get(name)) |root| {
if (root.len > 0 and buildDirExists(b, root)) return root;
}
}
return switch (builtin.os.tag) {
.macos => if (b.graph.environ_map.get("HOME")) |home|
b.pathJoin(&.{ home, "Library", "Android", "sdk" })
else
null,
.windows => if (b.graph.environ_map.get("LOCALAPPDATA")) |local_app_data|
b.pathJoin(&.{ local_app_data, "Android", "Sdk" })
else
null,
else => if (b.graph.environ_map.get("HOME")) |home|
b.pathJoin(&.{ home, "Android", "Sdk" })
else
null,
};
}
fn ndkSysrootUnder(b: *std.Build, ndk_root: []const u8) ?[]const u8 {
const prebuilt_path = b.pathJoin(&.{ ndk_root, "toolchains", "llvm", "prebuilt" });
var cwd = std.Io.Dir.cwd();
var dir = cwd.openDir(b.graph.io, prebuilt_path, .{ .iterate = true }) catch return null;
defer dir.close(b.graph.io);
var iterator = dir.iterate();
while (iterator.next(b.graph.io) catch return null) |entry| {
if (entry.kind != .directory) continue;
const sysroot = b.pathJoin(&.{ prebuilt_path, entry.name, "sysroot" });
if (buildDirExists(b, b.pathJoin(&.{ sysroot, "usr", "include" }))) return sysroot;
}
return null;
}
fn latestVersionSubdir(b: *std.Build, root: []const u8, parent: []const u8) ?[]const u8 {
const parent_path = b.pathJoin(&.{ root, parent });
var cwd = std.Io.Dir.cwd();
var dir = cwd.openDir(b.graph.io, parent_path, .{ .iterate = true }) catch return null;
defer dir.close(b.graph.io);
var best: ?[]const u8 = null;
defer if (best) |name| b.allocator.free(name);
var iterator = dir.iterate();
while (iterator.next(b.graph.io) catch return null) |entry| {
if (entry.kind != .directory) continue;
if (best) |current| {
if (!versionLess(current, entry.name)) continue;
b.allocator.free(current);
best = null;
}
best = b.allocator.dupe(u8, entry.name) catch @panic("out of memory");
}
const name = best orelse return null;
return b.pathJoin(&.{ parent_path, name });
}
fn versionLess(a: []const u8, b: []const u8) bool {
var a_parts = std.mem.splitScalar(u8, a, '.');
var b_parts = std.mem.splitScalar(u8, b, '.');
while (true) {
const a_part = a_parts.next();
const b_part = b_parts.next();
if (a_part == null and b_part == null) return false;
if (a_part == null) return true;
if (b_part == null) return false;
const a_num = std.fmt.parseUnsigned(u64, a_part.?, 10) catch null;
const b_num = std.fmt.parseUnsigned(u64, b_part.?, 10) catch null;
if (a_num != null and b_num != null) {
if (a_num.? != b_num.?) return a_num.? < b_num.?;
} else switch (std.mem.order(u8, a_part.?, b_part.?)) {
.lt => return true,
.gt => return false,
.eq => {},
}
}
}
fn buildDirExists(b: *std.Build, path: []const u8) bool {
var cwd = std.Io.Dir.cwd();
var dir = cwd.openDir(b.graph.io, path, .{}) catch return false;
dir.close(b.graph.io);
return true;
}
fn macosSdkPath(b: *std.Build) ?[]const u8 {
if (b.graph.environ_map.get("SDKROOT")) |sdkroot| {
if (sdkroot.len > 0) return sdkroot;
}
const result = std.process.run(b.allocator, b.graph.io, .{
.argv = &.{ "xcrun", "--sdk", "macosx", "--show-sdk-path" },
.stdout_limit = .limited(4096),
.stderr_limit = .limited(4096),
}) catch return null;
defer b.allocator.free(result.stderr);
if (result.term != .exited or result.term.exited != 0) {
b.allocator.free(result.stdout);
return null;
}
return std.mem.trimEnd(u8, result.stdout, "\r\n");
}
fn localModule(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, path: []const u8) *std.Build.Module {
return b.createModule(.{
.root_source_file = b.path(path),
.target = target,
.optimize = optimize,
});
}
fn nativeSdkModule(b: *std.Build, dep: *std.Build.Dependency, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode) *std.Build.Module {
return nativeSdkModuleWithTerminal(b, dep, target, optimize, false);
}
fn nativeSdkModuleWithTerminal(b: *std.Build, dep: *std.Build.Dependency, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, terminal_sessions: bool) *std.Build.Module {
const geometry_mod = externalModule(b, dep, target, optimize, "src/primitives/geometry/root.zig");
const assets_mod = externalModule(b, dep, target, optimize, "src/primitives/assets/root.zig");
const app_dirs_mod = externalModule(b, dep, target, optimize, "src/primitives/app_dirs/root.zig");
const trace_mod = externalModule(b, dep, target, optimize, "src/primitives/trace/root.zig");
const app_manifest_mod = externalModule(b, dep, target, optimize, "src/primitives/app_manifest/root.zig");
const diagnostics_mod = externalModule(b, dep, target, optimize, "src/primitives/diagnostics/root.zig");
const platform_info_mod = externalModule(b, dep, target, optimize, "src/primitives/platform_info/root.zig");
const json_mod = externalModule(b, dep, target, optimize, "src/primitives/json/root.zig");
const canvas_mod = externalModule(b, dep, target, optimize, "src/primitives/canvas/root.zig");
canvas_mod.addImport("geometry", geometry_mod);
canvas_mod.addImport("json", json_mod);
const debug_mod = externalModule(b, dep, target, optimize, "src/debug/root.zig");
debug_mod.addImport("app_dirs", app_dirs_mod);
debug_mod.addImport("trace", trace_mod);
const native_sdk_mod = externalModule(b, dep, target, optimize, "src/root.zig");
// The header makes the internal wrapper parsable even when lazy exports
// are reflected; the amalgamation itself is attached below only for an
// opted-in store/sqlite artifact.
native_sdk_mod.addIncludePath(dep.path("third_party/sqlite"));
native_sdk_mod.addImport("geometry", geometry_mod);
native_sdk_mod.addImport("assets", assets_mod);
native_sdk_mod.addImport("app_dirs", app_dirs_mod);
native_sdk_mod.addImport("trace", trace_mod);
native_sdk_mod.addImport("app_manifest", app_manifest_mod);
native_sdk_mod.addImport("diagnostics", diagnostics_mod);
native_sdk_mod.addImport("platform_info", platform_info_mod);
native_sdk_mod.addImport("json", json_mod);
native_sdk_mod.addImport("canvas", canvas_mod);
// The terminal-session emulator seam (see
// `AppOptions.terminal_sessions`): an opted-in app's OWN lazy
// ghostty pin resolves here with the app module's target/optimize;
// everything else gets the stub and never traverses ghostty's
// dependency graph. An opted-in build whose pin is still unfetched
// takes the stub for THIS configure pass — the build runner fetches
// the lazy dependency and re-runs, and the second pass wires the
// real module.
native_sdk_mod.addImport("terminal_vt", terminal_vt: {
if (terminal_sessions) {
if (b.lazyDependency("ghostty", .{
.target = target,
.optimize = optimize,
// Keep the vt module pure Zig: the SIMD paths pull
// vendored C++ dependencies the session store never uses.
.simd = false,
// Only the vt MODULE is consumed: ghostty's macOS app and
// xcframework artifacts default ON for Darwin hosts and
// their configure step resolves the iOS libc, which
// aborts on a machine with only the command-line tools.
.@"emit-xcframework" = false,
.@"emit-macos-app" = false,
})) |ghostty| {
const wrapper = externalModule(b, dep, target, optimize, "src/runtime/terminal_vt_ghostty.zig");
wrapper.addImport("ghostty-vt", ghostty.module("ghostty-vt"));
break :terminal_vt wrapper;
}
}
break :terminal_vt externalModule(b, dep, target, optimize, "src/runtime/terminal_vt_stub.zig");
});
return native_sdk_mod;
}
fn externalModule(b: *std.Build, dep: *std.Build.Dependency, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, path: []const u8) *std.Build.Module {
return b.createModule(.{
.root_source_file = dep.path(path),
.target = target,
.optimize = optimize,
});
}
// -fno-sanitize=builtin on every ObjC compile: Zig 0.16.0's Debug UBSan
// aborts any process whose first dispatch_once runs — the macOS SDK's
// inline `_dispatch_once` ends in `__builtin_assume(*predicate == ~0l)`
// (dispatch/once.h), Zig's bundled clang instruments that builtin, and the
// check fires spuriously at startup; zig's ubsan_rt then cannot even decode
// the report ("invalid enum value" / "passing zero to clz()" panics).
// Reproduced with a 10-line `zig cc` program against both the 14.5 and
// 26.0 SDKs. Release builds never hit it (no UBSan), which is why only
// Debug-built examples (standardOptimizeOption default) crashed.
fn linkPlatform(b: *std.Build, dep: *std.Build.Dependency, target: std.Build.ResolvedTarget, app_mod: *std.Build.Module, exe: *std.Build.Step.Compile, platform: PlatformOption, web_engine: WebEngineOption, web_layer: bool, cef_dir: []const u8, cef_auto_install: bool) void {
addPlatformLinkSearchPaths(b, platform, web_engine, cef_dir, app_mod);
if (platform == .macos) {
switch (web_engine) {
.system => {
const sdk_include = if (b.sysroot) |sysroot| b.fmt("-I{s}/usr/include", .{sysroot}) else "";
const flags: []const []const u8 = if (b.sysroot) |sysroot| &.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC", "-mmacosx-version-min=11.0", "-isysroot", sysroot, sdk_include } else &.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC", "-mmacosx-version-min=11.0" };
app_mod.addCSourceFile(.{ .file = dep.path("src/platform/macos/appkit_host.m"), .flags = flags });
app_mod.linkFramework("WebKit", .{});
},
.chromium => {
const cef_check = addCefCheck(b, target, cef_dir);
if (cef_auto_install) {
const cef_auto = b.addSystemCommand(&.{ "native", "cef", "install", "--dir", cef_dir });
cef_check.step.dependOn(&cef_auto.step);
}
exe.step.dependOn(&cef_check.step);
const include_arg = b.fmt("-I{s}", .{cef_dir});
const define_arg = b.fmt("-DNATIVE_SDK_CEF_DIR=\"{s}\"", .{cef_dir});
// The SDK's usr/include must stay a system include dir (searched after zig's
// bundled libc++/libc headers). A plain -I shadows libc++'s <string.h>/<math.h>
// wrappers in ObjC++ and surfaces SDK nullability gaps as a diagnostic flood.
const sdk_include = if (b.sysroot) |sysroot| b.fmt("-isystem{s}/usr/include", .{sysroot}) else "";
const flags: []const []const u8 = if (b.sysroot) |sysroot| &.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC++", "-std=c++17", "-stdlib=libc++", "-mmacosx-version-min=11.0", "-isysroot", sysroot, sdk_include, include_arg, define_arg } else &.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC++", "-std=c++17", "-stdlib=libc++", "-mmacosx-version-min=11.0", include_arg, define_arg };
app_mod.addCSourceFile(.{ .file = dep.path("src/platform/macos/cef_host.mm"), .flags = flags });
app_mod.addObjectFile(b.path(b.fmt("{s}/libcef_dll_wrapper/libcef_dll_wrapper.a", .{cef_dir})));
app_mod.linkFramework("Chromium Embedded Framework", .{});
},
}
app_mod.linkFramework("AppKit", .{});
// The audio playback service (the AppKit host's single AVPlayer).
app_mod.linkFramework("AVFoundation", .{});
app_mod.linkFramework("CoreMedia", .{});
app_mod.linkFramework("ScreenCaptureKit", .{ .weak = true });
// CVPixelBuffer for the video frame path (the video player's
// AVPlayerItemVideoOutput frames). CoreMedia's CMTime use stays
// header-only, but the pixel-buffer calls are real symbols.
app_mod.linkFramework("CoreVideo", .{});
// Spectrum analysis of the app's own playback: the MediaToolbox
// audio tap hands the player's PCM to the host, and Accelerate
// (vDSP) turns it into band magnitudes.
app_mod.linkFramework("MediaToolbox", .{});
app_mod.linkFramework("Accelerate", .{});
app_mod.linkFramework("Foundation", .{});
app_mod.linkFramework("CoreText", .{});
app_mod.linkFramework("UniformTypeIdentifiers", .{});
app_mod.linkFramework("Security", .{});
app_mod.linkFramework("Metal", .{});
app_mod.linkFramework("QuartzCore", .{});
app_mod.linkSystemLibrary("c", .{});
if (web_engine == .chromium) app_mod.linkSystemLibrary("c++", .{});
} else if (platform == .linux) {
switch (web_engine) {
.system => if (web_layer) {
app_mod.addCSourceFile(.{ .file = dep.path("src/platform/linux/gtk_host.c"), .flags = &.{} });
app_mod.linkSystemLibrary("gtk4", .{});
app_mod.linkSystemLibrary("webkitgtk-6.0", .{});
app_mod.linkSystemLibrary("dl", .{});
} else {
// Native-only app (nothing in app.zon declares web use):
// compile the GTK host without the embedded web layer.
// The stub define excludes the layer outright — the host
// honors it before probing for the WebKitGTK header, so
// the layer stays out even on machines where the
// development package is installed — libwebkitgtk is
// neither linked nor required at runtime, and the
// executable carries no WebKit reference at all. This
// is the expected, configured state of every canvas
// app on Linux, so the stub compile is deliberately
// silent — no build note, no compiler diagnostic (the
// host's seam comment explains why even an
// informational pragma is dangerous); a stubbed host
// teaches at runtime by reporting WebViewNotFound the
// moment an app actually uses a WebView.
app_mod.addCSourceFile(.{ .file = dep.path("src/platform/linux/gtk_host.c"), .flags = &.{"-DNATIVE_SDK_ALLOW_WEBKITGTK_STUB"} });
app_mod.linkSystemLibrary("gtk4", .{});
app_mod.linkSystemLibrary("dl", .{});
},
.chromium => {
const cef_check = addCefCheck(b, target, cef_dir);
if (cef_auto_install) {
const cef_auto = b.addSystemCommand(&.{ "native", "cef", "install", "--dir", cef_dir });
cef_check.step.dependOn(&cef_auto.step);
}
exe.step.dependOn(&cef_check.step);
const include_arg = b.fmt("-I{s}", .{cef_dir});
const define_arg = b.fmt("-DNATIVE_SDK_CEF_DIR=\"{s}\"", .{cef_dir});
app_mod.addCSourceFile(.{ .file = dep.path("src/platform/linux/cef_host.cpp"), .flags = &.{ "-std=c++17", include_arg, define_arg } });
app_mod.addObjectFile(b.path(b.fmt("{s}/libcef_dll_wrapper/libcef_dll_wrapper.a", .{cef_dir})));
app_mod.linkSystemLibrary("cef", .{});
},
}
app_mod.linkSystemLibrary("c", .{});
if (web_engine == .chromium) app_mod.linkSystemLibrary("stdc++", .{});
} else if (platform == .windows) {
// Common-controls v6 side-by-side dependency: without this
// manifest the loader binds the system-default v5 assembly, which
// renders classic-styled controls and lacks the v6-only exports.
// The manifest also declares per-monitor-v2 DPI awareness so the
// canvas rasterizes at real device scale instead of Windows
// bitmap-stretching a 96-DPI surface on scaled displays.
exe.win32_manifest = dep.path("assets/native-sdk.manifest");
switch (web_engine) {
.system => if (web_layer) {
// The vendored WebView2 SDK header (third_party/webview2)
// turns on the host's embedded-WebView layer; the host
// fails the compile by design if it cannot be found.
app_mod.addIncludePath(dep.path("third_party/webview2/include"));
app_mod.addCSourceFile(.{ .file = dep.path("src/platform/windows/webview2_host.cpp"), .flags = &.{"-std=c++17"} });
app_mod.addCSourceFile(.{ .file = dep.path("src/platform/windows/gpu_surface_renderer.cpp"), .flags = &.{"-std=c++17"} });
// WebView2Loader.dll rides next to the installed app
// executable: the host loads it at runtime to discover
// the machine's WebView2 runtime. Canvas apps never
// touch it.
const loader = b.addInstallBinFile(dep.path(webView2LoaderSubPath(target)), "WebView2Loader.dll");
b.getInstallStep().dependOn(&loader.step);
} else {
// Native-only app (nothing in app.zon declares web use):
// compile the host without the embedded-WebView layer.
// The stub define excludes the layer outright — the host
// honors it before probing for the WebView2 header, so
// the layer stays out even on machines where the SDK
// headers are reachable through the system include paths
// — no WebView2Loader.dll is installed or path-wired,
// and the executable carries no reference to it at all.
// This is the expected, configured state of every
// canvas app on Windows, so the stub compile is
// deliberately silent — no build note, no compiler
// diagnostic (the host's seam comment explains why
// even an informational pragma is dangerous); a
// stubbed host teaches at runtime by reporting
// WebViewNotFound the moment an app actually uses a
// WebView.
app_mod.addCSourceFile(.{ .file = dep.path("src/platform/windows/webview2_host.cpp"), .flags = &.{ "-std=c++17", "-DNATIVE_SDK_ALLOW_WEBVIEW2_STUB" } });
app_mod.addCSourceFile(.{ .file = dep.path("src/platform/windows/gpu_surface_renderer.cpp"), .flags = &.{"-std=c++17"} });
},
.chromium => {
const cef_check = addCefCheck(b, target, cef_dir);
if (cef_auto_install) {
const cef_auto = b.addSystemCommand(&.{ "native", "cef", "install", "--dir", cef_dir });
cef_check.step.dependOn(&cef_auto.step);
}
exe.step.dependOn(&cef_check.step);
const include_arg = b.fmt("-I{s}", .{cef_dir});
const define_arg = b.fmt("-DNATIVE_SDK_CEF_DIR=\"{s}\"", .{cef_dir});
app_mod.addCSourceFile(.{ .file = dep.path("src/platform/windows/cef_host.cpp"), .flags = &.{ "-std=c++17", include_arg, define_arg } });
app_mod.addCSourceFile(.{ .file = dep.path("src/platform/windows/gpu_surface_renderer.cpp"), .flags = &.{"-std=c++17"} });
app_mod.addObjectFile(b.path(b.fmt("{s}/libcef_dll_wrapper/libcef_dll_wrapper.lib", .{cef_dir})));
},
}
app_mod.linkSystemLibrary("c", .{});
app_mod.linkSystemLibrary("c++", .{});
app_mod.linkSystemLibrary("user32", .{});
app_mod.linkSystemLibrary("gdi32", .{});
// Retained gpu_surface packets are composited into a hardware
// Direct2D target; DirectWrite draws the engine-measured text
// runs (including registered in-memory fonts) on that target.
app_mod.linkSystemLibrary("d2d1", .{});
app_mod.linkSystemLibrary("dwrite", .{});
app_mod.linkSystemLibrary("imm32", .{});
app_mod.linkSystemLibrary("comctl32", .{});
app_mod.linkSystemLibrary("ole32", .{});
app_mod.linkSystemLibrary("oleacc", .{});
app_mod.linkSystemLibrary("shell32", .{});
// TypeScript cores link ScriptC's host runtime, whose network-interface
// helpers use GetAdaptersAddresses and Winsock address conversion; the
// in-process service archive shares the same runtime and additionally
// reaches advapi32 (crypto-backed randomness).
app_mod.linkSystemLibrary("iphlpapi", .{});
app_mod.linkSystemLibrary("ws2_32", .{});
app_mod.linkSystemLibrary("advapi32", .{});
// The audio backend: Media Foundation (session + source resolver
// + streaming audio renderer) and WinHTTP (the cache fill).
app_mod.linkSystemLibrary("mf", .{});
app_mod.linkSystemLibrary("mfplat", .{});
app_mod.linkSystemLibrary("winhttp", .{});
if (web_engine == .chromium) app_mod.linkSystemLibrary("libcef", .{});
}
}
/// Link-search metadata is not inherited through a compiled object in Zig's
/// build graph. Keep every path-only platform setting in this one helper so a
/// TypeScript app's cached app-code object and its final link receive the same
/// framework/library lookup and runtime-search policy.
fn addPlatformLinkSearchPaths(b: *std.Build, platform: PlatformOption, web_engine: WebEngineOption, cef_dir: []const u8, mod: *std.Build.Module) void {
if (platform == .macos) {
if (b.sysroot) |sysroot| {
mod.addFrameworkPath(.{ .cwd_relative = b.pathJoin(&.{ sysroot, "System/Library/Frameworks" }) });
}
if (web_engine == .chromium) {
mod.addFrameworkPath(b.path(b.fmt("{s}/Release", .{cef_dir})));
mod.addRPath(.{ .cwd_relative = "@executable_path/Frameworks" });
}
} else if (platform == .linux and web_engine == .chromium) {
mod.addLibraryPath(b.path(b.fmt("{s}/Release", .{cef_dir})));
mod.addRPath(.{ .cwd_relative = "$ORIGIN" });
} else if (platform == .windows and web_engine == .chromium) {
mod.addLibraryPath(b.path(b.fmt("{s}/Release", .{cef_dir})));
}
}
/// The vendored WebView2Loader.dll for the target architecture, relative
/// to the framework root.
fn webView2LoaderSubPath(target: std.Build.ResolvedTarget) []const u8 {
return if (target.result.cpu.arch == .aarch64)
"third_party/webview2/arm64/WebView2Loader.dll"
else
"third_party/webview2/x64/WebView2Loader.dll";
}
/// `zig build run` executes the cached artifact, which has no installed
/// WebView2Loader.dll beside it; the vendored loader's directory goes on
/// the run step's PATH so the host's LoadLibrary resolves it in dev runs.
fn addWebView2RuntimeRunFiles(dep: *std.Build.Dependency, target: std.Build.ResolvedTarget, run: *std.Build.Step.Run, web_engine: WebEngineOption, web_layer: bool) void {
if (web_engine != .system) return;
if (!web_layer) return;
if (target.result.os.tag != .windows) return;
const loader_dir = std.fs.path.dirname(webView2LoaderSubPath(target)).?;
run.addPathDir(dep.builder.pathFromRoot(loader_dir));
}
fn addCefRuntimeRunFiles(b: *std.Build, target: std.Build.ResolvedTarget, run: *std.Build.Step.Run, exe: *std.Build.Step.Compile, web_engine: WebEngineOption, cef_dir: []const u8) void {
if (web_engine != .chromium) return;
if (target.result.os.tag != .macos) return;
const copy = b.addSystemCommand(&.{
"sh", "-c",
b.fmt(
\\set -e
\\exe="$0"
\\exe_dir="$(dirname "$exe")"
\\rm -rf "zig-out/Frameworks/Chromium Embedded Framework.framework" "zig-out/bin/Frameworks/Chromium Embedded Framework.framework" ".zig-cache/o/Frameworks/Chromium Embedded Framework.framework" &&
\\mkdir -p "zig-out/Frameworks" "zig-out/bin/Frameworks" ".zig-cache/o/Frameworks" "$exe_dir" &&
\\cp -R "{s}/Release/Chromium Embedded Framework.framework" "zig-out/Frameworks/" &&
\\cp -R "{s}/Release/Chromium Embedded Framework.framework" "zig-out/bin/Frameworks/" &&
\\cp -R "{s}/Release/Chromium Embedded Framework.framework" ".zig-cache/o/Frameworks/" &&
\\cp "{s}/Release/Chromium Embedded Framework.framework/Libraries/libEGL.dylib" "$exe_dir/" &&
\\cp "{s}/Release/Chromium Embedded Framework.framework/Libraries/libGLESv2.dylib" "$exe_dir/" &&
\\cp "{s}/Release/Chromium Embedded Framework.framework/Libraries/libvk_swiftshader.dylib" "$exe_dir/" &&
\\cp "{s}/Release/Chromium Embedded Framework.framework/Libraries/vk_swiftshader_icd.json" "$exe_dir/"
, .{ cef_dir, cef_dir, cef_dir, cef_dir, cef_dir, cef_dir, cef_dir }),
});
copy.addFileArg(exe.getEmittedBin());
run.step.dependOn(&copy.step);
}
fn addCefCheck(b: *std.Build, target: std.Build.ResolvedTarget, cef_dir: []const u8) *std.Build.Step.Run {
const script = switch (target.result.os.tag) {
.macos => b.fmt(
\\test -f "{s}/include/cef_app.h" &&
\\test -d "{s}/Release/Chromium Embedded Framework.framework" &&
\\test -f "{s}/libcef_dll_wrapper/libcef_dll_wrapper.a" || {{
\\ echo "missing CEF dependency for -Dweb-engine=chromium" >&2
\\ echo "Fix with: native cef install --dir {s}" >&2
\\ exit 1
\\}}
, .{ cef_dir, cef_dir, cef_dir, cef_dir }),
.linux => b.fmt(
\\test -f "{s}/include/cef_app.h" &&
\\test -f "{s}/Release/libcef.so" &&
\\test -f "{s}/libcef_dll_wrapper/libcef_dll_wrapper.a" || {{
\\ echo "missing CEF dependency for -Dweb-engine=chromium" >&2
\\ echo "Fix with: native cef install --dir {s}" >&2
\\ exit 1
\\}}
, .{ cef_dir, cef_dir, cef_dir, cef_dir }),
.windows => b.fmt(
\\test -f "{s}/include/cef_app.h" &&
\\test -f "{s}/Release/libcef.dll" &&
\\test -f "{s}/libcef_dll_wrapper/libcef_dll_wrapper.lib" || {{
\\ echo "missing CEF dependency for -Dweb-engine=chromium" >&2
\\ echo "Fix with: native cef install --dir {s}" >&2
\\ exit 1
\\}}
, .{ cef_dir, cef_dir, cef_dir, cef_dir }),
else => "echo unsupported CEF target >&2; exit 1",
};
return b.addSystemCommand(&.{ "sh", "-c", script });
}
/// What the build graph reads out of app.zon: the web-engine/CEF knobs
/// and the web-layer inference inputs. An unreadable or unparsable
/// manifest falls back to the system engine WITH the web layer kept —
/// over-inclusion is a size cost, wrong exclusion is a broken app.
const AppManifestBuildConfig = struct {
app_id: []const u8 = "dev.native_sdk.app",
web_engine: WebEngineOption = .system,
cef_dir: []const u8 = "third_party/cef/macos",
cef_auto_install: bool = false,
webview_layer: WebLayerOption = .auto,
dock_visible: bool = true,
microphone_permission: bool = false,
system_audio_permission: bool = false,
persist_capability: bool = false,
persist_version: ?u64 = null,
service_packages: []const ServicePackageConfig = &.{},
service_carrier: []const u8 = "auto",
service_pool_size: ?u8 = null,
store_capability: bool = false,
relational_capability: bool = false,
credentials_capability: bool = false,
credentials_permission: bool = false,
filesystem_permission: bool = false,
max_image_pixel_bytes: usize = 1024 * 1024,
updates_enabled: bool = false,
sqlite_capability: bool = false,
/// The first web declaration found (for teaching messages), or null
/// when app.zon declares no web use. `web_engine = "system"` alone is
/// NOT web intent — it is the default in many canvas manifests.
web_declaration: ?web_layer_contract.Declaration = null,
};
const ServicePackageConfig = struct {
name: []const u8,
version: []const u8,
content_hash: []const u8,
};
/// The lenient app.zon shape the build graph parses for inference: web
/// inclusion, permissions, and the persistence capability/version handed to
/// the TypeScript checker. Everything else is ignored. Full schema validation
/// stays with `native validate` and the runner's comptime import.
const InferenceManifest = struct {
id: []const u8 = "dev.native_sdk.app",
capabilities: []const []const u8 = &.{},
permissions: []const []const u8 = &.{},
dock_visible: bool = true,
web_engine: []const u8 = "system",
webview_layer: []const u8 = "auto",
core_compiler: []const u8 = "external",
cef: struct {
dir: []const u8 = "third_party/cef/macos",
auto_install: bool = false,
} = .{},
frontend: ?struct {} = null,
persist: ?struct {
version: u64,
} = null,
images: struct {
max_image_pixel_bytes: usize = 1024 * 1024,
} = .{},
service_packages: []const ServicePackageConfig = &.{},
service_carrier: []const u8 = "auto",
service_pool_size: u8 = 0,
updates: ?struct {} = null,
shell: struct {
windows: []const struct {
views: []const struct {
kind: []const u8 = "",
} = &.{},
} = &.{},
} = .{},
};
fn defaultCefDir(platform: PlatformOption, configured: []const u8) []const u8 {
if (!std.mem.eql(u8, configured, "third_party/cef/macos")) return configured;
return switch (platform) {
.linux => "third_party/cef/linux",
.windows => "third_party/cef/windows",
else => configured,
};
}
/// Resolve an app-relative path against `app_root` (see AppOptions). Kept
/// lexical: `b.path` rejects absolute paths and the generated build graph
/// hands us "../..", which openat/b.path both resolve fine.
fn appPath(b: *std.Build, app_root: []const u8, sub_path: []const u8) []const u8 {
if (app_root.len == 0 or std.mem.eql(u8, app_root, ".")) return sub_path;
return b.pathJoin(&.{ app_root, sub_path });
}
fn appManifestBuildConfig(b: *std.Build, app_root: []const u8, manifest_name: []const u8) AppManifestBuildConfig {
// The fallback for a manifest this lenient parse cannot read keeps
// the web layer (see AppManifestBuildConfig): a shape mismatch here
// is not proof the app declares no web use.
const fallback: AppManifestBuildConfig = .{ .web_declaration = .unreadable_manifest };
const source = b.build_root.handle.readFileAlloc(b.graph.io, appPath(b, app_root, manifest_name), b.allocator, .limited(1024 * 1024)) catch return fallback;
@setEvalBranchQuota(2000);
const raw = if (std.ascii.eqlIgnoreCase(std.fs.path.extension(manifest_name), ".json"))
std.json.parseFromSliceLeaky(InferenceManifest, b.allocator, source, .{ .ignore_unknown_fields = true }) catch return fallback
else zon: {
const source_z = b.allocator.dupeZ(u8, source) catch return fallback;
break :zon std.zon.parse.fromSliceAlloc(InferenceManifest, b.allocator, source_z, null, .{ .ignore_unknown_fields = true }) catch return fallback;
};
// `.core_compiler` names the one lane there is; validated here so
// the removed transpiled lane's spelling teaches at configure time.
if (!std.mem.eql(u8, raw.core_compiler, "external")) {
if (std.mem.eql(u8, raw.core_compiler, "transpiler")) @panic(core_compiler_teaching);
@panic("\napp.zon .core_compiler must be \"external\" (the default and only lane)\n");
}
return .{
.app_id = raw.id,
.web_engine = web_layer_contract.parseWebEngine(raw.web_engine) orelse .system,
.cef_dir = raw.cef.dir,
.cef_auto_install = raw.cef.auto_install,
.webview_layer = web_layer_contract.parseWebViewLayer(raw.webview_layer) orelse @panic("app.zon .webview_layer must be \"auto\", \"include\", or \"exclude\""),
.dock_visible = raw.dock_visible,
.microphone_permission = hasManifestPermission(raw.permissions, "microphone"),
.system_audio_permission = hasManifestPermission(raw.permissions, "system_audio"),
.persist_capability = hasManifestCapability(raw.capabilities, "persist"),
.persist_version = if (raw.persist) |persist| persist.version else null,
.service_packages = raw.service_packages,
.service_carrier = raw.service_carrier,
.service_pool_size = if (raw.service_pool_size == 0) null else raw.service_pool_size,
.store_capability = hasManifestCapability(raw.capabilities, "store"),
.relational_capability = hasManifestCapability(raw.capabilities, "sqlite"),
.credentials_capability = hasManifestCapability(raw.capabilities, "credentials"),
.credentials_permission = hasManifestPermission(raw.permissions, "credentials"),
.filesystem_permission = hasManifestPermission(raw.permissions, "filesystem"),
.max_image_pixel_bytes = raw.images.max_image_pixel_bytes,
.updates_enabled = raw.updates != null,
.sqlite_capability = hasManifestCapability(raw.capabilities, "store") or hasManifestCapability(raw.capabilities, "sqlite"),
.web_declaration = web_layer_contract.manifestDeclaration(raw),
};
}
fn hasManifestPermission(permissions: []const []const u8, expected: []const u8) bool {
for (permissions) |permission| {
if (std.mem.eql(u8, permission, expected)) return true;
}
return false;
}
fn hasManifestCapability(capabilities: []const []const u8, expected: []const u8) bool {
for (capabilities) |capability| {
if (std.mem.eql(u8, capability, expected)) return true;
}
return false;
}
/// The web-layer decision for this build: the shared contract fed this
/// boundary's inputs — the manifest declarations from the lenient parse
/// and the engine RESOLVED from `-Dweb-engine` orelse app.zon. WEB means
/// the embedded-WebView layer compiles in; NATIVE-ONLY compiles the host
/// without it. `.webview_layer` (and `-Dweb-layer`) override the
/// inference — but an exclude that contradicts a web declaration is a
/// hard configure error, never a silently broken app.
fn resolveWebLayer(config: AppManifestBuildConfig, web_engine: WebEngineOption, override: ?WebLayerOption) bool {
const setting = override orelse config.webview_layer;
const declaration = web_layer_contract.foldEngine(config.web_declaration, web_engine);
const decision = web_layer_contract.decide(setting, declaration) catch std.debug.panic(
"the web layer is excluded ({s}) but the app declares web use ({s}); remove the exclude or drop the web declaration",
.{ if (override != null) "-Dweb-layer=exclude" else "app.zon .webview_layer = \"exclude\"", declaration.?.text() },
);
return decision.enabled;
}