From 7bd90e6ec4354e0a25f95225f1508293912612d4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 4 Jul 2025 13:05:50 -0700 Subject: [PATCH] Build system can build macOS app bundle and open it `zig build run` on macOS now builds the app bundle via the `xcodebuild` CLI and runs it. The experience for running the app is now very similar to Linux or the prior GLFW build, where the app runs, blocks the zig command, and logs to the terminal. `xcodebuild` has its own build cache system that we can't really hook into so it runs on every `zig build run` command, but it does cache and I find its actually relatively fast so I think this is a good replacement for the glfw-based system. --- build.zig | 95 +++++++++++++--------- macos/Sources/App/macOS/AppDelegate.swift | 16 ++++ src/build/GhosttyXcodebuild.zig | 99 +++++++++++++++++++++++ src/build/XCFrameworkStep.zig | 3 + src/build/main.zig | 1 + 5 files changed, 177 insertions(+), 37 deletions(-) create mode 100644 src/build/GhosttyXcodebuild.zig diff --git a/build.zig b/build.zig index 80af88488..4bd6e0b46 100644 --- a/build.zig +++ b/build.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const assert = std.debug.assert; const builtin = @import("builtin"); const buildpkg = @import("src/build/main.zig"); @@ -47,6 +48,25 @@ pub fn build(b: *std.Build) !void { exe.install(); resources.install(); i18n.install(); + + // Run runs the Ghostty exe. We only do this if we are building + // an apprt. + { + const run_cmd = b.addRunArtifact(exe.exe); + if (b.args) |args| run_cmd.addArgs(args); + + // Set the proper resources dir so things like shell integration + // work correctly. If we're running `zig build run` in Ghostty, + // this also ensures it overwrites the release one with our debug + // build. + run_cmd.setEnvironmentVariable( + "GHOSTTY_RESOURCES_DIR", + b.getInstallPath(.prefix, "share/ghostty"), + ); + + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); + } } // Libghostty @@ -55,52 +75,53 @@ pub fn build(b: *std.Build) !void { // heavily by Ghostty on macOS but it isn't built to be reusable yet. // As such, these build steps are lacking. For example, the Darwin // build only produces an xcframework. - if (config.app_runtime == .none) { - if (config.target.result.os.tag.isDarwin()) darwin: { - if (!config.emit_xcframework) break :darwin; - - // Build the xcframework - const xcframework = try buildpkg.GhosttyXCFramework.init(b, &deps); - xcframework.install(); - - // The xcframework build always installs resources because our - // macOS xcode project contains references to them. - resources.install(); - i18n.install(); - - // If we aren't emitting docs we need to emit a placeholder so - // our macOS xcodeproject builds. - if (!config.emit_docs) { - var wf = b.addWriteFiles(); - const path = "share/man/.placeholder"; - const placeholder = wf.add(path, "emit-docs not true so no man pages"); - b.getInstallStep().dependOn(&b.addInstallFile(placeholder, path).step); - } - } else { - const libghostty_shared = try buildpkg.GhosttyLib.initShared(b, &deps); - const libghostty_static = try buildpkg.GhosttyLib.initStatic(b, &deps); + if (config.app_runtime == .none) none: { + if (!config.target.result.os.tag.isDarwin()) { + const libghostty_shared = try buildpkg.GhosttyLib.initShared( + b, + &deps, + ); + const libghostty_static = try buildpkg.GhosttyLib.initStatic( + b, + &deps, + ); libghostty_shared.installHeader(); // Only need one header libghostty_shared.install("libghostty.so"); libghostty_static.install("libghostty.a"); + break :none; } - } - // Run runs the Ghostty exe - { - const run_cmd = b.addRunArtifact(exe.exe); - if (b.args) |args| run_cmd.addArgs(args); + assert(config.target.result.os.tag.isDarwin()); + if (!config.emit_xcframework) break :none; - // Set the proper resources dir so things like shell integration - // work correctly. If we're running `zig build run` in Ghostty, - // this also ensures it overwrites the release one with our debug - // build. - run_cmd.setEnvironmentVariable( - "GHOSTTY_RESOURCES_DIR", - b.getInstallPath(.prefix, "share/ghostty"), + // Build the xcframework + const xcframework = try buildpkg.GhosttyXCFramework.init(b, &deps); + xcframework.install(); + + // The xcframework build always installs resources because our + // macOS xcode project contains references to them. + resources.install(); + i18n.install(); + + // If we aren't emitting docs we need to emit a placeholder so + // our macOS xcodeproject builds. + if (!config.emit_docs) { + var wf = b.addWriteFiles(); + const path = "share/man/.placeholder"; + const placeholder = wf.add(path, "emit-docs not true so no man pages"); + b.getInstallStep().dependOn(&b.addInstallFile(placeholder, path).step); + } + + // Build our macOS app + const app = try buildpkg.GhosttyXcodebuild.init( + b, + &config, + &xcframework, ); + // Add a run command that opens our mac app. const run_step = b.step("run", "Run the app"); - run_step.dependOn(&run_cmd.step); + run_step.dependOn(&app.open.step); } // Tests diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 418005927..efc09ede9 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -255,6 +255,22 @@ class AppDelegate: NSObject, // Setup signal handlers setupSignals() + + // This is a hack used by our build scripts, specifically `zig build run`, + // to force our app to the foreground. + if ProcessInfo.processInfo.environment["GHOSTTY_MAC_ACTIVATE"] == "1" { + // This never gets called until we click the dock icon. This forces it + // activate immediately. + applicationDidBecomeActive(.init(name: NSApplication.didBecomeActiveNotification)) + + // We run in the background, this forces us to the front. + DispatchQueue.main.async { + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + NSApp.unhide(nil) + NSApp.arrangeInFront(nil) + } + } } func applicationDidBecomeActive(_ notification: Notification) { diff --git a/src/build/GhosttyXcodebuild.zig b/src/build/GhosttyXcodebuild.zig new file mode 100644 index 000000000..83ab0aed3 --- /dev/null +++ b/src/build/GhosttyXcodebuild.zig @@ -0,0 +1,99 @@ +const Ghostty = @This(); + +const std = @import("std"); +const RunStep = std.Build.Step.Run; +const Config = @import("Config.zig"); +const XCFramework = @import("GhosttyXCFramework.zig"); + +xcodebuild: *std.Build.Step.Run, +open: *std.Build.Step.Run, + +pub fn init( + b: *std.Build, + config: *const Config, + xcframework: *const XCFramework, +) !Ghostty { + const xc_config = switch (config.optimize) { + .Debug => "Debug", + .ReleaseSafe, + .ReleaseSmall, + .ReleaseFast, + => "Release", + }; + + // Our step to build the Ghostty macOS app. + const build = build: { + // External environment variables can mess up xcodebuild, so + // we create a new empty environment. + const env_map = try b.allocator.create(std.process.EnvMap); + env_map.* = .init(b.allocator); + + const build = RunStep.create(b, "xcodebuild"); + build.has_side_effects = true; + build.cwd = b.path("macos"); + build.env_map = env_map; + build.addArgs(&.{ + "xcodebuild", + "-target", + "Ghostty", + "-configuration", + xc_config, + }); + + // We need the xcframework + build.step.dependOn(xcframework.xcframework.step); + + // Expect success + build.expectExitCode(0); + + // Capture stdout/stderr so we don't pollute our zig build + _ = build.captureStdOut(); + _ = build.captureStdErr(); + break :build build; + }; + + // Our step to open the resulting Ghostty app. + const open = open: { + const open = RunStep.create(b, "run Ghostty app"); + open.has_side_effects = true; + open.cwd = b.path("macos"); + open.addArgs(&.{ + b.fmt( + "build/{s}/Ghostty.app/Contents/MacOS/ghostty", + .{xc_config}, + ), + }); + + // Open depends on the app + open.step.dependOn(&build.step); + + // This overrides our default behavior and forces logs to show + // up on stderr (in addition to the centralized macOS log). + open.setEnvironmentVariable("GHOSTTY_LOG", "1"); + + // This is hack so that we can activate the app and bring it to + // the front forcibly even though we're executing directly + // via the binary and not launch services. + open.setEnvironmentVariable("GHOSTTY_MAC_ACTIVATE", "1"); + + if (b.args) |args| { + open.addArgs(args); + } else { + // This tricks the app into thinking it's running from the + // app bundle so we don't execute our CLI mode. + open.setEnvironmentVariable("GHOSTTY_MAC_APP", "1"); + } + + break :open open; + }; + + return .{ + .xcodebuild = build, + .open = open, + }; +} + +pub fn install(self: *const Ghostty) void { + const b = self.xcodebuild.step.owner; + b.getInstallStep().dependOn(&self.xcodebuild.step); +} diff --git a/src/build/XCFrameworkStep.zig b/src/build/XCFrameworkStep.zig index 823e5aac4..8a0d5dc67 100644 --- a/src/build/XCFrameworkStep.zig +++ b/src/build/XCFrameworkStep.zig @@ -55,6 +55,9 @@ pub fn create(b: *std.Build, opts: Options) *XCFrameworkStep { } run.addArg("-output"); run.addArg(opts.out_path); + run.expectExitCode(0); + _ = run.captureStdOut(); + _ = run.captureStdErr(); break :run run; }; run_create.step.dependOn(&run_delete.step); diff --git a/src/build/main.zig b/src/build/main.zig index 3154d395f..f25ce1c23 100644 --- a/src/build/main.zig +++ b/src/build/main.zig @@ -15,6 +15,7 @@ pub const GhosttyFrameData = @import("GhosttyFrameData.zig"); pub const GhosttyLib = @import("GhosttyLib.zig"); pub const GhosttyResources = @import("GhosttyResources.zig"); pub const GhosttyI18n = @import("GhosttyI18n.zig"); +pub const GhosttyXcodebuild = @import("GhosttyXcodebuild.zig"); pub const GhosttyXCFramework = @import("GhosttyXCFramework.zig"); pub const GhosttyWebdata = @import("GhosttyWebdata.zig"); pub const HelpStrings = @import("HelpStrings.zig");