diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-11 15:58:58 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-11 16:26:20 -0300 |
| commit | 4ca28745d774c232cd31a29c17878f19bbe24cf5 (patch) | |
| tree | face852acae5bc347e6bab2bb5cede501e0ce1d3 /build.zig | |
| parent | dedfdea43f0d6c7151c541284c81027969d89032 (diff) | |
| parent | 89d93d5e7348304bc7d8a148f9ad9c1beb200459 (diff) | |
| download | pardes-4ca28745d774c232cd31a29c17878f19bbe24cf5.tar.gz pardes-4ca28745d774c232cd31a29c17878f19bbe24cf5.zip | |
merge the macOS app branch: the AppKit shell, pixel attachments, live theming, and mupdf -Djpx
Three commits off 38e9919 (macos-app@upstream) merged into main's ghostty bump.
No textual conflicts, and two things the merge needed:
- nested.zig asked libc for fstatat. Darwin has it; on linux std.c declares it
`void` (glibc hides it behind a versioned symbol std cannot name), so the tty
build stopped at 'type void not a function'. statNoFollow keeps fstatat on
darwin and asks statx on linux for the same three fields, which is what this
file did before the branch generalized it to both platforms.
- .DS_Store rode along with a797a1a. Deleted, and .gitignore now says so.
linux: snap 86/86, unit-test, image-harness and mupdf-check green. nested.zig
also type-checks for aarch64-macos.
Diffstat (limited to 'build.zig')
| -rw-r--r-- | build.zig | 278 |
1 files changed, 261 insertions, 17 deletions
@@ -1,10 +1,19 @@ const std = @import("std"); +const builtin = @import("builtin"); const mupdf_build = @import("mupdf.zig"); const snap_build = @import("build/snap.zig"); const grammar_manifest = @import("src/grammar_manifest.zig"); 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` 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 /// build.zig.zon pins (0.16.x branch) and is passed BOTH to ZLS's own /// `-Dversion-string` (its build.zig otherwise shells out to `git describe`, @@ -13,23 +22,52 @@ const zls_version = "0.16.1-dev+3e0d0820"; pub const TreeSitterGrammars = enum { disabled, zig, minimal, full }; pub fn build(b: *std.Build) void { + const platform = b.option(Platform, "platform", "which shell to build (tty, gui, web, macos)") orelse .tty; // Default target is the Steam Deck (deckcap's trick): x86_64 linux-gnu // with the glibc version pinned low, so a binary built on a rolling- // release host runs on SteamOS — a native build references the host's // newer versioned libm/libc symbols and dies with "GLIBC_2.4x not found" // on the deck. Override with -Dtarget= as usual. - const target = b.standardTargetOptions(.{ .default_target = .{ - .cpu_arch = .x86_64, - .os_tag = .linux, - .abi = .gnu, - .glibc_version = .{ .major = 2, .minor = 38, .patch = 0 }, + // + // -Dplatform=macos cannot take that default, and the failure is not + // subtle: swiftc links this archive, so a Steam Deck build hands ld64 ELF + // objects inside a GNU archive and the app link dies with "archive member + // '/SYM64/' not a mach-o file". On a Mac it therefore targets the host + // arch at macos_min_version — the same triple build-app.sh gives swiftc, + // so neither half of the app can disagree with the other about how old a + // macOS it supports. Naming the arch rather than leaving it null is + // ghostty's workaround (Config.genericMacOSTarget): a spelled arch + // resolves the CPU model to generic, where a bare native query would bake + // in apple_m2 and everything its LLVM backend has opinions about. + // + // Anywhere else it stays plain native, which is the whole point of the + // Linux dev loop: `zig build unit-test -Dplatform=macos` has to produce a + // binary that machine can actually execute. + const target = b.standardTargetOptions(.{ .default_target = switch (platform) { + .macos => if (builtin.os.tag.isDarwin()) .{ + .cpu_arch = builtin.target.cpu.arch, + .os_tag = .macos, + .os_version_min = .{ .semver = macos_min_version }, + } else .{}, + .tty, .gui, .web => .{ + .cpu_arch = .x86_64, + .os_tag = .linux, + .abi = .gnu, + .glibc_version = .{ .major = 2, .minor = 38, .patch = 0 }, + }, } }); const requested_optimize = b.standardOptimizeOption(.{}); - const platform = b.option(Platform, "platform", "which shell to build (tty, gui, web, macos)") orelse .tty; const static = b.option(bool, "static", "statically link") orelse false; const dump_path = b.option([]const u8, "dump", "dump .zon embedded into the web shell (-Dplatform=web)"); const is_web = platform == .web; const enable_mupdf = b.option(bool, "mupdf", "native PDF rendering with MuPDF (AGPL/commercial; native default on, web off; -Dmupdf=false disables)") orelse !is_web; + // JPEG 2000, and with it scanned PDFs: a scan is one /JPXDecode image per + // page, so without this MuPDF decodes nothing and every page comes back + // blank. On by default — a viewer that cannot open scans is the more + // surprising default — and a switch at all because it is 31 files of + // third-party C parsing untrusted input. See the OPENJPEG block in + // mupdf.zig. + const enable_jpx = b.option(bool, "jpx", "JPEG 2000 in PDFs, for scanned documents (default on; -Djpx=false drops openjpeg)") orelse true; const is_web_target = target.result.cpu.arch == .wasm32 and target.result.os.tag == .freestanding; // wasm: size is the budget const optimize = if (is_web) .ReleaseSmall else requested_optimize; @@ -46,6 +84,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. @@ -327,6 +369,7 @@ pub fn build(b: *std.Build) void { const mupdf = mupdf_build.add(b, io, mupdf_dep, .{ .target = target, .optimize = c_optimize, + .jpx = enable_jpx, }); const mupdf_mod = b.createModule(.{ .target = target, @@ -630,11 +673,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, @@ -655,14 +699,184 @@ pub fn build(b: *std.Build) void { // it or every build ends in undefined symbols at the swiftc link. lib.bundle_compiler_rt = true; lib.bundle_ubsan_rt = true; - b.installArtifact(lib); + // ...and neither does bundling stop at compiler_rt. addLibrary emits + // ONLY this module's own objects; MuPDF, tree-sitter, zstbi, ZLS and + // ghostty-vt's simdutf/highway stay in archives of their own that zig + // would hand a linker it drives itself. swiftc drives this one, is + // given one file, and fails with a page of undefined C++ symbols. So + // everything reachable is folded into a single archive first — this is + // ghostty's CombineArchivesStep, minus the non-Darwin half. + // 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). + 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.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, "")); - app.has_side_effects = true; // writes a bundle outside the cache - app.step.dependOn(b.getInstallStep()); - b.step("macos-app", "assemble zig-out/pardes.app (needs macOS + swiftc)").dependOn(&app.step); + // ---- 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)"); + 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 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(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 + // hermetic /tmp world per script — so it resolves both up front and + // must start somewhere it knows. + run_e2e.setCwd(b.path(".")); + run_e2e.addArg("test/macos-snapshots"); + // `-- --update` reaches the harness this way, exactly as the tty suite + // takes it: regenerating goldens is the same binary with one more flag. + if (b.args) |args| run_e2e.addArgs(args); + run_e2e.has_side_effects = true; // spawns shells, writes /tmp and goldens + run_e2e.step.dependOn(&e2e.step); + const e2e_step = b.step("macos-e2e", "run the offscreen AppKit snapshot suite (needs macOS + swiftc)"); + if (target.result.os.tag.isDarwin()) + e2e_step.dependOn(&run_e2e.step) + else + e2e_step.dependOn(&b.addFail("macos-e2e needs a Darwin target; drop -Dtarget= or pass -Dtarget=native").step); // Same step name the other native platforms use, because in this // configuration theirs is not declared: the ABI guard and the core's @@ -923,6 +1137,36 @@ fn failBuild(b: *std.Build, web_step: *std.Build.Step, msg: []const u8) void { b.getInstallStep().dependOn(fail); } +/// Fold `lib` and every static archive it transitively links into one file, +/// because swiftc is handed exactly one. getCompileDependencies walks the +/// module graph, so this stays correct as dependencies come and go — nothing +/// here names MuPDF or tree-sitter, and adding a third C library needs no edit. +fn fatArchive(b: *std.Build, lib: *std.Build.Step.Compile) std.Build.LazyPath { + const run = std.Build.Step.Run.create(b, "libtool libpardes.a"); + run.addArgs(&.{ "libtool", "-static", "-o" }); + const output = run.addOutputFileArg("libpardes.a"); + for (lib.getCompileDependencies(false), 0..) |dep, i| { + if (dep.kind != .lib) continue; + run.addFileArg(reindexed(b, dep.getEmittedBin(), i)); + } + return output; +} + +/// Rewrite one archive's index with Apple's ranlib, on the way past. Two of +/// Xcode's tools disagree with zig's archive layout and neither says so +/// usefully: ld64 refuses it outright ("64-bit mach-o member 'compiler_rt.o' +/// not 8-byte aligned"), and libtool silently DROPS members — a 15 MB input +/// came back as a 13 MB output with half the objects missing, which links +/// almost far enough to look like a source problem. ranlib rewrites both +/// complaints away. Ghostty hit the same two (src/build/LibtoolStep.zig); the +/// copy is because ranlib works in place and a build-cache input is not ours. +fn reindexed(b: *std.Build, archive: std.Build.LazyPath, index: usize) std.Build.LazyPath { + const run = std.Build.Step.Run.create(b, b.fmt("ranlib #{d}", .{index})); + run.addArgs(&.{ "/bin/sh", "-c", "/bin/cp \"$1\" \"$2\" && /usr/bin/ranlib \"$2\"", "_" }); + run.addFileArg(archive); + return run.addOutputFileArg(b.fmt("{d}.a", .{index})); +} + // glslc <src> -fshader-stage=<stage> -o <out> -> .spv, returned as a LazyPath // for @embedFile. Only used by the SDL3 GPU shell (-Dplatform=gui). fn compileGlsl(b: *std.Build, src: []const u8, stage: []const u8, out: []const u8) std.Build.LazyPath { |
