Quick start
Create a native macOS app that lives outside the zenit repo and builds and runs on its own.
Prerequisites
This release targets macOS, with Apple Silicon continuously verified. Make sure you have Zig 0.15.2 and the Xcode Command Line Tools; Bun is only needed for end-to-end tests.
zig version
xcode-select -pCreate a project
Start from templates/minimal-app in the repo. It is a complete downstream project:
minimal-app/
├── README.md
├── build.zig # zenit.attach() + run / app steps
├── build.zig.zon # declares the zenit dependency
├── e2e/
│ └── record-demo.ts # drives and records the app through the Harness
└── src/
└── main.zig # counter-button app- 1Copy the template
Copy templates/minimal-app to a new directory outside the zenit repo.
- 2Fix the dependency path
The template defaults to
.path = "../../", which only resolves inside the repo. After copying, point it at your zenit checkout relative to build.zig.zon, or pin a published revision withzig fetch --save=zenit <url>. - 3Generate a fingerprint
Don’t keep the template’s .fingerprint: two projects sharing it collide in Zig’s package cache. Delete the line, run zig build once, and paste back the value from the error. Rename .name = .myapp while you’re there.
git clone https://github.com/version-next/zenit.git
cp -R zenit/templates/minimal-app ./myapp
cd myapp # then set .zenit = .{ .path = "../zenit" } in build.zig.zon
zig buildWire up zenit
build.zig.zon declares the dependency; build.zig gets zenit’s own build API through @import("zenit").
.{
.name = .myapp,
.version = "0.1.0",
.fingerprint = 0x8798022a7f8e220e, // replace: delete this line, run zig build once
.minimum_zig_version = "0.15.2",
.dependencies = .{
// Relative to this file. Absolute paths are rejected by Zig 0.15.2.
.zenit = .{ .path = "../zenit" },
},
.paths = .{
"README.md",
"build.zig",
"build.zig.zon",
"e2e",
"src",
},
}Here is the template’s build.zig verbatim. It’s more than one attach call: it forwards the test switches, installs the Harness client, and defines the run step and .app packaging:
const std = @import("std");
const zenit = @import("zenit");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const test_mode = b.option(bool, "test-mode", "Enable the Zenit automation harness") orelse false;
const e2e_port = b.option(u16, "e2e-port", "Zenit Harness RPC directory suffix") orelse 19816;
const zenit_dep = b.dependency("zenit", .{
.target = target,
.optimize = optimize,
// Dependency build options are isolated in Zig. Forward these
// explicitly so `zig build -Dtest-mode=true` reaches Zenit.
.@"test-mode" = test_mode,
.@"e2e-port" = e2e_port,
});
const exe = b.addExecutable(.{
.name = "myapp",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
}),
});
zenit.attach(zenit_dep, exe);
zenit.installHarnessClient(b, zenit_dep);
b.installArtifact(exe);
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
const run_step = b.step("run", "Run the app");
run_step.dependOn(&run_cmd.step);
// Optional: `zig build app` packages a double-clickable .app bundle.
if (target.result.os.tag == .macos) {
const bundled = zenit.bundleApp(b, .{
.exe = exe,
.display_name = "My App",
.bundle_id = "com.example.myapp",
.version = "0.1.0",
.signing = .ad_hoc,
});
const app_step = b.step("app", "Build the .app bundle");
app_step.dependOn(bundled.final_step);
}
}zenit.attach(zenit_dep, exe)Adds the ui and zenit_app module imports, compiles the five macOS ObjC bridges and links Cocoa, Metal, CoreText and the other frameworks.@"test-mode" / .@"e2e-port"Dependency options are isolated in Zig; forward them explicitly so zig build -Dtest-mode=true reaches zenitzenit.installHarnessClient(b, zenit_dep)Installs the typed Harness client to zig-out/share/zenit/harness/client.ts for e2e/record-demo.tszenit.bundleApp(b, .{ ... })Produces a double-clickable zig-out/<display_name>.app, ad-hoc signed hereMount your first UI tree
App.runWith creates the root Scope, calls your mount function once and takes over the event loop. The mount function must have the signature fn (cx: *ui.Cx, scope: *ui.Scope) anyerror!*ui.Node; your app only returns the root node.
const std = @import("std");
const ui = @import("ui");
const App = @import("zenit_app").App;
const Counter = struct {
n: u32 = 0,
label: ?*ui.Node = null,
buf: [32]u8 = undefined,
pub fn increment(self: *Counter) void {
self.n += 1;
const node = self.label orelse return;
const content = std.fmt.bufPrint(&self.buf, "Clicked {d} times", .{self.n}) catch return;
if (node.getText()) |old| {
var t = old;
t.content = content;
node.setText(t);
}
node.markRenderDirty();
}
};
fn mountUi(cx: *ui.Cx, scope: *ui.Scope) anyerror!*ui.Node {
const allocator = cx.allocator;
const counter = try cx.bindState(Counter, .{});
const click = cx.on(Counter, counter, Counter.increment);
const root = try ui.box(cx, .{
.width = .fill(),
.height = .fill(),
.direction = .column,
.gap = 16,
.padding = ui.Padding.all(40),
.background = cx.tokens.color.bg_primary,
.align_items = .center,
.justify = .center,
}, .{});
try root.appendChild(allocator, try ui.text(cx, "Hello, zenit!", .{
.font_size = 24,
.font_weight = 600,
.color = cx.tokens.color.fg_primary,
}));
const button = try ui.widgets.Button(.{
.label = "Click me",
.variant = .primary,
.on_click = click,
}).mount(scope, cx);
button.meta.ownership.meta.test_id = "counter.increment";
try root.appendChild(allocator, button);
const label = try ui.text(cx, "Clicked 0 times", .{
.font_size = 14,
.color = cx.tokens.color.fg_secondary,
});
try root.appendChild(allocator, label);
counter.label = label;
return root;
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const app = try App.init(gpa.allocator(), .{
.window = .{ .width = 640, .height = 480, .title = "Hello, zenit" },
});
defer app.deinit();
try app.runWith(mountUi);
}cx.bindState hands Counter to the Cx, and cx.on turns one of its methods into the button callback; test_id lets E2E scripts find the button by meaning.
Build and run
zig build run # build and run the unbundled executable
zig build app # package zig-out/My App.app (macOS)The template also ships a recording script: build with zig build -Dtest-mode=true, start the app, then run e2e/record-demo.ts to get the same kind of clip. See E2E harness.