build: link libghostty-vt on Apple hosts with native linker (#13430)

Replaces: https://github.com/ghostty-org/ghostty/pull/13427

Zig's Mach-O linker does not emit LC_ENCRYPTION_INFO_64 for physical iOS
dylibs. This allows libghostty-vt to build successfully but causes
frameworks containing it to fail App Store validation.

I think it'd be cleaner to always just build Apple targets on Apple
hosts with the native linker. We don't need to rely on Zig being correct
and this helps ensure compatibility for details like this.
This commit is contained in:
Mitchell Hashimoto
2026-07-23 06:59:56 -07:00
committed by GitHub
5 changed files with 375 additions and 1 deletions

131
.github/scripts/check-apple-libghostty-vt.nu vendored Executable file
View File

@@ -0,0 +1,131 @@
#!/usr/bin/env nu
def fail [message: string] {
print -e $message
exit 1
}
def main [
binary: path
--expect-platform: int # LC_BUILD_VERSION platform value
--expect-arch: string # Mach-O architecture name
--expect-encryption # Require LC_ENCRYPTION_INFO_64
] {
if not ($binary | path exists) {
fail $"dylib not found: ($binary)"
}
# Verify the dylib contains exactly the architecture requested by the build
# matrix before interpreting any target-specific Mach-O load commands.
let actual_arch = (^lipo -archs $binary | str trim)
if $actual_arch != $expect_arch {
fail $"expected architecture ($expect_arch), found ($actual_arch)"
}
# Framework consumers load this dylib through @rpath. Keep this stable even
# though the installed file also has versioned compatibility symlinks.
let install_names = (^otool -D $binary | lines | skip 1)
if ($install_names | length) != 1 {
fail "expected exactly one dylib install name"
}
let install_name = ($install_names | first | str trim)
if $install_name != "@rpath/libghostty-vt.dylib" {
fail $"unexpected install name: ($install_name)"
}
let load_command_lines = (^otool -l $binary | lines)
let command_names = (
$load_command_lines
| parse --regex '^\s*cmd\s+(?<name>\S+)$'
| get name
)
# LC_BUILD_VERSION distinguishes macOS, physical iOS, and the iOS Simulator.
# Checking the numeric platform value catches an SDK or target-triple mixup.
if ($command_names | where $it == "LC_BUILD_VERSION" | length) != 1 {
fail "expected exactly one LC_BUILD_VERSION load command"
}
let platforms = (
$load_command_lines
| parse --regex '^\s*platform\s+(?<value>\d+)$'
| get value
)
if ($platforms | length) != 1 {
fail "expected exactly one LC_BUILD_VERSION platform field"
}
let actual_platform = ($platforms | first | into int)
if $actual_platform != $expect_platform {
fail $"expected platform ($expect_platform), found ($actual_platform)"
}
let encryption_commands = (
$command_names
| where { str starts-with "LC_ENCRYPTION_INFO" }
)
if $expect_encryption {
# App Store processing requires physical iOS dylibs to reserve an
# encryption range. cryptid remains zero until Apple encrypts the binary.
if $encryption_commands != ["LC_ENCRYPTION_INFO_64"] {
fail "missing or invalid LC_ENCRYPTION_INFO_64 load command"
}
let encryption_fields = (
$load_command_lines
| parse --regex '^\s*(?<name>cryptoff|cryptsize|cryptid)\s+(?<value>\d+)$'
)
if ($encryption_fields | length) != 3 {
fail "missing or invalid LC_ENCRYPTION_INFO_64 fields"
}
let cryptoff = (
$encryption_fields
| where name == "cryptoff"
| get 0.value
| into int
)
let cryptsize = (
$encryption_fields
| where name == "cryptsize"
| get 0.value
| into int
)
let cryptid = (
$encryption_fields
| where name == "cryptid"
| get 0.value
| into int
)
if $cryptoff <= 0 or $cryptsize <= 0 or $cryptid != 0 {
fail "missing or invalid LC_ENCRYPTION_INFO_64 fields"
}
} else {
# Apple does not emit an encryption load command for macOS or Simulator
# dylibs, so its presence would indicate the wrong platform was linked.
if not ($encryption_commands | is-empty) {
fail "unexpected LC_ENCRYPTION_INFO load command"
}
}
# nm -gjU lists defined external symbols without addresses. The export list
# passed to Apple's linker should leave only libghostty-vt's public C API.
let exports = (
^nm -gjU $binary
| lines
| where { not ($in | is-empty) }
)
if ($exports | is-empty) {
fail "dylib has no exported symbols"
}
let unexpected_exports = (
$exports
| where { not ($in | str starts-with "_ghostty_") }
)
if not ($unexpected_exports | is-empty) {
fail $"unexpected exported symbols:\n($unexpected_exports | str join "\n")"
}
}

View File

@@ -618,7 +618,23 @@ jobs:
build-libghostty-vt-macos:
strategy:
matrix:
target: [aarch64-macos, x86_64-macos, aarch64-ios]
include:
- target: aarch64-macos
platform: 1
arch: arm64
expect_encryption: false
- target: x86_64-macos
platform: 1
arch: x86_64
expect_encryption: false
- target: aarch64-ios
platform: 2
arch: arm64
expect_encryption: true
- target: aarch64-ios-simulator
platform: 7
arch: arm64
expect_encryption: false
runs-on: namespace-profile-ghostty-macos-tahoe
needs: test
env:
@@ -653,6 +669,14 @@ jobs:
nix develop -c zig build -Demit-lib-vt \
-Dtarget=${{ matrix.target }}
- name: Verify Apple dylib
run: |
nix develop -c nu .github/scripts/check-apple-libghostty-vt.nu \
zig-out/lib/libghostty-vt.dylib \
--expect-platform ${{ matrix.platform }} \
--expect-arch ${{ matrix.arch }} \
${{ matrix.expect_encryption && '--expect-encryption' || '' }}
# lib-vt requires the Android NDK for Android builds
build-libghostty-vt-android:
strategy:

View File

@@ -1,6 +1,9 @@
const std = @import("std");
const builtin = @import("builtin");
/// Helpers for linking Mach-O artifacts with Apple's native toolchain.
pub const nativeLink = @import("native_link.zig");
// The cache. This always uses b.allocator and never frees memory
// (which is idiomatic for a Zig build exe). We cache the libc txt
// file we create because it is expensive to generate (subprocesses).

View File

@@ -0,0 +1,85 @@
//! Helpers for invoking Apple's native linker through the target SDK.
//!
//! Callers provide all artifact-specific flags, inputs, and outputs.
const std = @import("std");
const builtin = @import("builtin");
const RunStep = std.Build.Step.Run;
/// Returns true when the host Apple toolchain can link the target.
///
/// This is limited to targets covered by the SDK and target-triple mappings
/// below. Add other Darwin platforms alongside support for those mappings.
pub fn available(target: std.Build.ResolvedTarget) bool {
if (!builtin.os.tag.isDarwin()) return false;
return switch (target.result.os.tag) {
.macos, .ios => true,
else => false,
};
}
/// Create an Apple Clang link command configured for the target SDK and
/// deployment version. The caller supplies the artifact-specific link flags,
/// inputs, and output.
pub fn addCommand(
b: *std.Build,
name: []const u8,
target: std.Build.ResolvedTarget,
) !*RunStep {
if (!available(target)) return error.AppleNativeLinkerUnavailable;
const run = RunStep.create(b, name);
run.addArgs(&.{
"/usr/bin/xcrun",
"--sdk",
try sdkName(target),
"clang",
"-fuse-ld=ld",
"-target",
try clangTarget(b, target),
});
return run;
}
fn sdkName(target: std.Build.ResolvedTarget) ![]const u8 {
return switch (target.result.os.tag) {
.macos => "macosx",
.ios => switch (target.result.abi) {
.simulator => "iphonesimulator",
else => "iphoneos",
},
else => error.UnsupportedAppleTarget,
};
}
fn clangTarget(
b: *std.Build,
target: std.Build.ResolvedTarget,
) ![]const u8 {
const arch = switch (target.result.cpu.arch) {
.aarch64 => "arm64",
.x86_64 => "x86_64",
else => return error.UnsupportedAppleArchitecture,
};
const minimum = target.query.os_version_min orelse
return error.AppleDeploymentTargetMissing;
return switch (target.result.os.tag) {
.macos => b.fmt(
"{s}-apple-macosx{f}",
.{ arch, minimum.semver },
),
.ios => switch (target.result.abi) {
.simulator => b.fmt(
"{s}-apple-ios{f}-simulator",
.{ arch, minimum.semver },
),
else => b.fmt(
"{s}-apple-ios{f}",
.{ arch, minimum.semver },
),
},
else => error.UnsupportedAppleTarget,
};
}

View File

@@ -102,6 +102,16 @@ pub fn initShared(
b: *std.Build,
zig: *const GhosttyZig,
) !GhosttyLibVt {
const target = zig.vt.resolved_target.?;
// Prefer Apple's linker for final Darwin dylibs when its toolchain is
// available. Besides producing the platform load commands expected by
// Apple distribution, this lets the static archive's libSystem symbol
// override take effect. Cross-compiled macOS dylibs continue through Zig.
if (@import("apple_sdk").nativeLink.available(target)) {
return initLibApple(b, zig);
}
return initLib(b, zig, .dynamic);
}
@@ -340,6 +350,127 @@ fn initLib(
};
}
/// Builds a shared Darwin library with Apple's linker.
fn initLibApple(
b: *std.Build,
zig: *const GhosttyZig,
) !GhosttyLibVt {
// Sorry, this function has a lot of comments because there are
// a lot of flags that aren't obvious.
const target = zig.vt.resolved_target.?;
assert(target.result.os.tag.isDarwin());
// Build the combined archive through the normal static path first.
const static = try initStatic(b, zig);
// Zig's module-level export metadata does not carry into this separate
// native link, so explicitly expose only libghostty-vt's public C ABI.
const exports = b.addWriteFiles().add(
"libghostty-vt.exports",
"_ghostty_*\n",
);
// Match Zig's versioned dylib naming so existing consumers and packaging
// continue to receive the same real name and compatibility symlinks.
const real_name = b.fmt("libghostty-vt.{d}.{d}.{d}.dylib", .{
zig.version.major,
zig.version.minor,
zig.version.patch,
});
const soname = b.fmt("libghostty-vt.{d}.dylib", .{zig.version.major});
const native_link = try @import("apple_sdk").nativeLink.addCommand(
b,
"link libghostty-vt with Apple ld",
target,
);
native_link.addArgs(&.{
// Emit a Mach-O dynamic library instead of an executable.
"-dynamiclib",
// Pull every object from the combined static archive.
"-Wl,-all_load",
// Remove unreachable private code pulled in by all_load.
"-Wl,-dead_strip",
// Leave room for packaging tools to rewrite install names.
"-Wl,-headerpad_max_install_names",
});
// Restrict exports to libghostty-vt's public C ABI.
native_link.addPrefixedFileArg("-Wl,-exported_symbols_list,", exports);
// Link the archive produced by the normal static-library path.
native_link.addFileArg(static.output);
native_link.addArgs(&.{
// Give framework consumers a stable runtime-relative identity.
"-install_name",
"@rpath/libghostty-vt.dylib",
// Record the exact package version for runtime inspection.
"-current_version",
b.fmt("{d}.{d}.{d}", .{
zig.version.major,
zig.version.minor,
zig.version.patch,
}),
// Preserve compatibility with the initial public ABI.
"-compatibility_version",
"1.0.0",
// Write the fully versioned dylib used by the install symlinks.
"-o",
});
const output = native_link.addOutputFileArg(real_name);
// Recreate addInstallArtifact's dylib layout: install the fully versioned
// file, then point the major-version and unversioned names at it.
const artifact_install = b.addInstallFileWithDir(
output,
.lib,
real_name,
);
const soname_install = b.addSystemCommand(&.{
"/bin/ln",
"-sf",
real_name,
b.getInstallPath(.lib, soname),
});
soname_install.step.dependOn(&artifact_install.step);
const unversioned_install = b.addSystemCommand(&.{
"/bin/ln",
"-sf",
soname,
b.getInstallPath(.lib, "libghostty-vt.dylib"),
});
unversioned_install.step.dependOn(&soname_install.step);
// The native link is a Run step rather than a Compile step, so install the
// public headers explicitly instead of relying on addInstallArtifact.
const headers_install = b.addInstallDirectory(.{
.source_dir = b.path("include/ghostty"),
.install_dir = .header,
.install_subdir = "ghostty",
.include_extensions = &.{".h"},
});
unversioned_install.step.dependOn(&headers_install.step);
// Preserve the debug-symbol output exposed by the normal shared-library
// path for framework and release packaging.
const dsymutil = RunStep.create(b, "dsymutil");
dsymutil.addArgs(&.{"dsymutil"});
dsymutil.addFileArg(output);
dsymutil.addArgs(&.{"-o"});
const dsym = dsymutil.addOutputFileArg("libghostty-vt.dSYM");
const pcs = pkgConfigFiles(b, zig, target.result.os.tag);
return .{
.step = &native_link.step,
.artifact = &unversioned_install.step,
.kind = .shared,
.output = output,
.dsym = dsym,
.pkg_config = pcs.shared,
.pkg_config_static = pcs.static,
};
}
/// Returns the Libs.private value for the pkg-config file.
/// Vendored C++ dependencies are built in no-libcxx mode so consumers
/// don't need libc++. System-provided simdutf still requires it.