diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index a8a9015b3..4f4cc77d4 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -19,6 +19,7 @@ const internal_os = @import("../../../os/main.zig"); const systemd = @import("../../../os/systemd.zig"); const terminal = @import("../../../terminal/main.zig"); const xev = @import("../../../global.zig").xev; +const Binding = @import("../../../input.zig").Binding; const CoreConfig = configpkg.Config; const CoreSurface = @import("../../../Surface.zig"); @@ -34,6 +35,7 @@ const Surface = @import("surface.zig").Surface; const Window = @import("window.zig").Window; const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; const ConfigErrorsDialog = @import("config_errors_dialog.zig").ConfigErrorsDialog; +const GlobalShortcuts = @import("global_shortcuts.zig").GlobalShortcuts; const log = std.log.scoped(.gtk_ghostty_application); @@ -105,6 +107,9 @@ pub const Application = extern struct { /// State and logic for the underlying windowing protocol. winproto: winprotopkg.App, + /// The global shortcut logic. + global_shortcuts: *GlobalShortcuts, + /// The base path of the transient cgroup used to put all surfaces /// into their own cgroup. This is only set if cgroups are enabled /// and initialization was successful. @@ -305,6 +310,7 @@ pub const Application = extern struct { .winproto = wp, .css_provider = css_provider, .custom_css_providers = .empty, + .global_shortcuts = gobject.ext.newInstance(GlobalShortcuts, .{}), }; // Signals @@ -332,6 +338,7 @@ pub const Application = extern struct { const priv = self.private(); priv.config.unref(); priv.winproto.deinit(alloc); + priv.global_shortcuts.unref(); if (priv.transient_cgroup_base) |base| alloc.free(base); if (gdk.Display.getDefault()) |display| { gtk.StyleContext.removeProviderForDisplay( @@ -935,6 +942,9 @@ pub const Application = extern struct { // Setup our action map self.startupActionMap(); + // Setup our global shortcuts + self.startupGlobalShortcuts(); + // Setup our cgroup for the application. self.startupCgroup() catch |err| { log.warn("cgroup initialization failed err={}", .{err}); @@ -1073,6 +1083,34 @@ pub const Application = extern struct { } } + /// Setup our global shortcuts. + fn startupGlobalShortcuts(self: *Self) void { + const priv = self.private(); + + // On startup, our dbus connection should be available. + priv.global_shortcuts.setDbusConnection( + self.as(gio.Application).getDbusConnection(), + ); + + // Setup a binding so that the shortcut config always matches the app. + _ = gobject.Object.bindProperty( + self.as(gobject.Object), + "config", + priv.global_shortcuts.as(gobject.Object), + "config", + .{ .sync_create = true }, + ); + + // Setup the signal handler for global shortcut triggers + _ = GlobalShortcuts.signals.trigger.connect( + priv.global_shortcuts, + *Application, + globalShortcutTrigger, + self, + .{}, + ); + } + const CgroupError = error{ DbusConnectionFailed, CgroupInitFailed, @@ -1303,6 +1341,16 @@ pub const Application = extern struct { dialog.present(null); } + fn globalShortcutTrigger( + _: *GlobalShortcuts, + action: *const Binding.Action, + self: *Self, + ) callconv(.c) void { + self.core().performAllAction(self.rt(), action.*) catch |err| { + log.warn("failed to perform action={}", .{err}); + }; + } + fn actionReloadConfig( _: *gio.SimpleAction, _: ?*glib.Variant, diff --git a/src/apprt/gtk-ng/class/global_shortcuts.zig b/src/apprt/gtk-ng/class/global_shortcuts.zig new file mode 100644 index 000000000..b15a0d5a9 --- /dev/null +++ b/src/apprt/gtk-ng/class/global_shortcuts.zig @@ -0,0 +1,623 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const adw = @import("adw"); +const gio = @import("gio"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const Binding = @import("../../../input.zig").Binding; +const gresource = @import("../build/gresource.zig"); +const key = @import("../key.zig"); +const Common = @import("../class.zig").Common; +const Application = @import("application.zig").Application; +const Config = @import("config.zig").Config; + +const log = std.log.scoped(.gtk_ghostty_global_shortcuts); + +pub const GlobalShortcuts = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = gobject.Object; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyGlobalShortcuts", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const config = struct { + pub const name = "config"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Config, + .{ + .nick = "Config", + .blurb = "The configuration that this is using.", + .accessor = C.privateObjFieldAccessor("config"), + }, + ); + }; + + pub const @"dbus-connection" = struct { + pub const name = "dbus-connection"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*gio.DBusConnection, + .{ + .nick = "Dbus Connection", + .blurb = "The dbus connection to use.", + .accessor = C.privateObjFieldAccessor("dbus_connection"), + }, + ); + }; + }; + + const Private = struct { + /// The configuration that this is using. + config: ?*Config = null, + + /// The dbus connection. + dbus_connection: ?*gio.DBusConnection = null, + + /// An arena allocator that is present for each refresh. + arena: ?std.heap.ArenaAllocator = null, + + /// A mapping from a unique ID to an action. + /// Currently the unique ID is simply the serialized representation of the + /// trigger that was used for the action as triggers are unique in the keymap, + /// but this may change in the future. + map: std.StringArrayHashMapUnmanaged(Binding.Action) = .{}, + + /// The handle of the current global shortcuts portal session, + /// as a D-Bus object path. + handle: ?[:0]const u8 = null, + + /// The D-Bus signal subscription for the response signal on requests. + /// The ID is guaranteed to be non-zero, so we can use 0 to indicate null. + response_subscription: c_uint = 0, + + /// The D-Bus signal subscription for the keybind activate signal. + /// The ID is guaranteed to be non-zero, so we can use 0 to indicate null. + activate_subscription: c_uint = 0, + + pub var offset: c_int = 0; + }; + + pub const signals = struct { + /// Emitted whenever a global shortcut is triggered. + pub const trigger = struct { + pub const name = "trigger"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{*const Binding.Action}, + void, + ); + }; + }; + + fn init(self: *Self, _: *Class) callconv(.C) void { + _ = gobject.Object.signals.notify.connect( + self, + *Self, + propConfig, + self, + .{ .detail = "config" }, + ); + } + + fn close(self: *Self) void { + const priv = self.private(); + const dbus = priv.dbus_connection orelse return; + + if (priv.response_subscription != 0) { + dbus.signalUnsubscribe(priv.response_subscription); + priv.response_subscription = 0; + } + + if (priv.activate_subscription != 0) { + dbus.signalUnsubscribe(priv.activate_subscription); + priv.activate_subscription = 0; + } + + if (priv.handle) |handle| { + // Close existing session + dbus.call( + "org.freedesktop.portal.Desktop", + handle, + "org.freedesktop.portal.Session", + "Close", + null, + null, + .{}, + -1, + null, + null, + null, + ); + priv.handle = null; + } + + if (priv.arena) |*arena| { + arena.deinit(); + priv.arena = null; + priv.map = .{}; // Uses arena memory + } + } + + fn refresh(self: *Self) !void { + // Always close our previous state first. + self.close(); + + const priv = self.private(); + + // We need configuration to proceed. + const config = if (priv.config) |v| v.get() else return; + + // Setup our new arena that we'll use for memory allocations. + assert(priv.arena == null); + var arena: std.heap.ArenaAllocator = .init(Application.default().allocator()); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + // Our map starts out empty again. We don't need to worry about + // memory because its part of the arena we clear. + priv.map = .{}; + errdefer priv.map = .{}; + + // Update map + var trigger_buf: [256]u8 = undefined; + var it = config.keybind.set.bindings.iterator(); + while (it.next()) |entry| { + const leaf = switch (entry.value_ptr.*) { + // Global shortcuts can't have leaders + .leader => continue, + .leaf => |leaf| leaf, + }; + if (!leaf.flags.global) continue; + + const trigger = try key.xdgShortcutFromTrigger( + &trigger_buf, + entry.key_ptr.*, + ) orelse continue; + + try priv.map.put( + alloc, + try alloc.dupeZ(u8, trigger), + leaf.action, + ); + } + + // Store our arena + priv.arena = arena; + + // Create our session if we have global shortcuts. + if (priv.map.count() > 0) try self.request(.create_session); + } + + const Method = enum { + create_session, + bind_shortcuts, + + fn name(self: Method) [:0]const u8 { + return switch (self) { + .create_session => "CreateSession", + .bind_shortcuts => "BindShortcuts", + }; + } + + /// Construct the payload expected by the XDG portal call. + fn makePayload( + self: Method, + shortcuts: *GlobalShortcuts, + request_token: [:0]const u8, + ) ?*glib.Variant { + switch (self) { + // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-createsession + .create_session => { + var session_token: Token = undefined; + return glib.Variant.newParsed( + "({'handle_token': <%s>, 'session_handle_token': <%s>},)", + request_token.ptr, + generateToken(&session_token).ptr, + ); + }, + + // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-bindshortcuts + .bind_shortcuts => { + const priv = shortcuts.private(); + const handle = priv.handle orelse return null; + + const bind_type = glib.VariantType.new("a(sa{sv})"); + defer glib.free(bind_type); + + var binds: glib.VariantBuilder = undefined; + glib.VariantBuilder.init(&binds, bind_type); + + var action_buf: [256]u8 = undefined; + + var it = priv.map.iterator(); + while (it.next()) |entry| { + const trigger = entry.key_ptr.*.ptr; + const action = std.fmt.bufPrintZ( + &action_buf, + "{}", + .{entry.value_ptr.*}, + ) catch continue; + + binds.addParsed( + "(%s, {'description': <%s>, 'preferred_trigger': <%s>})", + trigger, + action.ptr, + trigger, + ); + } + + return glib.Variant.newParsed( + "(%o, %*, '', {'handle_token': <%s>})", + handle.ptr, + binds.end(), + request_token.ptr, + ); + }, + } + } + + fn onResponse(self: Method, shortcuts: *GlobalShortcuts, vardict: *glib.Variant) void { + switch (self) { + .create_session => { + var handle: ?[*:0]u8 = null; + if (vardict.lookup("session_handle", "&s", &handle) == 0) { + log.warn( + "session handle not found in response={s}", + .{vardict.print(@intFromBool(true))}, + ); + return; + } + + const priv = shortcuts.private(); + const dbus = priv.dbus_connection.?; + const alloc = priv.arena.?.allocator(); + priv.handle = alloc.dupeZ(u8, std.mem.span(handle.?)) catch { + log.warn("out of memory: failed to clone session handle", .{}); + return; + }; + log.debug("session_handle={?s}", .{handle}); + + // Subscribe to keybind activations + assert(priv.activate_subscription == 0); + priv.activate_subscription = dbus.signalSubscribe( + null, + "org.freedesktop.portal.GlobalShortcuts", + "Activated", + "/org/freedesktop/portal/desktop", + handle, + .{ .match_arg0_path = true }, + shortcutActivated, + shortcuts, + null, + ); + + shortcuts.request(.bind_shortcuts) catch |err| { + log.warn("failed to bind shortcuts={}", .{err}); + return; + }; + }, + .bind_shortcuts => {}, + } + } + }; + + /// Submit a request to the global shortcuts portal. + fn request( + self: *Self, + comptime method: Method, + ) Allocator.Error!void { + // NOTE(pluiedev): + // XDG Portals are really, really poorly-designed pieces of hot garbage. + // How the protocol is _initially_ designed to work is as follows: + // + // 1. The client calls a method which returns the path of a Request object; + // 2. The client waits for the Response signal under said object path; + // 3. When the signal arrives, the actual return value and status code + // become available for the client for further processing. + // + // THIS DOES NOT WORK. Once the first two steps are complete, the client + // needs to immediately start listening for the third step, but an overeager + // server implementation could easily send the Response signal before the + // client is even ready, causing communications to break down over a simple + // race condition/two generals' problem that even _TCP_ had figured out + // decades ago. Worse yet, you get exactly _one_ chance to listen for the + // signal, or else your communication attempt so far has all been in vain. + // + // And they know this. Instead of fixing their freaking protocol, they just + // ask clients to manually construct the expected object path and subscribe + // to the request signal beforehand, making the whole response value of + // the original call COMPLETELY MEANINGLESS. + // + // Furthermore, this is _entirely undocumented_ aside from one tiny + // paragraph under the documentation for the Request interface, and + // anyone would be forgiven for missing it without reading the libportal + // source code. + // + // When in Rome, do as the Romans do, I guess...? + + const callbacks = struct { + fn gotResponseHandle( + source: ?*gobject.Object, + res: *gio.AsyncResult, + _: ?*anyopaque, + ) callconv(.c) void { + const dbus_ = gobject.ext.cast(gio.DBusConnection, source.?).?; + + var err: ?*glib.Error = null; + defer if (err) |err_| err_.free(); + + const params_ = dbus_.callFinish(res, &err) orelse { + if (err) |err_| log.warn("request failed={s} ({})", .{ + err_.f_message orelse "(unknown)", + err_.f_code, + }); + return; + }; + defer params_.unref(); + + // TODO: XDG recommends updating the signal subscription if the actual + // returned request path is not the same as the expected request + // path, to retain compatibility with older versions of XDG portals. + // Although it suffers from the race condition outlined above, + // we should still implement this at some point. + } + + // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html#org-freedesktop-portal-request-response + fn responded( + dbus: *gio.DBusConnection, + _: ?[*:0]const u8, + _: [*:0]const u8, + _: [*:0]const u8, + _: [*:0]const u8, + params_: *glib.Variant, + ud: ?*anyopaque, + ) callconv(.c) void { + const self_cb: *GlobalShortcuts = @ptrCast(@alignCast(ud)); + const priv = self_cb.private(); + + // Unsubscribe from the response signal + if (priv.response_subscription != 0) { + dbus.signalUnsubscribe(priv.response_subscription); + priv.response_subscription = 0; + } + + var response: u32 = 0; + var vardict: ?*glib.Variant = null; + defer if (vardict) |v| v.unref(); + params_.get("(u@a{sv})", &response, &vardict); + + switch (response) { + 0 => { + log.debug("request successful", .{}); + method.onResponse(self_cb, vardict.?); + }, + 1 => log.debug("request was cancelled by user", .{}), + 2 => log.warn("request ended unexpectedly", .{}), + else => log.warn("unrecognized response code={}", .{response}), + } + } + }; + + var request_token_buf: Token = undefined; + const request_token = generateToken(&request_token_buf); + + const payload = method.makePayload(self, request_token) orelse return; + const request_path = try self.getRequestPath(request_token); + + const priv = self.private(); + const dbus = priv.dbus_connection.?; + + assert(priv.response_subscription == 0); + priv.response_subscription = dbus.signalSubscribe( + null, + "org.freedesktop.portal.Request", + "Response", + request_path, + null, + .{}, + callbacks.responded, + self, + null, + ); + + dbus.call( + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.GlobalShortcuts", + method.name(), + payload, + null, + .{}, + -1, + null, + callbacks.gotResponseHandle, + null, + ); + } + + /// Get the XDG portal request path for the current Ghostty instance. + /// + /// If this sounds like nonsense, see `request` for an explanation as to + /// why we need to do this. + /// + /// Precondition: dbus connection exists, arena setup + fn getRequestPath(self: *Self, token: [:0]const u8) Allocator.Error![:0]const u8 { + const priv = self.private(); + const dbus = priv.dbus_connection.?; + const alloc = priv.arena.?.allocator(); + + // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html + // for the syntax XDG portals expect. + + // `getUniqueName` should never return null here as we're using an ordinary + // message bus connection. If it doesn't, something is very wrong + const unique_name = std.mem.span(dbus.getUniqueName().?); + + const object_path = try std.mem.joinZ( + alloc, + "/", + &.{ + "/org/freedesktop/portal/desktop/request", + unique_name[1..], // Remove leading `:` + token, + }, + ); + + // Sanitize the unique name by replacing every `.` with `_`. + // In effect, this will turn a unique name like `:1.192` into `1_192`. + // Valid D-Bus object path components never contain `.`s anyway, so we're + // free to replace all instances of `.` here and avoid extra allocation. + std.mem.replaceScalar(u8, object_path, '.', '_'); + + return object_path; + } + + //--------------------------------------------------------------- + // Property Handlers + + pub fn setDbusConnection( + self: *Self, + dbus_connection: ?*gio.DBusConnection, + ) void { + const priv = self.private(); + + // If we have a prior dbus connection we need to close our prior + // registrations first. + if (priv.dbus_connection) |v| { + self.close(); + v.unref(); + priv.dbus_connection = null; + } + + priv.dbus_connection = null; + if (dbus_connection) |v| { + v.ref(); // Weird this doesn't return self + priv.dbus_connection = v; + self.refresh() catch |err| { + log.warn("error refreshing global shortcuts: {}", .{err}); + }; + } + + self.as(gobject.Object).notifyByPspec(properties.@"dbus-connection".impl.param_spec); + } + + fn propConfig( + _: *Self, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + self.refresh() catch |err| { + log.warn("error refreshing global shortcuts: {}", .{err}); + }; + } + + //--------------------------------------------------------------- + // Signal Handlers + + fn shortcutActivated( + _: *gio.DBusConnection, + _: ?[*:0]const u8, + _: [*:0]const u8, + _: [*:0]const u8, + _: [*:0]const u8, + params: *glib.Variant, + ud: ?*anyopaque, + ) callconv(.c) void { + const self: *Self = @ptrCast(@alignCast(ud)); + + // 2nd value in the tuple is the activated shortcut ID + // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-activated + var shortcut_id: [*:0]const u8 = undefined; + params.getChild(1, "&s", &shortcut_id); + log.debug("activated={s}", .{shortcut_id}); + + const action = self.private().map.get(std.mem.span(shortcut_id)) orelse return; + signals.trigger.impl.emit( + self, + null, + .{&action}, + null, + ); + } + + //--------------------------------------------------------------- + // Virtual methods + + fn dispose(self: *Self) callconv(.C) void { + // Since we drop references here we may lose access to things like + // dbus connections, so we need to close all our connections right + // away instead of in finalize. + self.close(); + + const priv = self.private(); + if (priv.config) |v| { + v.unref(); + priv.config = null; + } + if (priv.dbus_connection) |v| { + v.unref(); + priv.dbus_connection = null; + } + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + pub const unref = C.unref; + const private = C.private; + + pub const Class = extern struct { + parent_class: Parent.Class, + var parent: *Parent.Class = undefined; + pub const Instance = Self; + + fn init(class: *Class) callconv(.C) void { + // Properties + gobject.ext.registerProperties(class, &.{ + properties.config.impl, + properties.@"dbus-connection".impl, + }); + + // Signals + signals.trigger.impl.register(.{}); + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + }; +}; + +const Token = [16]u8; + +/// Generate a random token suitable for use in requests. +fn generateToken(buf: *Token) [:0]const u8 { + // u28 takes up 7 bytes in hex, 8 bytes for "ghostty_" and 1 byte for NUL + // 7 + 8 + 1 = 16 + return std.fmt.bufPrintZ( + buf, + "ghostty_{x:0<7}", + .{std.crypto.random.int(u28)}, + ) catch unreachable; +} diff --git a/src/apprt/gtk/GlobalShortcuts.zig b/src/apprt/gtk/GlobalShortcuts.zig index ac9dbaa8a..2506bef97 100644 --- a/src/apprt/gtk/GlobalShortcuts.zig +++ b/src/apprt/gtk/GlobalShortcuts.zig @@ -335,6 +335,7 @@ fn request( var response: u32 = 0; var vardict: ?*glib.Variant = null; + defer if (vardict) |v| v.unref(); params_.get("(u@a{sv})", &response, &vardict); switch (response) { diff --git a/src/input/Binding.zig b/src/input/Binding.zig index f76da360a..b20319810 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -5,6 +5,7 @@ const Binding = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; +const build_config = @import("../build_config.zig"); const ziglyph = @import("ziglyph"); const key = @import("key.zig"); const KeyEvent = key.KeyEvent; @@ -729,6 +730,16 @@ pub const Action = union(enum) { pub const Key = @typeInfo(Action).@"union".tag_type.?; + /// Make this a valid gobject if we're in a GTK environment. + pub const getGObjectType = switch (build_config.app_runtime) { + .gtk, .@"gtk-ng" => @import("gobject").ext.defineBoxed( + Action, + .{ .name = "GhosttyBindingAction" }, + ), + + .none => void, + }; + pub const CrashThread = enum { main, io,