use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use ikigai_engine::Viewer;
const OPENER_KEY: &str = "show.opener";
pub struct FileViewer;
impl Viewer for FileViewer {
fn show(&self, target: &str, _media: &str, bytes: &[u8]) -> Result<String, String> {
let path = picture_path(&std::env::temp_dir(), target, bytes);
std::fs::write(&path, bytes)
.map_err(|e| format!("could not write {}: {e}", path.display()))?;
let wrote = format!("wrote {} ({} bytes)", path.display(), bytes.len());
let opener = ikigai_embedded::config::get(OPENER_KEY);
let Some(mut command) = opener_command(opener.as_deref()) else {
return Ok(format!("{wrote} · not opened ({OPENER_KEY} = none)"));
};
let name = format!("{command:?}");
let mut child = command
.arg(&path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| {
format!("{wrote}, and {name} could not open it: {e} (set {OPENER_KEY} in the config home)")
})?;
std::thread::spawn(move || {
let _ = child.wait();
});
Ok(format!("{wrote} · opened with {name}"))
}
}
fn opener_command(configured: Option<&str>) -> Option<Command> {
match configured.map(str::trim) {
Some("none" | "off" | "") => None,
Some(line) => {
let mut words = line.split_whitespace();
let mut command = Command::new(words.next()?);
command.args(words);
Some(command)
}
None => Some(platform_opener()),
}
}
#[cfg(target_os = "macos")]
fn platform_opener() -> Command {
Command::new("open")
}
#[cfg(windows)]
fn platform_opener() -> Command {
let mut command = Command::new("cmd");
command.args(["/C", "start", ""]);
command
}
#[cfg(not(any(target_os = "macos", windows)))]
fn platform_opener() -> Command {
Command::new("xdg-open")
}
fn picture_path(dir: &Path, target: &str, bytes: &[u8]) -> PathBuf {
let mut hasher = DefaultHasher::new();
bytes.hash(&mut hasher);
let slug: String = target
.trim_start_matches("urn:")
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
let slug = slug.trim_matches('-');
let slug = &slug[slug.len().saturating_sub(40)..];
dir.join(format!("ikigai-show-{slug}-{:016x}.svg", hasher.finish()))
}
#[cfg(test)]
mod tests {
use super::{opener_command, picture_path};
use std::path::Path;
#[test]
fn one_picture_is_one_file_named_for_what_it_shows() {
let dir = Path::new("/tmp");
let a = picture_path(dir, "urn:diagram:kernel", b"<svg/>");
assert_eq!(a, picture_path(dir, "urn:diagram:kernel", b"<svg/>"));
assert_ne!(a, picture_path(dir, "urn:diagram:kernel", b"<svg></svg>"));
let name = a.file_name().unwrap().to_string_lossy().into_owned();
assert!(
name.starts_with("ikigai-show-diagram-kernel-") && name.ends_with(".svg"),
"{name}"
);
let hostile = picture_path(dir, "urn:x:../../etc/passwd", b"x");
assert_eq!(hostile.parent(), Some(dir));
}
#[test]
fn the_opener_is_the_platform_s_unless_the_config_home_names_one() {
assert!(opener_command(None).is_some());
assert!(opener_command(Some("none")).is_none());
assert!(opener_command(Some(" off ")).is_none());
let custom = opener_command(Some("open -a Safari")).unwrap();
assert_eq!(custom.get_program(), "open");
assert_eq!(custom.get_args().collect::<Vec<_>>(), vec!["-a", "Safari"]);
}
}