libghostty: build universal xcframework and release it on tip (#12149)

This produces a `ghostty-vt.xcframework` for `zig build -Demit-lib-vt`
when the host is macOS and the target is Apple platforms. Our CI has
been updated to release this via tip channels (GH releases and our blob
storage), too.

The xcframework contains binaries for macOS Universal (x86_64 +
aarch64), iOS, and iOS simulator.

I've added a Swift example we run in CI to verify this works. Users can
also drag and drop the XCFramework directly into Xcode.

## Example

```swift
// swift-tools-version: 5.9
import PackageDescription

let package = Package(
    name: "swift-vt-xcframework",
    platforms: [.macOS(.v13)],
    targets: [
        .executableTarget(
            name: "swift-vt-xcframework",
            dependencies: ["GhosttyVt"],
            path: "Sources",
            linkerSettings: [
                .linkedLibrary("c++"),
            ]
        ),
        .binaryTarget(
            name: "GhosttyVt",
            path: "../../zig-out/lib/ghostty-vt.xcframework"
        ),
    ]
)
```

```swift
import GhosttyVt

// Create a terminal with a small grid
var terminal: GhosttyTerminal?
var opts = GhosttyTerminalOptions(
    cols: 80,
    rows: 24,
    max_scrollback: 0
)
let result = ghostty_terminal_new(nil, &terminal, opts)
guard result == GHOSTTY_SUCCESS, let terminal else {
    fatalError("Failed to create terminal")
}

// Write some VT-encoded content
let text = "Hello from \u{1b}[1mSwift\u{1b}[0m via xcframework!\r\n"
text.withCString { ptr in
    ghostty_terminal_vt_write(terminal, ptr, strlen(ptr))
}

// Format the terminal contents as plain text
var fmtOpts = GhosttyFormatterTerminalOptions()
fmtOpts.size = MemoryLayout<GhosttyFormatterTerminalOptions>.size
fmtOpts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN
fmtOpts.trim = true

var formatter: GhosttyFormatter?
let fmtResult = ghostty_formatter_terminal_new(nil, &formatter, terminal, fmtOpts)
guard fmtResult == GHOSTTY_SUCCESS, let formatter else {
    fatalError("Failed to create formatter")
}

var buf: UnsafeMutablePointer<UInt8>?
var len: Int = 0
let allocResult = ghostty_formatter_format_alloc(formatter, nil, &buf, &len)
guard allocResult == GHOSTTY_SUCCESS, let buf else {
    fatalError("Failed to format")
}

print("Plain text (\(len) bytes):")
print(String(cString: buf))

ghostty_free(nil, buf, len)
ghostty_formatter_free(formatter)
ghostty_terminal_free(terminal)
```
This commit is contained in:
Mitchell Hashimoto
2026-04-06 14:53:39 -07:00
committed by GitHub
9 changed files with 442 additions and 2 deletions

View File

@@ -219,6 +219,7 @@ jobs:
)
runs-on: namespace-profile-ghostty-sm
env:
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
@@ -260,6 +261,110 @@ jobs:
libghostty-vt-source.tar.gz.minisig
token: ${{ secrets.GH_RELEASE_TOKEN }}
- name: Prep R2 Storage
run: |
mkdir -p blob/${GHOSTTY_COMMIT_LONG}
cp libghostty-vt-source.tar.gz blob/${GHOSTTY_COMMIT_LONG}/libghostty-vt-source.tar.gz
- name: Upload to R2
uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1.4
with:
r2-account-id: ${{ secrets.CF_R2_TIP_ACCOUNT_ID }}
r2-access-key-id: ${{ secrets.CF_R2_TIP_AWS_KEY }}
r2-secret-access-key: ${{ secrets.CF_R2_TIP_SECRET_KEY }}
r2-bucket: ghostty-tip
source-dir: blob
destination-dir: ./
- name: Echo Release URLs
run: |
echo "Release URLs:"
echo " Source Tarball: https://tip.files.ghostty.org/${GHOSTTY_COMMIT_LONG}/libghostty-vt-source.tar.gz"
build-lib-vt-xcframework:
needs: [setup]
if: |
needs.setup.outputs.should_skip != 'true' &&
(
github.event_name == 'workflow_dispatch' ||
(
github.repository_owner == 'ghostty-org' &&
github.ref_name == 'main'
)
)
runs-on: namespace-profile-ghostty-macos-tahoe
env:
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2
with:
cache: |
xcode
path: |
/Users/runner/zig
# TODO(tahoe): https://github.com/NixOS/nix/issues/13342
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Xcode Select
run: sudo xcode-select -s /Applications/Xcode_26.3.app
- name: Build XCFramework
run: nix develop -c zig build -Demit-lib-vt -Doptimize=ReleaseFast
- name: Zip XCFramework
run: |
cd zig-out/lib
zip -9 -r ../../ghostty-vt.xcframework.zip ghostty-vt.xcframework
- name: Sign XCFramework
run: |
echo -n "${{ secrets.MINISIGN_KEY }}" > minisign.key
echo -n "${{ secrets.MINISIGN_PASSWORD }}" > minisign.password
nix develop -c minisign -S -m ghostty-vt.xcframework.zip -s minisign.key < minisign.password
- name: Update Release
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
with:
name: 'Ghostty Tip ("Nightly")'
prerelease: true
tag_name: tip
target_commitish: ${{ github.sha }}
files: |
ghostty-vt.xcframework.zip
ghostty-vt.xcframework.zip.minisig
token: ${{ secrets.GH_RELEASE_TOKEN }}
- name: Prep R2 Storage
run: |
mkdir -p blob/${GHOSTTY_COMMIT_LONG}
cp ghostty-vt.xcframework.zip blob/${GHOSTTY_COMMIT_LONG}/ghostty-vt.xcframework.zip
- name: Upload to R2
uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1.4
with:
r2-account-id: ${{ secrets.CF_R2_TIP_ACCOUNT_ID }}
r2-access-key-id: ${{ secrets.CF_R2_TIP_AWS_KEY }}
r2-secret-access-key: ${{ secrets.CF_R2_TIP_SECRET_KEY }}
r2-bucket: ghostty-tip
source-dir: blob
destination-dir: ./
- name: Echo Release URLs
run: |
echo "Release URLs:"
echo " XCFramework: https://tip.files.ghostty.org/${GHOSTTY_COMMIT_LONG}/ghostty-vt.xcframework.zip"
build-macos:
needs: [setup]
if: |

View File

@@ -89,6 +89,7 @@ jobs:
- build-examples-zig
- build-examples-cmake
- build-examples-cmake-windows
- build-examples-swift
- build-cmake
- build-flatpak
- build-libghostty-vt
@@ -181,6 +182,7 @@ jobs:
outputs:
zig: ${{ steps.list.outputs.zig }}
cmake: ${{ steps.list.outputs.cmake }}
swift: ${{ steps.list.outputs.swift }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -193,6 +195,9 @@ jobs:
cmake=$(ls example/*/CMakeLists.txt 2>/dev/null | xargs -n1 dirname | xargs -n1 basename | jq -R -s -c 'split("\n") | map(select(. != ""))')
echo "$cmake" | jq .
echo "cmake=$cmake" >> "$GITHUB_OUTPUT"
swift=$(ls example/*/Package.swift 2>/dev/null | xargs -n1 dirname | xargs -n1 basename | jq -R -s -c 'split("\n") | map(select(. != ""))')
echo "$swift" | jq .
echo "swift=$swift" >> "$GITHUB_OUTPUT"
build-examples-zig:
strategy:
@@ -290,6 +295,49 @@ jobs:
cmake -B build -DFETCHCONTENT_SOURCE_DIR_GHOSTTY=${{ github.workspace }}
cmake --build build
build-examples-swift:
strategy:
fail-fast: false
matrix:
dir: ${{ fromJSON(needs.list-examples.outputs.swift) }}
name: Example ${{ matrix.dir }}
runs-on: namespace-profile-ghostty-macos-tahoe
needs: [test, list-examples]
env:
ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2
with:
cache: |
xcode
path: |
/Users/runner/zig
# TODO(tahoe): https://github.com/NixOS/nix/issues/13342
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Xcode Select
run: sudo xcode-select -s /Applications/Xcode_26.3.app
- name: Build XCFramework
run: nix develop -c zig build -Demit-lib-vt
- name: Build and Run Example
run: |
cd example/${{ matrix.dir }}
swift run
build-cmake:
runs-on: namespace-profile-ghostty-sm
needs: test

View File

@@ -152,6 +152,20 @@ pub fn build(b: *std.Build) !void {
).step);
}
// libghostty-vt xcframework (Apple only, universal binary).
// Only when building on macOS (not cross-compiling) since
// xcodebuild is required.
if (builtin.os.tag.isDarwin() and config.target.result.os.tag.isDarwin()) {
const apple_libs = try buildpkg.GhosttyLibVt.initStaticAppleUniversal(
b,
&config,
&deps,
&mod,
);
const xcframework = buildpkg.GhosttyLibVt.xcframework(&apple_libs, b);
b.getInstallStep().dependOn(xcframework.step);
}
// Helpgen
if (config.emit_helpgen) deps.help_strings.install();

1
example/.gitignore vendored
View File

@@ -3,3 +3,4 @@ dist/
node_modules/
example.wasm*
build/
.build/

View File

@@ -0,0 +1,21 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "swift-vt-xcframework",
platforms: [.macOS(.v13)],
targets: [
.executableTarget(
name: "swift-vt-xcframework",
dependencies: ["GhosttyVt"],
path: "Sources",
linkerSettings: [
.linkedLibrary("c++"),
]
),
.binaryTarget(
name: "GhosttyVt",
path: "../../zig-out/lib/ghostty-vt.xcframework"
),
]
)

View File

@@ -0,0 +1,23 @@
# swift-vt-xcframework
Demonstrates consuming libghostty-vt from a Swift Package using the
pre-built XCFramework. Creates a terminal, writes VT sequences into it,
and formats the screen contents as plain text.
This example requires the XCFramework to be built first.
## Building
First, build the XCFramework from the repository root:
```shell-session
zig build -Demit-lib-vt
```
Then build and run the Swift package:
```shell-session
cd example/swift-vt-xcframework
swift build
swift run
```

View File

@@ -0,0 +1,47 @@
import Foundation
import GhosttyVt
// Create a terminal with a small grid
var terminal: GhosttyTerminal?
var opts = GhosttyTerminalOptions(
cols: 80,
rows: 24,
max_scrollback: 0
)
let result = ghostty_terminal_new(nil, &terminal, opts)
guard result == GHOSTTY_SUCCESS, let terminal else {
fatalError("Failed to create terminal")
}
// Write some VT-encoded content
let text = "Hello from \u{1b}[1mSwift\u{1b}[0m via xcframework!\r\n"
text.withCString { ptr in
ghostty_terminal_vt_write(terminal, ptr, strlen(ptr))
}
// Format the terminal contents as plain text
var fmtOpts = GhosttyFormatterTerminalOptions()
fmtOpts.size = MemoryLayout<GhosttyFormatterTerminalOptions>.size
fmtOpts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN
fmtOpts.trim = true
var formatter: GhosttyFormatter?
let fmtResult = ghostty_formatter_terminal_new(nil, &formatter, terminal, fmtOpts)
guard fmtResult == GHOSTTY_SUCCESS, let formatter else {
fatalError("Failed to create formatter")
}
var buf: UnsafeMutablePointer<UInt8>?
var len: Int = 0
let allocResult = ghostty_formatter_format_alloc(formatter, nil, &buf, &len)
guard allocResult == GHOSTTY_SUCCESS, let buf else {
fatalError("Failed to format")
}
print("Plain text (\(len) bytes):")
let data = Data(bytes: buf, count: len)
print(String(data: data, encoding: .utf8) ?? "<invalid UTF-8>")
ghostty_free(nil, buf, len)
ghostty_formatter_free(formatter)
ghostty_terminal_free(terminal)

View File

@@ -4,9 +4,12 @@ const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const RunStep = std.Build.Step.Run;
const Config = @import("Config.zig");
const GhosttyZig = @import("GhosttyZig.zig");
const LibtoolStep = @import("LibtoolStep.zig");
const LipoStep = @import("LipoStep.zig");
const SharedDeps = @import("SharedDeps.zig");
const XCFrameworkStep = @import("XCFrameworkStep.zig");
/// The step that generates the file.
step: *std.Build.Step,
@@ -99,6 +102,93 @@ pub fn initShared(
return initLib(b, zig, .dynamic);
}
/// Apple platform targets for xcframework slices.
pub const ApplePlatform = enum {
macos_universal,
ios,
ios_simulator,
// tvOS, watchOS, and visionOS are not yet supported by Zig's
// standard library (missing PATH_MAX, mcontext fields, etc.).
/// Platforms that have device + simulator pairs, gated on SDK detection.
const sdk_platforms = [_]struct {
os_tag: std.Target.Os.Tag,
device: ApplePlatform,
simulator: ApplePlatform,
}{
.{ .os_tag = .ios, .device = .ios, .simulator = .ios_simulator },
};
};
/// Static libraries for each Apple platform, keyed by `ApplePlatform`.
pub const AppleLibs = std.EnumMap(ApplePlatform, GhosttyLibVt);
/// Build static libraries for all available Apple platforms.
/// Always builds a macOS universal (arm64 + x86_64) fat binary.
/// Additional platforms are included if their SDK is detected.
pub fn initStaticAppleUniversal(
b: *std.Build,
cfg: *const Config,
deps: *const SharedDeps,
zig: *const GhosttyZig,
) !AppleLibs {
var result: AppleLibs = .{};
// macOS universal (arm64 + x86_64)
const aarch64_zig = try zig.retarget(
b,
cfg,
deps,
Config.genericMacOSTarget(b, .aarch64),
);
const x86_64_zig = try zig.retarget(
b,
cfg,
deps,
Config.genericMacOSTarget(b, .x86_64),
);
const aarch64 = try initStatic(b, &aarch64_zig);
const x86_64 = try initStatic(b, &x86_64_zig);
const universal = LipoStep.create(b, .{
.name = "ghostty-vt",
.out_name = "libghostty-vt.a",
.input_a = aarch64.output,
.input_b = x86_64.output,
});
result.put(.macos_universal, .{
.step = universal.step,
.artifact = universal.step,
.kind = .static,
.output = universal.output,
.dsym = null,
.pkg_config = null,
});
// Additional Apple platforms, each gated on SDK availability.
for (ApplePlatform.sdk_platforms) |p| {
const target_query: std.Target.Query = .{
.cpu_arch = .aarch64,
.os_tag = p.os_tag,
.os_version_min = Config.osVersionMin(p.os_tag),
};
if (detectAppleSDK(b.resolveTargetQuery(target_query).result)) {
const dev_zig = try zig.retarget(b, cfg, deps, b.resolveTargetQuery(target_query));
result.put(p.device, try initStatic(b, &dev_zig));
const sim_zig = try zig.retarget(b, cfg, deps, b.resolveTargetQuery(.{
.cpu_arch = .aarch64,
.os_tag = p.os_tag,
.os_version_min = Config.osVersionMin(p.os_tag),
.abi = .simulator,
.cpu_model = .{ .explicit = &std.Target.aarch64.cpu.apple_a17 },
}));
result.put(p.simulator, try initStatic(b, &sim_zig));
}
}
return result;
}
fn initLib(
b: *std.Build,
zig: *const GhosttyZig,
@@ -294,6 +384,59 @@ fn requiresPrivate(b: *std.Build) []const u8 {
return "";
}
/// Create an XCFramework bundle from Apple platform static libraries.
pub fn xcframework(
apple_libs: *const AppleLibs,
b: *std.Build,
) *XCFrameworkStep {
// Generate a headers directory with a module map for Swift PM.
// We can't use include/ directly because it contains a module map
// for GhosttyKit (the macOS app library).
const wf = b.addWriteFiles();
_ = wf.addCopyDirectory(
b.path("include/ghostty"),
"ghostty",
.{ .include_extensions = &.{".h"} },
);
_ = wf.add("module.modulemap",
\\module GhosttyVt {
\\ umbrella header "ghostty/vt.h"
\\ export *
\\}
\\
);
const headers = wf.getDirectory();
var libraries: [AppleLibs.len]XCFrameworkStep.Library = undefined;
var lib_count: usize = 0;
for (std.enums.values(ApplePlatform)) |platform| {
if (apple_libs.get(platform)) |lib| {
libraries[lib_count] = .{
.library = lib.output,
.headers = headers,
.dsym = null,
};
lib_count += 1;
}
}
return XCFrameworkStep.create(b, .{
.name = "ghostty-vt",
.out_path = b.pathJoin(&.{ b.install_prefix, "lib/ghostty-vt.xcframework" }),
.libraries = libraries[0..lib_count],
});
}
/// Returns true if the Apple SDK for the given target is installed.
fn detectAppleSDK(target: std.Target) bool {
_ = std.zig.LibCInstallation.findNative(.{
.allocator = std.heap.page_allocator,
.target = &target,
.verbose = false,
}) catch return false;
return true;
}
pub fn install(
self: *const GhosttyLibVt,
step: *std.Build.Step,

View File

@@ -24,6 +24,44 @@ pub fn init(
b: *std.Build,
cfg: *const Config,
deps: *const SharedDeps,
) !GhosttyZig {
return initInner(b, cfg, deps, "ghostty-vt", "ghostty-vt-c");
}
/// Create a new GhosttyZig with modules retargeted to a different
/// architecture. Used to produce universal (fat) binaries on macOS.
pub fn retarget(
self: *const GhosttyZig,
b: *std.Build,
cfg: *const Config,
deps: *const SharedDeps,
target: std.Build.ResolvedTarget,
) !GhosttyZig {
_ = self;
const retargeted_config = try b.allocator.create(Config);
retargeted_config.* = cfg.*;
retargeted_config.target = target;
const retargeted_deps = try b.allocator.create(SharedDeps);
retargeted_deps.* = try deps.retarget(b, target);
// Use unique module names to avoid collisions with the original target.
const arch_name = @tagName(target.result.cpu.arch);
return initInner(
b,
retargeted_config,
retargeted_deps,
b.fmt("ghostty-vt-{s}", .{arch_name}),
b.fmt("ghostty-vt-c-{s}", .{arch_name}),
);
}
fn initInner(
b: *std.Build,
cfg: *const Config,
deps: *const SharedDeps,
vt_name: []const u8,
vt_c_name: []const u8,
) !GhosttyZig {
// Terminal module build options
var vt_options = cfg.terminalOptions(.lib);
@@ -37,7 +75,7 @@ pub fn init(
return .{
.vt = try initVt(
"ghostty-vt",
vt_name,
b,
cfg,
deps,
@@ -46,7 +84,7 @@ pub fn init(
),
.vt_c = try initVt(
"ghostty-vt-c",
vt_c_name,
b,
cfg,
deps,