Skip to main content

i3status_rs/blocks/
privacy.rs

1//! Privacy Monitor
2//!
3//! # Configuration
4//!
5//! Key        | Values | Default|
6//! -----------|--------|--------|
7//! `driver` | The configuration of a driver (see below). | **Required**
8//! `format`   | [MultiFormat][MaybeMultiFormatConfig] string. | <code>[\"{ $icon_audio \|}{ $icon_audio_sink \|}{ $icon_video \|}{ $icon_webcam \|}{ $icon_unknown \|}\", \"{ $icon_audio $info_audio \|}{ $icon_audio_sink $info_audio_sink \|}{ $icon_video $info_video \|}{ $icon_webcam $info_webcam \|}{ $icon_unknown $info_unknown \|}\"]</code> |
9//!
10//! # pipewire Options (requires the pipewire feature to be enabled)
11//!
12//! Key | Values | Required | Default
13//! ----|--------|----------|--------
14//! `name` | `pipewire` | Yes | None
15//! `exclude_output` | An output node to ignore, example: `["HD Pro Webcam C920"]` | No | `[]`
16//! `exclude_input` | An input node to ignore, example: `["openrgb"]` | No | `[]`
17//! `display`   | Which node field should be used as a display name, options: `name`, `description`, `nickname` | No | `name`
18//!
19//! # vl4 Options
20//!
21//! Key | Values | Required | Default
22//! ----|--------|----------|--------
23//! `name` | `vl4` | Yes | None
24//! `exclude_device` | A device to ignore, example: `["/dev/video5"]` | No | `[]`
25//! `exclude_consumer` | Processes to ignore | No | `["pipewire", "wireplumber"]`
26//!
27//! # Available Format Keys
28//!
29//! Placeholder                                      | Value                                          | Type     | Unit
30//! -------------------------------------------------|------------------------------------------------|----------|-----
31//! `icon_{audio,audio_sink,video,webcam,unknown}`   | A static icon                                  | Icon     | -
32//! `info_{audio,audio_sink,video,webcam,unknown}`   | The mapping of which source are being consumed | Text     | -
33//!
34//! You can use the suffixes noted above to get the following:
35//!
36//! Suffix       | Description
37//! -------------|------------
38//! `audio`      | Captured audio (ex. Mic)
39//! `audio_sink` | Audio captured from a sink (ex. openrgb)
40//! `video`      | Video capture (ex. screen capture)
41//! `webcam`     | Webcam capture
42//! `unknown`    | Anything else
43//!
44//! # Available Actions
45//!
46//! Action          | Description                               | Default button
47//! ----------------|-------------------------------------------|---------------
48//! `toggle_format` **DEPRECATED** | Toggles between `format` and `format_alt` | -
49//! `next_format`  | Switches to the next format in the list     | Left
50//! `prev_format`  | Switches to the previous format in the list | Right
51//!
52//! # Example
53//!
54//! ```toml
55//! [[block]]
56//! block = "privacy"
57//! [[block.driver]]
58//! name = "v4l"
59//! [[block.driver]]
60//! name = "pipewire"
61//! exclude_input = ["openrgb"]
62//! display = "nickname"
63//! ```
64//!
65//! # Icons Used
66//! - `microphone` (`$icon_audio`)
67//! - `volume` (`$icon_audio_sink`)
68//! - `xrandr` (`$icon_video`)
69//! - `webcam` (`$icon_webcam`)
70//! - `unknown` (`$icon_unknown`)
71
72use futures::future::{select_all, try_join_all};
73
74use super::prelude::*;
75
76make_log_macro!(debug, "privacy");
77
78#[cfg(feature = "pipewire")]
79mod pipewire;
80mod v4l;
81
82#[derive(Deserialize, Debug)]
83pub struct Config {
84    #[serde(flatten)]
85    pub formats: MaybeMultiFormatConfig,
86    pub driver: Vec<PrivacyDriver>,
87}
88
89#[derive(Deserialize, Debug)]
90#[serde(tag = "name", rename_all = "snake_case")]
91pub enum PrivacyDriver {
92    #[cfg(feature = "pipewire")]
93    Pipewire(pipewire::Config),
94    V4l(v4l::Config),
95}
96
97#[derive(Debug, Clone, Eq, Hash, PartialEq)]
98enum Type {
99    Audio,
100    AudioSink,
101    Video,
102    Webcam,
103    Unknown,
104}
105
106// {type: {source: {destination: count}}
107type PrivacyInfo = HashMap<Type, PrivacyInfoInner>;
108
109type PrivacyInfoInnerType = HashMap<String, HashMap<String, usize>>;
110#[derive(Default, Debug)]
111struct PrivacyInfoInner(PrivacyInfoInnerType);
112
113impl std::ops::Deref for PrivacyInfoInner {
114    type Target = PrivacyInfoInnerType;
115    fn deref(&self) -> &Self::Target {
116        &self.0
117    }
118}
119
120impl std::ops::DerefMut for PrivacyInfoInner {
121    fn deref_mut(&mut self) -> &mut Self::Target {
122        &mut self.0
123    }
124}
125
126impl std::fmt::Display for PrivacyInfoInner {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        write!(
129            f,
130            "{{ {} }}",
131            itertools::join(
132                self.iter().map(|(source, destinations)| {
133                    format!(
134                        "{} => [ {} ]",
135                        source,
136                        itertools::join(
137                            destinations
138                                .iter()
139                                .map(|(destination, count)| if count == &1 {
140                                    destination.into()
141                                } else {
142                                    format!("{destination} (x{count})")
143                                }),
144                            ", "
145                        )
146                    )
147                }),
148                ", ",
149            )
150        )
151    }
152}
153
154#[async_trait]
155trait PrivacyMonitor {
156    async fn get_info(&mut self) -> Result<PrivacyInfo>;
157    async fn wait_for_change(&mut self) -> Result<()>;
158}
159
160/// The icon each icon-valued placeholder carries, per capture type.
161const TYPE_ICONS: [(Type, &str, &str); 5] = [
162    (Type::Audio, "icon_audio", icons::MICROPHONE),
163    (Type::AudioSink, "icon_audio_sink", icons::VOLUME),
164    (Type::Video, "icon_video", icons::XRANDR),
165    (Type::Webcam, "icon_webcam", icons::WEBCAM),
166    (Type::Unknown, "icon_unknown", icons::UNKNOWN),
167];
168
169impl PrivacyDriver {
170    /// The capture types this driver's monitor can report.
171    fn capture_types(&self) -> &'static [Type] {
172        match self {
173            #[cfg(feature = "pipewire")]
174            PrivacyDriver::Pipewire(_) => &[
175                Type::Audio,
176                Type::AudioSink,
177                Type::Video,
178                Type::Webcam,
179                Type::Unknown,
180            ],
181            PrivacyDriver::V4l(_) => &[Type::Webcam],
182        }
183    }
184}
185
186pub(crate) fn prepare(config: &Config) -> Result<Arc<BlockPlan>> {
187    let formats = config.formats.with_default_formats(&[
188        "{ $icon_audio |}{ $icon_audio_sink |}{ $icon_video |}{ $icon_webcam |}{ $icon_unknown |}"
189            .parse()?,
190        "{ $icon_audio $info_audio |}{ $icon_audio_sink $info_audio_sink |}{ $icon_video $info_video |}{ $icon_webcam $info_webcam |}{ $icon_unknown $info_unknown |}"
191            .parse()?,
192    ]);
193
194    // Only capture types some configured driver can actually report are
195    // declared: a V4L-only setup can never set the audio or video icons.
196    let declare = |mut output: OutputPlan| {
197        for (ty, placeholder, icon) in &TYPE_ICONS {
198            if config
199                .driver
200                .iter()
201                .any(|driver| driver.capture_types().contains(ty))
202            {
203                output = output.icon(placeholder, IconChoices::one(*icon));
204            }
205        }
206        output
207    };
208    BlockPlan::new(format_outputs(formats, declare))
209}
210
211pub(crate) async fn run(config: &Config, api: &CommonApi, plan: &Arc<BlockPlan>) -> Result<()> {
212    let mut actions = api.get_actions()?;
213    api.set_default_actions(&[
214        (MouseButton::Left, None, "next_format"),
215        (MouseButton::Right, None, "prev_format"),
216    ])?;
217
218    let mut formats = FormatRotation::new(plan)?;
219
220    let mut drivers: Vec<Box<dyn PrivacyMonitor + Send + Sync>> = Vec::new();
221
222    for driver in &config.driver {
223        drivers.push(match driver {
224            #[cfg(feature = "pipewire")]
225            PrivacyDriver::Pipewire(driver_config) => {
226                Box::new(pipewire::Monitor::new(driver_config).await?)
227            }
228            PrivacyDriver::V4l(driver_config) => {
229                Box::new(v4l::Monitor::new(driver_config, api.error_interval).await?)
230            }
231        });
232    }
233
234    loop {
235        let output = formats.current();
236        let mut widget = output.new_widget();
237
238        let mut info = PrivacyInfo::default();
239        //Merge driver info
240        for driver_info in try_join_all(drivers.iter_mut().map(|driver| driver.get_info())).await? {
241            for (type_, mapping) in driver_info {
242                let existing_mapping = info.entry(type_).or_default();
243                for (source, dest) in mapping.0 {
244                    existing_mapping.entry(source).or_default().extend(dest);
245                }
246            }
247        }
248        if !info.is_empty() {
249            widget.state = State::Warning;
250        }
251
252        let mut values = Values::new();
253
254        if let Some(info_by_type) = info.get(&Type::Audio) {
255            map! { @extend values
256                "icon_audio" => Value::icon(icons::MICROPHONE),
257                "info_audio" => Value::text(info_by_type.to_string())
258            }
259        }
260        if let Some(info_by_type) = info.get(&Type::AudioSink) {
261            map! { @extend values
262                "icon_audio_sink" => Value::icon(icons::VOLUME),
263                "info_audio_sink" => Value::text(info_by_type.to_string())
264            }
265        }
266        if let Some(info_by_type) = info.get(&Type::Video) {
267            map! { @extend values
268                "icon_video" => Value::icon(icons::XRANDR),
269                "info_video" => Value::text(info_by_type.to_string())
270            }
271        }
272        if let Some(info_by_type) = info.get(&Type::Webcam) {
273            map! { @extend values
274                "icon_webcam" => Value::icon(icons::WEBCAM),
275                "info_webcam" => Value::text(info_by_type.to_string())
276            }
277        }
278        if let Some(info_by_type) = info.get(&Type::Unknown) {
279            map! { @extend values
280                "icon_unknown" => Value::icon(icons::UNKNOWN),
281                "info_unknown" => Value::text(info_by_type.to_string())
282            }
283        }
284
285        widget.set_values(values);
286
287        api.set_widget(widget)?;
288
289        select! {
290            _ = api.wait_for_update_request() => (),
291            _ = select_all(drivers.iter_mut().map(|driver| driver.wait_for_change())) =>(),
292            Some(action) = actions.recv() => match action.as_ref() {
293                "next_format" | "toggle_format" => {
294                    formats.next();
295                }
296                "prev_format" => {
297                    formats.prev();
298                }
299                _ => (),
300            }
301        }
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    fn config() -> Config {
310        Config {
311            formats: Default::default(),
312            driver: Vec::new(),
313        }
314    }
315
316    fn config_with_drivers(toml_drivers: &str) -> Config {
317        toml::from_str(toml_drivers).unwrap()
318    }
319
320    #[test]
321    fn plan_declares_driver_reachable_icons_on_both_outputs() {
322        // A V4L-only configuration can only ever report webcam capture.
323        let config = config_with_drivers("[[driver]]\nname = \"v4l\"");
324        let plan = prepare(&config).unwrap();
325        let ids: Vec<_> = plan.outputs().map(|o| o.id()).collect();
326        assert_eq!(ids, ["format", "format2"]);
327        for id in ids {
328            let output = plan.output(id).unwrap();
329            assert_eq!(output.single_icon("icon_webcam").unwrap(), "webcam");
330            assert_eq!(
331                output.output().icon_placeholders().count(),
332                1,
333                "{id} must declare only V4L-reachable icons"
334            );
335        }
336    }
337
338    #[cfg(feature = "pipewire")]
339    #[test]
340    fn pipewire_declares_every_type_icon() {
341        let config = config_with_drivers("[[driver]]\nname = \"pipewire\"");
342        let plan = prepare(&config).unwrap();
343        for id in ["format", "format2"] {
344            let output = plan.output(id).unwrap();
345            for (_, placeholder, icon) in &TYPE_ICONS {
346                assert_eq!(
347                    output.single_icon(placeholder).unwrap(),
348                    *icon,
349                    "{id} must declare {icon} for ${placeholder}"
350                );
351            }
352        }
353    }
354
355    #[test]
356    fn every_capture_type_has_a_declared_icon() {
357        // Drift test: the exhaustive match stops compiling when a `Type`
358        // variant is added, forcing TYPE_ICONS (and the value mapping in
359        // `run()`) to be extended in lockstep.
360        let types = [
361            Type::Audio,
362            Type::AudioSink,
363            Type::Video,
364            Type::Webcam,
365            Type::Unknown,
366        ];
367        assert_eq!(types.len(), TYPE_ICONS.len());
368        for type_ in types {
369            let (placeholder, icon) = match type_ {
370                Type::Audio => ("icon_audio", "microphone"),
371                Type::AudioSink => ("icon_audio_sink", "volume"),
372                Type::Video => ("icon_video", "xrandr"),
373                Type::Webcam => ("icon_webcam", "webcam"),
374                Type::Unknown => ("icon_unknown", "unknown"),
375            };
376            assert!(
377                TYPE_ICONS
378                    .iter()
379                    .any(|(t, p, i)| *t == type_ && *p == placeholder && *i == icon)
380            );
381        }
382    }
383
384    #[test]
385    fn the_second_default_format_is_the_detailed_one() {
386        let plan = prepare(&config()).unwrap();
387        assert!(
388            !plan
389                .output("format")
390                .unwrap()
391                .format()
392                .contains_key("info_audio")
393        );
394        assert!(
395            plan.output("format2")
396                .unwrap()
397                .format()
398                .contains_key("info_audio")
399        );
400    }
401
402    #[test]
403    fn every_configured_format_becomes_an_output() {
404        let config: Config = toml::from_str(
405            "format = [\"{ $icon_webcam |}\", \"{ $icon_webcam $info_webcam |}\"]\n\
406             [[driver]]\nname = \"v4l\"",
407        )
408        .unwrap();
409        let plan = prepare(&config).unwrap();
410        let ids: Vec<_> = plan.outputs().map(|o| o.id()).collect();
411        assert_eq!(ids, ["format", "format2"]);
412        for id in ids {
413            assert_eq!(
414                plan.output(id).unwrap().single_icon("icon_webcam").unwrap(),
415                "webcam"
416            );
417        }
418    }
419}