Skip to main content

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