Skip to main content

qframe/desktop/
launch.rs

1//! Starting a program on a file, the right way for its kind: a terminal program is handed the
2//! terminal, a graphical one starts beside the application.
3
4use std::fmt;
5use std::path::Path;
6
7use super::apps::DesktopApp;
8use crate::runtime::{Command, Handoff, HandoffOutcome, Open, OpenOutcome};
9
10/// Whether there is a graphical session for a program with windows to open on: `DISPLAY` or
11/// `WAYLAND_DISPLAY` is set and not empty. Over SSH, in a console or in a desktop drawn inside the
12/// terminal there is none.
13///
14/// `lookup` returns a variable's value, as for [`XdgDirs::from_env`](super::XdgDirs::from_env), so
15/// a test decides the answer instead of the machine it runs on.
16pub fn graphical_session(lookup: impl Fn(&str) -> Option<String>) -> bool {
17    ["DISPLAY", "WAYLAND_DISPLAY"].iter().any(|name| lookup(name).is_some_and(|value| !value.is_empty()))
18}
19
20/// How starting a program through [`DesktopApp::launch`] ended.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum Launched {
23    /// A terminal program ran and the application has the screen back; `code` is `None` when a
24    /// signal ended it.
25    Returned {
26        /// The exit code, or `None` after a signal.
27        code: Option<i32>,
28    },
29    /// A graphical program was started beside the application. Whether it then showed the file
30    /// is out of a terminal's reach.
31    Started,
32    /// The program could not be started; the reason is the system's.
33    Failed(String),
34}
35
36/// Why [`DesktopApp::launch`] did not even try.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum LaunchError {
39    /// The program has windows of its own and there is no graphical session to open them on.
40    NoGraphicalSession,
41    /// The `Exec` line gives no command. [`Apps::load`](super::Apps::load) never offers such a
42    /// program; only a [`DesktopApp`] built by hand can have one.
43    NoCommand,
44}
45
46impl fmt::Display for LaunchError {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        f.write_str(match self {
49            Self::NoGraphicalSession => "there is no graphical session to open this program on",
50            Self::NoCommand => "the program's Exec line gives no command",
51        })
52    }
53}
54
55impl std::error::Error for LaunchError {}
56
57impl DesktopApp {
58    /// Whether this program can start here: a terminal program always can, a graphical one only
59    /// in a graphical session (see [`graphical_session`]). A menu shows the others dimmed.
60    #[must_use]
61    pub fn can_start(&self, graphical: bool) -> bool {
62        self.terminal || graphical
63    }
64
65    /// The command that opens `file` with this program, delivering `on_done` with how it ended.
66    ///
67    /// A terminal program (`Terminal=true`) goes through a [`Handoff`]: the application steps
68    /// aside, the program owns the terminal, and the screen comes back when it ends. A graphical
69    /// one goes through [`Open::program`] and starts quietly beside the application. `graphical`
70    /// says whether there is a graphical session; without one a graphical program is refused
71    /// instead of tried. The arguments are those of [`DesktopApp::command`], so no shell is
72    /// involved.
73    ///
74    /// In a [`Harness`](crate::runtime::Harness) nothing runs: the handoff or the opening is
75    /// recorded, and a test reads it from `handoffs()` or `opens()`.
76    ///
77    /// # Errors
78    ///
79    /// [`LaunchError::NoGraphicalSession`] for a graphical program without a graphical session,
80    /// [`LaunchError::NoCommand`] when the `Exec` line gives no command.
81    pub fn launch<Msg: Send + 'static>(
82        &self,
83        file: &Path,
84        graphical: bool,
85        on_done: impl FnOnce(Launched) -> Msg + Send + 'static,
86    ) -> Result<Command<Msg>, LaunchError> {
87        if !self.can_start(graphical) {
88            return Err(LaunchError::NoGraphicalSession);
89        }
90        let mut words = self.command(file).ok_or(LaunchError::NoCommand)?.into_iter();
91        let program = words.next().ok_or(LaunchError::NoCommand)?;
92        if self.terminal {
93            let handoff = Handoff::new(program, move |outcome| {
94                on_done(match outcome {
95                    HandoffOutcome::Finished { code } => Launched::Returned { code },
96                    HandoffOutcome::Failed(reason) => Launched::Failed(reason),
97                })
98            });
99            return Ok(Command::handoff(handoff.args(words)));
100        }
101        let open = Open::program(program).args(words).answer(move |outcome| {
102            on_done(match outcome {
103                OpenOutcome::Opened => Launched::Started,
104                OpenOutcome::Failed(reason) => Launched::Failed(reason),
105            })
106        });
107        Ok(Command::open_with(open))
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use std::ffi::OsString;
114    use std::path::PathBuf;
115
116    use super::*;
117    use crate::runtime::{App, Harness};
118    use crate::widget::View;
119
120    /// An application that opens one file with one program, as a file explorer would.
121    struct Opener {
122        app: DesktopApp,
123        graphical: bool,
124        file: PathBuf,
125        heard: Vec<Launched>,
126        refused: Option<LaunchError>,
127    }
128
129    #[derive(Debug, Clone, PartialEq, Eq)]
130    enum Msg {
131        Open,
132        Done(Launched),
133    }
134
135    impl App for Opener {
136        type Msg = Msg;
137        fn update(&mut self, msg: Msg) -> Command<Msg> {
138            match msg {
139                Msg::Open => match self.app.launch(&self.file, self.graphical, Msg::Done) {
140                    Ok(command) => command,
141                    Err(error) => {
142                        self.refused = Some(error);
143                        Command::none()
144                    }
145                },
146                Msg::Done(launched) => {
147                    self.heard.push(launched);
148                    Command::none()
149                }
150            }
151        }
152        fn view(&self, _ui: &mut View<'_, Msg>) {}
153    }
154
155    const FILE: &str = "/home/ada/my \"odd\" notes.txt";
156
157    fn app(exec: &str, terminal: bool) -> DesktopApp {
158        DesktopApp {
159            id: "x.desktop".to_owned(),
160            name: "X".to_owned(),
161            exec: exec.to_owned(),
162            terminal,
163            mime_types: Vec::new(),
164            path: PathBuf::from("/apps/x.desktop"),
165            icon: None,
166        }
167    }
168
169    fn opener(app: DesktopApp, graphical: bool) -> Harness<Opener> {
170        let file = PathBuf::from(FILE);
171        Harness::new(Opener { app, graphical, file, heard: Vec::new(), refused: None }, 20, 2)
172    }
173
174    #[test]
175    fn a_terminal_program_is_a_recorded_handoff() {
176        let mut harness = opener(app("less %f", true), false);
177        harness.send(Msg::Open);
178        let handoffs = harness.handoffs();
179        assert_eq!(handoffs.len(), 1, "a terminal program starts without a graphical session");
180        assert_eq!(handoffs[0].program, "less");
181        assert_eq!(handoffs[0].args, [OsString::from(FILE)]);
182        assert!(harness.opens().is_empty());
183        assert_eq!(harness.app().heard, [Launched::Returned { code: Some(0) }], "the harness answers it");
184    }
185
186    #[test]
187    fn a_graphical_program_is_a_recorded_opening() {
188        let mut harness = opener(app("\"text editor\" --new %F", false), true);
189        harness.send(Msg::Open);
190        let opens = harness.opens();
191        assert_eq!(opens.len(), 1);
192        assert_eq!(opens[0].program, "text editor");
193        assert_eq!(opens[0].args, [OsString::from("--new"), OsString::from(FILE)]);
194        assert_eq!(opens[0].target, None, "a program of its own, not the desktop's opener");
195        assert!(harness.handoffs().is_empty());
196        assert_eq!(harness.app().heard, [Launched::Started]);
197    }
198
199    #[test]
200    fn without_a_graphical_session_a_graphical_program_is_refused() {
201        let mut harness = opener(app("editor %f", false), false);
202        assert!(!harness.app().app.can_start(false));
203        harness.send(Msg::Open);
204        assert!(harness.opens().is_empty() && harness.handoffs().is_empty(), "nothing is even asked for");
205        assert_eq!(harness.app().refused, Some(LaunchError::NoGraphicalSession));
206    }
207
208    #[test]
209    fn a_line_that_gives_no_command_is_refused() {
210        let mut harness = opener(app("editor \"unclosed %f", true), true);
211        harness.send(Msg::Open);
212        assert!(harness.handoffs().is_empty());
213        assert_eq!(harness.app().refused, Some(LaunchError::NoCommand));
214    }
215
216    #[test]
217    fn the_session_comes_from_either_display_variable() {
218        let with = |pairs: &'static [(&str, &str)]| {
219            graphical_session(|name| pairs.iter().find(|(key, _)| *key == name).map(|(_, value)| (*value).to_owned()))
220        };
221        assert!(with(&[("DISPLAY", ":0")]));
222        assert!(with(&[("WAYLAND_DISPLAY", "wayland-0")]));
223        assert!(!with(&[("DISPLAY", ""), ("WAYLAND_DISPLAY", "")]), "empty is none");
224        assert!(!with(&[]));
225    }
226}