use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use crate::error::{Context, Result};
use crate::{bail, err};
pub fn open(path: &Path, reader: Option<&str>) -> Result<()> {
let path = resolve(path)?;
match reader.map(str::trim).filter(|r| !r.is_empty()) {
#[cfg(target_os = "macos")]
Some(app) => run(Command::new("open").arg("-a").arg(app).arg(&path)),
#[cfg(not(target_os = "macos"))]
Some(command) => {
detach(Command::new(command).arg(&path)).with_context(|| {
format!("could not start `{command}` — is it installed and on your PATH?")
})
}
None => open_with_default(&path),
}
}
pub fn reveal(path: &Path) -> Result<()> {
let path = resolve(path)?;
#[cfg(target_os = "macos")]
{
run(Command::new("open").arg("-R").arg(&path))
}
#[cfg(target_os = "windows")]
{
detach(Command::new("explorer").arg(format!("/select,{}", path.display())))
}
#[cfg(all(unix, not(target_os = "macos")))]
{
let shown = run(Command::new("dbus-send")
.arg("--session")
.arg("--dest=org.freedesktop.FileManager1")
.arg("--type=method_call")
.arg("/org/freedesktop/FileManager1")
.arg("org.freedesktop.FileManager1.ShowItems")
.arg(format!("array:string:{}", file_uri(&path)))
.arg("string:"))
.is_ok();
if shown {
return Ok(());
}
let folder = path.parent().unwrap_or(Path::new("."));
open_with_default(folder)
}
}
fn open_with_default(path: &Path) -> Result<()> {
#[cfg(target_os = "macos")]
{
run(Command::new("open").arg(path))
}
#[cfg(target_os = "windows")]
{
detach(Command::new("cmd").arg("/C").arg("start").arg("").arg(path))
}
#[cfg(all(unix, not(target_os = "macos")))]
{
if run(Command::new("xdg-open").arg(path)).is_ok() {
return Ok(());
}
run(Command::new("gio").arg("open").arg(path)).map_err(|_| {
err!(
"could not open {} — neither `xdg-open` nor `gio` worked. \
Set `reader` in the config to name a program directly.",
path.display()
)
})
}
}
fn resolve(path: &Path) -> Result<PathBuf> {
let resolved = std::fs::canonicalize(path).map_err(|_| {
err!(
"{} is no longer there — it was moved or deleted since it was downloaded",
path.display()
)
})?;
if !resolved.is_file() && !resolved.is_dir() {
bail!("{} is not a file", resolved.display());
}
Ok(resolved)
}
#[allow(dead_code)]
fn run(command: &mut Command) -> Result<()> {
let output = command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output()
.with_context(|| {
format!(
"could not run `{}`",
command.get_program().to_string_lossy()
)
})?;
if output.status.success() {
return Ok(());
}
let message = String::from_utf8_lossy(&output.stderr);
let message = message.lines().next().unwrap_or("").trim();
if message.is_empty() {
bail!("`{}` failed", command.get_program().to_string_lossy());
}
bail!("{message}")
}
#[allow(dead_code)]
fn detach(command: &mut Command) -> Result<()> {
command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map(|_| ())
.with_context(|| {
format!(
"could not start `{}`",
command.get_program().to_string_lossy()
)
})
}
#[cfg(all(unix, not(target_os = "macos")))]
fn file_uri(path: &Path) -> String {
let mut out = String::from("file://");
for byte in path.to_string_lossy().bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
out.push(byte as char)
}
other => out.push_str(&format!("%{other:02X}")),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_missing_file_is_reported_clearly() {
let err = open(Path::new("/definitely/not/here.epub"), None)
.unwrap_err()
.to_string();
assert!(err.contains("no longer there"), "got: {err}");
let err = reveal(Path::new("/definitely/not/here.epub"))
.unwrap_err()
.to_string();
assert!(err.contains("no longer there"), "got: {err}");
}
#[test]
fn resolving_makes_the_path_absolute() {
let dir = std::env::temp_dir();
let file = dir.join(format!("tomesole-launch-{}.txt", std::process::id()));
std::fs::write(&file, b"x").unwrap();
let resolved = resolve(&file).unwrap();
assert!(resolved.is_absolute());
assert!(resolved.to_string_lossy().starts_with('/') || cfg!(windows));
let _ = std::fs::remove_file(&file);
}
#[test]
fn an_unknown_reader_fails_gracefully() {
let dir = std::env::temp_dir();
let file = dir.join(format!("tomesole-launch-r-{}.txt", std::process::id()));
std::fs::write(&file, b"x").unwrap();
let result = open(&file, Some("tomesole-no-such-application-9c1f"));
assert!(result.is_err(), "a bogus reader should not report success");
let _ = std::fs::remove_file(&file);
}
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn file_uris_escape_what_they_must() {
assert_eq!(
file_uri(Path::new("/books/A Book & Co.epub")),
"file:///books/A%20Book%20%26%20Co.epub"
);
}
}