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.
This commit is contained in:
Mitchell Hashimoto
2025-07-04 13:05:50 -07:00
parent 908eb6d156
commit 7bd90e6ec4
5 changed files with 177 additions and 37 deletions

View File

@ -1,4 +1,5 @@
const std = @import("std"); const std = @import("std");
const assert = std.debug.assert;
const builtin = @import("builtin"); const builtin = @import("builtin");
const buildpkg = @import("src/build/main.zig"); const buildpkg = @import("src/build/main.zig");
@ -47,6 +48,25 @@ pub fn build(b: *std.Build) !void {
exe.install(); exe.install();
resources.install(); resources.install();
i18n.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 // Libghostty
@ -55,9 +75,24 @@ pub fn build(b: *std.Build) !void {
// heavily by Ghostty on macOS but it isn't built to be reusable yet. // 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 // As such, these build steps are lacking. For example, the Darwin
// build only produces an xcframework. // build only produces an xcframework.
if (config.app_runtime == .none) { if (config.app_runtime == .none) none: {
if (config.target.result.os.tag.isDarwin()) darwin: { if (!config.target.result.os.tag.isDarwin()) {
if (!config.emit_xcframework) break :darwin; 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;
}
assert(config.target.result.os.tag.isDarwin());
if (!config.emit_xcframework) break :none;
// Build the xcframework // Build the xcframework
const xcframework = try buildpkg.GhosttyXCFramework.init(b, &deps); const xcframework = try buildpkg.GhosttyXCFramework.init(b, &deps);
@ -76,31 +111,17 @@ pub fn build(b: *std.Build) !void {
const placeholder = wf.add(path, "emit-docs not true so no man pages"); const placeholder = wf.add(path, "emit-docs not true so no man pages");
b.getInstallStep().dependOn(&b.addInstallFile(placeholder, path).step); 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);
libghostty_shared.installHeader(); // Only need one header
libghostty_shared.install("libghostty.so");
libghostty_static.install("libghostty.a");
}
}
// Run runs the Ghostty exe // Build our macOS app
{ const app = try buildpkg.GhosttyXcodebuild.init(
const run_cmd = b.addRunArtifact(exe.exe); b,
if (b.args) |args| run_cmd.addArgs(args); &config,
&xcframework,
// 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"),
); );
// Add a run command that opens our mac app.
const run_step = b.step("run", "Run the app"); const run_step = b.step("run", "Run the app");
run_step.dependOn(&run_cmd.step); run_step.dependOn(&app.open.step);
} }
// Tests // Tests

View File

@ -255,6 +255,22 @@ class AppDelegate: NSObject,
// Setup signal handlers // Setup signal handlers
setupSignals() 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) { func applicationDidBecomeActive(_ notification: Notification) {

View File

@ -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);
}

View File

@ -55,6 +55,9 @@ pub fn create(b: *std.Build, opts: Options) *XCFrameworkStep {
} }
run.addArg("-output"); run.addArg("-output");
run.addArg(opts.out_path); run.addArg(opts.out_path);
run.expectExitCode(0);
_ = run.captureStdOut();
_ = run.captureStdErr();
break :run run; break :run run;
}; };
run_create.step.dependOn(&run_delete.step); run_create.step.dependOn(&run_delete.step);

View File

@ -15,6 +15,7 @@ pub const GhosttyFrameData = @import("GhosttyFrameData.zig");
pub const GhosttyLib = @import("GhosttyLib.zig"); pub const GhosttyLib = @import("GhosttyLib.zig");
pub const GhosttyResources = @import("GhosttyResources.zig"); pub const GhosttyResources = @import("GhosttyResources.zig");
pub const GhosttyI18n = @import("GhosttyI18n.zig"); pub const GhosttyI18n = @import("GhosttyI18n.zig");
pub const GhosttyXcodebuild = @import("GhosttyXcodebuild.zig");
pub const GhosttyXCFramework = @import("GhosttyXCFramework.zig"); pub const GhosttyXCFramework = @import("GhosttyXCFramework.zig");
pub const GhosttyWebdata = @import("GhosttyWebdata.zig"); pub const GhosttyWebdata = @import("GhosttyWebdata.zig");
pub const HelpStrings = @import("HelpStrings.zig"); pub const HelpStrings = @import("HelpStrings.zig");