Skip to main content

qframe/runtime/
open.rs

1//! Opening an address, a file or a program on the person's own desktop, without leaving the screen.
2//!
3//! A [`Handoff`](super::Handoff) gives the terminal away and draws everything again afterwards,
4//! which is right when the user is about to talk to the program. Handing a link to the browser on
5//! the person's own screen is not that: none of it happens in the terminal, so stepping aside for
6//! it only makes the screen blink. An [`Open`] starts the program with its standard streams
7//! thrown away and, on Unix, a process group of its own, so the keys' signals never reach it and
8//! it lives on after the application. The screen is never touched.
9
10use std::ffi::OsString;
11#[cfg(unix)]
12use std::os::unix::process::CommandExt;
13use std::path::PathBuf;
14use std::process::{Child, Command as ChildCommand, Stdio};
15
16/// Starts a program beside the application: the desktop's own opener for an address or a path, or
17/// a program named outright. The screen stays where it is and nothing is drawn again.
18///
19/// The answer is optional. With [`Open::answer`] a message says whether the program was started;
20/// whether the desktop then really showed the thing is not something a terminal can know, so
21/// [`OpenOutcome::Opened`] says the opener was handed the target and no more.
22///
23/// ```
24/// use qframe::prelude::*;
25/// use qframe::runtime::{Open, OpenOutcome};
26///
27/// enum Msg {
28///     SignIn(String),
29///     Opened(OpenOutcome),
30/// }
31///
32/// fn update(msg: Msg) -> Command<Msg> {
33///     match msg {
34///         // The address goes to whatever browser this person uses; the screen never blinks.
35///         Msg::SignIn(address) => Command::open_with(Open::new(address).answer(Msg::Opened)),
36///         Msg::Opened(_) => Command::none(),
37///     }
38/// }
39/// ```
40pub struct Open<Msg> {
41    program: OsString,
42    args: Vec<OsString>,
43    target: Option<OsString>,
44    dir: Option<PathBuf>,
45    env: Vec<(OsString, OsString)>,
46    on_open: Option<Box<dyn FnOnce(OpenOutcome) -> Msg + Send>>,
47}
48
49impl<Msg: Send + 'static> Open<Msg> {
50    /// Opens `target` — an address, a file or a folder — with the opener of this desktop:
51    /// `xdg-open`, `open` on macOS, `start` on Windows.
52    #[must_use]
53    pub fn new(target: impl Into<OsString>) -> Self {
54        let target = target.into();
55        let (program, args) = opener(target.clone());
56        Self { program, args, target: Some(target), dir: None, env: Vec::new(), on_open: None }
57    }
58
59    /// Starts `program` itself instead of the desktop's opener, the same way: quietly, beside the
60    /// application, with nothing drawn again.
61    #[must_use]
62    pub fn program(program: impl Into<OsString>) -> Self {
63        Self { program: program.into(), args: Vec::new(), target: None, dir: None, env: Vec::new(), on_open: None }
64    }
65
66    /// Adds one argument.
67    #[must_use]
68    pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
69        self.args.push(arg.into());
70        self
71    }
72
73    /// Adds several arguments, in order.
74    #[must_use]
75    pub fn args(mut self, args: impl IntoIterator<Item = impl Into<OsString>>) -> Self {
76        self.args.extend(args.into_iter().map(Into::into));
77        self
78    }
79
80    /// Starts the program in `dir` instead of the application's working directory.
81    #[must_use]
82    pub fn dir(mut self, dir: impl Into<PathBuf>) -> Self {
83        self.dir = Some(dir.into());
84        self
85    }
86
87    /// Sets an environment variable for the program. The rest of the environment is inherited.
88    #[must_use]
89    pub fn env(mut self, key: impl Into<OsString>, value: impl Into<OsString>) -> Self {
90        self.env.push((key.into(), value.into()));
91        self
92    }
93
94    /// Delivers `on_open(outcome)` once the program has been started, or could not be.
95    ///
96    /// Without it nothing is delivered: an application that has nothing to say about the opening
97    /// asks for no message.
98    #[must_use]
99    pub fn answer(mut self, on_open: impl FnOnce(OpenOutcome) -> Msg + Send + 'static) -> Self {
100        self.on_open = Some(Box::new(on_open));
101        self
102    }
103
104    /// What a test sees of this opening.
105    pub(crate) fn request(&self) -> OpenRequest {
106        OpenRequest {
107            program: self.program.clone(),
108            args: self.args.clone(),
109            target: self.target.clone(),
110            dir: self.dir.clone(),
111        }
112    }
113
114    /// The message of `outcome`, for a harness that never starts the program.
115    pub(crate) fn finish(self, outcome: OpenOutcome) -> Option<Msg> {
116        self.on_open.map(|on_open| on_open(outcome))
117    }
118
119    /// Starts the program and returns the message of what came of it, with the child itself when
120    /// one was started.
121    ///
122    /// The caller is what waits for that child, and only after the message has been delivered:
123    /// an opener may live as long as the window it opened, and nothing waits for that.
124    pub(crate) fn start(self) -> (Option<Msg>, Option<Child>) {
125        let mut command = ChildCommand::new(&self.program);
126        command.args(&self.args);
127        if let Some(dir) = &self.dir {
128            command.current_dir(dir);
129        }
130        for (key, value) in &self.env {
131            command.env(key, value);
132        }
133        // Nothing of this program belongs on the screen the application is drawing on.
134        command.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
135        // A group of its own: the keys' signals go to the application's group, never to this.
136        #[cfg(unix)]
137        command.process_group(0);
138        match command.spawn() {
139            Ok(child) => (self.finish(OpenOutcome::Opened), Some(child)),
140            Err(error) => (self.finish(OpenOutcome::Failed(error.to_string())), None),
141        }
142    }
143
144    /// The same opening delivering `map(message)` wherever it would deliver `message`.
145    pub(crate) fn map<B: Send + 'static>(self, map: impl FnOnce(Msg) -> B + Send + 'static) -> Open<B> {
146        let on_open = self.on_open;
147        Open {
148            program: self.program,
149            args: self.args,
150            target: self.target,
151            dir: self.dir,
152            env: self.env,
153            on_open: on_open.map(|on_open| -> Box<dyn FnOnce(OpenOutcome) -> B + Send> {
154                Box::new(move |outcome| map(on_open(outcome)))
155            }),
156        }
157    }
158}
159
160/// What came of an opening.
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub enum OpenOutcome {
163    /// The program was started with what it was given. What the desktop did with it afterwards is
164    /// out of reach from a terminal, so this is as far as the answer goes.
165    Opened,
166    /// The program could not be started at all: this desktop has no opener installed, or the
167    /// program was not found.
168    Failed(String),
169}
170
171/// An opening a [`Harness`](super::Harness) recorded instead of carrying out.
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct OpenRequest {
174    /// The program asked for: the desktop's opener, or the one [`Open::program`] named.
175    pub program: OsString,
176    /// Its arguments, in order. For an opener the target is the only one.
177    pub args: Vec<OsString>,
178    /// What [`Open::new`] was given, so a test can read the address or the path without knowing
179    /// which opener this system has. `None` after [`Open::program`].
180    pub target: Option<OsString>,
181    /// The working folder [`Open::dir`] gave; `None` when the program starts in the application's
182    /// own.
183    pub dir: Option<PathBuf>,
184}
185
186/// The opener of this desktop and the arguments that give it `target`.
187#[cfg(target_os = "macos")]
188fn opener(target: OsString) -> (OsString, Vec<OsString>) {
189    (OsString::from("open"), vec![target])
190}
191
192/// The opener of this desktop and the arguments that give it `target`.
193#[cfg(all(unix, not(target_os = "macos")))]
194fn opener(target: OsString) -> (OsString, Vec<OsString>) {
195    (OsString::from("xdg-open"), vec![target])
196}
197
198/// The opener of this desktop and the arguments that give it `target`. The empty argument is the
199/// window title `start` would otherwise read the target as.
200#[cfg(windows)]
201fn opener(target: OsString) -> (OsString, Vec<OsString>) {
202    (OsString::from("cmd"), vec![OsString::from("/C"), OsString::from("start"), OsString::new(), target])
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn an_address_goes_to_the_opener_of_this_desktop_with_the_target_kept_for_the_test() {
211        let request = Open::new("https://example.com/sign-in").answer(|outcome| outcome).request();
212        assert_eq!(request.target.as_deref(), Some(std::ffi::OsStr::new("https://example.com/sign-in")));
213        assert_eq!(request.args, [OsString::from("https://example.com/sign-in")]);
214        assert!(!request.program.is_empty(), "the desktop's opener is named: {:?}", request.program);
215    }
216
217    #[test]
218    fn a_program_of_its_own_carries_its_arguments_and_no_target() {
219        let request = Open::<OpenOutcome>::program("gimp").arg("--new-instance").args(["a.png", "b.png"]).request();
220        assert_eq!(request.program, OsString::from("gimp"));
221        assert_eq!(request.args, ["--new-instance", "a.png", "b.png"].map(OsString::from));
222        assert_eq!(request.target, None, "nothing was handed to an opener");
223        assert_eq!(request.dir, None, "without `dir` the program starts where the application runs");
224        let placed = Open::<OpenOutcome>::program("gimp").dir("/srv/pictures").request();
225        assert_eq!(placed.dir, Some(PathBuf::from("/srv/pictures")), "the folder is recorded");
226    }
227
228    #[test]
229    fn a_program_that_starts_answers_opened_and_leaves_a_child_to_wait_for() {
230        let (message, child) = Open::program("sh").args(["-c", "exit 0"]).answer(|outcome| outcome).start();
231        assert_eq!(message, Some(OpenOutcome::Opened));
232        let mut child = child.expect("a program that started has a child");
233        assert!(child.wait().is_ok(), "the caller is what reaps it");
234    }
235
236    #[test]
237    fn a_program_that_is_not_there_fails_with_the_reason_and_leaves_no_child() {
238        let (message, child) = Open::program("quvyta-no-such-program").answer(|outcome| outcome).start();
239        let Some(OpenOutcome::Failed(reason)) = message else {
240            panic!("a program that is not there cannot have been opened: {message:?}");
241        };
242        assert!(!reason.is_empty(), "the reason names what went wrong");
243        assert!(child.is_none(), "nothing was started");
244    }
245
246    #[test]
247    fn the_directory_and_the_environment_reach_the_program() {
248        let path = std::env::temp_dir().join(format!(
249            "quvyta-open-{}",
250            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos()
251        ));
252        let (message, child) = Open::program("sh")
253            .args(["-c", r#"test "$QUVYTA_OPEN_TEST" = ok && pwd > "$0""#, &path.to_string_lossy()])
254            .dir("/")
255            .env("QUVYTA_OPEN_TEST", "ok")
256            .answer(|outcome| outcome)
257            .start();
258        assert_eq!(message, Some(OpenOutcome::Opened));
259        let status = child.expect("a child").wait().expect("it ends");
260        assert_eq!(status.code(), Some(0), "the environment reached it");
261        assert_eq!(std::fs::read_to_string(&path).unwrap_or_default().trim(), "/", "it ran in the directory");
262        let _ = std::fs::remove_file(&path);
263    }
264
265    #[test]
266    fn an_opening_without_an_answer_delivers_nothing() {
267        let (message, child) = Open::<OpenOutcome>::program("sh").args(["-c", "exit 0"]).start();
268        assert!(message.is_none(), "no message was asked for");
269        let _ = child.expect("a child").wait();
270    }
271
272    #[test]
273    fn a_mapped_opening_delivers_the_converted_message() {
274        let open = Open::new("https://example.com").answer(|outcome| outcome);
275        let mapped = open.map(|outcome| format!("{outcome:?}"));
276        assert_eq!(mapped.finish(OpenOutcome::Opened), Some("Opened".to_owned()));
277    }
278}