diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index 28e56e7fb..0bebc2ff7 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -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: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7c40ad3e8..a3e6639d7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/build.zig b/build.zig index 78977a8c9..e8c784611 100644 --- a/build.zig +++ b/build.zig @@ -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(); diff --git a/example/.gitignore b/example/.gitignore index 6f372bc4d..9f88ccfeb 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -3,3 +3,4 @@ dist/ node_modules/ example.wasm* build/ +.build/ diff --git a/example/swift-vt-xcframework/Package.swift b/example/swift-vt-xcframework/Package.swift new file mode 100644 index 000000000..a831a42c8 --- /dev/null +++ b/example/swift-vt-xcframework/Package.swift @@ -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" + ), + ] +) diff --git a/example/swift-vt-xcframework/README.md b/example/swift-vt-xcframework/README.md new file mode 100644 index 000000000..3bbe8948c --- /dev/null +++ b/example/swift-vt-xcframework/README.md @@ -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 +``` diff --git a/example/swift-vt-xcframework/Sources/main.swift b/example/swift-vt-xcframework/Sources/main.swift new file mode 100644 index 000000000..d374f539f --- /dev/null +++ b/example/swift-vt-xcframework/Sources/main.swift @@ -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.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? +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) ?? "") + +ghostty_free(nil, buf, len) +ghostty_formatter_free(formatter) +ghostty_terminal_free(terminal) diff --git a/src/build/GhosttyLibVt.zig b/src/build/GhosttyLibVt.zig index d5e76b9de..e3e6cf8c1 100644 --- a/src/build/GhosttyLibVt.zig +++ b/src/build/GhosttyLibVt.zig @@ -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, diff --git a/src/build/GhosttyZig.zig b/src/build/GhosttyZig.zig index 3f3db95c6..8d5b78fb4 100644 --- a/src/build/GhosttyZig.zig +++ b/src/build/GhosttyZig.zig @@ -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,