1use std::fmt;
5use std::path::Path;
6
7use super::apps::DesktopApp;
8use crate::runtime::{Command, Handoff, HandoffOutcome, Open, OpenOutcome};
9
10pub 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#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum Launched {
23 Returned {
26 code: Option<i32>,
28 },
29 Started,
32 Failed(String),
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum LaunchError {
39 NoGraphicalSession,
41 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 #[must_use]
61 pub fn can_start(&self, graphical: bool) -> bool {
62 self.terminal || graphical
63 }
64
65 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 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 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
137enum 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 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 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 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 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}