Skip to main content

qframe/
install.rs

1//! Installing a missing program's package here, in front of the person.
2//!
3//! When an application needs a program the machine does not have, it does not tell the person to
4//! copy a command into a terminal: it offers to do it. [`Install`] finds this machine's package
5//! manager, builds the exact command, asks with a [`Confirm`] that shows that command, and hands
6//! the terminal to the package manager with a [`Handoff`] once the person agreed. The package
7//! manager then asks for the password and its own questions itself, on the terminal, and its last
8//! lines stay on screen until a key is pressed.
9//!
10//! ```
11//! use qframe::install::{Install, Manager};
12//! use qframe::prelude::*;
13//! use qframe::runtime::HandoffOutcome;
14//!
15//! enum Msg {
16//!     AskToInstall,
17//!     Install,
18//!     Installed(HandoffOutcome),
19//! }
20//!
21//! struct Viewer {
22//!     /// `None` when this machine has no package manager the framework knows.
23//!     install: Option<Install>,
24//! }
25//!
26//! impl Viewer {
27//!     fn new() -> Self {
28//!         // Debian and its relatives ship `bsdtar` in a package of another name.
29//!         Self { install: Install::package("libarchive").map(|i| i.name_for(Manager::Apt, "libarchive-tools")) }
30//!     }
31//!
32//!     fn update(&mut self, msg: Msg) -> Command<Msg> {
33//!         match (msg, &self.install) {
34//!             // First the question: nothing runs until the person answers it.
35//!             (Msg::AskToInstall, Some(install)) => Command::confirm(install.confirm(Msg::Install)),
36//!             (Msg::Install, Some(install)) => Command::handoff(install.handoff(Msg::Installed)),
37//!             _ => Command::none(),
38//!         }
39//!     }
40//! }
41//! ```
42
43use std::ffi::OsString;
44use std::path::PathBuf;
45
46use crate::runtime::{Confirm, Handoff, HandoffOutcome};
47
48/// A package manager [`Install`] knows how to ask for a package.
49///
50/// AUR helpers such as `paru` and `yay` are not among them on purpose: they build packages from
51/// source as the user who runs them, which is a different act from "install this package", and
52/// the package a program needs is in the official repositories.
53#[non_exhaustive]
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
55pub enum Manager {
56    /// Arch Linux and its relatives: `pacman -S --needed`.
57    Pacman,
58    /// Debian, Ubuntu and their relatives: `apt-get install`.
59    Apt,
60    /// Fedora and its relatives: `dnf install`.
61    Dnf,
62    /// openSUSE: `zypper install`.
63    Zypper,
64    /// Alpine Linux: `apk add`.
65    Apk,
66    /// Homebrew, on macOS and Linux: `brew install`, never as root.
67    Brew,
68}
69
70impl Manager {
71    /// Every manager, in the order [`Install::package`] looks for them.
72    #[must_use]
73    pub fn all() -> &'static [Manager] {
74        &[Manager::Pacman, Manager::Apt, Manager::Dnf, Manager::Zypper, Manager::Apk, Manager::Brew]
75    }
76
77    /// The manager's program, as it is found on `PATH` and run: `pacman`, `apt-get`, `dnf`,
78    /// `zypper`, `apk` or `brew`.
79    #[must_use]
80    pub fn name(self) -> &'static str {
81        match self {
82            Manager::Pacman => "pacman",
83            Manager::Apt => "apt-get",
84            Manager::Dnf => "dnf",
85            Manager::Zypper => "zypper",
86            Manager::Apk => "apk",
87            Manager::Brew => "brew",
88        }
89    }
90
91    /// The words after the program that install one package. No "yes" flag is among them: the
92    /// person answers the manager's own question.
93    fn install_words(self) -> &'static [&'static str] {
94        match self {
95            Manager::Pacman => &["-S", "--needed"],
96            Manager::Apt | Manager::Dnf | Manager::Zypper | Manager::Brew => &["install"],
97            Manager::Apk => &["add"],
98        }
99    }
100
101    /// Whether installing needs root. Homebrew installs into a folder the user owns and refuses
102    /// to run as root.
103    fn needs_root(self) -> bool {
104        self != Manager::Brew
105    }
106}
107
108/// One package to install with this machine's package manager: the command, the question that
109/// shows it and the handoff that runs it.
110///
111/// Made by [`Install::package`], which looks for `pacman`, `apt-get`, `dnf`, `zypper`, `apk` and
112/// `brew` on `PATH` in that order and takes the first. The command is the manager's own install
113/// command with `sudo` in front when the application does not run as root, such as
114/// `sudo pacman -S --needed libarchive`; no "yes" flag is added, so the manager shows its own
115/// question and the person answers it.
116///
117/// ```
118/// use std::path::PathBuf;
119///
120/// use qframe::install::{Install, Manager};
121///
122/// // A machine with `apt-get` and nothing else, the application not running as root.
123/// let lookup = |program: &str| (program == "apt-get").then(|| PathBuf::from("/usr/bin/apt-get"));
124/// let install = Install::package_with("libarchive", lookup, false)
125///     .expect("a package manager")
126///     .name_for(Manager::Apt, "libarchive-tools");
127/// assert_eq!(install.manager(), Manager::Apt);
128/// assert_eq!(install.command_line(), "sudo apt-get install libarchive-tools");
129/// ```
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct Install {
132    manager: Manager,
133    package: String,
134    root: bool,
135}
136
137impl Install {
138    /// The package `name` with this machine's package manager, found on `PATH`; `None` when there
139    /// is none, or when `name` cannot be a package name (empty, starting with `-`, or holding a
140    /// space or a control character).
141    ///
142    /// Homebrew is passed over when the application runs as root, since it refuses to run so.
143    #[must_use]
144    pub fn package(name: &str) -> Option<Install> {
145        let path = std::env::var_os("PATH");
146        let lookup = |program: &str| {
147            crate::desktop::program::find_program(program, path.as_deref(), crate::desktop::program::is_executable)
148        };
149        Self::package_with(name, lookup, running_as_root())
150    }
151
152    /// [`Install::package`] with the search given: `lookup` says where a program is, or `None`
153    /// when it is not installed, and `root` whether the application runs as root. For tests, and
154    /// for an application that decides itself where programs are.
155    #[must_use]
156    pub fn package_with(name: &str, lookup: impl Fn(&str) -> Option<PathBuf>, root: bool) -> Option<Install> {
157        if !is_package_name(name) {
158            return None;
159        }
160        let manager = Manager::all()
161            .iter()
162            .copied()
163            .filter(|manager| !(root && *manager == Manager::Brew))
164            .find(|manager| lookup(manager.name()).is_some())?;
165        Some(Install { manager, package: name.to_owned(), root })
166    }
167
168    /// Uses `name` instead when the package manager is `manager`, for a package that is called
169    /// differently there: `libarchive` on Arch is `libarchive-tools` on Debian. Nothing changes on
170    /// other managers, nor when `name` cannot be a package name.
171    #[must_use]
172    pub fn name_for(mut self, manager: Manager, name: &str) -> Self {
173        if manager == self.manager && is_package_name(name) {
174            name.clone_into(&mut self.package);
175        }
176        self
177    }
178
179    /// The package manager that installs the package.
180    #[must_use]
181    pub fn manager(&self) -> Manager {
182        self.manager
183    }
184
185    /// The package's name for this manager.
186    #[must_use]
187    pub fn name(&self) -> &str {
188        &self.package
189    }
190
191    /// The command's words, the program first: `["sudo", "pacman", "-S", "--needed", "libarchive"]`.
192    #[must_use]
193    pub fn program(&self) -> Vec<OsString> {
194        self.words().into_iter().map(OsString::from).collect()
195    }
196
197    /// The command as the person reads it: `sudo pacman -S --needed libarchive`. The words need
198    /// no quoting, since a package name holds no space.
199    #[must_use]
200    pub fn command_line(&self) -> String {
201        self.words().join(" ")
202    }
203
204    /// The question to ask before installing: which package, the exact command line, and that
205    /// the package manager may ask for a password. The confirm button reads Install and sends
206    /// `on_yes`; Cancel, Esc and the close mark install nothing. Add
207    /// [`on_cancel`](Confirm::on_cancel) for a message when the person declines.
208    ///
209    /// The text comes from `quvyta.install.title`, `quvyta.install.runs`,
210    /// `quvyta.install.asks` and `quvyta.install.confirm` in the active language.
211    #[must_use]
212    pub fn confirm<Msg>(&self, on_yes: Msg) -> Confirm<Msg> {
213        let manager = self.manager.name();
214        let message = format!(
215            "{}\n\n{}\n\n{}",
216            crate::t!("quvyta.install.runs"),
217            self.command_line(),
218            crate::t!("quvyta.install.asks", manager = manager),
219        );
220        Confirm::new(crate::t!("quvyta.install.title", package = self.package.as_str()), on_yes)
221            .message(message)
222            .confirm_label(crate::t!("quvyta.install.confirm"))
223    }
224
225    /// The handoff that runs the command: the terminal is the package manager's until it ends,
226    /// with a line saying what is being installed (`quvyta.install.notice`) above its output,
227    /// and a key press awaited afterwards so its last lines can be read. `on_finish` receives how
228    /// it ended; an exit code other than zero means the package was not installed.
229    ///
230    /// Return it only after the person agreed to [`confirm`](Self::confirm).
231    #[must_use]
232    pub fn handoff<Msg: Send + 'static>(
233        &self,
234        on_finish: impl FnOnce(HandoffOutcome) -> Msg + Send + 'static,
235    ) -> Handoff<Msg> {
236        let words = self.words();
237        let notice = crate::t!("quvyta.install.notice", package = self.package.as_str(), manager = self.manager.name());
238        Handoff::new(words[0], on_finish).args(words[1..].iter().copied()).notice(notice).pause(true)
239    }
240
241    /// The command's words, the program first.
242    fn words(&self) -> Vec<&str> {
243        let mut words = Vec::with_capacity(5);
244        if !self.root && self.manager.needs_root() {
245            words.push("sudo");
246        }
247        words.push(self.manager.name());
248        words.extend_from_slice(self.manager.install_words());
249        words.push(&self.package);
250        words
251    }
252}
253
254/// Whether `name` can stand as one package name on a command line: not empty, not taken for an
255/// option, one word.
256fn is_package_name(name: &str) -> bool {
257    !name.is_empty() && !name.starts_with('-') && !name.chars().any(|c| c.is_whitespace() || c.is_control())
258}
259
260/// Whether this process runs as root, which needs no `sudo`.
261fn running_as_root() -> bool {
262    #[cfg(unix)]
263    {
264        rustix::process::geteuid().is_root()
265    }
266    #[cfg(not(unix))]
267    {
268        false
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use std::ffi::OsString;
275    use std::path::PathBuf;
276    use std::time::Duration;
277
278    use super::{Install, Manager};
279    use crate::runtime::{App, Command, HandoffOutcome, Harness};
280    use crate::widget::View;
281    use crate::widgets::{Button, Text};
282
283    /// A machine that has exactly `programs`, in `/usr/bin`.
284    fn machine(programs: &'static [&'static str]) -> impl Fn(&str) -> Option<PathBuf> {
285        move |program| programs.contains(&program).then(|| PathBuf::from("/usr/bin").join(program))
286    }
287
288    fn words(line: &str) -> Vec<OsString> {
289        line.split(' ').map(OsString::from).collect()
290    }
291
292    #[test]
293    fn without_a_package_manager_there_is_nothing_to_offer() {
294        assert_eq!(Install::package_with("libarchive", machine(&["ls", "paru", "yay"]), false), None);
295    }
296
297    #[test]
298    fn pacman_installs_with_sudo_and_only_what_is_missing() {
299        let install = Install::package_with("libarchive", machine(&["pacman"]), false).expect("pacman");
300        assert_eq!(install.manager(), Manager::Pacman);
301        assert_eq!(install.command_line(), "sudo pacman -S --needed libarchive");
302        assert_eq!(install.program(), words("sudo pacman -S --needed libarchive"));
303    }
304
305    #[test]
306    fn as_root_the_command_has_no_sudo() {
307        let install = Install::package_with("libarchive", machine(&["pacman"]), true).expect("pacman");
308        assert_eq!(install.command_line(), "pacman -S --needed libarchive");
309        assert_eq!(install.program()[0], OsString::from("pacman"));
310    }
311
312    #[test]
313    fn apt_get_installs_and_asks_its_own_question() {
314        let install = Install::package_with("bsdtar", machine(&["apt-get"]), false).expect("apt-get");
315        assert_eq!(install.command_line(), "sudo apt-get install bsdtar");
316    }
317
318    #[test]
319    fn every_manager_has_its_own_command() {
320        let cases: [(&'static [&'static str], &str); 4] = [
321            (&["dnf"], "sudo dnf install bsdtar"),
322            (&["zypper"], "sudo zypper install bsdtar"),
323            (&["apk"], "sudo apk add bsdtar"),
324            (&["brew"], "brew install bsdtar"),
325        ];
326        for (programs, line) in cases {
327            let install = Install::package_with("bsdtar", machine(programs), false).expect("a manager");
328            assert_eq!(install.command_line(), line);
329        }
330    }
331
332    #[test]
333    fn the_first_manager_in_the_order_wins() {
334        let install = Install::package_with("bsdtar", machine(&["brew", "dnf", "apt-get"]), false).expect("a manager");
335        assert_eq!(install.manager(), Manager::Apt, "apt-get comes before dnf and brew");
336    }
337
338    #[test]
339    fn homebrew_is_passed_over_as_root() {
340        assert_eq!(Install::package_with("bsdtar", machine(&["brew"]), true), None);
341        let install = Install::package_with("bsdtar", machine(&["brew"]), false).expect("brew");
342        assert_eq!(install.manager(), Manager::Brew);
343    }
344
345    #[test]
346    fn a_distribution_specific_name_is_used_on_its_manager_only() {
347        let named = |programs| {
348            Install::package_with("libarchive", machine(programs), false)
349                .expect("a manager")
350                .name_for(Manager::Apt, "libarchive-tools")
351                .name_for(Manager::Dnf, "bsdtar")
352        };
353        assert_eq!(named(&["apt-get"]).command_line(), "sudo apt-get install libarchive-tools");
354        assert_eq!(named(&["dnf"]).name(), "bsdtar");
355        assert_eq!(named(&["pacman"]).command_line(), "sudo pacman -S --needed libarchive");
356    }
357
358    #[test]
359    fn a_name_that_is_not_one_package_is_refused() {
360        for name in ["", "-Syu", "libarchive; rm", "a\tb", "a\nb"] {
361            assert_eq!(Install::package_with(name, machine(&["pacman"]), false), None, "{name:?}");
362        }
363        let kept = Install::package_with("libarchive", machine(&["apt-get"]), false)
364            .expect("apt-get")
365            .name_for(Manager::Apt, "--force");
366        assert_eq!(kept.name(), "libarchive", "an option is never taken for a name");
367    }
368
369    /// A program that misses `bsdtar` and offers to install it.
370    struct Missing {
371        install: Install,
372        outcomes: Vec<HandoffOutcome>,
373    }
374
375    #[derive(Clone)]
376    enum Msg {
377        Ask,
378        Install,
379        Installed(HandoffOutcome),
380    }
381
382    impl App for Missing {
383        type Msg = Msg;
384        fn update(&mut self, msg: Msg) -> Command<Msg> {
385            match msg {
386                Msg::Ask => Command::confirm(self.install.confirm(Msg::Install)),
387                Msg::Install => Command::handoff(self.install.handoff(Msg::Installed)),
388                Msg::Installed(outcome) => {
389                    self.outcomes.push(outcome);
390                    Command::none()
391                }
392            }
393        }
394        fn view(&self, ui: &mut View<'_, Msg>) {
395            ui.column(|ui| {
396                ui.add(Text::new("bsdtar is missing"));
397                ui.add(Button::new("Install").on_press(Msg::Ask)).id("install");
398            });
399        }
400    }
401
402    fn missing() -> Harness<Missing> {
403        let install = Install::package_with("libarchive", machine(&["pacman"]), false).expect("pacman");
404        Harness::new(Missing { install, outcomes: Vec::new() }, 72, 16)
405    }
406
407    /// Clicks the application's Install button and lets the question open.
408    fn ask(h: &mut Harness<Missing>) {
409        h.click_text("Install").advance(Duration::from_millis(200));
410    }
411
412    #[test]
413    fn the_question_shows_the_command_and_nothing_runs_before_the_answer() {
414        let mut h = missing();
415        ask(&mut h);
416        let screen = h.screen();
417        assert!(screen.contains("Install libarchive?"), "{screen}");
418        assert!(screen.contains("sudo pacman -S --needed libarchive"), "the exact command is shown: {screen}");
419        assert!(screen.contains("pacman may ask for your password"), "{screen}");
420        assert!(h.handoffs().is_empty(), "nothing is handed over before the person answers");
421    }
422
423    #[test]
424    fn escape_installs_nothing() {
425        let mut h = missing();
426        ask(&mut h);
427        h.press("esc");
428        assert!(!h.screen().contains("Install libarchive?"), "{}", h.screen());
429        assert!(h.handoffs().is_empty());
430        assert!(h.app().outcomes.is_empty());
431    }
432
433    #[test]
434    fn confirming_hands_the_terminal_to_the_package_manager_once() {
435        let mut h = missing();
436        ask(&mut h);
437        // The dialog's own Install button sits right of Cancel on the answer row.
438        let screen = h.screen();
439        let (cancel_x, row) = h.find("Cancel").unwrap_or_else(|| panic!("an answer row: {screen}"));
440        let line = screen.lines().nth(usize::try_from(row).unwrap_or(0)).unwrap_or_default();
441        let after_cancel = line.chars().skip(usize::try_from(cancel_x).unwrap_or(0)).collect::<String>();
442        let offset = after_cancel.find("Install").unwrap_or_else(|| panic!("Install beside Cancel: {line}"));
443        let x = cancel_x + i32::try_from(after_cancel[..offset].chars().count()).unwrap_or(0);
444        h.click(x + 1, row);
445        let asked = h.handoffs();
446        assert_eq!(asked.len(), 1, "exactly one handoff");
447        assert_eq!(asked[0].program, OsString::from("sudo"));
448        assert_eq!(asked[0].args, words("pacman -S --needed libarchive"));
449        assert!(asked[0].pause, "the manager's last lines stay until a key is pressed");
450        assert_eq!(asked[0].notice.as_deref(), Some("Installing libarchive with pacman…"));
451        assert_eq!(h.app().outcomes, [HandoffOutcome::Finished { code: Some(0) }], "the outcome comes back");
452    }
453}