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}