i3status_rs/blocks/
packages.rs

1//! Pending updates for different package manager like apt, pacman, etc.
2//!
3//! Currently, these package managers are supported:
4//! - `apk` for Alpine Linux
5//! - `apt` for Debian/Ubuntu-based systems
6//! - `aur` for Arch-based systems
7//! - `brew` for the Homebrew Package Manager
8//! - `dnf` for Fedora-based systems
9//! - `flatpak` for Flatpak packages
10//! - `pacman` for Arch-based systems
11//! - `snap` for Snap packages
12//! - `xbps` for Void Linux
13//!
14//! # Configuration
15//!
16//! Key | Values | Default
17//! ----|--------|--------
18//! `interval` | Update interval in seconds. | `600`
19//! `package_manager` | Package manager to check for updates | Automatically derived from format templates, but can be used to influence the `$total` value
20//! `format` | A string to customise the output of this block. See below for available placeholders. | `" $icon $total.eng(w:1) "`
21//! `format_singular` | Same as `format`, but for when exactly one update is available. | `" $icon $total.eng(w:1) "`
22//! `format_up_to_date` | Same as `format`, but for when no updates are available. | `" $icon $total.eng(w:1) "`
23//! `warning_updates_regex` | Display block as warning if updates matching regex are available. | `None`
24//! `critical_updates_regex` | Display block as critical if updates matching regex are available. | `None`
25//! `ignore_updates_regex` | Doesn't include updates matching regex in the count. | `None`
26//! `ignore_phased_updates` | Doesn't include potentially held back phased updates in the count. (For Debian/Ubuntu-based systems) | `false`
27//! `aur_command` | AUR command to check available updates, which outputs in the same format as pacman. E.g. `yay -Qua` (For Arch-based systems) | Required if `$aur` is used
28//!
29//!  Placeholder | Value                                                                            | Type   | Unit
30//! -------------|----------------------------------------------------------------------------------|--------|-----
31//! `icon`       | A static icon                                                                    | Icon   | -
32//! `apk`        | Number of updates available in Alpine Linux                                      | Number | -
33//! `apt`        | Number of updates available in Debian/Ubuntu-based systems                       | Number | -
34//! `aur`        | Number of updates available in Arch-based systems                                | Number | -
35//! `brew`       | Number of updates available in the Homebrew Package Manager                      | Number | -
36//! `dnf`        | Number of updates available in Fedora-based systems                              | Number | -
37//! `flatpak`    | Number of updates available in Flatpak packages                                  | Number | -
38//! `pacman`     | Number of updates available in Arch-based systems                                | Number | -
39//! `snap`       | Number of updates available in Snap packages                                     | Number | -
40//! `xbps`       | Number of updates available in Void Linux                                        | Number | -
41//! `total`      | Number of updates available in all package manager listed                        | Number | -
42//!
43//! # Apt
44//!
45//! Behind the scenes this uses `apt`, and in order to run it without root privileges i3status-rust will create its own package database in `/tmp/i3rs-apt/` which may take up several MB or more. If you have a custom apt config then this block may not work as expected - in that case please open an issue.
46//!
47//! Tip: You can grab the list of available updates using `APT_CONFIG=/tmp/i3rs-apt/apt.conf apt list --upgradable`
48//!
49//! # Pacman
50//!
51//! Requires fakeroot to be installed (only required for pacman).
52//!
53//! Tip: You can grab the list of available updates using `fakeroot pacman -Qu --dbpath /tmp/checkup-db-i3statusrs-$USER/`.
54//! If you have the `CHECKUPDATES_DB` env var set on your system then substitute that dir instead.
55//!
56//! Note: `pikaur` may hang the whole block if there is no internet connectivity [reference](https://github.com/actionless/pikaur/issues/595). In that case, try a different AUR helper.
57//!
58//! ### Pacman hook
59//!
60//! Tip: On Arch Linux you can setup a `pacman` hook to signal i3status-rs to update after packages
61//! have been upgraded, so you won't have stale info in your pacman block.
62//!
63//! In the block configuration, set `signal = 1` (or another number if `1` is being used by some
64//! other block):
65//!
66//! ```toml
67//! [[block]]
68//! block = "packages"
69//! signal = 1
70//! ```
71//!
72//! Create `/etc/pacman.d/hooks/i3status-rust.hook` with the below contents:
73//!
74//! ```ini
75//! [Trigger]
76//! Operation = Upgrade
77//! Type = Package
78//! Target = *
79//!
80//! [Action]
81//! When = PostTransaction
82//! Exec = /usr/bin/pkill -SIGRTMIN+1 i3status-rs
83//! ```
84//!
85//! # Example
86//!
87//! Apk-only config:
88//!
89//! ```toml
90//! [[block]]
91//! block = "packages"
92//! package_manager = ["apk"]
93//! interval = 1800
94//! error_interval = 300
95//! max_retries = 5
96//! format = " $icon $apk.eng(w:1) updates available "
97//! format_singular = " $icon One update available "
98//! format_up_to_date = " $icon system up to date "
99//! [[block.click]]
100//! # shows dmenu with available updates. Any dmenu alternative should also work.
101//! button = "left"
102//! cmd = "apk --no-cache --upgradable list | dmenu -l 10"
103//! ```
104//!
105//! Apt-only config
106//!
107//! ```toml
108//! [[block]]
109//! block = "packages"
110//! interval = 1800
111//! error_interval = 300
112//! max_retries = 5
113//! package_manager = ["apt"]
114//! format = " $icon $apt updates available"
115//! format_singular = " $icon One update available "
116//! format_up_to_date = " $icon system up to date "
117//! [[block.click]]
118//! # shows dmenu with cached available updates. Any dmenu alternative should also work.
119//! button = "left"
120//! cmd = "APT_CONFIG=/tmp/i3rs-apt/apt.conf apt list --upgradable | tail -n +2 | rofi -dmenu"
121//! [[block.click]]
122//! # Updates the block on right click
123//! button = "right"
124//! update = true
125//! ```
126//!
127//! Brew-only config:
128//!
129//! ```toml
130//! [[block]]
131//! block = "packages"
132//! package_manager = ["brew"]
133//! interval = 1800
134//! error_interval = 300
135//! max_retries = 5
136//! format = " $icon $brew.eng(w:1) updates available "
137//! format_singular = " $icon One update available "
138//! format_up_to_date = " $icon system up to date "
139//! [[block.click]]
140//! # shows dmenu with available updates. Any dmenu alternative should also work.
141//! button = "left"
142//! cmd = "brew outdated | dmenu -l 10"
143//! ```
144//!
145//! Dnf-only config:
146//!
147//! ```toml
148//! [[block]]
149//! block = "packages"
150//! package_manager = ["dnf"]
151//! interval = 1800
152//! error_interval = 300
153//! max_retries = 5
154//! format = " $icon $dnf.eng(w:1) updates available "
155//! format_singular = " $icon One update available "
156//! format_up_to_date = " $icon system up to date "
157//! [[block.click]]
158//! # shows dmenu with cached available updates. Any dmenu alternative should also work.
159//! button = "left"
160//! cmd = "dnf list -q --upgrades | tail -n +2 | rofi -dmenu"
161//! ```
162//!
163//! Flatpak-only config:
164//!
165//! ```toml
166//! [[block]]
167//! block = "packages"
168//! package_manager = ["flatpak"]
169//! interval = 1800
170//! error_interval = 300
171//! max_retries = 5
172//! format = " $icon $flatpak.eng(w:1) updates available "
173//! format_singular = " $icon One update available "
174//! format_up_to_date = " $icon system up to date "
175//! [[block.click]]
176//! # shows dmenu with cached available updates. Any dmenu alternative should also work.
177//! button = "left"
178//! cmd = "flatpak remote-ls --updates --columns=ref | rofi -dmenu"
179//! ```
180//!
181//! Pacman-only config:
182//!
183//! ```toml
184//! [[block]]
185//! block = "packages"
186//! package_manager = ["pacman"]
187//! interval = 600
188//! error_interval = 300
189//! max_retries = 5
190//! format = " $icon $pacman updates available "
191//! format_singular = " $icon $pacman update available "
192//! format_up_to_date = " $icon system up to date "
193//! [[block.click]]
194//! # pop-up a menu showing the available updates. Replace wofi with your favourite menu command.
195//! button = "left"
196//! cmd = "fakeroot pacman -Qu --dbpath /tmp/checkup-db-i3statusrs-$USER/ | wofi --show dmenu"
197//! [[block.click]]
198//! # Updates the block on right click
199//! button = "right"
200//! update = true
201//! ```
202//!
203//! Pacman and AUR helper config:
204//!
205//! ```toml
206//! [[block]]
207//! block = "packages"
208//! package_manager = ["pacman", "aur"]
209//! interval = 600
210//! error_interval = 300
211//! max_retries = 5
212//! format = " $icon $pacman + $aur = $total updates available "
213//! format_singular = " $icon $total update available "
214//! format_up_to_date = " $icon system up to date "
215//! # aur_command should output available updates to stdout (ie behave as echo -ne "update\n")
216//! aur_command = "yay -Qua"
217//! ```
218//!
219//! Snap-only config:
220//!
221//! ```toml
222//! [[block]]
223//! block = "packages"
224//! package_manager = ["snap"]
225//! interval = 1800
226//! error_interval = 300
227//! max_retries = 5
228//! format = " $icon $snap.eng(w:1) updates available "
229//! format_singular = " $icon One update available "
230//! format_up_to_date = " $icon system up to date "
231//! [[block.click]]
232//! # shows dmenu with available updates. Any dmenu alternative should also work.
233//! button = "left"
234//! cmd = "snap refresh --list | dmenu -l 10"
235//! ```
236//!
237//! Xbps-only config:
238//!
239//! ```toml
240//! [[block]]
241//! block = "packages"
242//! package_manager = ["xbps"]
243//! interval = 1800
244//! error_interval = 300
245//! max_retries = 5
246//! format = " $icon $xbps.eng(w:1) updates available "
247//! format_singular = " $icon One update available "
248//! format_up_to_date = " $icon system up to date "
249//! [[block.click]]
250//! # shows dmenu with available updates. Any dmenu alternative should also work.
251//! button = "left"
252//! cmd = "xbps-install -Mun | dmenu -l 10"
253//! ```
254//!
255//! Multiple package managers config:
256//!
257//! Update the list of pending updates every thirty minutes (1800 seconds):
258//!
259//! ```toml
260//! [[block]]
261//! block = "packages"
262//! package_manager = ["apk", "apt", "aur", "brew", "dnf", "flatpak", "pacman", "snap", "xbps"]
263//! interval = 1800
264//! error_interval = 300
265//! max_retries = 5
266//! format = " $icon $apk + $apt + $aur + $brew + $dnf + $flatpak + $pacman + $snap + $xbps = $total updates available "
267//! format_singular = " $icon One update available "
268//! format_up_to_date = " $icon system up to date "
269//! # If a linux update is available, but no ZFS package, it won't be possible to
270//! # actually perform a system upgrade, so we show a warning.
271//! warning_updates_regex = "(linux|linux-lts|linux-zen)"
272//! # If ZFS is available, we know that we can and should do an upgrade, so we show
273//! # the status as critical.
274//! critical_updates_regex = "(zfs|zfs-lts)"
275//! ```
276//!
277//! # Icons Used
278//!
279//! - `update`
280
281pub mod apk;
282use apk::Apk;
283
284pub mod apt;
285use apt::Apt;
286
287pub mod brew;
288use brew::Brew;
289
290pub mod dnf;
291use dnf::Dnf;
292
293pub mod flatpak;
294use flatpak::Flatpak;
295
296pub mod pacman;
297use pacman::{Aur, Pacman};
298
299pub mod xbps;
300use xbps::Xbps;
301
302pub mod snap;
303use snap::Snap;
304
305use regex::Regex;
306
307use super::prelude::*;
308
309#[derive(Deserialize, Debug, SmartDefault, Clone)]
310#[serde(deny_unknown_fields, default)]
311pub struct Config {
312    #[default(600.into())]
313    pub interval: Seconds,
314    pub package_manager: Vec<PackageManager>,
315    pub format: FormatConfig,
316    pub format_singular: FormatConfig,
317    pub format_up_to_date: FormatConfig,
318    pub warning_updates_regex: Option<String>,
319    pub critical_updates_regex: Option<String>,
320    pub ignore_updates_regex: Option<String>,
321    pub ignore_phased_updates: bool,
322    pub aur_command: Option<String>,
323}
324
325#[derive(Deserialize, Debug, Clone, Copy, PartialEq)]
326#[serde(rename_all = "lowercase")]
327pub enum PackageManager {
328    Apk,
329    Apt,
330    Aur,
331    Brew,
332    Dnf,
333    Flatpak,
334    Pacman,
335    Snap,
336    Xbps,
337}
338
339impl PackageManager {
340    /// The name of the package manager, as used in format strings.
341    fn name(&self) -> &'static str {
342        match self {
343            PackageManager::Apk => "apk",
344            PackageManager::Apt => "apt",
345            PackageManager::Aur => "aur",
346            PackageManager::Brew => "brew",
347            PackageManager::Dnf => "dnf",
348            PackageManager::Flatpak => "flatpak",
349            PackageManager::Pacman => "pacman",
350            PackageManager::Snap => "snap",
351            PackageManager::Xbps => "xbps",
352        }
353    }
354
355    /// Builds a backend for the package manager.
356    async fn build(&self, config: &Config) -> Result<Box<dyn Backend>> {
357        Ok(match self {
358            PackageManager::Apk => Box::new(Apk::new()),
359            PackageManager::Apt => Box::new(Apt::new(config.ignore_phased_updates).await?),
360            PackageManager::Aur => Box::new(Aur::new(
361                config.aur_command.clone().error("aur_command is not set")?,
362            )),
363            PackageManager::Brew => Box::new(Brew::new()),
364            PackageManager::Dnf => Box::new(Dnf::new()),
365            PackageManager::Flatpak => Box::new(Flatpak::new()),
366            PackageManager::Pacman => Box::new(Pacman::new().await?),
367            PackageManager::Snap => Box::new(Snap::new()),
368            PackageManager::Xbps => Box::new(Xbps::new()),
369        })
370    }
371}
372
373pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
374    let mut config: Config = config.clone();
375
376    let format = config.format.with_default(" $icon $total.eng(w:1) ")?;
377    let format_singular = config
378        .format_singular
379        .with_default(" $icon $total.eng(w:1) ")?;
380    let format_up_to_date = config
381        .format_up_to_date
382        .with_default(" $icon $total.eng(w:1) ")?;
383
384    // Check if the user specified a package manager in any format string, then
385    // add that package manager to the config list.
386    macro_rules! check_manager {
387        ($manager:expr) => {{
388            let name = $manager.name();
389            let in_format = format.contains_key(name)
390                || format_singular.contains_key(name)
391                || format_up_to_date.contains_key(name);
392
393            if !config.package_manager.contains(&$manager) && in_format {
394                config.package_manager.push($manager);
395            }
396        }};
397    }
398
399    check_manager!(PackageManager::Apk);
400    check_manager!(PackageManager::Apt);
401    check_manager!(PackageManager::Aur);
402    check_manager!(PackageManager::Brew);
403    check_manager!(PackageManager::Dnf);
404    check_manager!(PackageManager::Flatpak);
405    check_manager!(PackageManager::Pacman);
406    check_manager!(PackageManager::Snap);
407    check_manager!(PackageManager::Xbps);
408
409    let warning_updates_regex = config
410        .warning_updates_regex
411        .as_deref()
412        .map(Regex::new)
413        .transpose()
414        .error("invalid warning updates regex")?;
415    let critical_updates_regex = config
416        .critical_updates_regex
417        .as_deref()
418        .map(Regex::new)
419        .transpose()
420        .error("invalid critical updates regex")?;
421    let ignore_updates_regex = config
422        .ignore_updates_regex
423        .as_deref()
424        .map(Regex::new)
425        .transpose()
426        .error("invalid ignore updates regex")?;
427
428    let mut package_manager_vec: Vec<Box<dyn Backend>> = Vec::new();
429
430    for &package_manager in config.package_manager.iter() {
431        package_manager_vec.push(package_manager.build(&config).await?);
432    }
433
434    loop {
435        let mut package_manager_map: HashMap<Cow<'static, str>, Value> = HashMap::new();
436
437        let mut critical = false;
438        let mut warning = false;
439        let mut total_count = 0;
440
441        // Iterate over the all package manager listed in Config
442        for package_manager in &package_manager_vec {
443            let mut updates = package_manager.get_updates_list().await?;
444            if let Some(regex) = ignore_updates_regex.clone() {
445                updates.retain(|u| !regex.is_match(u));
446            }
447
448            let updates_count = updates.len();
449
450            package_manager_map.insert(package_manager.name(), Value::number(updates_count));
451            total_count += updates_count;
452
453            warning |= warning_updates_regex
454                .as_ref()
455                .is_some_and(|regex| has_matching_update(&updates, regex));
456            critical |= critical_updates_regex
457                .as_ref()
458                .is_some_and(|regex| has_matching_update(&updates, regex));
459        }
460
461        let mut widget = Widget::new();
462
463        package_manager_map.insert("icon".into(), Value::icon("update"));
464        package_manager_map.insert("total".into(), Value::number(total_count));
465
466        widget.set_format(match total_count {
467            0 => format_up_to_date.clone(),
468            1 => format_singular.clone(),
469            _ => format.clone(),
470        });
471        widget.set_values(package_manager_map);
472
473        widget.state = match total_count {
474            0 => State::Idle,
475            _ => {
476                if critical {
477                    State::Critical
478                } else if warning {
479                    State::Warning
480                } else {
481                    State::Info
482                }
483            }
484        };
485        api.set_widget(widget)?;
486
487        select! {
488            _ = sleep(config.interval.0) => (),
489            _ = api.wait_for_update_request() => (),
490        }
491    }
492}
493
494#[async_trait]
495pub trait Backend {
496    fn name(&self) -> Cow<'static, str>;
497
498    async fn get_updates_list(&self) -> Result<Vec<String>>;
499}
500
501pub fn has_matching_update(updates: &[String], regex: &Regex) -> bool {
502    updates.iter().any(|line| regex.is_match(line))
503}