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>(
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 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}