From ed584e769f0633f837910fcc9cae1edd1fa21a57 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 25 Jul 2025 14:24:40 -0500 Subject: [PATCH] gtk-ng: add ipc infrastructure and connect +new-window ipcs --- src/apprt/gtk-ng/App.zig | 27 +++- src/apprt/gtk-ng/class/application.zig | 64 +++++++++- src/apprt/gtk-ng/ipc/new_window.zig | 170 +++++++++++++++++++++++++ 3 files changed, 255 insertions(+), 6 deletions(-) create mode 100644 src/apprt/gtk-ng/ipc/new_window.zig diff --git a/src/apprt/gtk-ng/App.zig b/src/apprt/gtk-ng/App.zig index f630f7533..bc6c11102 100644 --- a/src/apprt/gtk-ng/App.zig +++ b/src/apprt/gtk-ng/App.zig @@ -3,6 +3,7 @@ const App = @This(); const std = @import("std"); +const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const adw = @import("adw"); const gio = @import("gio"); @@ -16,6 +17,7 @@ const Application = @import("class/application.zig").Application; const Surface = @import("Surface.zig"); const gtk_version = @import("gtk_version.zig"); const adw_version = @import("adw_version.zig"); +const ipcNewWindow = @import("ipc/new_window.zig").newWindow; const log = std.log.scoped(.gtk); @@ -24,6 +26,18 @@ const log = std.log.scoped(.gtk); /// because GTK's `GLArea` does not support drawing from a different thread. pub const must_draw_from_app_thread = true; +/// GTK application ID +pub const application_id = switch (builtin.mode) { + .Debug, .ReleaseSafe => "com.mitchellh.ghostty-debug", + .ReleaseFast, .ReleaseSmall => "com.mitchellh.ghostty", +}; + +/// GTK object path +pub const object_path = switch (builtin.mode) { + .Debug, .ReleaseSafe => "/com/mitchellh/ghostty_debug", + .ReleaseFast, .ReleaseSmall => "/com/mitchellh/ghostty", +}; + /// The GObject Application instance app: *Application, @@ -67,16 +81,21 @@ pub fn performAction( return try self.app.performAction(target, action, value); } +/// Send the given IPC to a running Ghostty. Returns `true` if the action was +/// able to be performed, `false` otherwise. +/// +/// Note that this is a static function. Since this is called from a CLI app (or +/// some other process that is not Ghostty) there is no full-featured apprt App +/// to use. pub fn performIpc( alloc: Allocator, target: apprt.ipc.Target, comptime action: apprt.ipc.Action.Key, value: apprt.ipc.Action.Value(action), ) !bool { - _ = alloc; - _ = target; - _ = value; - return false; + switch (action) { + .new_window => return try ipcNewWindow(alloc, target, value), + } } /// Redraw the inspector for the given surface. diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index ba25a5ffe..3682c5ba6 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -227,8 +227,7 @@ pub const Application = extern struct { } } - const default_id = comptime build_config.bundle_id; - break :app_id if (builtin.mode == .Debug) default_id ++ "-debug" else default_id; + break :app_id ApprtApp.application_id; }; const display: *gdk.Display = gdk.Display.getDefault() orelse { @@ -781,8 +780,23 @@ pub const Application = extern struct { /// Setup our action map. fn startupActionMap(self: *Self) void { + const t_variant_type = glib.ext.VariantType.newFor(u64); + defer t_variant_type.free(); + + const as_variant_type = glib.VariantType.new("as"); + defer as_variant_type.free(); + + // The set of actions. Each action has (in order): + // [0] The action name + // [1] The callback function + // [2] The glib.VariantType of the parameter + // + // For action names: + // https://docs.gtk.org/gio/type_func.Action.name_is_valid.html const actions = .{ .{ "quit", actionQuit, null }, + .{ "new-window", actionNewWindow, null }, + .{ "new-window-command", actionNewWindow, as_variant_type }, }; const action_map = self.as(gio.ActionMap); @@ -1013,6 +1027,52 @@ pub const Application = extern struct { }; } + /// Handle `app.new-window` and `app.new-window-command` GTK actions + pub fn actionNewWindow( + _: *gio.SimpleAction, + parameter_: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + log.debug("received new window action", .{}); + + parameter: { + // were we given a parameter? + const parameter = parameter_ orelse break :parameter; + + const as_variant_type = glib.VariantType.new("as"); + defer as_variant_type.free(); + + // ensure that the supplied parameter is an array of strings + if (glib.Variant.isOfType(parameter, as_variant_type) == 0) { + log.warn("parameter is of type {s}", .{parameter.getTypeString()}); + break :parameter; + } + + const s_variant_type = glib.VariantType.new("s"); + defer s_variant_type.free(); + + var it: glib.VariantIter = undefined; + _ = it.init(parameter); + + while (it.nextValue()) |value| { + defer value.unref(); + + // just to be sure + if (value.isOfType(s_variant_type) == 0) continue; + + var len: usize = undefined; + const buf = value.getString(&len); + const str = buf[0..len]; + + log.debug("new-window command argument: {s}", .{str}); + } + } + + _ = self.core().mailbox.push(.{ + .new_window = .{}, + }, .{ .forever = {} }); + } + //---------------------------------------------------------------- // Boilerplate/Noise diff --git a/src/apprt/gtk-ng/ipc/new_window.zig b/src/apprt/gtk-ng/ipc/new_window.zig new file mode 100644 index 000000000..f67498ae1 --- /dev/null +++ b/src/apprt/gtk-ng/ipc/new_window.zig @@ -0,0 +1,170 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const gio = @import("gio"); +const glib = @import("glib"); + +const apprt = @import("../../../apprt.zig"); +const ApprtApp = @import("../App.zig"); + +// Use a D-Bus method call to open a new window on GTK. +// See: https://wiki.gnome.org/Projects/GLib/GApplication/DBusAPI +// +// `ghostty +new-window` is equivalent to the following command (on a release build): +// +// ``` +// gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty --method org.gtk.Actions.Activate new-window [] [] +// ``` +// +// `ghostty +new-window -e echo hello` would be equivalent to the following command (on a release build): +// +// ``` +// gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty --method org.gtk.Actions.Activate new-window-command '[<@as ["echo" "hello"]>]' [] +// ``` +pub fn newWindow(alloc: Allocator, target: apprt.ipc.Target, value: apprt.ipc.Action.NewWindow) (Allocator.Error || std.posix.WriteError || apprt.ipc.Errors)!bool { + const stderr = std.io.getStdErr().writer(); + + // 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 = switch (target) { + .class => |class| result: { + // Force the usage of the class specified on the CLI to determine the + // bus name and object path. + 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 }; + }, + .detect => .{ ApprtApp.application_id, ApprtApp.object_path }, + }; + defer { + switch (target) { + .class => alloc.free(object_path), + .detect => {}, + } + } + + if (gio.Application.idIsValid(bus_name.ptr) == 0) { + try stderr.print("D-Bus bus name is not valid: {s}\n", .{bus_name}); + return error.IPCFailed; + } + + if (glib.Variant.isObjectPath(object_path.ptr) == 0) { + try stderr.print("D-Bus object path is not valid: {s}\n", .{object_path}); + return error.IPCFailed; + } + + 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 error.IPCFailed; + } + + break :dbus dbus_ orelse { + try stderr.print("gio.busGetSync returned null\n", .{}); + return error.IPCFailed; + }; + }; + defer dbus.unref(); + + // use a builder to create the D-Bus method call payload + const payload = payload: { + const payload_variant_type = glib.VariantType.new("(sava{sv})"); + defer glib.free(payload_variant_type); + + // Initialize our builder to build up our parameters + var builder: glib.VariantBuilder = undefined; + builder.init(payload_variant_type); + errdefer builder.clear(); + + // action + if (value.arguments == null) { + builder.add("s", "new-window"); + } else { + builder.add("s", "new-window-command"); + } + + // parameters + { + const av_variant_type = glib.VariantType.new("av"); + defer av_variant_type.free(); + + var parameters: glib.VariantBuilder = undefined; + parameters.init(av_variant_type); + errdefer parameters.clear(); + + if (value.arguments) |arguments| { + // If `-e` was specified on the command line, the first + // parameter is an array of strings that contain the arguments + // that came after `-e`, which will be interpreted as a command + // to run. + { + const as = glib.VariantType.new("as"); + defer as.free(); + + var command: glib.VariantBuilder = undefined; + command.init(as); + errdefer command.clear(); + + for (arguments) |argument| { + command.add("s", argument.ptr); + } + + parameters.add("v", command.end()); + } + } + + builder.addValue(parameters.end()); + } + + { + const platform_data_variant_type = glib.VariantType.new("a{sv}"); + defer platform_data_variant_type.free(); + + builder.open(platform_data_variant_type); + 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 error.IPCFailed; + } + } + + return true; +}