//! `GhosttyGlobalShortcuts` (ZIG/src/apprt/gtk/class/global_shortcuts.zig):
//! registers `global:` keybinds with the XDG GlobalShortcuts portal over
//! D-Bus and emits `trigger` when one is activated.
//!
//! The `trigger` signal carries the binding action serialized in its keybind
//! syntax (a `String`); use [`GlobalShortcuts::connect_trigger`] to receive
//! the parsed [`ghostty_input::binding::Action`].
#![allow(deprecated)] // gio D-Bus signal_subscribe: replacement API is awkward for portal use
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::sync::OnceLock;
use ghostty_input::binding::Action as BindingAction;
use ghostty_input::binding::set::Value as BindingValue;
use gtk::gio::{self, prelude::*};
use gtk::glib::{self, subclass::Signal, subclass::prelude::*, variant::ObjectPath};
use crate::class::config::Config;
mod imp {
use super::{BindingAction, Cell, Config, OnceLock, RefCell, Signal, gio, glib};
use glib::prelude::*;
use glib::subclass::prelude::*;
#[derive(glib::Properties, Default)]
#[properties(wrapper_type = super::GlobalShortcuts)]
pub struct GlobalShortcuts {
/// The configuration that this is using.
#[property(get, set = Self::set_config, explicit_notify, nullable)]
pub config: RefCell>,
/// The dbus connection.
#[property(get, set = Self::set_dbus_connection, explicit_notify, nullable)]
pub dbus_connection: RefCell >,
/// Unique ID (the xdg shortcut string) -> action.
pub map: RefCell>,
/// The current portal session handle (D-Bus object path).
pub handle: RefCell>,
pub response_subscription: RefCell >,
pub activate_subscription: RefCell >,
/// Counter making sure async callbacks of stale sessions are ignored.
pub generation: Cell,
}
#[glib::object_subclass]
impl ObjectSubclass for GlobalShortcuts {
const NAME: &'static str = "GhosttyGlobalShortcuts";
type Type = super::GlobalShortcuts;
type ParentType = glib::Object;
}
impl GlobalShortcuts {
fn set_config(&self, config: Option) {
*self.config.borrow_mut() = config;
self.obj().notify_config();
// propConfig
if let Err(err) = self.obj().refresh() {
log::warn!("error refreshing global shortcuts: {err}");
}
}
fn set_dbus_connection(&self, conn: Option) {
let obj = self.obj();
// If we have a prior dbus connection we need to close our prior
// registrations first.
if self.dbus_connection.borrow().is_some() {
obj.close();
*self.dbus_connection.borrow_mut() = None;
}
let has = conn.is_some();
*self.dbus_connection.borrow_mut() = conn;
if has && let Err(err) = obj.refresh() {
log::warn!("error refreshing global shortcuts: {err}");
}
obj.notify_dbus_connection();
}
}
#[glib::derived_properties]
impl ObjectImpl for GlobalShortcuts {
fn signals() -> &'static [Signal] {
static SIGNALS: OnceLock> = OnceLock::new();
SIGNALS.get_or_init(|| {
vec![
// Emitted whenever a global shortcut is triggered. The
// parameter is the action in keybind syntax.
Signal::builder("trigger")
.param_types([String::static_type()])
.build(),
]
})
}
fn dispose(&self) {
// 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.obj().close();
*self.config.borrow_mut() = None;
*self.dbus_connection.borrow_mut() = None;
}
}
}
glib::wrapper! {
pub struct GlobalShortcuts(ObjectSubclass);
}
impl Default for GlobalShortcuts {
fn default() -> Self {
Self::new()
}
}
/// The two portal methods we call.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Method {
CreateSession,
BindShortcuts,
}
impl Method {
fn name(self) -> &'static str {
match self {
Method::CreateSession => "CreateSession",
Method::BindShortcuts => "BindShortcuts",
}
}
}
type Token = String;
/// Generate a random token suitable for use in requests
/// (`ghostty_` + 7 hex digits).
fn generate_token() -> Token {
format!("ghostty_{:07x}", glib::random_int_range(0, 1 << 28))
}
fn vardict(entries: Vec<(&str, glib::Variant)>) -> glib::Variant {
let map: HashMap = entries
.into_iter()
.map(|(k, v)| (k.to_owned(), v))
.collect();
map.to_variant()
}
impl GlobalShortcuts {
pub fn new() -> Self {
glib::Object::new()
}
/// Set the D-Bus connection (`setDbusConnection`).
pub fn set_connection(&self, conn: Option<&gio::DBusConnection>) {
self.set_dbus_connection(conn);
}
/// Connect to the `trigger` signal.
pub fn connect_trigger(
&self,
f: F,
) -> glib::SignalHandlerId {
self.connect_closure(
"trigger",
false,
glib::closure_local!(move |this: &Self, action: String| {
match BindingAction::parse(&action) {
Ok(a) => f(this, &a),
Err(err) => log::warn!("invalid global shortcut action={action}: {err:?}"),
}
}),
)
}
fn close(&self) {
let imp = self.imp();
let Some(dbus) = imp.dbus_connection.borrow().clone() else {
return;
};
if let Some(id) = imp.response_subscription.borrow_mut().take() {
dbus.signal_unsubscribe(id);
}
if let Some(id) = imp.activate_subscription.borrow_mut().take() {
dbus.signal_unsubscribe(id);
}
if let Some(handle) = imp.handle.borrow_mut().take() {
// Close existing session
dbus.call(
Some("org.freedesktop.portal.Desktop"),
&handle,
"org.freedesktop.portal.Session",
"Close",
None,
None,
gio::DBusCallFlags::NONE,
-1,
gio::Cancellable::NONE,
|_| {},
);
}
imp.generation.set(imp.generation.get() + 1);
imp.map.borrow_mut().clear();
}
#[allow(clippy::unnecessary_wraps)]
fn refresh(&self) -> Result<(), glib::Error> {
// Always close our previous state first.
self.close();
let imp = self.imp();
// We need configuration to proceed.
let Some(config) = imp.config.borrow().as_ref().map(Config::get) else {
return Ok(());
};
// Update map
let mut map: Vec<(String, BindingAction)> = Vec::new();
for (trigger, value) in &config.keybind.set.bindings {
let actions: &[BindingAction] = match value {
BindingValue::Leader(_) => continue,
BindingValue::Leaf(l) => {
if !l.flags.global {
continue;
}
std::slice::from_ref(&l.action)
}
BindingValue::LeafChained(l) => {
if !l.flags.global {
continue;
}
&l.actions
}
};
// We only allow global keybinds that map to exactly a single
// action for now. TODO: remove this restriction
if actions.len() != 1 {
continue;
}
let Some(shortcut) = crate::key::xdg_shortcut_from_trigger(trigger.0) else {
continue;
};
if let Some(slot) = map.iter_mut().find(|(k, _)| *k == shortcut) {
slot.1 = actions[0].clone();
} else {
map.push((shortcut, actions[0].clone()));
}
}
let has = !map.is_empty();
*imp.map.borrow_mut() = map;
// Create our session if we have global shortcuts.
if has {
self.request(Method::CreateSession);
}
Ok(())
}
/// Build the payload for a portal method.
fn make_payload(&self, method: Method, request_token: &str) -> Option {
match method {
// https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-createsession
Method::CreateSession => {
let opts = vardict(vec![
("handle_token", request_token.to_variant().to_variant()),
(
"session_handle_token",
generate_token().to_variant().to_variant(),
),
]);
Some((opts,).to_variant())
}
// https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-bindshortcuts
Method::BindShortcuts => {
let imp = self.imp();
let handle = imp.handle.borrow().clone()?;
let handle = ObjectPath::try_from(handle).ok()?;
let binds: Vec<(String, glib::Variant)> = imp
.map
.borrow()
.iter()
.map(|(trigger, action)| {
let opts = vardict(vec![
("description", action.to_string().to_variant().to_variant()),
("preferred_trigger", trigger.to_variant().to_variant()),
]);
(trigger.clone(), opts)
})
.collect();
let opts = vardict(vec![(
"handle_token",
request_token.to_variant().to_variant(),
)]);
Some((handle, binds, String::new(), opts).to_variant())
}
}
}
fn on_response(&self, method: Method, dict: &HashMap) {
match method {
Method::CreateSession => {
let Some(handle) = dict.get("session_handle").and_then(|v| v.str()) else {
log::warn!("session handle not found in response={dict:?}");
return;
};
let imp = self.imp();
let Some(dbus) = imp.dbus_connection.borrow().clone() else {
return;
};
*imp.handle.borrow_mut() = Some(handle.to_owned());
log::debug!("session_handle={handle}");
// Subscribe to keybind activations
let this = self.downgrade();
let id = dbus.signal_subscribe(
None,
Some("org.freedesktop.portal.GlobalShortcuts"),
Some("Activated"),
Some("/org/freedesktop/portal/desktop"),
Some(handle),
gio::DBusSignalFlags::MATCH_ARG0_PATH,
move |_, _, _, _, _, params| {
if let Some(this) = this.upgrade() {
this.shortcut_activated(params);
}
},
);
*imp.activate_subscription.borrow_mut() = Some(id);
self.request(Method::BindShortcuts);
}
Method::BindShortcuts => {}
}
}
/// Submit a request to the global shortcuts portal.
///
/// XDG portals require subscribing to the Request's `Response` signal at
/// a manually constructed object path *before* making the call (see the
/// long rant in the Zig source).
fn request(&self, method: Method) {
let imp = self.imp();
let request_token = generate_token();
let Some(payload) = self.make_payload(method, &request_token) else {
return;
};
let Some(dbus) = imp.dbus_connection.borrow().clone() else {
return;
};
let Some(request_path) = self.request_path(&dbus, &request_token) else {
return;
};
let generation = imp.generation.get();
let this = self.downgrade();
let id = dbus.signal_subscribe(
None,
Some("org.freedesktop.portal.Request"),
Some("Response"),
Some(&request_path),
None,
gio::DBusSignalFlags::NONE,
move |dbus, _, _, _, _, params| {
let Some(this) = this.upgrade() else { return };
let imp = this.imp();
if imp.generation.get() != generation {
return;
}
// Unsubscribe from the response signal
if let Some(id) = imp.response_subscription.borrow_mut().take() {
dbus.signal_unsubscribe(id);
}
let Some((response, dict)) = params.get::<(u32, HashMap)>()
else {
log::warn!("unexpected response payload type={}", params.type_());
return;
};
match response {
0 => {
log::debug!("request successful");
this.on_response(method, &dict);
}
1 => log::debug!("request was cancelled by user"),
2 => log::warn!("request ended unexpectedly"),
other => log::warn!("unrecognized response code={other}"),
}
},
);
// Zig asserts there is no prior subscription.
if let Some(old) = imp.response_subscription.borrow_mut().replace(id) {
dbus.signal_unsubscribe(old);
}
dbus.call(
Some("org.freedesktop.portal.Desktop"),
"/org/freedesktop/portal/desktop",
"org.freedesktop.portal.GlobalShortcuts",
method.name(),
Some(&payload),
None,
gio::DBusCallFlags::NONE,
-1,
gio::Cancellable::NONE,
|res| {
if let Err(err) = res {
log::warn!("request failed={} ({:?})", err.message(), err);
}
// TODO(upstream): XDG recommends updating the signal
// subscription if the actual returned request path differs.
},
);
}
/// The XDG portal request path for the current Ghostty instance.
#[allow(clippy::unused_self)]
fn request_path(&self, dbus: &gio::DBusConnection, token: &str) -> Option {
let unique = dbus.unique_name()?;
let unique = unique.strip_prefix(':').unwrap_or(&unique);
// Replace every `.` with `_`: `:1.192` -> `1_192`.
let unique = unique.replace('.', "_");
Some(format!(
"/org/freedesktop/portal/desktop/request/{unique}/{token}"
))
}
fn shortcut_activated(&self, params: &glib::Variant) {
// 2nd value in the tuple is the activated shortcut ID.
let child = params.child_value(1);
let Some(id) = child.str() else { return };
log::debug!("activated={id}");
let action = {
let map = self.imp().map.borrow();
map.iter().find(|(k, _)| k == id).map(|(_, a)| a.clone())
};
let Some(action) = action else { return };
self.emit_by_name::<()>("trigger", &[&action.to_string()]);
}
}