Skip to main content

i3status_rs/blocks/
custom_dbus.rs

1//! A block controlled by the DBus
2//!
3//! This block creates a new DBus object in `rs.i3status` service. This object implements
4//! `rs.i3status.custom` interface which allows you to set block's icon, text and state.
5//!
6//! Output of `busctl --user introspect rs.i3status /<path> rs.i3status.custom`:
7//! ```text
8//! NAME                                TYPE      SIGNATURE RESULT/VALUE FLAGS
9//! rs.i3status.custom                  interface -         -            -
10//! .SetIcon                            method    s         s            -
11//! .SetState                           method    s         s            -
12//! .SetText                            method    ss        s            -
13//! ```
14//!
15//! # Configuration
16//!
17//! Key | Values | Default
18//! ----|--------|--------
19//! `format` | A string to customise the output of this block. | <code>\"{ $icon\|}{ $text.pango-str()\|} \"</code>
20//!
21//! Placeholder  | Value                                  | Type   | Unit
22//! -------------|-------------------------------------------------------------------|--------|---------------
23//! `icon`       | Value of icon set via `SetIcon` if the value is non-empty string. | Icon   | -
24//! `text`       | Value of the first string from SetText                            | Text   | -
25//! `short_text` | Value of the second string from SetText                           | Text   | -
26//!
27//! # Example
28//!
29//! Config:
30//! ```toml
31//! [[block]]
32//! block = "custom_dbus"
33//! path = "/my_path"
34//! ```
35//!
36//! Usage:
37//! ```sh
38//! # set full text to 'hello' and short text to 'hi'
39//! busctl --user call rs.i3status /my_path rs.i3status.custom SetText ss hello hi
40//! # set icon to 'music'
41//! busctl --user call rs.i3status /my_path rs.i3status.custom SetIcon s music
42//! # set state to 'good'
43//! busctl --user call rs.i3status /my_path rs.i3status.custom SetState s good
44//! ```
45//!
46//! Because it's impossible to publish objects to the same name from different
47//! processes, having multiple dbus blocks in different bars won't work. As a workaround,
48//! you can set the env var `I3RS_DBUS_NAME` to set the interface a bar works on to
49//! differentiate between different processes. For example, setting this to 'top', will allow you
50//! to use `rs.i3status.top`.
51//!
52//! # TODO
53//! - Send a signal on click?
54
55use super::prelude::*;
56use std::env;
57use zbus::fdo;
58
59// Share DBus connection between multiple block instances
60static DBUS_CONNECTION: tokio::sync::OnceCell<Result<zbus::Connection>> =
61    tokio::sync::OnceCell::const_new();
62
63const DBUS_NAME: &str = "rs.i3status";
64
65#[derive(Deserialize, Debug)]
66#[serde(deny_unknown_fields)]
67pub struct Config {
68    #[serde(default)]
69    pub format: FormatConfig,
70    pub path: String,
71}
72
73struct Block {
74    widget: Widget,
75    api: CommonApi,
76    icon: Option<String>,
77    text: Option<String>,
78    short_text: Option<String>,
79}
80
81fn block_values(block: &Block) -> HashMap<Cow<'static, str>, Value> {
82    map! {
83        [if let Some(icon) = &block.icon] "icon" => Value::icon(icon.to_string()),
84        [if let Some(text) = &block.text] "text" => Value::text(text.to_string()),
85        [if let Some(short_text) = &block.short_text] "short_text" => Value::text(short_text.to_string()),
86    }
87}
88
89#[zbus::interface(name = "rs.i3status.custom")]
90impl Block {
91    async fn set_icon(&mut self, icon: &str) -> fdo::Result<()> {
92        self.icon = if icon.is_empty() {
93            None
94        } else {
95            Some(icon.to_string())
96        };
97        self.widget.set_values(block_values(self));
98        self.api.set_widget(self.widget.clone())?;
99        Ok(())
100    }
101
102    async fn set_text(&mut self, full: String, short: String) -> fdo::Result<()> {
103        self.text = Some(full);
104        self.short_text = Some(short);
105        self.widget.set_values(block_values(self));
106        self.api.set_widget(self.widget.clone())?;
107        Ok(())
108    }
109
110    async fn set_state(&mut self, state: &str) -> fdo::Result<()> {
111        self.widget.state = match state {
112            "idle" => State::Idle,
113            "info" => State::Info,
114            "good" => State::Good,
115            "warning" => State::Warning,
116            "critical" => State::Critical,
117            _ => return Err(Error::new(format!("'{state}' is not a valid state")).into()),
118        };
119        self.api.set_widget(self.widget.clone())?;
120        Ok(())
121    }
122}
123
124pub(crate) fn prepare(config: &Config) -> Result<Arc<BlockPlan>> {
125    // The icon name arrives over D-Bus at runtime, so any name is permitted;
126    // it resolves through the normal icon set and override rules.
127    BlockPlan::new(vec![
128        OutputPlan::new(
129            "main",
130            config.format.with_defaults(
131                "{ $icon|}{ $text.pango-str()|} ",
132                "{ $icon|} $short_text.pango-str() | ",
133            )?,
134        )
135        .icon("icon", IconChoices::OpenResolvable),
136    ])
137}
138
139pub(crate) async fn run(config: &Config, api: &CommonApi, plan: &Arc<BlockPlan>) -> Result<()> {
140    let output = plan.output("main")?;
141    let widget = output.new_widget();
142
143    let dbus_conn = DBUS_CONNECTION
144        .get_or_init(dbus_conn)
145        .await
146        .as_ref()
147        .map_err(Clone::clone)?;
148    dbus_conn
149        .object_server()
150        .at(
151            config.path.clone(),
152            Block {
153                widget,
154                api: api.clone(),
155                icon: None,
156                text: None,
157                short_text: None,
158            },
159        )
160        .await
161        .error("Failed to setup DBus server")?;
162    Ok(())
163}
164
165async fn dbus_conn() -> Result<zbus::Connection> {
166    let dbus_interface_name = match env::var("I3RS_DBUS_NAME") {
167        Ok(v) => format!("{DBUS_NAME}.{v}"),
168        Err(_) => DBUS_NAME.to_string(),
169    };
170
171    let conn = new_dbus_connection().await?;
172    conn.request_name(dbus_interface_name)
173        .await
174        .error("Failed to request DBus name")?;
175    Ok(conn)
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    fn config(toml: &str) -> Config {
183        toml::from_str(toml).unwrap()
184    }
185
186    #[test]
187    fn plan_declares_an_open_icon_because_the_name_arrives_over_dbus() {
188        let plan = prepare(&config(r#"path = "/my_path""#)).unwrap();
189        let ids: Vec<_> = plan.outputs().map(|o| o.id()).collect();
190        assert_eq!(ids, ["main"]);
191
192        let output = plan.output("main").unwrap();
193        let choices = output.output().choices_for("icon").unwrap();
194        assert!(matches!(choices, IconChoices::OpenResolvable));
195        assert!(choices.permits("any_name_set_over_dbus"));
196        // Whatever name arrives over D-Bus passes the publish-time check,
197        // which is where the contract is enforced.
198        let mut widget = output.new_widget();
199        widget.set_values(map!("icon" => Value::icon("whatever_was_sent")));
200        widget.check_contract().unwrap();
201    }
202
203    #[test]
204    fn custom_format_is_respected() {
205        // The default short format also carries `$icon`, so both halves have
206        // to be overridden for the icon to be gone from the output.
207        let plan = prepare(&config(
208            r#"
209            path = "/my_path"
210            format = { full = " $text.pango-str() ", short = " $short_text.pango-str() " }
211            "#,
212        ))
213        .unwrap();
214        let output = plan.output("main").unwrap();
215        assert!(output.format().contains_key("text"));
216        assert!(output.format().contains_key("short_text"));
217        assert!(!output.format().contains_key("icon"));
218    }
219}