diff --git a/src/cli/ghostty.zig b/src/cli/ghostty.zig index c1b661f70..adb715d68 100644 --- a/src/cli/ghostty.zig +++ b/src/cli/ghostty.zig @@ -18,6 +18,7 @@ const validate_config = @import("validate_config.zig"); const crash_report = @import("crash_report.zig"); const show_face = @import("show_face.zig"); const boo = @import("boo.zig"); +const new_window = @import("new_window.zig"); /// Special commands that can be invoked via CLI flags. These are all /// invoked by using `+` as a CLI flag. The only exception is @@ -65,6 +66,9 @@ pub const Action = enum { // Boo! boo, + // Use IPC to tell the running Ghostty to open a new window. + @"new-window", + pub fn detectSpecialCase(arg: []const u8) ?SpecialCase(Action) { // If we see a "-e" and we haven't seen a command yet, then // we are done looking for commands. This special case enables @@ -136,6 +140,7 @@ pub const Action = enum { .@"crash-report" => try crash_report.run(alloc), .@"show-face" => try show_face.run(alloc), .boo => try boo.run(alloc), + .@"new-window" => try new_window.run(alloc), }; } @@ -174,6 +179,7 @@ pub const Action = enum { .@"crash-report" => crash_report.Options, .@"show-face" => show_face.Options, .boo => boo.Options, + .@"new-window" => new_window.Options, }; } } diff --git a/src/cli/new_window.zig b/src/cli/new_window.zig new file mode 100644 index 000000000..eff187dcf --- /dev/null +++ b/src/cli/new_window.zig @@ -0,0 +1,231 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const build_config = @import("../build_config.zig"); +const Action = @import("../cli.zig").ghostty.Action; +const args = @import("args.zig"); +const diagnostics = @import("diagnostics.zig"); +const font = @import("../font/main.zig"); +const configpkg = @import("../config.zig"); +const Config = configpkg.Config; + +pub const Options = struct { + /// This is set by the CLI parser for deinit. + _arena: ?ArenaAllocator = null, + + /// If `true`, open up a new window in a debug instance of Ghostty. + /// Otherwise open up a new window in a release instance of Ghostty. + debug: bool = false, + + /// If set, open up a new window in a custom instance of Ghostty. Takes + /// precedence over `--debug`. + class: ?[:0]const u8 = null, + + // Enable arg parsing diagnostics so that we don't get an error if + // there is a "normal" config setting on the cli. + _diagnostics: diagnostics.DiagnosticList = .{}, + + pub fn deinit(self: *Options) void { + if (self._arena) |arena| arena.deinit(); + self.* = undefined; + } + + /// Enables "-h" and "--help" to work. + pub fn help(self: Options) !void { + _ = self; + return Action.help_error; + } +}; + +/// The `new-window` will use native platform IPC to open up a new window in a +/// running instance of Ghostty. +/// +/// On GTK, if D-Bus activation is properly configured, Ghostty does not need +/// to be running for this to open a new window, making it suitable for binding +/// to keys in your window manager (if other methods of configuring global +/// shortcuts are unavailable). See the Ghostty website for information on +/// properly configuring D-Bus activation. +/// +/// If Ghostty is already running, D-Bus activation is unnecessary and this +/// command should cause the running instance to open a new window. +/// +/// Only supported on GTK. +/// +/// Flags: +/// +/// * `--debug`: If `true`, open up a new window in a debug instance of +/// Ghostty. Otherwise open up a new window in a release instance of +/// Ghostty. +/// +/// * `--class`: If set, open up a new window in a custom instance of Ghostty. +/// This takes precedence over the `--debug` flag. +/// +/// Available since: 1.2.0 +pub fn run(alloc: Allocator) !u8 { + var iter = try args.argsIterator(alloc); + defer iter.deinit(); + return try runArgs(alloc, &iter); +} + +fn runArgs(alloc_gpa: Allocator, argsIter: anytype) !u8 { + const stderr = std.io.getStdErr().writer(); + + var opts: Options = .{}; + defer opts.deinit(); + + args.parse(Options, alloc_gpa, &opts, argsIter) catch |err| switch (err) { + error.ActionHelpRequested => return err, + else => { + try stderr.print("Error parsing args: {}\n", .{err}); + return 1; + }, + }; + + // Print out any diagnostics, unless it's likely that the diagnostic was + // generated trying to parse a "normal" configuration setting. Exit with an + // error code if any diagnostics were printed. + if (!opts._diagnostics.empty()) { + var exit: bool = false; + outer: for (opts._diagnostics.items()) |diagnostic| { + if (diagnostic.location != .cli) continue :outer; + inner: inline for (@typeInfo(Options).@"struct".fields) |field| { + if (field.name[0] == '_') continue :inner; + if (std.mem.eql(u8, field.name, diagnostic.key)) { + try stderr.writeAll("config error: "); + try diagnostic.write(stderr); + try stderr.writeAll("\n"); + exit = true; + } + } + } + if (exit) return 1; + } + + var arena = ArenaAllocator.init(alloc_gpa); + defer arena.deinit(); + const alloc = arena.allocator(); + + // Use a D-Bus method call to open a new window on GTK. + // See: https://wiki.gnome.org/Projects/GLib/GApplication/DBusAPI + if (comptime build_config.app_runtime == .gtk) { + const gio = @import("gio"); + const glib = @import("glib"); + + // Get the appropriate bus name and object path for contacting the + // Ghostty instance we're interested in. + const bus_name: [:0]const u8, const object_path: [:0]const u8 = result: { + if (opts.class) |class| { + const object_path = try std.fmt.allocPrintZ(alloc, "/{s}", .{class}); + + std.mem.replaceScalar(u8, object_path, '.', '/'); + std.mem.replaceScalar(u8, object_path, '-', '_'); + + break :result .{ class, object_path }; + } + if (opts.debug) { + break :result .{ "com.mitchellh.ghostty-debug", "/com/mitchellh/ghostty_debug" }; + } + break :result .{ "com.mitchellh.ghostty", "/com/mitchellh/ghostty" }; + }; + + if (gio.Application.idIsValid(bus_name.ptr) == 0) { + try stderr.print("D-Bus bus name is not valid: {s}\n", .{bus_name}); + return 1; + } + + if (glib.Variant.isObjectPath(object_path.ptr) == 0) { + try stderr.print("D-Bus object path is not valid: {s}\n", .{object_path}); + return 1; + } + + const dbus = dbus: { + var err_: ?*glib.Error = null; + defer if (err_) |err| err.free(); + + const dbus_ = gio.busGetSync(.session, null, &err_); + if (err_) |err| { + try stderr.print( + "Unable to establish connection to D-Bus session bus: {s}\n", + .{err.f_message orelse "(unknown)"}, + ); + return 1; + } + + break :dbus dbus_ orelse { + try stderr.print("gio.busGetSync returned null\n", .{}); + return 1; + }; + }; + defer dbus.unref(); + + // use a builder to create the D-Bus method call payload + const payload = payload: { + const builder_type = glib.VariantType.new("(sava{sv})"); + defer glib.free(builder_type); + + // Initialize our builder to build up our parameters + var builder: glib.VariantBuilder = undefined; + builder.init(builder_type); + errdefer builder.unref(); + + // action + builder.add("s", "new-window"); + + // parameters + { + const parameters = glib.VariantType.new("av"); + defer glib.free(parameters); + + builder.open(parameters); + defer builder.close(); + + // we have no parameters + } + { + const platform_data = glib.VariantType.new("a{sv}"); + defer glib.free(platform_data); + + builder.open(platform_data); + defer builder.close(); + + // we have no platform data + } + + break :payload builder.end(); + }; + + { + var err_: ?*glib.Error = null; + defer if (err_) |err| err.free(); + + const result_ = dbus.callSync( + bus_name, + object_path, + "org.gtk.Actions", + "Activate", + payload, + null, // We don't care about the return type, we don't do anything with it. + .{}, // no flags + -1, // default timeout + null, // not cancellable + &err_, + ); + defer if (result_) |result| result.unref(); + + if (err_) |err| { + try stderr.print( + "D-Bus method call returned an error err={s}\n", + .{err.f_message orelse "(unknown)"}, + ); + return 1; + } + } + + return 0; + } + + // If we get here, the platform is unsupported. + try stderr.print("+new-window is unsupported on this platform\n", .{}); + return 1; +}