diff --git a/Cargo.toml b/Cargo.toml index e3f85e0..460f3d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,3 +9,5 @@ edition = "2021" dbus = "0.9.7" nu-plugin = "0.89.0" nu-protocol = { version = "0.89.0", features = ["plugin"] } +serde = { version = "1.0.196", features = ["derive"] } +serde-xml-rs = "0.6.0" diff --git a/src/client.rs b/src/client.rs index a56de73..d3f0a79 100644 --- a/src/client.rs +++ b/src/client.rs @@ -2,7 +2,7 @@ use dbus::{channel::{Channel, BusType}, Message}; use nu_plugin::LabeledError; use nu_protocol::{Spanned, Value}; -use crate::{config::{DbusClientConfig, DbusBusChoice}, dbus_type::DbusType, convert::to_message_item}; +use crate::{config::{DbusClientConfig, DbusBusChoice}, dbus_type::DbusType, convert::to_message_item, introspection::Node}; /// Executes D-Bus actions on a connection, handling nushell types pub struct DbusClient { @@ -10,6 +10,17 @@ pub struct DbusClient { conn: Channel, } +// Convenience macros for error handling +macro_rules! validate_with { + ($type:ty, $spanned:expr) => (<$type>::new(&$spanned.item).map_err(|msg| { + LabeledError { + label: msg, + msg: "this argument is incorrect".into(), + span: Some($spanned.span), + } + })) +} + impl DbusClient { pub fn new(config: DbusClientConfig) -> Result { // Try to connect to the correct D-Bus destination, as specified in the config @@ -35,6 +46,71 @@ impl DbusClient { }) } + fn error(&self, err: impl std::fmt::Display, msg: impl std::fmt::Display) -> LabeledError { + LabeledError { + label: err.to_string(), + msg: msg.to_string(), + span: Some(self.config.span) + } + } + + /// Introspect a D-Bus object + pub fn introspect( + &self, + dest: &Spanned, + object: &Spanned, + ) -> Result { + let context = "while introspecting a D-Bus method"; + let valid_dest = validate_with!(dbus::strings::BusName, dest)?; + let valid_object = validate_with!(dbus::strings::Path, object)?; + + // Create the introspection method call + let message = Message::new_method_call( + valid_dest, + valid_object, + "org.freedesktop.DBus.Introspectable", + "Introspect" + ).map_err(|err| self.error(err, context))?; + + // Send and get the response + let resp = self.conn.send_with_reply_and_block(message, self.config.timeout.item) + .map_err(|err| self.error(err, context))?; + + // Parse it to a Node + let xml: &str = resp.get1() + .ok_or_else(|| self.error("Introspect method returned the wrong type", context))?; + + Node::from_xml(xml).map_err(|err| self.error(err, context)) + } + + /// Try to use introspection to get the signature of a method + fn get_method_signature_by_introspection( + &self, + dest: &Spanned, + object: &Spanned, + interface: &Spanned, + method: &Spanned, + ) -> Result, LabeledError> { + let node = self.introspect(dest, object)?; + + if let Some(sig) = node.get_method_args_signature(&interface.item, &method.item) { + DbusType::parse_all(&sig).map_err(|err| LabeledError { + label: format!("while getting interface {:?} method {:?} signature: {}", + interface.item, + method.item, + err), + msg: "try running with --no-introspect or --signature".into(), + span: Some(self.config.span), + }) + } else { + Err(LabeledError { + label: format!("Method {:?} not found on {:?}", method.item, interface.item), + msg: "check that this method/interface is correct".into(), + span: Some(method.span), + }) + } + } + /// Call a D-Bus method and wait for the response pub fn call( &self, @@ -45,31 +121,16 @@ impl DbusClient { signature: Option<&Spanned>, args: &[Value], ) -> Result, LabeledError> { - macro_rules! error { - ($label:expr) => (LabeledError { - label: $label, - msg: "while calling a D-Bus method".into(), - span: Some(self.config.span) - }) - } + let context = "while calling a D-Bus method"; // Validate inputs before sending to the dbus lib so we don't panic - macro_rules! validate_with { - ($type:ty, $spanned:expr) => (<$type>::new(&$spanned.item).map_err(|msg| { - LabeledError { - label: msg, - msg: "this argument is incorrect".into(), - span: Some($spanned.span), - } - })) - } let valid_dest = validate_with!(dbus::strings::BusName, dest)?; let valid_object = validate_with!(dbus::strings::Path, object)?; let valid_interface = validate_with!(dbus::strings::Interface, interface)?; let valid_method = validate_with!(dbus::strings::Member, method)?; // Parse the signature - let valid_signature = signature.map(|s| DbusType::parse_all(&s.item).map_err(|err| { + let mut valid_signature = signature.map(|s| DbusType::parse_all(&s.item).map_err(|err| { LabeledError { label: err, msg: "in signature specified here".into(), @@ -77,9 +138,25 @@ impl DbusClient { } })).transpose()?; + // If not provided, try introspection (unless disabled) + if valid_signature.is_none() && self.config.introspect { + match self.get_method_signature_by_introspection(dest, object, interface, method) { + Ok(sig) => { + valid_signature = Some(sig); + }, + Err(err) => { + eprintln!("Warning: D-Bus introspection failed on {:?}. \ + Use `--no-introspect` or pass `--signature` to silence this warning. \ + Cause: {}", + object.item, + err.label); + } + } + } + if let Some(sig) = &valid_signature { if sig.len() != args.len() { - error!(format!("expected {} arguments, got {}", sig.len(), args.len())); + self.error(format!("expected {} arguments, got {}", sig.len(), args.len()), context); } } @@ -89,7 +166,7 @@ impl DbusClient { valid_object, valid_interface, valid_method, - ).map_err(|err| error!(err))?; + ).map_err(|err| self.error(err, context))?; // Convert the args to message items let sigs_iter = valid_signature.iter().flatten().map(Some).chain(std::iter::repeat(None)); @@ -99,8 +176,8 @@ impl DbusClient { // Send it on the channel and get the response let resp = self.conn.send_with_reply_and_block(message, self.config.timeout.item) - .map_err(|err| error!(err.to_string()))?; + .map_err(|err| self.error(err, context))?; - crate::convert::from_message(&resp).map_err(|err| error!(err)) + crate::convert::from_message(&resp).map_err(|err| self.error(err, context)) } } diff --git a/src/config.rs b/src/config.rs index 4c57164..3fe75c4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,8 +7,12 @@ use nu_protocol::{Spanned, Span}; #[derive(Debug, Clone)] pub struct DbusClientConfig { pub span: Span, + /// Which bus should we connect to? pub bus_choice: Spanned, + /// How long to wait for a method call to return pub timeout: Spanned, + /// Enable introspection if signature unknown (default true) + pub introspect: bool, } /// Where to connect to the D-Bus server @@ -35,6 +39,7 @@ impl TryFrom<&EvaluatedCall> for DbusClientConfig { span: call.head, bus_choice: Spanned { item: DbusBusChoice::default(), span: call.head }, timeout: Spanned { item: Duration::from_secs(2), span: call.head }, + introspect: true, }; // Handle recognized config args @@ -75,6 +80,11 @@ impl TryFrom<&EvaluatedCall> for DbusClientConfig { config.timeout = Spanned { item, span: value.span() }; } }, + "no-introspect" => { + config.introspect = !value.as_ref() + .and_then(|v| v.as_bool().ok()) + .unwrap_or(false); + }, _ => () } } diff --git a/src/convert.rs b/src/convert.rs index efb8427..8c1a650 100644 --- a/src/convert.rs +++ b/src/convert.rs @@ -142,12 +142,12 @@ pub fn to_message_item(value: &Value, expected_type: Option<&DbusType>) Ok(MessageItem::Double(try_convert!(f64::from_str(&val[..])))), // List/array - (Value::List { vals, .. }, Some(DbusType::Array(content_type))) => { - let content_sig = Signature::from(content_type.stringify()); + (Value::List { vals, .. }, Some(r#type @ DbusType::Array(content_type))) => { + let sig = Signature::from(r#type.stringify()); let items = vals.iter() .map(|content| to_message_item(content, Some(content_type))) .collect::, _>>()?; - Ok(MessageItem::Array(MessageItemArray::new(items, content_sig).unwrap())) + Ok(MessageItem::Array(MessageItemArray::new(items, sig).unwrap())) }, // Struct diff --git a/src/introspection.rs b/src/introspection.rs new file mode 100644 index 0000000..8af4afa --- /dev/null +++ b/src/introspection.rs @@ -0,0 +1,259 @@ +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "kebab-case")] +pub struct Node { + #[serde(default)] + pub name: Option, + #[serde(default, rename = "interface")] + pub interfaces: Vec, + #[serde(default, rename = "node")] + pub children: Vec, +} + +impl Node { + pub fn from_xml(xml: &str) -> Result { + let mut deserializer = serde_xml_rs::de::Deserializer::new_from_reader(xml.as_bytes()) + .non_contiguous_seq_elements(true); + Node::deserialize(&mut deserializer) + } + + #[cfg(test)] + pub fn with_name(name: impl Into) -> Node { + Node { + name: Some(name.into()), + interfaces: vec![], + children: vec![], + } + } + + pub fn get_interface(&self, name: &str) -> Option<&Interface> { + self.interfaces.iter().find(|i| i.name == name) + } + + /// Find a method on an interface on this node, and then generate the signature of the method + /// args + pub fn get_method_args_signature(&self, interface: &str, method: &str) -> Option { + Some(self.get_interface(interface)?.get_method(method)?.in_signature()) + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub struct Interface { + pub name: String, + #[serde(default, rename = "method")] + pub methods: Vec, + #[serde(default, rename = "signal")] + pub signals: Vec, + #[serde(default, rename = "property")] + pub properties: Vec, + #[serde(default, rename = "annotation")] + pub annotations: Vec, +} + +impl Interface { + pub fn get_method(&self, name: &str) -> Option<&Method> { + self.methods.iter().find(|m| m.name == name) + } + + #[allow(dead_code)] + pub fn get_signal(&self, name: &str) -> Option<&Signal> { + self.signals.iter().find(|s| s.name == name) + } + + #[allow(dead_code)] + pub fn get_property(&self, name: &str) -> Option<&Property> { + self.properties.iter().find(|p| p.name == name) + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub struct Method { + pub name: String, + #[serde(default, rename = "arg")] + pub args: Vec, + #[serde(default, rename = "annotation")] + pub annotations: Vec, +} + +impl Method { + /// Get the signature of the method args + pub fn in_signature(&self) -> String { + self.args.iter() + .filter(|arg| arg.direction == Direction::In) + .map(|arg| &arg.r#type[..]) + .collect() + } + + #[allow(dead_code)] + /// Get the signature of the method result + pub fn out_signature(&self) -> String { + self.args.iter() + .filter(|arg| arg.direction == Direction::Out) + .map(|arg| &arg.r#type[..]) + .collect() + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub struct MethodArg { + #[serde(default)] + pub name: Option, + pub r#type: String, + #[serde(default)] + pub direction: Direction, +} + +impl MethodArg { + #[cfg(test)] + pub fn new( + name: impl Into, + r#type: impl Into, + direction: Direction + ) -> MethodArg { + MethodArg { + name: Some(name.into()), + r#type: r#type.into(), + direction, + } + } +} + +#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum Direction { + #[default] + In, + Out, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub struct Signal { + pub name: String, + #[serde(default, rename = "arg")] + pub args: Vec, + #[serde(default, rename = "annotation")] + pub annotations: Vec, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub struct SignalArg { + #[serde(default)] + pub name: Option, + pub r#type: String, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub struct Property { + pub name: String, + pub r#type: String, + pub access: Access, + #[serde(default, rename = "annotation")] + pub annotations: Vec, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Access { + Read, + Write, + ReadWrite, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub struct Annotation { + pub name: String, + pub value: String, +} + +impl Annotation { + #[cfg(test)] + pub fn new(name: impl Into, value: impl Into) -> Annotation { + Annotation { name: name.into(), value: value.into() } + } +} + +#[cfg(test)] +pub fn test_introspection_doc_rs() -> Node { + Node { + name: Some("/com/example/sample_object0".into()), + interfaces: vec![Interface { + name: "com.example.SampleInterface0".into(), + methods: vec![ + Method { + name: "Frobate".into(), + args: vec![ + MethodArg::new("foo", "i", Direction::In), + MethodArg::new("bar", "as", Direction::In), + MethodArg::new("baz", "a{us}", Direction::Out), + ], + annotations: vec![ + Annotation::new("org.freedesktop.DBus.Deprecated", "true"), + ], + }, + Method { + name: "Bazify".into(), + args: vec![ + MethodArg::new("bar", "(iiu)", Direction::In), + MethodArg::new("len", "u", Direction::Out), + MethodArg::new("bar", "v", Direction::Out), + ], + annotations: vec![], + }, + Method { + name: "Mogrify".into(), + args: vec![ + MethodArg::new("bar", "(iiav)", Direction::In), + ], + annotations: vec![] + }, + ], + signals: vec![ + Signal { + name: "Changed".into(), + args: vec![ + SignalArg { name: "new_value".into(), r#type: "b".into() }, + ], + annotations: vec![] + }, + ], + properties: vec![ + Property { + name: "Bar".into(), + r#type: "y".into(), + access: Access::ReadWrite, + annotations: vec![], + } + ], + annotations: vec![] + }], + children: vec![ + Node::with_name("child_of_sample_object"), + Node::with_name("another_child_of_sample_object"), + ] + } +} + +#[test] +pub fn test_parse_introspection_doc() -> Result<(), serde_xml_rs::Error> { + let xml = include_str!("test_introspection_doc.xml"); + let result = Node::from_xml(xml)?; + assert_eq!(result, test_introspection_doc_rs()); + Ok(()) +} + +#[test] +pub fn test_get_method_args_signature() { + assert_eq!( + test_introspection_doc_rs() + .get_method_args_signature("com.example.SampleInterface0", "Frobate"), + Some("ias".into()) + ); +} diff --git a/src/main.rs b/src/main.rs index 9e06742..d47755e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod config; mod client; mod convert; mod dbus_type; +mod introspection; use config::*; use client::*; @@ -18,6 +19,9 @@ struct NuPluginDbus; impl Plugin for NuPluginDbus { fn signature(&self) -> Vec { + macro_rules! str { + ($s:expr) => (Value::string($s, Span::unknown())) + } vec![ PluginSignature::build("dbus") .is_dbus_command() @@ -29,9 +33,14 @@ impl Plugin for NuPluginDbus { .extra_usage("Returns an array if the method call returns more than one value.") .named("timeout", SyntaxShape::Duration, "How long to wait for a response", None) .named("signature", SyntaxShape::String, - "Signature of the arguments to send, in D-Bus format\n\ - If not provided, they will be guessed automatically (but poorly)", None) - .switch("no-flatten", "Always return a list of all return values", None) + "Signature of the arguments to send, in D-Bus format.\n \ + If not provided, they will be determined from introspection.\n \ + If --no-introspect is specified and this is not provided, they will \ + be guessed (poorly)", None) + .switch("no-flatten", + "Always return a list of all return values", None) + .switch("no-introspect", + "Don't use introspection to determine the correct argument signature", None) .required_named("dest", SyntaxShape::String, "The name of the connection to send the method to", None) @@ -49,7 +58,29 @@ impl Plugin for NuPluginDbus { /org/freedesktop/DBus org.freedesktop.DBus.Peer Ping".into(), description: "Ping the D-Bus server itself".into(), result: None - } + }, + PluginExample { + example: "dbus call --dest=org.mpris.MediaPlayer2.spotify \ + /org/mpris/MediaPlayer2 org.freedesktop.DBus.Properties Get \ + org.mpris.MediaPlayer2.Player Metadata".into(), + description: "Get the currently playing song in Spotify".into(), + result: Some(Value::record(nu_protocol::record!( + "xesam:title" => str!("Birdie"), + "xesam:artist" => Value::list(vec![ + str!("LOVE PSYCHEDELICO") + ], Span::unknown()), + "xesam:album" => str!("Love Your Love"), + "xesam:url" => str!("https://open.spotify.com/track/51748BvzeeMs4PIdPuyZmv"), + ), Span::unknown())) + }, + PluginExample { + example: "dbus call --dest=org.freedesktop.Notifications \ + /org/freedesktop/Notifications org.freedesktop.Notifications \ + Notify \"Floppy disks\" 0 \"media-floppy\" \"Rarely seen\" \ + \"But sometimes still used\" [] {} 5000".into(), + description: "Show a notification on the desktop for 5 seconds".into(), + result: None + }, ]), ] } diff --git a/src/test_introspection_doc.xml b/src/test_introspection_doc.xml new file mode 100644 index 0000000..c04dcd2 --- /dev/null +++ b/src/test_introspection_doc.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +