diff --git a/apps/lightcraft/src/editors.rs b/apps/lightcraft/src/editors.rs index c8fe298b3..a0b453449 100644 --- a/apps/lightcraft/src/editors.rs +++ b/apps/lightcraft/src/editors.rs @@ -146,12 +146,7 @@ fn find_photocraft() -> Option { // registers one on first run; its Exec a path or a name on the PATH), an AppImage where people // keep them, else the Flatpak let path_dirs: Vec = env("PATH").into_iter().flat_map(|p| std::env::split_paths(&p).collect::>()).collect(); - let on_path = |name: &Path| -> Option { - if name.is_absolute() || name.starts_with("/") { - return Some(name.to_path_buf()).filter(|p| p.is_file()); - } - path_dirs.iter().map(|d| d.join(name)).find(|p| p.is_file()) - }; + let on_path = |name: &Path| resolve_program(name, &path_dirs); let from_entry = || { desktop_entry_dirs(env).into_iter().find_map(|d| { let text = std::fs::read_to_string(d.join(format!("{PHOTOCRAFT_ID}.desktop"))).ok()?; @@ -164,8 +159,8 @@ fn find_photocraft() -> Option { dirs.iter().find_map(|d| { let mut found: Vec = std::fs::read_dir(d).ok()?.flatten().map(|e| e.path()).filter(|p| is_photocraft_appimage(p) && p.is_file()).collect(); - // the newest version by name - found.sort(); + // the newest version by name (1.10 after 1.9) + found.sort_by_key(|p| version_key(&p.file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default())); found.pop() }) }; @@ -239,6 +234,31 @@ fn desktop_exec(entry: &str) -> Option { (!program.is_empty()).then(|| PathBuf::from(program)) } +/// A program from a desktop entry or the PATH: a path as it is (if it is a file), a bare name +/// looked for in `dirs`. A leading `/` counts as absolute on every platform (desktop entries are +/// a POSIX format; `Path::is_absolute` wants a drive letter on Windows, where the tests run too). +fn resolve_program(name: &Path, dirs: &[PathBuf]) -> Option { + if name.is_absolute() || name.starts_with("/") { + return Some(name.to_path_buf()).filter(|p| p.is_file()); + } + dirs.iter().map(|d| d.join(name)).find(|p| p.is_file()) +} + +/// A sort key that orders the digit runs in a name by value (`1.9` before `1.10`). +fn version_key(name: &str) -> Vec<(u64, String)> { + let mut out = Vec::new(); + let mut rest = name; + while !rest.is_empty() { + let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len(); + let (num, tail) = rest.split_at(digits); + let text_len = tail.find(|c: char| c.is_ascii_digit()).unwrap_or(tail.len()); + let (text, next) = tail.split_at(text_len); + out.push((num.parse().unwrap_or(u64::MAX), text.to_string())); + rest = next; + } + out +} + /// An AppImage of PhotoCraft by its file name (`PhotoCraft-1.2-x86_64.AppImage`, any case). fn is_photocraft_appimage(p: &Path) -> bool { let name = p.file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default(); @@ -284,6 +304,68 @@ mod tests { assert!(all_command(Launcher::Default, &paths).is_none(), "the system default opens them one by one"); } + #[test] + fn windows_lookup_prefers_the_recorded_install_folder() { + let env = |k: &str| match k { + "ProgramFiles" => Some(r"C:\Program Files".to_string()), + "LOCALAPPDATA" => Some(r"C:\Users\Simon\AppData\Local".to_string()), + _ => None, + }; + let c = windows_candidates(Some(r"D:\Apps\PhotoCraft\".into()), Some(r#""C:\Program Files\PhotoCraft\photocraft.exe""#.into()), env); + let c: Vec = c.iter().map(|p| p.to_string_lossy().replace('/', "\\")).collect(); + assert!(c[0].starts_with(r"D:\Apps\PhotoCraft") && c[0].ends_with("photocraft.exe"), "{c:?}"); + assert_eq!(c[1], r"C:\Program Files\PhotoCraft\photocraft.exe", "App Paths, quotes dropped"); + assert!(c[2].starts_with(r"C:\Program Files") && c[2].ends_with("photocraft.exe"), "{c:?}"); + assert!(c[3].contains("Programs") && c[3].ends_with("photocraft.exe"), "{c:?}"); + assert_eq!(c.len(), 4); + } + + #[test] + fn reg_query_output_is_read() { + let out = "\r\nHKEY_LOCAL_MACHINE\\Software\\PhotoCraft\r\n InstallDir REG_SZ C:\\Program Files\\PhotoCraft\\\r\n\r\n"; + assert_eq!(reg_value(out).as_deref(), Some(r"C:\Program Files\PhotoCraft\")); + let default = "\r\nHKEY_LOCAL_MACHINE\\...\\photocraft.exe\r\n (Default) REG_SZ C:\\Program Files\\PhotoCraft\\photocraft.exe\r\n"; + assert_eq!(reg_value(default).as_deref(), Some(r"C:\Program Files\PhotoCraft\photocraft.exe")); + assert_eq!(reg_value("ERROR: The system was unable to find the specified registry key or value."), None); + } + + #[test] + fn desktop_entries_give_their_program() { + let appimage = "[Desktop Entry]\nName=PhotoCraft\nExec=\"/home/simon/Apps/photocraft-1.2-linux-x86_64.AppImage\" %F\n"; + assert_eq!(desktop_exec(appimage), Some(PathBuf::from("/home/simon/Apps/photocraft-1.2-linux-x86_64.AppImage"))); + assert_eq!(desktop_exec("Exec=/opt/photocraft/photocraft %F\n"), Some(PathBuf::from("/opt/photocraft/photocraft"))); + assert_eq!(desktop_exec("Exec=photocraft %F\nTryExec=photocraft\n"), Some(PathBuf::from("photocraft")), "a bare name, for a PATH lookup"); + assert_eq!(desktop_exec("[Desktop Entry]\n"), None); + let dirs = desktop_entry_dirs(|k| (k == "HOME").then(|| "/home/simon".to_string())); + assert_eq!(dirs[0], PathBuf::from("/home/simon/.local/share/applications")); + assert!(dirs.contains(&PathBuf::from("/usr/share/applications"))); + } + + #[test] + fn a_bare_exec_name_is_found_on_the_path() { + let a = std::env::temp_dir().join(format!("lc-editors-a-{}", std::process::id())); + let b = std::env::temp_dir().join(format!("lc-editors-b-{}", std::process::id())); + std::fs::create_dir_all(&a).unwrap(); + std::fs::create_dir_all(&b).unwrap(); + std::fs::write(b.join("photocraft"), b"").unwrap(); + let dirs = vec![a.clone(), b.clone()]; + assert_eq!(resolve_program(Path::new("photocraft"), &dirs), Some(b.join("photocraft"))); + assert_eq!(resolve_program(Path::new("nothere"), &dirs), None); + let abs = b.join("photocraft"); + assert_eq!(resolve_program(&abs, &[]), Some(abs.clone()), "a path needs no PATH"); + assert_eq!(resolve_program(&b.join("missing"), &dirs), None); + let _ = std::fs::remove_dir_all(&a); + let _ = std::fs::remove_dir_all(&b); + } + + #[test] + fn appimage_versions_sort_numerically() { + let mut names = ["photocraft-1.10-x86_64.appimage", "photocraft-1.9-x86_64.appimage", "photocraft-1.2-x86_64.appimage"]; + names.sort_by_key(|n| version_key(n)); + assert_eq!(names[2], "photocraft-1.10-x86_64.appimage"); + assert_eq!(names[0], "photocraft-1.2-x86_64.appimage"); + } + #[test] fn a_failed_open_is_an_error() { assert!(open_result(true, "").is_ok());