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    /// Either way the program runs in the file's folder, as desktop file managers start it:
75    /// relative paths, "Save as" and a shell opened from the program begin beside the file, not
76    /// wherever the application happened to be started. A file given without a folder leaves the
77    /// application's own.
78    ///
79    /// In a [`Harness`](crate::runtime::Harness) nothing runs: the handoff or the opening is
80    /// recorded, and a test reads it, folder included, from `handoffs()` or `opens()`.
81    ///
82    /// # Errors
83    ///
84    /// [`LaunchError::NoGraphicalSession`] for a graphical program without a graphical session,
85    /// [`LaunchError::NoCommand`] when the `Exec` line gives no command.
86    pub fn launch<Msg: Send + 'static>(
87        &self,
88        file: &Path,
89        graphical: bool,
90        on_done: impl FnOnce(Launched) -> Msg + Send + 'static,
91    ) -> Result<Command<Msg>, LaunchError> {
92        if !self.can_start(graphical) {
93            return Err(LaunchError::NoGraphicalSession);
94        }
95        Ok(match self.start(file, on_done)? {
96            Start::Terminal(handoff) => Command::handoff(handoff),
97            Start::Beside(open) => Command::open_with(open),
98        })
99    }
100
101    /// The handoff or the opening that starts this program on `file` in the file's folder.
102    fn start<Msg: Send + 'static>(
103        &self,
104        file: &Path,
105        on_done: impl FnOnce(Launched) -> Msg + Send + 'static,
106    ) -> Result<Start<Msg>, LaunchError> {
107        let mut words = self.command(file).ok_or(LaunchError::NoCommand)?.into_iter();
108        let program = words.next().ok_or(LaunchError::NoCommand)?;
109        // `Path::parent` of a bare name is the empty path, which is no folder to start in.
110        let folder = file.parent().filter(|folder| !folder.as_os_str().is_empty());
111        if self.terminal {
112            let mut handoff = Handoff::new(program, move |outcome| {
113                on_done(match outcome {
114                    HandoffOutcome::Finished { code } => Launched::Returned { code },
115                    HandoffOutcome::Failed(reason) => Launched::Failed(reason),
116                })
117            })
118            .args(words);
119            if let Some(folder) = folder {
120                handoff = handoff.dir(folder);
121            }
122            return Ok(Start::Terminal(handoff));
123        }
124        let mut open = Open::program(program).args(words).answer(move |outcome| {
125            on_done(match outcome {
126                OpenOutcome::Opened => Launched::Started,
127                OpenOutcome::Failed(reason) => Launched::Failed(reason),
128            })
129        });
130        if let Some(folder) = folder {
131            open = open.dir(folder);
132        }
133        Ok(Start::Beside(open))
134    }
135}
136
137/// How [`DesktopApp::launch`] starts a program: handed the terminal, or beside the application.
138enum Start<Msg> {
139    Terminal(Handoff<Msg>),
140    Beside(Open<Msg>),
141}
142
143#[cfg(test)]
144mod tests {
145    use std::ffi::OsString;
146    use std::path::PathBuf;
147
148    use super::*;
149    use crate::runtime::{App, Harness};
150    use crate::widget::View;
151
152    /// An application that opens one file with one program, as a file explorer would.
153    struct Opener {
154        app: DesktopApp,
155        graphical: bool,
156        file: PathBuf,
157        heard: Vec<Launched>,
158        refused: Option<LaunchError>,
159    }
160
161    #[derive(Debug, Clone, PartialEq, Eq)]
162    enum Msg {
163        Open,
164        Done(Launched),
165    }
166
167    impl App for Opener {
168        type Msg = Msg;
169        fn update(&mut self, msg: Msg) -> Command<Msg> {
170            match msg {
171                Msg::Open => match self.app.launch(&self.file, self.graphical, Msg::Done) {
172                    Ok(command) => command,
173                    Err(error) => {
174                        self.refused = Some(error);
175                        Command::none()
176                    }
177                },
178                Msg::Done(launched) => {
179                    self.heard.push(launched);
180                    Command::none()
181                }
182            }
183        }
184        fn view(&self, _ui: &mut View<'_, Msg>) {}
185    }
186
187    const FILE: &str = "/home/ada/my \"odd\" notes.txt";
188
189    fn app(exec: &str, terminal: bool) -> DesktopApp {
190        DesktopApp {
191            id: "x.desktop".to_owned(),
192            name: "X".to_owned(),
193            exec: exec.to_owned(),
194            terminal,
195            mime_types: Vec::new(),
196            path: PathBuf::from("/apps/x.desktop"),
197            icon: None,
198        }
199    }
200
201    fn opener(app: DesktopApp, graphical: bool) -> Harness<Opener> {
202        let file = PathBuf::from(FILE);
203        Harness::new(Opener { app, graphical, file, heard: Vec::new(), refused: None }, 20, 2)
204    }
205
206    #[test]
207    fn a_terminal_program_is_a_recorded_handoff() {
208        let mut harness = opener(app("less %f", true), false);
209        harness.send(Msg::Open);
210        let handoffs = harness.handoffs();
211        assert_eq!(handoffs.len(), 1, "a terminal program starts without a graphical session");
212        assert_eq!(handoffs[0].program, "less");
213        assert_eq!(handoffs[0].args, [OsString::from(FILE)]);
214        assert!(harness.opens().is_empty());
215        assert_eq!(harness.app().heard, [Launched::Returned { code: Some(0) }], "the harness answers it");
216    }
217
218    #[test]
219    fn a_terminal_program_runs_in_the_files_folder() {
220        let mut harness = opener(app("less %f", true), false);
221        harness.send(Msg::Open);
222        assert_eq!(harness.handoffs()[0].dir, Some(PathBuf::from("/home/ada")), "beside the file, not the app");
223    }
224
225    #[test]
226    fn a_graphical_program_runs_in_the_files_folder() {
227        let mut harness = opener(app("editor %F", false), true);
228        harness.send(Msg::Open);
229        assert_eq!(harness.opens()[0].dir, Some(PathBuf::from("/home/ada")), "beside the file, not the app");
230    }
231
232    #[test]
233    fn a_file_named_without_a_folder_leaves_the_applications_own() {
234        for (terminal, graphical) in [(true, false), (false, true)] {
235            let file = PathBuf::from("notes.txt");
236            let app = app("editor %f", terminal);
237            let mut harness = Harness::new(Opener { app, graphical, file, heard: Vec::new(), refused: None }, 20, 2);
238            harness.send(Msg::Open);
239            let dir = if terminal { harness.handoffs()[0].dir.clone() } else { harness.opens()[0].dir.clone() };
240            assert_eq!(dir, None, "an empty folder is no folder to start in (terminal: {terminal})");
241        }
242    }
243
244    /// A folder of this test's own with a file in it, removed when the test ends.
245    struct Folder(PathBuf);
246
247    impl Folder {
248        fn new(name: &str) -> Self {
249            let path = std::env::temp_dir().join(format!("qframe-launch-{name}-{}", std::process::id()));
250            let _ = std::fs::remove_dir_all(&path);
251            std::fs::create_dir_all(&path).expect("the temporary folder can be made");
252            std::fs::write(path.join("notes.txt"), "notes\n").expect("the file can be written");
253            Self(path.canonicalize().expect("the folder is there"))
254        }
255
256        /// What `pwd` wrote into the folder it ran in.
257        fn heard(&self) -> String {
258            std::fs::read_to_string(self.0.join("notes.txt.where")).unwrap_or_default().trim().to_owned()
259        }
260    }
261
262    impl Drop for Folder {
263        fn drop(&mut self) {
264            let _ = std::fs::remove_dir_all(&self.0);
265        }
266    }
267
268    /// `sh` writes the folder it runs in beside the file it was given (`$0`), wherever it runs.
269    const PWD: &str = "sh -c \"pwd >\\\"\\$0.where\\\"\" %f";
270
271    #[test]
272    fn a_terminal_program_really_starts_in_the_files_folder() {
273        let folder = Folder::new("terminal");
274        let Ok(Start::Terminal(handoff)) = app(PWD, true).start(&folder.0.join("notes.txt"), |launched| launched)
275        else {
276            panic!("a terminal program is handed the terminal");
277        };
278        let mut release = |_: Option<&str>| Ok(());
279        let mut take = || Ok(());
280        let mut wait_for_key = || Ok(());
281        let launched = crate::runtime::handoff::run(
282            handoff,
283            &mut crate::runtime::handoff::HandoffScreen {
284                release: &mut release,
285                take: &mut take,
286                wait_for_key: &mut wait_for_key,
287            },
288        );
289        assert_eq!(launched, Launched::Returned { code: Some(0) });
290        assert_eq!(folder.heard(), folder.0.to_string_lossy(), "the program ran in the file's folder");
291    }
292
293    #[test]
294    fn a_graphical_program_really_starts_in_the_files_folder() {
295        let folder = Folder::new("graphical");
296        let Ok(Start::Beside(open)) = app(PWD, false).start(&folder.0.join("notes.txt"), |launched| launched) else {
297            panic!("a graphical program starts beside the application");
298        };
299        let (launched, child) = open.start();
300        assert_eq!(launched, Some(Launched::Started));
301        let status = child.expect("a child").wait().expect("it ends");
302        assert_eq!(status.code(), Some(0));
303        assert_eq!(folder.heard(), folder.0.to_string_lossy(), "the program ran in the file's folder");
304    }
305
306    #[test]
307    fn a_graphical_program_is_a_recorded_opening() {
308        let mut harness = opener(app("\"text editor\" --new %F", false), true);
309        harness.send(Msg::Open);
310        let opens = harness.opens();
311        assert_eq!(opens.len(), 1);
312        assert_eq!(opens[0].program, "text editor");
313        assert_eq!(opens[0].args, [OsString::from("--new"), OsString::from(FILE)]);
314        assert_eq!(opens[0].target, None, "a program of its own, not the desktop's opener");
315        assert!(harness.handoffs().is_empty());
316        assert_eq!(harness.app().heard, [Launched::Started]);
317    }
318
319    #[test]
320    fn without_a_graphical_session_a_graphical_program_is_refused() {
321        let mut harness = opener(app("editor %f", false), false);
322        assert!(!harness.app().app.can_start(false));
323        harness.send(Msg::Open);
324        assert!(harness.opens().is_empty() && harness.handoffs().is_empty(), "nothing is even asked for");
325        assert_eq!(harness.app().refused, Some(LaunchError::NoGraphicalSession));
326    }
327
328    #[test]
329    fn a_line_that_gives_no_command_is_refused() {
330        let mut harness = opener(app("editor \"unclosed %f", true), true);
331        harness.send(Msg::Open);
332        assert!(harness.handoffs().is_empty());
333        assert_eq!(harness.app().refused, Some(LaunchError::NoCommand));
334    }
335
336    #[test]
337    fn the_session_comes_from_either_display_variable() {
338        let with = |pairs: &'static [(&str, &str)]| {
339            graphical_session(|name| pairs.iter().find(|(key, _)| *key == name).map(|(_, value)| (*value).to_owned()))
340        };
341        assert!(with(&[("DISPLAY", ":0")]));
342        assert!(with(&[("WAYLAND_DISPLAY", "wayland-0")]));
343        assert!(!with(&[("DISPLAY", ""), ("WAYLAND_DISPLAY", "")]), "empty is none");
344        assert!(!with(&[]));
345    }
346}