1use 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
16pub 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 #[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 #[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 #[must_use]
68 pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
69 self.args.push(arg.into());
70 self
71 }
72
73 #[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 #[must_use]
82 pub fn dir(mut self, dir: impl Into<PathBuf>) -> Self {
83 self.dir = Some(dir.into());
84 self
85 }
86
87 #[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 #[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 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 pub(crate) fn finish(self, outcome: OpenOutcome) -> Option<Msg> {
116 self.on_open.map(|on_open| on_open(outcome))
117 }
118
119 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 command.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
135 #[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 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#[derive(Debug, Clone, PartialEq, Eq)]
162pub enum OpenOutcome {
163 Opened,
166 Failed(String),
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct OpenRequest {
174 pub program: OsString,
176 pub args: Vec<OsString>,
178 pub target: Option<OsString>,
181 pub dir: Option<PathBuf>,
184}
185
186#[cfg(target_os = "macos")]
188fn opener(target: OsString) -> (OsString, Vec<OsString>) {
189 (OsString::from("open"), vec![target])
190}
191
192#[cfg(all(unix, not(target_os = "macos")))]
194fn opener(target: OsString) -> (OsString, Vec<OsString>) {
195 (OsString::from("xdg-open"), vec![target])
196}
197
198#[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}