use std::collections::HashMap;
use zbus::zvariant::Value;
use crate::error::{Error, Result};
const GTK_ACTIONS_IFACE: &str = "org.gtk.Actions";
const DISCOVERY_ATTEMPTS: usize = 5;
const DISCOVERY_INTERVAL: std::time::Duration = std::time::Duration::from_millis(100);
#[derive(Debug, Clone)]
pub struct GtkApplicationAddress {
pub bus_name: String,
pub base_path: String,
}
pub struct GtkActionsTarget {
pub conn: zbus::Connection,
pub addr: GtkApplicationAddress,
}
struct ParsedAction {
group: String,
name: String,
target: Option<String>,
}
pub async fn connect_session_bus(dbus_address: &str) -> Result<zbus::Connection> {
let addr: zbus::Address = dbus_address.try_into().map_err(|e: zbus::Error| {
Error::atspi_with("org.gtk.Actions: invalid session bus address", e)
})?;
zbus::connection::Builder::address(addr)
.map_err(|e| Error::atspi_with("org.gtk.Actions: session bus builder", e))?
.build()
.await
.map_err(|e| Error::atspi_with("org.gtk.Actions: connect session bus", e))
}
fn base_path_for(bus_name: &str) -> String {
let mut path = String::with_capacity(bus_name.len() + 1);
path.push('/');
for c in bus_name.chars() {
match c {
'.' => path.push('/'),
'-' => path.push('_'),
other => path.push(other),
}
}
path
}
pub async fn discover_application(
conn: &zbus::Connection,
app_pid: u32,
) -> Result<GtkApplicationAddress> {
let dbus = zbus::fdo::DBusProxy::new(conn)
.await
.map_err(|e| Error::atspi_with("org.gtk.Actions: D-Bus daemon proxy", e))?;
let mut last_err: Option<Error> = None;
for attempt in 0..DISCOVERY_ATTEMPTS {
match find_app_once(&dbus, conn, app_pid).await {
Ok(Some(addr)) => return Ok(addr),
Ok(None) => {}
Err(e) => last_err = Some(e),
}
if attempt + 1 < DISCOVERY_ATTEMPTS {
tokio::time::sleep(DISCOVERY_INTERVAL).await;
}
}
Err(last_err.unwrap_or_else(|| {
Error::atspi(format!(
"org.gtk.Actions: no application owned by pid {app_pid} found on the session bus \
(the app may not be a registered GApplication with an id)"
))
}))
}
async fn find_app_once(
dbus: &zbus::fdo::DBusProxy<'_>,
conn: &zbus::Connection,
app_pid: u32,
) -> Result<Option<GtkApplicationAddress>> {
let names = dbus
.list_names()
.await
.map_err(|e| Error::atspi_with("org.gtk.Actions: ListNames", e))?;
for owned in &names {
let name = owned.as_str();
if name.starts_with(':') || name == "org.freedesktop.DBus" {
continue;
}
let pid = match dbus.get_connection_unix_process_id(owned.into()).await {
Ok(pid) => pid,
Err(_) => continue,
};
if pid != app_pid {
continue;
}
let base_path = base_path_for(name);
if list_action_names(conn, name, &base_path).await.is_ok() {
return Ok(Some(GtkApplicationAddress {
bus_name: name.to_string(),
base_path,
}));
}
}
Ok(None)
}
async fn list_action_names(
conn: &zbus::Connection,
bus_name: &str,
path: &str,
) -> Result<Vec<String>> {
let proxy = zbus::Proxy::new(
conn,
bus_name.to_owned(),
path.to_owned(),
GTK_ACTIONS_IFACE,
)
.await
.map_err(|e| Error::atspi_with("org.gtk.Actions proxy", e))?;
let names: Vec<String> = proxy
.call("List", &())
.await
.map_err(|e| Error::atspi_with(format!("org.gtk.Actions.List at {path}"), e))?;
Ok(names)
}
async fn activate_at(
conn: &zbus::Connection,
bus_name: &str,
path: &str,
name: &str,
target: Option<&str>,
) -> Result<()> {
let proxy = zbus::Proxy::new(
conn,
bus_name.to_owned(),
path.to_owned(),
GTK_ACTIONS_IFACE,
)
.await
.map_err(|e| Error::atspi_with("org.gtk.Actions proxy", e))?;
let parameter: Vec<Value> = match target {
Some(t) => vec![Value::from(t.to_owned())],
None => Vec::new(),
};
let platform_data: HashMap<String, Value> = HashMap::new();
let _: () = proxy
.call("Activate", &(name, parameter, platform_data))
.await
.map_err(|e| Error::atspi_with(format!("org.gtk.Actions.Activate {name} at {path}"), e))?;
Ok(())
}
async fn window_paths(conn: &zbus::Connection, bus_name: &str, base_path: &str) -> Vec<String> {
let window_root = format!("{base_path}/window");
let mut paths = Vec::new();
if let Some(xml) = introspect(conn, bus_name, &window_root).await {
for child in parse_child_node_names(&xml) {
paths.push(format!("{window_root}/{child}"));
}
}
if paths.is_empty() {
paths.push(format!("{window_root}/1"));
}
paths
}
async fn introspect(conn: &zbus::Connection, bus_name: &str, path: &str) -> Option<String> {
let proxy = zbus::fdo::IntrospectableProxy::builder(conn)
.destination(bus_name.to_owned())
.ok()?
.path(path.to_owned())
.ok()?
.build()
.await
.ok()?;
proxy.introspect().await.ok()
}
fn parse_child_node_names(introspect_xml: &str) -> Vec<String> {
let mut names = Vec::new();
let mut rest = introspect_xml;
while let Some(pos) = rest.find("<node") {
let after = &rest[pos + "<node".len()..];
rest = after;
if !matches!(after.chars().next(), Some(c) if c.is_whitespace()) {
continue;
}
let Some(tag_end) = after.find('>') else {
break;
};
if let Some(name) = extract_attr_value(&after[..tag_end], "name") {
names.push(name);
}
}
names
}
fn extract_attr_value(tag: &str, attr: &str) -> Option<String> {
let needle = format!(" {attr}=");
let start = tag.find(&needle)? + needle.len();
let quote = tag.as_bytes().get(start).copied()?;
if quote != b'"' && quote != b'\'' {
return None;
}
let value_start = start + 1;
let end = tag[value_start..].find(quote as char)?;
Some(tag[value_start..value_start + end].to_string())
}
fn parse_action(action: &str) -> Result<ParsedAction> {
let (full, target) = match action.split_once("::") {
Some((full, target)) => (full, Some(target.to_string())),
None => (action, None),
};
let (group, name) = full.split_once('.').ok_or_else(|| {
Error::atspi(format!(
"action {action:?} must be prefixed with its group, \
e.g. \"app.quit\" or \"win.close\""
))
})?;
if group.is_empty() || name.is_empty() {
return Err(Error::atspi(format!(
"action {action:?} has an empty group or action name"
)));
}
Ok(ParsedAction {
group: group.to_string(),
name: name.to_string(),
target,
})
}
pub async fn activate(
conn: &zbus::Connection,
addr: &GtkApplicationAddress,
action: &str,
) -> Result<()> {
let parsed = parse_action(action)?;
match parsed.group.as_str() {
"app" => {
activate_at(
conn,
&addr.bus_name,
&addr.base_path,
&parsed.name,
parsed.target.as_deref(),
)
.await
}
"win" => {
let paths = window_paths(conn, &addr.bus_name, &addr.base_path).await;
for path in &paths {
if let Ok(names) = list_action_names(conn, &addr.bus_name, path).await {
if names.iter().any(|n| n == &parsed.name) {
return activate_at(
conn,
&addr.bus_name,
path,
&parsed.name,
parsed.target.as_deref(),
)
.await;
}
}
}
Err(Error::atspi(format!(
"org.gtk.Actions: win action {:?} not found on any application window \
({} window path(s) checked)",
parsed.name,
paths.len()
)))
}
other => Err(Error::atspi(format!(
"org.gtk.Actions: unsupported action group {other:?} in {action:?}; \
only \"app\" and \"win\" are addressable"
))),
}
}
pub async fn list_all(
conn: &zbus::Connection,
addr: &GtkApplicationAddress,
) -> Result<Vec<String>> {
let mut out = Vec::new();
for name in list_action_names(conn, &addr.bus_name, &addr.base_path).await? {
out.push(format!("app.{name}"));
}
for path in window_paths(conn, &addr.bus_name, &addr.base_path).await {
if let Ok(names) = list_action_names(conn, &addr.bus_name, &path).await {
for name in names {
let prefixed = format!("win.{name}");
if !out.contains(&prefixed) {
out.push(prefixed);
}
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base_path_maps_dots_and_hyphens() {
assert_eq!(
base_path_for("io.github.bohdantkachenko.waydriver.FixtureGtk"),
"/io/github/bohdantkachenko/waydriver/FixtureGtk"
);
assert_eq!(
base_path_for("org.gnome.gedit-test"),
"/org/gnome/gedit_test"
);
assert_eq!(base_path_for("a"), "/a");
}
#[test]
fn parse_action_plain() {
let p = parse_action("app.quit").unwrap();
assert_eq!(p.group, "app");
assert_eq!(p.name, "quit");
assert_eq!(p.target, None);
}
#[test]
fn parse_action_window_group() {
let p = parse_action("win.close").unwrap();
assert_eq!(p.group, "win");
assert_eq!(p.name, "close");
assert_eq!(p.target, None);
}
#[test]
fn parse_action_string_target() {
let p = parse_action("app.section::adw").unwrap();
assert_eq!(p.group, "app");
assert_eq!(p.name, "section");
assert_eq!(p.target.as_deref(), Some("adw"));
}
#[test]
fn parse_action_dotted_name_splits_on_first_dot() {
let p = parse_action("app.preferences.open").unwrap();
assert_eq!(p.group, "app");
assert_eq!(p.name, "preferences.open");
}
#[test]
fn parse_action_requires_group_prefix() {
assert!(parse_action("quit").is_err());
assert!(parse_action("app.").is_err());
assert!(parse_action(".quit").is_err());
}
#[test]
fn parse_child_nodes_extracts_window_ids() {
let xml = r#"<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN" "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
<node>
<node name="1"/>
<node name="2"/>
</node>"#;
assert_eq!(parse_child_node_names(xml), vec!["1", "2"]);
}
#[test]
fn parse_child_nodes_ignores_interfaces_and_root() {
let xml = r#"<node>
<interface name="org.gtk.Actions">
<method name="Activate">
<arg type="s" name="action_name" direction="in"/>
</method>
</interface>
<node name="window"/>
</node>"#;
assert_eq!(parse_child_node_names(xml), vec!["window"]);
}
#[test]
fn parse_child_nodes_empty_when_no_children() {
let xml = r#"<node><interface name="org.gtk.Actions"></interface></node>"#;
assert!(parse_child_node_names(xml).is_empty());
}
#[test]
fn extract_attr_value_handles_both_quote_styles() {
assert_eq!(
extract_attr_value(r#" name="1"/"#, "name").as_deref(),
Some("1")
);
assert_eq!(
extract_attr_value(r#" name='win'/"#, "name").as_deref(),
Some("win")
);
}
}