Nushell plugin for interacting with D-Bus

automatically use introspection to determine the right values to pass

+435 -29
+2
Cargo.toml
··· 9 9 dbus = "0.9.7" 10 10 nu-plugin = "0.89.0" 11 11 nu-protocol = { version = "0.89.0", features = ["plugin"] } 12 + serde = { version = "1.0.196", features = ["derive"] } 13 + serde-xml-rs = "0.6.0"
+99 -22
src/client.rs
··· 2 2 use nu_plugin::LabeledError; 3 3 use nu_protocol::{Spanned, Value}; 4 4 5 - use crate::{config::{DbusClientConfig, DbusBusChoice}, dbus_type::DbusType, convert::to_message_item}; 5 + use crate::{config::{DbusClientConfig, DbusBusChoice}, dbus_type::DbusType, convert::to_message_item, introspection::Node}; 6 6 7 7 /// Executes D-Bus actions on a connection, handling nushell types 8 8 pub struct DbusClient { 9 9 config: DbusClientConfig, 10 10 conn: Channel, 11 + } 12 + 13 + // Convenience macros for error handling 14 + macro_rules! validate_with { 15 + ($type:ty, $spanned:expr) => (<$type>::new(&$spanned.item).map_err(|msg| { 16 + LabeledError { 17 + label: msg, 18 + msg: "this argument is incorrect".into(), 19 + span: Some($spanned.span), 20 + } 21 + })) 11 22 } 12 23 13 24 impl DbusClient { ··· 35 46 }) 36 47 } 37 48 49 + fn error(&self, err: impl std::fmt::Display, msg: impl std::fmt::Display) -> LabeledError { 50 + LabeledError { 51 + label: err.to_string(), 52 + msg: msg.to_string(), 53 + span: Some(self.config.span) 54 + } 55 + } 56 + 57 + /// Introspect a D-Bus object 58 + pub fn introspect( 59 + &self, 60 + dest: &Spanned<String>, 61 + object: &Spanned<String>, 62 + ) -> Result<Node, LabeledError> { 63 + let context = "while introspecting a D-Bus method"; 64 + let valid_dest = validate_with!(dbus::strings::BusName, dest)?; 65 + let valid_object = validate_with!(dbus::strings::Path, object)?; 66 + 67 + // Create the introspection method call 68 + let message = Message::new_method_call( 69 + valid_dest, 70 + valid_object, 71 + "org.freedesktop.DBus.Introspectable", 72 + "Introspect" 73 + ).map_err(|err| self.error(err, context))?; 74 + 75 + // Send and get the response 76 + let resp = self.conn.send_with_reply_and_block(message, self.config.timeout.item) 77 + .map_err(|err| self.error(err, context))?; 78 + 79 + // Parse it to a Node 80 + let xml: &str = resp.get1() 81 + .ok_or_else(|| self.error("Introspect method returned the wrong type", context))?; 82 + 83 + Node::from_xml(xml).map_err(|err| self.error(err, context)) 84 + } 85 + 86 + /// Try to use introspection to get the signature of a method 87 + fn get_method_signature_by_introspection( 88 + &self, 89 + dest: &Spanned<String>, 90 + object: &Spanned<String>, 91 + interface: &Spanned<String>, 92 + method: &Spanned<String>, 93 + ) -> Result<Vec<DbusType>, LabeledError> { 94 + let node = self.introspect(dest, object)?; 95 + 96 + if let Some(sig) = node.get_method_args_signature(&interface.item, &method.item) { 97 + DbusType::parse_all(&sig).map_err(|err| LabeledError { 98 + label: format!("while getting interface {:?} method {:?} signature: {}", 99 + interface.item, 100 + method.item, 101 + err), 102 + msg: "try running with --no-introspect or --signature".into(), 103 + span: Some(self.config.span), 104 + }) 105 + } else { 106 + Err(LabeledError { 107 + label: format!("Method {:?} not found on {:?}", method.item, interface.item), 108 + msg: "check that this method/interface is correct".into(), 109 + span: Some(method.span), 110 + }) 111 + } 112 + } 113 + 38 114 /// Call a D-Bus method and wait for the response 39 115 pub fn call( 40 116 &self, ··· 45 121 signature: Option<&Spanned<String>>, 46 122 args: &[Value], 47 123 ) -> Result<Vec<Value>, LabeledError> { 48 - macro_rules! error { 49 - ($label:expr) => (LabeledError { 50 - label: $label, 51 - msg: "while calling a D-Bus method".into(), 52 - span: Some(self.config.span) 53 - }) 54 - } 124 + let context = "while calling a D-Bus method"; 55 125 56 126 // Validate inputs before sending to the dbus lib so we don't panic 57 - macro_rules! validate_with { 58 - ($type:ty, $spanned:expr) => (<$type>::new(&$spanned.item).map_err(|msg| { 59 - LabeledError { 60 - label: msg, 61 - msg: "this argument is incorrect".into(), 62 - span: Some($spanned.span), 63 - } 64 - })) 65 - } 66 127 let valid_dest = validate_with!(dbus::strings::BusName, dest)?; 67 128 let valid_object = validate_with!(dbus::strings::Path, object)?; 68 129 let valid_interface = validate_with!(dbus::strings::Interface, interface)?; 69 130 let valid_method = validate_with!(dbus::strings::Member, method)?; 70 131 71 132 // Parse the signature 72 - let valid_signature = signature.map(|s| DbusType::parse_all(&s.item).map_err(|err| { 133 + let mut valid_signature = signature.map(|s| DbusType::parse_all(&s.item).map_err(|err| { 73 134 LabeledError { 74 135 label: err, 75 136 msg: "in signature specified here".into(), ··· 77 138 } 78 139 })).transpose()?; 79 140 141 + // If not provided, try introspection (unless disabled) 142 + if valid_signature.is_none() && self.config.introspect { 143 + match self.get_method_signature_by_introspection(dest, object, interface, method) { 144 + Ok(sig) => { 145 + valid_signature = Some(sig); 146 + }, 147 + Err(err) => { 148 + eprintln!("Warning: D-Bus introspection failed on {:?}. \ 149 + Use `--no-introspect` or pass `--signature` to silence this warning. \ 150 + Cause: {}", 151 + object.item, 152 + err.label); 153 + } 154 + } 155 + } 156 + 80 157 if let Some(sig) = &valid_signature { 81 158 if sig.len() != args.len() { 82 - error!(format!("expected {} arguments, got {}", sig.len(), args.len())); 159 + self.error(format!("expected {} arguments, got {}", sig.len(), args.len()), context); 83 160 } 84 161 } 85 162 ··· 89 166 valid_object, 90 167 valid_interface, 91 168 valid_method, 92 - ).map_err(|err| error!(err))?; 169 + ).map_err(|err| self.error(err, context))?; 93 170 94 171 // Convert the args to message items 95 172 let sigs_iter = valid_signature.iter().flatten().map(Some).chain(std::iter::repeat(None)); ··· 99 176 100 177 // Send it on the channel and get the response 101 178 let resp = self.conn.send_with_reply_and_block(message, self.config.timeout.item) 102 - .map_err(|err| error!(err.to_string()))?; 179 + .map_err(|err| self.error(err, context))?; 103 180 104 - crate::convert::from_message(&resp).map_err(|err| error!(err)) 181 + crate::convert::from_message(&resp).map_err(|err| self.error(err, context)) 105 182 } 106 183 }
+10
src/config.rs
··· 7 7 #[derive(Debug, Clone)] 8 8 pub struct DbusClientConfig { 9 9 pub span: Span, 10 + /// Which bus should we connect to? 10 11 pub bus_choice: Spanned<DbusBusChoice>, 12 + /// How long to wait for a method call to return 11 13 pub timeout: Spanned<Duration>, 14 + /// Enable introspection if signature unknown (default true) 15 + pub introspect: bool, 12 16 } 13 17 14 18 /// Where to connect to the D-Bus server ··· 35 39 span: call.head, 36 40 bus_choice: Spanned { item: DbusBusChoice::default(), span: call.head }, 37 41 timeout: Spanned { item: Duration::from_secs(2), span: call.head }, 42 + introspect: true, 38 43 }; 39 44 40 45 // Handle recognized config args ··· 74 79 let item = Duration::from_nanos(nanos); 75 80 config.timeout = Spanned { item, span: value.span() }; 76 81 } 82 + }, 83 + "no-introspect" => { 84 + config.introspect = !value.as_ref() 85 + .and_then(|v| v.as_bool().ok()) 86 + .unwrap_or(false); 77 87 }, 78 88 _ => () 79 89 }
+3 -3
src/convert.rs
··· 142 142 Ok(MessageItem::Double(try_convert!(f64::from_str(&val[..])))), 143 143 144 144 // List/array 145 - (Value::List { vals, .. }, Some(DbusType::Array(content_type))) => { 146 - let content_sig = Signature::from(content_type.stringify()); 145 + (Value::List { vals, .. }, Some(r#type @ DbusType::Array(content_type))) => { 146 + let sig = Signature::from(r#type.stringify()); 147 147 let items = vals.iter() 148 148 .map(|content| to_message_item(content, Some(content_type))) 149 149 .collect::<Result<Vec<MessageItem>, _>>()?; 150 - Ok(MessageItem::Array(MessageItemArray::new(items, content_sig).unwrap())) 150 + Ok(MessageItem::Array(MessageItemArray::new(items, sig).unwrap())) 151 151 }, 152 152 153 153 // Struct
+259
src/introspection.rs
··· 1 + use serde::Deserialize; 2 + 3 + #[derive(Debug, Clone, Deserialize, PartialEq, Eq, Default)] 4 + #[serde(rename_all = "kebab-case")] 5 + pub struct Node { 6 + #[serde(default)] 7 + pub name: Option<String>, 8 + #[serde(default, rename = "interface")] 9 + pub interfaces: Vec<Interface>, 10 + #[serde(default, rename = "node")] 11 + pub children: Vec<Node>, 12 + } 13 + 14 + impl Node { 15 + pub fn from_xml(xml: &str) -> Result<Node, serde_xml_rs::Error> { 16 + let mut deserializer = serde_xml_rs::de::Deserializer::new_from_reader(xml.as_bytes()) 17 + .non_contiguous_seq_elements(true); 18 + Node::deserialize(&mut deserializer) 19 + } 20 + 21 + #[cfg(test)] 22 + pub fn with_name(name: impl Into<String>) -> Node { 23 + Node { 24 + name: Some(name.into()), 25 + interfaces: vec![], 26 + children: vec![], 27 + } 28 + } 29 + 30 + pub fn get_interface(&self, name: &str) -> Option<&Interface> { 31 + self.interfaces.iter().find(|i| i.name == name) 32 + } 33 + 34 + /// Find a method on an interface on this node, and then generate the signature of the method 35 + /// args 36 + pub fn get_method_args_signature(&self, interface: &str, method: &str) -> Option<String> { 37 + Some(self.get_interface(interface)?.get_method(method)?.in_signature()) 38 + } 39 + } 40 + 41 + #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] 42 + #[serde(rename_all = "kebab-case")] 43 + pub struct Interface { 44 + pub name: String, 45 + #[serde(default, rename = "method")] 46 + pub methods: Vec<Method>, 47 + #[serde(default, rename = "signal")] 48 + pub signals: Vec<Signal>, 49 + #[serde(default, rename = "property")] 50 + pub properties: Vec<Property>, 51 + #[serde(default, rename = "annotation")] 52 + pub annotations: Vec<Annotation>, 53 + } 54 + 55 + impl Interface { 56 + pub fn get_method(&self, name: &str) -> Option<&Method> { 57 + self.methods.iter().find(|m| m.name == name) 58 + } 59 + 60 + #[allow(dead_code)] 61 + pub fn get_signal(&self, name: &str) -> Option<&Signal> { 62 + self.signals.iter().find(|s| s.name == name) 63 + } 64 + 65 + #[allow(dead_code)] 66 + pub fn get_property(&self, name: &str) -> Option<&Property> { 67 + self.properties.iter().find(|p| p.name == name) 68 + } 69 + } 70 + 71 + #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] 72 + #[serde(rename_all = "kebab-case")] 73 + pub struct Method { 74 + pub name: String, 75 + #[serde(default, rename = "arg")] 76 + pub args: Vec<MethodArg>, 77 + #[serde(default, rename = "annotation")] 78 + pub annotations: Vec<Annotation>, 79 + } 80 + 81 + impl Method { 82 + /// Get the signature of the method args 83 + pub fn in_signature(&self) -> String { 84 + self.args.iter() 85 + .filter(|arg| arg.direction == Direction::In) 86 + .map(|arg| &arg.r#type[..]) 87 + .collect() 88 + } 89 + 90 + #[allow(dead_code)] 91 + /// Get the signature of the method result 92 + pub fn out_signature(&self) -> String { 93 + self.args.iter() 94 + .filter(|arg| arg.direction == Direction::Out) 95 + .map(|arg| &arg.r#type[..]) 96 + .collect() 97 + } 98 + } 99 + 100 + #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] 101 + #[serde(rename_all = "kebab-case")] 102 + pub struct MethodArg { 103 + #[serde(default)] 104 + pub name: Option<String>, 105 + pub r#type: String, 106 + #[serde(default)] 107 + pub direction: Direction, 108 + } 109 + 110 + impl MethodArg { 111 + #[cfg(test)] 112 + pub fn new( 113 + name: impl Into<String>, 114 + r#type: impl Into<String>, 115 + direction: Direction 116 + ) -> MethodArg { 117 + MethodArg { 118 + name: Some(name.into()), 119 + r#type: r#type.into(), 120 + direction, 121 + } 122 + } 123 + } 124 + 125 + #[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)] 126 + #[serde(rename_all = "kebab-case")] 127 + pub enum Direction { 128 + #[default] 129 + In, 130 + Out, 131 + } 132 + 133 + #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] 134 + #[serde(rename_all = "kebab-case")] 135 + pub struct Signal { 136 + pub name: String, 137 + #[serde(default, rename = "arg")] 138 + pub args: Vec<SignalArg>, 139 + #[serde(default, rename = "annotation")] 140 + pub annotations: Vec<Annotation>, 141 + } 142 + 143 + #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] 144 + #[serde(rename_all = "kebab-case")] 145 + pub struct SignalArg { 146 + #[serde(default)] 147 + pub name: Option<String>, 148 + pub r#type: String, 149 + } 150 + 151 + #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] 152 + #[serde(rename_all = "kebab-case")] 153 + pub struct Property { 154 + pub name: String, 155 + pub r#type: String, 156 + pub access: Access, 157 + #[serde(default, rename = "annotation")] 158 + pub annotations: Vec<Annotation>, 159 + } 160 + 161 + #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] 162 + #[serde(rename_all = "lowercase")] 163 + pub enum Access { 164 + Read, 165 + Write, 166 + ReadWrite, 167 + } 168 + 169 + #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] 170 + #[serde(rename_all = "kebab-case")] 171 + pub struct Annotation { 172 + pub name: String, 173 + pub value: String, 174 + } 175 + 176 + impl Annotation { 177 + #[cfg(test)] 178 + pub fn new(name: impl Into<String>, value: impl Into<String>) -> Annotation { 179 + Annotation { name: name.into(), value: value.into() } 180 + } 181 + } 182 + 183 + #[cfg(test)] 184 + pub fn test_introspection_doc_rs() -> Node { 185 + Node { 186 + name: Some("/com/example/sample_object0".into()), 187 + interfaces: vec![Interface { 188 + name: "com.example.SampleInterface0".into(), 189 + methods: vec![ 190 + Method { 191 + name: "Frobate".into(), 192 + args: vec![ 193 + MethodArg::new("foo", "i", Direction::In), 194 + MethodArg::new("bar", "as", Direction::In), 195 + MethodArg::new("baz", "a{us}", Direction::Out), 196 + ], 197 + annotations: vec![ 198 + Annotation::new("org.freedesktop.DBus.Deprecated", "true"), 199 + ], 200 + }, 201 + Method { 202 + name: "Bazify".into(), 203 + args: vec![ 204 + MethodArg::new("bar", "(iiu)", Direction::In), 205 + MethodArg::new("len", "u", Direction::Out), 206 + MethodArg::new("bar", "v", Direction::Out), 207 + ], 208 + annotations: vec![], 209 + }, 210 + Method { 211 + name: "Mogrify".into(), 212 + args: vec![ 213 + MethodArg::new("bar", "(iiav)", Direction::In), 214 + ], 215 + annotations: vec![] 216 + }, 217 + ], 218 + signals: vec![ 219 + Signal { 220 + name: "Changed".into(), 221 + args: vec![ 222 + SignalArg { name: "new_value".into(), r#type: "b".into() }, 223 + ], 224 + annotations: vec![] 225 + }, 226 + ], 227 + properties: vec![ 228 + Property { 229 + name: "Bar".into(), 230 + r#type: "y".into(), 231 + access: Access::ReadWrite, 232 + annotations: vec![], 233 + } 234 + ], 235 + annotations: vec![] 236 + }], 237 + children: vec![ 238 + Node::with_name("child_of_sample_object"), 239 + Node::with_name("another_child_of_sample_object"), 240 + ] 241 + } 242 + } 243 + 244 + #[test] 245 + pub fn test_parse_introspection_doc() -> Result<(), serde_xml_rs::Error> { 246 + let xml = include_str!("test_introspection_doc.xml"); 247 + let result = Node::from_xml(xml)?; 248 + assert_eq!(result, test_introspection_doc_rs()); 249 + Ok(()) 250 + } 251 + 252 + #[test] 253 + pub fn test_get_method_args_signature() { 254 + assert_eq!( 255 + test_introspection_doc_rs() 256 + .get_method_args_signature("com.example.SampleInterface0", "Frobate"), 257 + Some("ias".into()) 258 + ); 259 + }
+35 -4
src/main.rs
··· 5 5 mod client; 6 6 mod convert; 7 7 mod dbus_type; 8 + mod introspection; 8 9 9 10 use config::*; 10 11 use client::*; ··· 18 19 19 20 impl Plugin for NuPluginDbus { 20 21 fn signature(&self) -> Vec<PluginSignature> { 22 + macro_rules! str { 23 + ($s:expr) => (Value::string($s, Span::unknown())) 24 + } 21 25 vec![ 22 26 PluginSignature::build("dbus") 23 27 .is_dbus_command() ··· 29 33 .extra_usage("Returns an array if the method call returns more than one value.") 30 34 .named("timeout", SyntaxShape::Duration, "How long to wait for a response", None) 31 35 .named("signature", SyntaxShape::String, 32 - "Signature of the arguments to send, in D-Bus format\n\ 33 - If not provided, they will be guessed automatically (but poorly)", None) 34 - .switch("no-flatten", "Always return a list of all return values", None) 36 + "Signature of the arguments to send, in D-Bus format.\n \ 37 + If not provided, they will be determined from introspection.\n \ 38 + If --no-introspect is specified and this is not provided, they will \ 39 + be guessed (poorly)", None) 40 + .switch("no-flatten", 41 + "Always return a list of all return values", None) 42 + .switch("no-introspect", 43 + "Don't use introspection to determine the correct argument signature", None) 35 44 .required_named("dest", SyntaxShape::String, 36 45 "The name of the connection to send the method to", 37 46 None) ··· 49 58 /org/freedesktop/DBus org.freedesktop.DBus.Peer Ping".into(), 50 59 description: "Ping the D-Bus server itself".into(), 51 60 result: None 52 - } 61 + }, 62 + PluginExample { 63 + example: "dbus call --dest=org.mpris.MediaPlayer2.spotify \ 64 + /org/mpris/MediaPlayer2 org.freedesktop.DBus.Properties Get \ 65 + org.mpris.MediaPlayer2.Player Metadata".into(), 66 + description: "Get the currently playing song in Spotify".into(), 67 + result: Some(Value::record(nu_protocol::record!( 68 + "xesam:title" => str!("Birdie"), 69 + "xesam:artist" => Value::list(vec![ 70 + str!("LOVE PSYCHEDELICO") 71 + ], Span::unknown()), 72 + "xesam:album" => str!("Love Your Love"), 73 + "xesam:url" => str!("https://open.spotify.com/track/51748BvzeeMs4PIdPuyZmv"), 74 + ), Span::unknown())) 75 + }, 76 + PluginExample { 77 + example: "dbus call --dest=org.freedesktop.Notifications \ 78 + /org/freedesktop/Notifications org.freedesktop.Notifications \ 79 + Notify \"Floppy disks\" 0 \"media-floppy\" \"Rarely seen\" \ 80 + \"But sometimes still used\" [] {} 5000".into(), 81 + description: "Show a notification on the desktop for 5 seconds".into(), 82 + result: None 83 + }, 53 84 ]), 54 85 ] 55 86 }
+27
src/test_introspection_doc.xml
··· 1 + <!-- Edited from: https://dbus.freedesktop.org/doc/dbus-specification.html#introspection-format --> 2 + <!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN" 3 + "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd"> 4 + <node name="/com/example/sample_object0"> 5 + <interface name="com.example.SampleInterface0"> 6 + <method name="Frobate"> 7 + <arg name="foo" type="i" direction="in"/> 8 + <arg name="bar" type="as" direction="in"/> 9 + <arg name="baz" type="a{us}" direction="out"/> 10 + <annotation name="org.freedesktop.DBus.Deprecated" value="true"/> 11 + </method> 12 + <method name="Bazify"> 13 + <arg name="bar" type="(iiu)" direction="in"/> 14 + <arg name="len" type="u" direction="out"/> 15 + <arg name="bar" type="v" direction="out"/> 16 + </method> 17 + <method name="Mogrify"> 18 + <arg name="bar" type="(iiav)" direction="in"/> 19 + </method> 20 + <signal name="Changed"> 21 + <arg name="new_value" type="b"/> 22 + </signal> 23 + <property name="Bar" type="y" access="readwrite"/> 24 + </interface> 25 + <node name="child_of_sample_object"/> 26 + <node name="another_child_of_sample_object"/> 27 + </node>