use std::ffi::OsStr;
use std::io;
use std::path::Path;
#[cfg_attr(windows, allow(dead_code))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Platform {
Windows,
Linux,
Mac,
}
#[cfg_attr(windows, allow(dead_code))]
pub const HERE: Platform = if cfg!(windows) {
Platform::Windows
} else if cfg!(target_os = "macos") {
Platform::Mac
} else {
Platform::Linux
};
#[cfg_attr(windows, allow(dead_code))]
pub fn launcher(platform: Platform) -> Option<&'static str> {
match platform {
Platform::Windows => None,
Platform::Linux => Some("xdg-open"),
Platform::Mac => Some("open"),
}
}
pub const DOCUMENTS: &[&str] = &[
"png", "jpg", "jpeg", "gif", "bmp", "webp", "tif", "tiff", "pdf", "csv", "tsv", "txt", "md",
"json", "xlsx", "docx",
];
pub fn is_document(name: &str) -> bool {
let Some(ext) = Path::new(name).extension().and_then(OsStr::to_str) else {
return false;
};
let ext = ext.to_ascii_lowercase();
DOCUMENTS.contains(&ext.as_str())
}
pub fn decide(path: &Path) -> Option<(Opens, &Path)> {
let name = path.file_name().and_then(OsStr::to_str).unwrap_or_default();
if is_document(name) {
Some((Opens::File, path))
} else {
path.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(|dir| (Opens::Folder, dir))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Opens {
File,
Folder,
}
pub fn open(path: &Path) -> io::Result<()> {
launch(path)
}
pub const FROM_ELSEWHERE: &[u8] = b"[ZoneTransfer]\r\nZoneId=3\r\n";
pub fn zone_of(path: &Path) -> Option<Vec<u8>> {
#[cfg(windows)]
{
std::fs::read(zone_stream(path))
.ok()
.filter(|zone| !zone.is_empty())
}
#[cfg(not(windows))]
{
let _ = path;
None
}
}
pub fn set_zone(path: &Path, zone: &[u8]) -> io::Result<()> {
#[cfg(windows)]
{
if !path.is_file() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
"there is no file to mark",
));
}
std::fs::write(zone_stream(path), zone)
}
#[cfg(not(windows))]
{
let _ = (path, zone);
Ok(())
}
}
#[cfg(windows)]
fn zone_stream(path: &Path) -> std::path::PathBuf {
let mut stream = path.as_os_str().to_owned();
stream.push(":Zone.Identifier");
stream.into()
}
#[cfg(windows)]
fn launch(path: &Path) -> io::Result<()> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::UI::Shell::ShellExecuteW;
use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
let file: Vec<u16> = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let verb: [u16; 5] = [b'o' as u16, b'p' as u16, b'e' as u16, b'n' as u16, 0];
let rc = unsafe {
ShellExecuteW(
std::ptr::null_mut(),
verb.as_ptr(),
file.as_ptr(),
std::ptr::null(),
std::ptr::null(),
SW_SHOWNORMAL,
)
};
let code = rc as isize;
match code {
c if c > 32 => Ok(()),
31 => Err(io::Error::new(
io::ErrorKind::NotFound,
"no application is associated with this file type",
)),
2 | 3 => Err(io::Error::new(
io::ErrorKind::NotFound,
"the shell did not find the file",
)),
c => Err(io::Error::other(format!("ShellExecuteW failed ({c})"))),
}
}
#[cfg(not(windows))]
fn launch(path: &Path) -> io::Result<()> {
launch_with(launcher(HERE).unwrap_or("xdg-open"), path)
}
#[cfg(not(windows))]
fn launch_with(program: &str, path: &Path) -> io::Result<()> {
spawn_reaped(program, path).map(|_| ())
}
#[cfg(not(windows))]
fn spawn_reaped(
program: &str,
path: &Path,
) -> io::Result<(u32, Option<std::thread::JoinHandle<()>>)> {
use std::process::{Command, Stdio};
let mut child = Command::new(program)
.arg(path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
let pid = child.id();
let reaper = std::thread::Builder::new()
.name("open-reaper".into())
.spawn(move || {
let _ = child.wait();
})
.ok();
Ok((pid, reaper))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn each_platform_has_its_launcher() {
assert_eq!(launcher(Platform::Linux), Some("xdg-open"));
assert_eq!(launcher(Platform::Mac), Some("open"));
assert_eq!(launcher(Platform::Windows), None);
assert_eq!(
HERE,
if cfg!(windows) {
Platform::Windows
} else if cfg!(target_os = "macos") {
Platform::Mac
} else {
Platform::Linux
}
);
}
#[test]
fn documents_open_and_everything_else_does_not() {
for name in [
"chart.png",
"chart.PNG",
"photo.JPEG",
"report.pdf",
"sales.csv",
"sales.xlsx",
"notes.md",
"data.json",
"letter.docx",
] {
assert!(is_document(name), "{name} should open directly");
}
for name in [
"plot.svg",
"report.html",
"book.xlsm",
"letter.docm",
"run.bat",
"run.cmd",
"run.ps1",
"run.sh",
"link.lnk",
"installer.exe",
"report.pdf.bat",
"Makefile",
"",
] {
assert!(!is_document(name), "{name} must not be handed to a handler");
}
}
#[cfg(unix)]
#[test]
fn the_unix_launch_hands_over_one_whole_argument() {
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("a temp dir");
let log = dir.path().join("argv.txt");
let stub = dir.path().join("xdg-open");
let mut f = std::fs::File::create(&stub).expect("the stub");
writeln!(f, "#!/bin/sh").unwrap();
writeln!(f, "printf '%s\\n' \"$#\" \"$1\" > '{}'", log.display()).unwrap();
drop(f);
std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
let target = dir.path().join("my chart (1).png");
std::fs::write(&target, b"x").unwrap();
launch_with(stub.to_str().unwrap(), &target).expect("the stub launcher started");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1);
let recorded = loop {
if let Ok(text) = std::fs::read_to_string(&log)
&& text.lines().count() >= 2
{
break text;
}
assert!(
std::time::Instant::now() < deadline,
"the stub launcher recorded nothing"
);
std::thread::sleep(std::time::Duration::from_millis(10));
};
let mut lines = recorded.lines();
assert_eq!(lines.next(), Some("1"), "exactly one argument: {recorded}");
assert_eq!(lines.next(), target.to_str(), "the path arrived split");
let missing = launch_with("mindfork-no-such-launcher", &target);
assert!(
missing.is_err(),
"a machine with no launcher must report it"
);
}
#[cfg(target_os = "linux")]
#[test]
fn the_unix_launcher_is_reaped_rather_than_left_a_zombie() {
let dir = tempfile::tempdir().expect("a temp dir");
let target = dir.path().join("chart.png");
std::fs::write(&target, b"x").unwrap();
let (pid, reaper) = spawn_reaped("true", &target).expect("`true` started");
reaper
.expect("the waiting thread started")
.join()
.expect("the waiting thread finished");
assert!(
!Path::new(&format!("/proc/{pid}")).exists(),
"the launcher (pid {pid}) was left a zombie"
);
}
#[cfg(windows)]
#[test]
fn a_zone_is_written_beside_the_bytes_and_read_back() {
let dir = tempfile::tempdir().expect("a temp dir");
let file = dir.path().join("sales.csv");
std::fs::write(&file, b"=1+1\n").unwrap();
assert_eq!(
zone_of(&file),
None,
"a file we wrote carries no mark of its own"
);
set_zone(&file, b"[ZoneTransfer]\r\nZoneId=0\r\n").unwrap();
set_zone(&file, FROM_ELSEWHERE).unwrap();
assert_eq!(zone_of(&file).as_deref(), Some(FROM_ELSEWHERE));
assert_eq!(
std::fs::read(&file).unwrap(),
b"=1+1\n",
"the bytes are untouched"
);
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
let missing = dir.path().join("gone.csv");
assert!(set_zone(&missing, FROM_ELSEWHERE).is_err());
assert!(
!missing.exists(),
"marking a missing file must not create it"
);
}
#[cfg(not(windows))]
#[test]
fn off_windows_there_is_no_mark_and_setting_one_succeeds() {
let dir = tempfile::tempdir().expect("a temp dir");
let file = dir.path().join("sales.csv");
std::fs::write(&file, b"=1+1\n").unwrap();
assert!(set_zone(&file, FROM_ELSEWHERE).is_ok());
assert_eq!(zone_of(&file), None);
}
#[test]
#[ignore = "opens windows on the desktop; needs MINDFORK_OPEN_LIVE"]
fn opens_a_document_and_a_folder_live() {
if std::env::var("MINDFORK_OPEN_LIVE").is_err() {
println!("skipped: set MINDFORK_OPEN_LIVE=1 to open windows on this desktop");
return;
}
let dir = tempfile::tempdir().expect("a temp dir");
let doc = dir.path().join("mindfork open gate.txt");
std::fs::write(&doc, b"stage 4: this file was opened by /file open\n").unwrap();
let (opens, at) = decide(&doc).expect("a document opens");
assert_eq!(opens, Opens::File);
println!("opening the document: {}", at.display());
open(at).expect("the document opened");
let script = dir.path().join("run.bat");
std::fs::write(&script, b"@echo off\n").unwrap();
let (opens, at) = decide(&script).expect("its folder opens");
assert_eq!(opens, Opens::Folder);
println!("opening the folder instead of run.bat: {}", at.display());
open(at).expect("the folder opened");
std::thread::sleep(std::time::Duration::from_secs(5));
}
#[test]
fn a_refused_type_opens_its_folder_instead() {
let dir = Path::new("/data/files/chat");
let chart = dir.join("chart.png");
let (opens, at) = decide(&chart).expect("a document opens");
assert_eq!(opens, Opens::File);
assert_eq!(at, chart);
let script = dir.join("run.bat");
let (opens, at) = decide(&script).expect("its folder opens");
assert_eq!(opens, Opens::Folder);
assert_eq!(at, dir);
assert_eq!(decide(Path::new("run.bat")), None);
let bare = Path::new("report.pdf");
assert_eq!(decide(bare), Some((Opens::File, bare)));
}
}