diff --git a/.github/scripts/check-apple-libghostty-vt.nu b/.github/scripts/check-apple-libghostty-vt.nu new file mode 100755 index 000000000..9b3174d64 --- /dev/null +++ b/.github/scripts/check-apple-libghostty-vt.nu @@ -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+(?\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+(?\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*(?cryptoff|cryptsize|cryptid)\s+(?\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")" + } +} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 26e597ec4..8e82a2853 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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: diff --git a/pkg/apple-sdk/build.zig b/pkg/apple-sdk/build.zig index 770b36531..3f76c0bf4 100644 --- a/pkg/apple-sdk/build.zig +++ b/pkg/apple-sdk/build.zig @@ -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). diff --git a/pkg/apple-sdk/native_link.zig b/pkg/apple-sdk/native_link.zig new file mode 100644 index 000000000..46ceeb766 --- /dev/null +++ b/pkg/apple-sdk/native_link.zig @@ -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, + }; +} diff --git a/src/build/GhosttyLibVt.zig b/src/build/GhosttyLibVt.zig index 32ed2aa72..b3b89a88e 100644 --- a/src/build/GhosttyLibVt.zig +++ b/src/build/GhosttyLibVt.zig @@ -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.