//! Open a `.ppd` from the command line or the file manager. //! //! The manifest name stays `character.ppd`. Windows records the open command //! for the current user the first time the program starts. Linux packages //! install the shared MIME XML and a desktop file with `%f`. use std::path::{Component, Path, PathBuf}; use crate::project::MANIFEST_NAME; pub const MIME_TYPE: &str = "application/x-paperdoll-project"; pub const PROG_ID: &str = "Paperdoll.Project"; pub fn file_open_command(exe: &Path) -> String { format!("\"{}\" \"%1\"", exe.display()) } pub fn resolve_project_argument(base: &Path, arg: &str) -> Result { if arg.is_empty() || arg.chars().any(|ch| ch == '\0') { return Err("het projectpad is leeg of bevat een leeg teken".to_string()); } let joined = if Path::new(arg).is_absolute() { PathBuf::from(arg) } else { base.join(arg) }; let full = normalize(&joined); let candidate = if full.is_dir() { full.join(MANIFEST_NAME) } else { full }; if candidate.extension().and_then(|ext| ext.to_str()) != Some("ppd") { return Err("open een .ppd-bestand of een projectmap met character.ppd".to_string()); } if !candidate.is_file() { return Err(format!("geen projectbestand op {}", candidate.display())); } Ok(candidate) } fn normalize(path: &Path) -> PathBuf { let mut out = PathBuf::new(); for component in path.components() { match component { Component::CurDir => {} Component::ParentDir => { out.pop(); } other => out.push(other.as_os_str()), } } out } /// Writes the per-user open-with command. A missing write is reported and /// does not stop the window. #[cfg(windows)] pub fn ensure_user_file_association(exe: &Path) -> Result<(), String> { use winreg::enums::*; use winreg::RegKey; let hkcu = RegKey::predef(HKEY_CURRENT_USER); let (extension, _) = hkcu .create_subkey("Software\\Classes\\.ppd") .map_err(|err| err.to_string())?; let (open_with, _) = extension .create_subkey("OpenWithProgids") .map_err(|err| err.to_string())?; open_with .set_value(PROG_ID, &String::new()) .map_err(|err| err.to_string())?; extension .set_value("Content Type", &MIME_TYPE) .map_err(|err| err.to_string())?; let (prog, _) = hkcu .create_subkey(format!("Software\\Classes\\{PROG_ID}")) .map_err(|err| err.to_string())?; prog.set_value("", &"Paperdoll-project") .map_err(|err| err.to_string())?; let (icon, _) = prog .create_subkey("DefaultIcon") .map_err(|err| err.to_string())?; icon.set_value("", &format!("\"{}\",0", exe.display())) .map_err(|err| err.to_string())?; let (command, _) = hkcu .create_subkey(format!( "Software\\Classes\\{PROG_ID}\\shell\\open\\command" )) .map_err(|err| err.to_string())?; command .set_value("", &file_open_command(exe)) .map_err(|err| err.to_string())?; Ok(()) } /// Register an AppImage or standalone Linux binary for the current user. /// Do not change the user's default application for an existing MIME type. #[cfg(target_os = "linux")] pub fn install_linux_integration(exe: &Path) -> Result<(), String> { let data = std::env::var_os("XDG_DATA_HOME") .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share"))) .ok_or("HOME of XDG_DATA_HOME ontbreekt")?; write_linux_integration(&data, exe)?; for (command, path) in [ ("update-mime-database", data.join("mime")), ("update-desktop-database", data.join("applications")), ] { let status = std::process::Command::new(command) .arg(path) .status() .map_err(|e| format!("{command}: {e}"))?; if !status.success() { return Err(format!("{command} is mislukt")); } } Ok(()) } pub fn linux_desktop_entry(exe: &Path) -> Result { let path = exe.to_str().ok_or("het uitvoerbare pad moet UTF-8 zijn")?; if !exe.is_absolute() || path.chars().any(|ch| ch.is_control()) { return Err("kies een absoluut pad zonder stuurtekens".into()); } // Desktop Exec quoting has an extra escape layer for reserved characters. let escaped = path .replace('\\', "\\\\\\\\") .replace('"', "\\\\\\\"") .replace('`', "\\\\`") .replace('$', "\\\\$") .replace('%', "%%"); Ok(format!("[Desktop Entry]\nType=Application\nName=Paperdoll Renderer\nExec=\"{escaped}\" %f\nIcon=paperdoll-renderer\nStartupWMClass=paperdoll-renderer\nTerminal=false\nCategories=Graphics;3DGraphics;\nMimeType={MIME_TYPE};\n")) } pub fn write_linux_integration(data: &Path, exe: &Path) -> Result<(), String> { let desktop = linux_desktop_entry(exe)?; let files: [(&str, &[u8]); 3] = [ ( "applications/paperdoll-renderer.desktop", desktop.as_bytes(), ), ( "mime/packages/paperdoll-project.xml", include_bytes!("../packaging/linux/paperdoll-project.xml"), ), ( "icons/hicolor/128x128/apps/paperdoll-renderer.png", include_bytes!("../packaging/linux/hicolor/128x128/apps/paperdoll-renderer.png"), ), ]; for (relative, bytes) in files { let path = data.join(relative); std::fs::create_dir_all(path.parent().unwrap()).map_err(|e| e.to_string())?; std::fs::write(path, bytes).map_err(|e| e.to_string())?; } Ok(()) } #[cfg(test)] mod tests { use super::*; use std::fs; #[test] #[cfg(unix)] fn desktop_entry_keeps_spaces_and_does_not_set_a_default() { let entry = linux_desktop_entry(Path::new("/home/test/My Tools/Paperdoll.AppImage")).unwrap(); assert!(entry.contains("Exec=\"/home/test/My Tools/Paperdoll.AppImage\" %f")); assert!(entry.contains("MimeType=application/x-paperdoll-project;")); assert!(linux_desktop_entry(Path::new("/bad\npath")).is_err()); assert!(linux_desktop_entry(Path::new("relative")).is_err()); let quoted = linux_desktop_entry(Path::new("/test/100% ready.AppImage")).unwrap(); assert!(quoted.contains("100%% ready")); } #[test] fn open_command_quotes_the_executable_and_the_file() { let command = file_open_command(Path::new(r"C:\Program Files\paperdoll-renderer.exe")); assert!(command.contains(r"C:\Program Files\paperdoll-renderer.exe")); assert!(command.ends_with("\"%1\"")); assert!(command.starts_with('"')); } #[test] fn project_argument_accepts_spaces_relative_and_absolute_ppd() { let root = std::env::temp_dir().join(format!("paperdoll open {}", std::process::id())); let _ = fs::remove_dir_all(&root); fs::create_dir_all(root.join("sub")).unwrap(); let manifest = root.join(MANIFEST_NAME); fs::write(&manifest, b"{}").unwrap(); let relative = resolve_project_argument( root.parent().unwrap(), root.file_name().unwrap().to_str().unwrap(), ) .unwrap(); assert_eq!(relative, manifest); let direct = resolve_project_argument(&root, "character.ppd").unwrap(); assert_eq!(direct, manifest); let absolute = resolve_project_argument(Path::new("/"), manifest.to_str().unwrap()).unwrap(); assert_eq!(absolute, manifest); let missing = resolve_project_argument(&root, "nope.ppd"); assert!(missing.is_err()); let model = resolve_project_argument(&root, "body.glb"); assert!(model.is_err()); let _ = fs::remove_dir_all(&root); } }