summaryrefslogtreecommitdiff
path: root/build.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-10 17:23:18 -0300
committerGabriel Schneider <[email protected]>2026-08-11 09:58:59 -0300
commita797a1ab2f648e773f7a1b28d12bf9441b9f13f4 (patch)
tree45db37a3d190425b3ca2453d46c57d2af27cfc93 /build.zig
parenteb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c (diff)
downloadpardes-a797a1ab2f648e773f7a1b28d12bf9441b9f13f4.tar.gz
pardes-a797a1ab2f648e773f7a1b28d12bf9441b9f13f4.zip
macos: pixel attachments, live theming, and a signed app
The AppKit shell now draws what the core renders, follows the theme without a relaunch, and builds into something you can hand to someone. - Pixel attachments. Surface.images was dropped on the floor here, so a PDF pane showed nothing at all: native_images is now set, pardes_image_s carries the geometry the core already clipped, and PardesView keeps one CGImage per (serial, page, revision) so scrolling costs a draw and not a decode. Image panes get real pixels instead of the petscii fallback. - Themes take hold live. pardes_tick never advanced the chrome animation, so every tagline kept the previous theme's colours until the next launch and the 16 ms re-pump spun for the rest of the session. pardes_theme_bg retires the hand-agreed #121212 and drives the window background and the titlebar appearance; a theme with no background of its own now gets a transparent window over an NSVisualEffectView. - The cell snaps to whole DEVICE pixels rather than whole points. Monaco advances 8.4014pt at 14, so ceiling to 9 spaced every column 7.1% wider than the face was drawn for. - The dial is one notch per 10 degrees instead of 20, and a release keeps turning in proportion to how hard it was thrown -- ramping up from zero at the floor, so a slow twist coasts not a little but not at all. - A file dropped on the grid is a click plus Look, so it opens beside the pane it was dropped on. No drop concept was added to the core. - The titlebar follows the focused pane: proxy icon, filename, and the dirty dot. File.saved_revision is the watermark that last one needed. - Config (SPC f c) prints the resolved startup config path. - build.zig assembles, signs and packages the bundle itself; build-app.sh is gone. -Dmacos-identity= takes a Developer ID, macos-dmg makes the image, and the icon is Glenda.
Diffstat (limited to 'build.zig')
-rw-r--r--build.zig187
1 files changed, 153 insertions, 34 deletions
diff --git a/build.zig b/build.zig
index 8f654ff1..2d2b7840 100644
--- a/build.zig
+++ b/build.zig
@@ -8,10 +8,10 @@ pub const Platform = enum { tty, gui, web, macos };
/// The oldest macOS pardes.app claims to run on, spelled ONCE. Three things
/// have to agree about it or the bundle is a lie: the target this build gives
-/// the static library, the `-target` build-app.sh passes swiftc (which is what
-/// writes LC_BUILD_VERSION, the thing dyld actually enforces), and the plist's
-/// LSMinimumSystemVersion. The script gets it as an argument and sets the last
-/// two from it, so there is one string and no drift.
+/// the static library, the `-target` the app's swiftc link is given (which is
+/// what writes LC_BUILD_VERSION, the thing dyld actually enforces), and the
+/// plist's LSMinimumSystemVersion. The macos branch below derives the last two
+/// from this, so there is one string and no drift.
pub const macos_min_version: std.SemanticVersion = .{ .major = 13, .minor = 0, .patch = 0 };
/// The ZLS the language backend links, spelled once. It names the commit
@@ -77,6 +77,10 @@ pub fn build(b: *std.Build) void {
const default_grammars: TreeSitterGrammars = if (is_web) .zig else .full;
const tree_sitter_grammars = b.option(TreeSitterGrammars, "tree-sitter", "tree-sitter grammar set: disabled, zig, minimal (c/c++/zig), full") orelse default_grammars;
const tracy = b.option([]const u8, "tracy", "enable Tracy profiling; supply the path to a Tracy source checkout");
+ // Who signs pardes.app. Ad-hoc ("-") is what makes a bundle launchable on
+ // the machine that built it and needs no keychain; a Developer ID here is
+ // what makes one launchable on someone else's. See the macos branch below.
+ const macos_identity = b.option([]const u8, "macos-identity", "codesigning identity for pardes.app (default: ad-hoc)") orelse "-";
// The browser shell is a freestanding wasm core plus ordinary web files.
// JavaScript owns the loop and IO; HTML/CSS own rendering.
@@ -661,11 +665,12 @@ pub fn build(b: *std.Build) void {
// draws the cell grid with CoreText. See docs/macos.md.
//
// The Zig half is ordinary POSIX and builds anywhere, which is what
- // makes the boundary testable without a Mac — only `macos-app` needs
- // one. Deliberately NOT here: an xcframework, lipo, an Xcode project
- // and codesigning. Those exist to ship a signed universal bundle, and
- // this is a dev build; ghostty's src/build/GhosttyXCFramework.zig is
- // the map when distribution matters.
+ // makes the boundary testable without a Mac — only the bundle needs
+ // one. Deliberately NOT here: an xcframework, lipo and an Xcode
+ // project. The first two are for a UNIVERSAL binary and this ships
+ // arm64; the third is a second build system to keep in step for what
+ // a plist and three install steps already do. ghostty's
+ // src/build/GhosttyXCFramework.zig is the map if that day comes.
const header = b.addTranslateC(.{
.root_source_file = b.path("src/macos/pardes.h"),
.target = target,
@@ -696,43 +701,157 @@ pub fn build(b: *std.Build) void {
// libtool and ranlib are Apple's, so this needs a Darwin host as well
// as a Darwin target; a cross-build installs the plain archive, which
// is all the Linux dev loop ever links (nothing).
- if (builtin.os.tag.isDarwin() and target.result.os.tag.isDarwin())
- b.getInstallStep().dependOn(&b.addInstallLibFile(fatArchive(b, lib), "libpardes.a").step)
+ const archive: ?std.Build.LazyPath =
+ if (builtin.os.tag.isDarwin() and target.result.os.tag.isDarwin()) fatArchive(b, lib) else null;
+ // The one artifact everything downstream links. Named as a step rather
+ // than folded into the install step so the e2e link below can wait for
+ // the LIBRARY without also waiting for the app bundle.
+ const install_lib: *std.Build.Step = if (archive) |a|
+ &b.addInstallLibFile(a, "libpardes.a").step
else
- b.installArtifact(lib);
+ &b.addInstallArtifact(lib, .{}).step;
+ b.getInstallStep().dependOn(install_lib);
b.installFile("src/macos/pardes.h", "include/pardes.h");
- const app = b.addSystemCommand(&.{"src/macos/build-app.sh"});
- app.addArg(b.getInstallPath(.prefix, ""));
- // The deployment target travels as an argument rather than being
- // written twice: see macos_min_version.
- app.addArg(b.fmt("{d}.{d}", .{ macos_min_version.major, macos_min_version.minor }));
- // ...and so does the optimize mode, so swiftc compiles the shell the
- // same way zig compiled the core. Hardcoding -O there meant the default
- // `zig build macos-app` shipped an optimized shell around a Debug core,
- // which costs 5x a frame and looks like a slow backend rather than a
- // Debug build. See the note in build-app.sh.
- app.addArg(@tagName(optimize));
- app.has_side_effects = true; // writes a bundle outside the cache
- app.step.dependOn(b.getInstallStep());
- // The library the script links has to BE a Mach-O one. Cross-building
- // the ABI for another host is supported and tested (see the Linux dev
- // loop in docs/macos.md); assembling a bundle out of it is not.
+ // ---- pardes.app ----
+ //
+ // A bundle is a directory with a plist, a binary and an icon in it, so
+ // it is assembled HERE rather than by a script the build shells out to.
+ // Every input is a file the graph knows — the archive, the three Swift
+ // sources, the header, the plist, the icon generator — which is what
+ // makes the app rebuild when one of them moves and stay untouched when
+ // none does. The script this replaced took an install PREFIX and
+ // re-derived its inputs from whatever happened to be sitting in
+ // zig-out, so it could neither be cached nor be wrong out loud.
const app_step = b.step("macos-app", "assemble zig-out/pardes.app (needs macOS + swiftc)");
- if (target.result.os.tag.isDarwin())
- app_step.dependOn(&app.step)
- else
- app_step.dependOn(&b.addFail("macos-app needs a Darwin target; drop -Dtarget= or pass -Dtarget=native").step);
+ const dmg_step = b.step("macos-dmg", "package zig-out/pardes.dmg for distribution (needs macOS + swiftc)");
+ if (archive) |lib_archive| {
+ // The deployment target, spelled once (macos_min_version) and given
+ // to all three things that have to agree about it: the archive was
+ // built for it, this triple is what writes LC_BUILD_VERSION — the
+ // thing dyld actually enforces — and the plist key below is the
+ // claim Finder reads. Apple spells aarch64 "arm64".
+ const apple_arch: []const u8 =
+ if (target.result.cpu.arch == .aarch64) "arm64" else @tagName(target.result.cpu.arch);
+ const triple = b.fmt("{s}-apple-macos{d}.{d}", .{
+ apple_arch, macos_min_version.major, macos_min_version.minor,
+ });
+ const min_version = b.fmt("{d}.{d}", .{ macos_min_version.major, macos_min_version.minor });
+
+ // swiftc compiles the shell the way zig compiled the core. Pinning
+ // -O here meant the ordinary build shipped an optimized shell
+ // around a Debug core, which costs 5x a frame and reads as "the mac
+ // backend is slow" rather than "you built Debug": one frame at
+ // 190x56 measured 4.5 ms against a Debug core and 0.88 ms against a
+ // ReleaseFast one, 4.1 ms of it in pardes_frame alone.
+ const swift_mode: []const u8 = switch (optimize) {
+ .Debug => "-Onone",
+ .ReleaseSmall => "-Osize",
+ .ReleaseFast, .ReleaseSafe => "-O",
+ };
+
+ // -import-objc-header rather than a module map: the header is
+ // consumed straight from the source tree, so there is nothing to
+ // stage and nothing to keep in sync. -lc++ because ghostty-vt pulls
+ // in simdutf and highway; the Zig side bundles compiler_rt and
+ // ubsan_rt into the archive above, so the C++ runtime is all this
+ // link still has to supply.
+ const link = b.addSystemCommand(&.{ "swiftc", swift_mode, "-target", triple, "-import-objc-header" });
+ link.addFileArg(b.path("src/macos/pardes.h"));
+ link.addArg("-o");
+ const app_bin = link.addOutputFileArg("pardes");
+ for ([_][]const u8{ "main.swift", "AppDelegate.swift", "PardesView.swift" }) |src|
+ link.addFileArg(b.path(b.fmt("src/macos/Sources/{s}", .{src})));
+ link.addFileArg(lib_archive);
+ link.addArgs(&.{ "-lc++", "-framework", "AppKit", "-framework", "CoreText", "-framework", "CoreGraphics" });
+
+ // The icon is generated, not committed: the mark is drawn out of
+ // the same palette PardesView.swift renders cells with, so a colour
+ // that moves there moves here on the next build instead of a binary
+ // blob quietly disagreeing with the app it ships in. Compiled alone
+ // because the file is top-level code — one file, one module.
+ const icon_build = b.addSystemCommand(&.{ "swiftc", "-O", "-target", triple, "-o" });
+ const icon_bin = icon_build.addOutputFileArg("pardes-icon");
+ icon_build.addFileArg(b.path("src/macos/icon.swift"));
+ const icon_run = std.Build.Step.Run.create(b, "pardes-icon");
+ icon_run.addFileArg(icon_bin);
+ const icon_dir = icon_run.addOutputDirectoryArg("Resources");
+
+ // One version, two consumers: LSMinimumSystemVersion is stamped
+ // from the same string the link above enforces, so a bumped
+ // deployment target cannot leave a stale claim behind. plutil reads
+ // the committed plist and writes a new one into the cache — the
+ // source file is never mutated, which is what PlistBuddy did.
+ const plist = b.addSystemCommand(&.{
+ "plutil", "-replace", "LSMinimumSystemVersion", "-string", min_version, "-o",
+ });
+ const plist_out = plist.addOutputFileArg("Info.plist");
+ plist.addFileArg(b.path("src/macos/Info.plist"));
+
+ const install_app_bin = b.addInstallFileWithDir(app_bin, .{ .custom = "pardes.app/Contents/MacOS" }, "pardes");
+ const install_plist = b.addInstallFileWithDir(plist_out, .{ .custom = "pardes.app/Contents" }, "Info.plist");
+ // CFBundleIconFile names this without its extension; without both
+ // halves the Dock shows the generic blank page.
+ const install_icon = b.addInstallFileWithDir(icon_dir.path(b, "pardes.icns"), .{ .custom = "pardes.app/Contents/Resources" }, "pardes.icns");
+
+ // Gatekeeper. Ad-hoc by default, which is what an arm64 bundle
+ // needs to launch at all and needs no keychain; -Dmacos-identity=
+ // names a Developer ID for a bundle that leaves this machine, and
+ // only then are the hardened runtime and a trusted timestamp worth
+ // asking for — both are notarization's requirements rather than a
+ // signature's, and --timestamp on an ad-hoc signature is an error.
+ //
+ // It signs the DIRECTORY, so it must follow all three installs: a
+ // signature taken before the icon lands is a signature the icon
+ // then breaks. Always runs, because the bundle it edits lives
+ // outside the cache and zig cannot know what is in it.
+ const sign = b.addSystemCommand(&.{ "codesign", "--force", "--sign", macos_identity });
+ if (!std.mem.eql(u8, macos_identity, "-")) sign.addArgs(&.{ "--options", "runtime", "--timestamp" });
+ sign.addArg(b.getInstallPath(.prefix, "pardes.app"));
+ sign.has_side_effects = true;
+ sign.step.dependOn(&install_app_bin.step);
+ sign.step.dependOn(&install_plist.step);
+ sign.step.dependOn(&install_icon.step);
+ app_step.dependOn(&sign.step);
+
+ // ...and the thing you hand someone. UDZO is the compressed
+ // read-only image every mac already knows how to open; the app
+ // inside it carries the signature made above, which is what
+ // survives the copy out.
+ const dmg = b.addSystemCommand(&.{ "hdiutil", "create", "-volname", "pardes", "-ov", "-format", "UDZO", "-srcfolder" });
+ dmg.addArg(b.getInstallPath(.prefix, "pardes.app"));
+ dmg.addArg(b.getInstallPath(.prefix, "pardes.dmg"));
+ dmg.has_side_effects = true;
+ dmg.step.dependOn(&sign.step);
+ dmg_step.dependOn(&dmg.step);
+
+ // The app is part of an ORDINARY build rather than a verb to
+ // remember: `zig build -Dplatform=macos` leaves a launchable,
+ // signed bundle in zig-out beside the library it was linked from.
+ // The dmg stays opt-in — it is for handing over, not for running.
+ b.getInstallStep().dependOn(&sign.step);
+ } else {
+ // The library a bundle links has to BE a Mach-O one, and libtool
+ // is Apple's. Cross-building the ABI for another host is supported
+ // and tested (the Linux dev loop in docs/macos.md); assembling a
+ // bundle out of it is not.
+ const why = b.addFail("pardes.app needs a Darwin host and target; drop -Dtarget= or pass -Dtarget=native");
+ app_step.dependOn(&why.step);
+ dmg_step.dependOn(&why.step);
+ }
// The offscreen AppKit suite. A second link rather than a flag on the
// app: test scaffolding does not ship in the product, and main.swift's
// top-level code is already an entry point (see src/macos/build-e2e.sh).
- // Same two arguments as the app, because it must be the same link.
+ // Same two arguments the app's own link uses, because it must be the
+ // same link — it waits on the LIBRARY rather than the whole install, so
+ // running the suite does not also assemble and sign a bundle it never
+ // opens.
const e2e = b.addSystemCommand(&.{"src/macos/build-e2e.sh"});
e2e.addArg(b.getInstallPath(.prefix, ""));
e2e.addArg(b.fmt("{d}.{d}", .{ macos_min_version.major, macos_min_version.minor }));
e2e.has_side_effects = true; // writes a binary outside the cache
- e2e.step.dependOn(b.getInstallStep());
+ e2e.step.dependOn(install_lib);
const run_e2e = b.addSystemCommand(&.{b.getInstallPath(.prefix, "bin/pardes-macos-e2e")});
// Relative, and pinned to the build root: the scripts and their goldens
// are source, not an install artifact, and the harness chdirs into a