apprt/gtk-ng: global shortcuts

This commit is contained in:
Mitchell Hashimoto
2025-08-04 08:43:21 -07:00
parent eccff1ea95
commit c8fce8850b
4 changed files with 683 additions and 0 deletions

View File

@@ -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,

View File

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

View File

@@ -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) {

View File

@@ -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,