e8525c0fd9
This commit represents the majority of the work necessary to upgrade Ghostty to use Zig 0.16.0. Key parts: * In addition to its previous responsibilities, the global state now houses state for global I/O implementations and the process environment. It is now also utilized in the main application along with the C library. Where necessary, global state is isolated from key parts of the implementation (e.g., in libghostty subsystems), and it's expected that this list will grow. * We currently manage our own C translation layer where necessary. In these cases, cImport has been removed in favor of the new external translate-c package. Due to fixes that have needed be made to properly translate the dependencies that were swapped out, as mentioned, we have had to backport fixes from the current translate-c package (and the upstream Arocc dependency). We will host this ourselves until Zig 0.17.0 is released with these fixes. * Where necessary (only a small number of cases), some stdlib code from 0.15.2 (and even from 0.17.0) has been taken, adopted, and vendored in lib/compat. Co-authored-by: Leah Amelia Chen <hi@pluie.me>
640 lines
23 KiB
Zig
640 lines
23 KiB
Zig
const std = @import("std");
|
|
const assert = @import("../../../quirks.zig").inlineAssert;
|
|
const Allocator = std.mem.Allocator;
|
|
const gio = @import("gio");
|
|
const glib = @import("glib");
|
|
const gobject = @import("gobject");
|
|
const global = @import("../../../global.zig");
|
|
|
|
const Binding = @import("../../../input.zig").Binding;
|
|
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,
|
|
.{
|
|
.accessor = C.privateObjFieldAccessor("config"),
|
|
},
|
|
);
|
|
};
|
|
|
|
pub const @"dbus-connection" = struct {
|
|
pub const name = "dbus-connection";
|
|
const impl = gobject.ext.defineProperty(
|
|
name,
|
|
Self,
|
|
?*gio.DBusConnection,
|
|
.{
|
|
.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();
|
|
|
|
if (priv.dbus_connection) |dbus| {
|
|
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) Allocator.Error!void {
|
|
// Always close our previous state first.
|
|
self.close();
|
|
|
|
const priv = self.private();
|
|
|
|
// We need a dbus connection and configuration to proceed.
|
|
if (priv.dbus_connection == null) return;
|
|
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: [1024]u8 = undefined;
|
|
var it = config.keybind.set.bindings.iterator();
|
|
while (it.next()) |entry| {
|
|
const leaf: Binding.Set.GenericLeaf = switch (entry.value_ptr.*) {
|
|
.leader => continue,
|
|
inline .leaf, .leaf_chained => |leaf| leaf.generic(),
|
|
};
|
|
if (!leaf.flags.global) continue;
|
|
|
|
// We only allow global keybinds that map to exactly a single
|
|
// action for now. TODO: remove this restriction
|
|
const actions = leaf.actionsSlice();
|
|
if (actions.len != 1) continue;
|
|
|
|
const trigger = if (key.xdgShortcutFromTrigger(
|
|
&trigger_buf,
|
|
entry.key_ptr.*,
|
|
)) |shortcut_|
|
|
shortcut_ orelse continue
|
|
else |err| switch (err) {
|
|
// If there isn't space to translate the trigger, then our
|
|
// buffer might be too small (but 1024 is insane!). In any case
|
|
// we don't want to stop registering globals.
|
|
error.WriteFailed => {
|
|
log.warn(
|
|
"buffer too small to translate trigger, ignoring={f}",
|
|
.{entry.key_ptr.*},
|
|
);
|
|
continue;
|
|
},
|
|
};
|
|
|
|
try priv.map.put(
|
|
alloc,
|
|
try alloc.dupeZ(u8, trigger),
|
|
actions[0],
|
|
);
|
|
}
|
|
|
|
// 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,
|
|
"{f}",
|
|
.{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}",
|
|
.{rand_int: {
|
|
const rng_impl: std.Random.IoSource = .{ .io = global.io() };
|
|
break :rand_int rng_impl.interface().int(u28);
|
|
}},
|
|
) catch unreachable;
|
|
}
|