use std::fmt;
use std::path::Path;
use super::apps::DesktopApp;
use crate::runtime::{Command, Handoff, HandoffOutcome, Open, OpenOutcome};
pub fn graphical_session(lookup: impl Fn(&str) -> Option<String>) -> bool {
["DISPLAY", "WAYLAND_DISPLAY"].iter().any(|name| lookup(name).is_some_and(|value| !value.is_empty()))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Launched {
Returned {
code: Option<i32>,
},
Started,
Failed(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LaunchError {
NoGraphicalSession,
NoCommand,
}
impl fmt::Display for LaunchError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::NoGraphicalSession => "there is no graphical session to open this program on",
Self::NoCommand => "the program's Exec line gives no command",
})
}
}
impl std::error::Error for LaunchError {}
impl DesktopApp {
#[must_use]
pub fn can_start(&self, graphical: bool) -> bool {
self.terminal || graphical
}
pub fn launch<Msg: Send + 'static>(
&self,
file: &Path,
graphical: bool,
on_done: impl FnOnce(Launched) -> Msg + Send + 'static,
) -> Result<Command<Msg>, LaunchError> {
if !self.can_start(graphical) {
return Err(LaunchError::NoGraphicalSession);
}
Ok(match self.start(file, on_done)? {
Start::Terminal(handoff) => Command::handoff(handoff),
Start::Beside(open) => Command::open_with(open),
})
}
fn start<Msg: Send + 'static>(
&self,
file: &Path,
on_done: impl FnOnce(Launched) -> Msg + Send + 'static,
) -> Result<Start<Msg>, LaunchError> {
let mut words = self.command(file).ok_or(LaunchError::NoCommand)?.into_iter();
let program = words.next().ok_or(LaunchError::NoCommand)?;
let folder = file.parent().filter(|folder| !folder.as_os_str().is_empty());
if self.terminal {
let mut handoff = Handoff::new(program, move |outcome| {
on_done(match outcome {
HandoffOutcome::Finished { code } => Launched::Returned { code },
HandoffOutcome::Failed(reason) => Launched::Failed(reason),
})
})
.args(words);
if let Some(folder) = folder {
handoff = handoff.dir(folder);
}
return Ok(Start::Terminal(handoff));
}
let mut open = Open::program(program).args(words).answer(move |outcome| {
on_done(match outcome {
OpenOutcome::Opened => Launched::Started,
OpenOutcome::Failed(reason) => Launched::Failed(reason),
})
});
if let Some(folder) = folder {
open = open.dir(folder);
}
Ok(Start::Beside(open))
}
}
enum Start<Msg> {
Terminal(Handoff<Msg>),
Beside(Open<Msg>),
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use std::path::PathBuf;
use super::*;
use crate::runtime::{App, Harness};
use crate::widget::View;
struct Opener {
app: DesktopApp,
graphical: bool,
file: PathBuf,
heard: Vec<Launched>,
refused: Option<LaunchError>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Msg {
Open,
Done(Launched),
}
impl App for Opener {
type Msg = Msg;
fn update(&mut self, msg: Msg) -> Command<Msg> {
match msg {
Msg::Open => match self.app.launch(&self.file, self.graphical, Msg::Done) {
Ok(command) => command,
Err(error) => {
self.refused = Some(error);
Command::none()
}
},
Msg::Done(launched) => {
self.heard.push(launched);
Command::none()
}
}
}
fn view(&self, _ui: &mut View<'_, Msg>) {}
}
const FILE: &str = "/home/ada/my \"odd\" notes.txt";
fn app(exec: &str, terminal: bool) -> DesktopApp {
DesktopApp {
id: "x.desktop".to_owned(),
name: "X".to_owned(),
exec: exec.to_owned(),
terminal,
mime_types: Vec::new(),
path: PathBuf::from("/apps/x.desktop"),
icon: None,
}
}
fn opener(app: DesktopApp, graphical: bool) -> Harness<Opener> {
let file = PathBuf::from(FILE);
Harness::new(Opener { app, graphical, file, heard: Vec::new(), refused: None }, 20, 2)
}
#[test]
fn a_terminal_program_is_a_recorded_handoff() {
let mut harness = opener(app("less %f", true), false);
harness.send(Msg::Open);
let handoffs = harness.handoffs();
assert_eq!(handoffs.len(), 1, "a terminal program starts without a graphical session");
assert_eq!(handoffs[0].program, "less");
assert_eq!(handoffs[0].args, [OsString::from(FILE)]);
assert!(harness.opens().is_empty());
assert_eq!(harness.app().heard, [Launched::Returned { code: Some(0) }], "the harness answers it");
}
#[test]
fn a_terminal_program_runs_in_the_files_folder() {
let mut harness = opener(app("less %f", true), false);
harness.send(Msg::Open);
assert_eq!(harness.handoffs()[0].dir, Some(PathBuf::from("/home/ada")), "beside the file, not the app");
}
#[test]
fn a_graphical_program_runs_in_the_files_folder() {
let mut harness = opener(app("editor %F", false), true);
harness.send(Msg::Open);
assert_eq!(harness.opens()[0].dir, Some(PathBuf::from("/home/ada")), "beside the file, not the app");
}
#[test]
fn a_file_named_without_a_folder_leaves_the_applications_own() {
for (terminal, graphical) in [(true, false), (false, true)] {
let file = PathBuf::from("notes.txt");
let app = app("editor %f", terminal);
let mut harness = Harness::new(Opener { app, graphical, file, heard: Vec::new(), refused: None }, 20, 2);
harness.send(Msg::Open);
let dir = if terminal { harness.handoffs()[0].dir.clone() } else { harness.opens()[0].dir.clone() };
assert_eq!(dir, None, "an empty folder is no folder to start in (terminal: {terminal})");
}
}
struct Folder(PathBuf);
impl Folder {
fn new(name: &str) -> Self {
let path = std::env::temp_dir().join(format!("qframe-launch-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("the temporary folder can be made");
std::fs::write(path.join("notes.txt"), "notes\n").expect("the file can be written");
Self(path.canonicalize().expect("the folder is there"))
}
fn heard(&self) -> String {
std::fs::read_to_string(self.0.join("notes.txt.where")).unwrap_or_default().trim().to_owned()
}
}
impl Drop for Folder {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
const PWD: &str = "sh -c \"pwd >\\\"\\$0.where\\\"\" %f";
#[test]
fn a_terminal_program_really_starts_in_the_files_folder() {
let folder = Folder::new("terminal");
let Ok(Start::Terminal(handoff)) = app(PWD, true).start(&folder.0.join("notes.txt"), |launched| launched)
else {
panic!("a terminal program is handed the terminal");
};
let mut release = |_: Option<&str>| Ok(());
let mut take = || Ok(());
let mut wait_for_key = || Ok(());
let launched = crate::runtime::handoff::run(
handoff,
&mut crate::runtime::handoff::HandoffScreen {
release: &mut release,
take: &mut take,
wait_for_key: &mut wait_for_key,
},
);
assert_eq!(launched, Launched::Returned { code: Some(0) });
assert_eq!(folder.heard(), folder.0.to_string_lossy(), "the program ran in the file's folder");
}
#[test]
fn a_graphical_program_really_starts_in_the_files_folder() {
let folder = Folder::new("graphical");
let Ok(Start::Beside(open)) = app(PWD, false).start(&folder.0.join("notes.txt"), |launched| launched) else {
panic!("a graphical program starts beside the application");
};
let (launched, child) = open.start();
assert_eq!(launched, Some(Launched::Started));
let status = child.expect("a child").wait().expect("it ends");
assert_eq!(status.code(), Some(0));
assert_eq!(folder.heard(), folder.0.to_string_lossy(), "the program ran in the file's folder");
}
#[test]
fn a_graphical_program_is_a_recorded_opening() {
let mut harness = opener(app("\"text editor\" --new %F", false), true);
harness.send(Msg::Open);
let opens = harness.opens();
assert_eq!(opens.len(), 1);
assert_eq!(opens[0].program, "text editor");
assert_eq!(opens[0].args, [OsString::from("--new"), OsString::from(FILE)]);
assert_eq!(opens[0].target, None, "a program of its own, not the desktop's opener");
assert!(harness.handoffs().is_empty());
assert_eq!(harness.app().heard, [Launched::Started]);
}
#[test]
fn without_a_graphical_session_a_graphical_program_is_refused() {
let mut harness = opener(app("editor %f", false), false);
assert!(!harness.app().app.can_start(false));
harness.send(Msg::Open);
assert!(harness.opens().is_empty() && harness.handoffs().is_empty(), "nothing is even asked for");
assert_eq!(harness.app().refused, Some(LaunchError::NoGraphicalSession));
}
#[test]
fn a_line_that_gives_no_command_is_refused() {
let mut harness = opener(app("editor \"unclosed %f", true), true);
harness.send(Msg::Open);
assert!(harness.handoffs().is_empty());
assert_eq!(harness.app().refused, Some(LaunchError::NoCommand));
}
#[test]
fn the_session_comes_from_either_display_variable() {
let with = |pairs: &'static [(&str, &str)]| {
graphical_session(|name| pairs.iter().find(|(key, _)| *key == name).map(|(_, value)| (*value).to_owned()))
};
assert!(with(&[("DISPLAY", ":0")]));
assert!(with(&[("WAYLAND_DISPLAY", "wayland-0")]));
assert!(!with(&[("DISPLAY", ""), ("WAYLAND_DISPLAY", "")]), "empty is none");
assert!(!with(&[]));
}
}