Files
OneCAD/crates/engine/src/slicers.rs
T
froglemonade2 0872eb959a Save as mesh: one dialog for STL and 3MF, with Send to slicer (#1272)
One docked Save as mesh dialog replaces Export STL / 3MF / 3MF with face tags, with an optional Send to slicer that lists the slicers installed on this machine. Engine: file.save_mesh and file.slicers; the Convert Mesh dialog goes (mesh.convert and the MeshConvert feature stay, so existing designs open). Strings in all catalogs; screenshots in the PR. Checked on main + this branch: cargo check --workspace, the engine slicer/mesh/file tests, the UI catalog and menu tests and scenario s14 (the one local colour-token failure is epaint's TexturesDelta panic, which main shows on this machine too).
2026-10-15 03:51:28 -04:00

577 lines
24 KiB
Rust

//! Slicers a mesh can be sent to: find the installed ones on Linux, macOS and Windows, start one
//! with a file, and read its application icon (never shipped: it comes from the installed
//! application).
//!
//! Detection reads desktop entries and Flatpak exports (Linux), `.app` bundles (macOS) and the
//! program folders (Windows), plus `PATH` and the usual AppImage folders. Nothing here runs a
//! program except [`Slicer::open`].
use std::path::{Path, PathBuf};
use crate::slicer_icon;
/// Most directory entries looked at in one folder.
const MAX_ENTRIES: usize = 5_000;
/// Largest desktop entry read.
const MAX_DESKTOP_BYTES: u64 = 64 * 1024;
/// A slicer the user can send a mesh to.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Slicer {
/// `prusa`, `bambu`, … for the known ones; `custom` for a program the user picked.
pub id: String,
pub name: String,
/// The program and its leading arguments; the file to open is appended.
pub command: Vec<String>,
pub icon: Option<IconSource>,
}
/// Where an application's icon is read from.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum IconSource {
/// A PNG file (Linux icon themes).
Image(PathBuf),
/// A macOS `.icns` file.
Icns(PathBuf),
/// A Windows program with icon resources.
Exe(PathBuf),
}
impl IconSource {
/// The icon as PNG or ICO bytes, ready for an image decoder; `None` when it can't be read.
pub fn load(&self) -> Option<Vec<u8>> {
match self {
IconSource::Image(p) => slicer_icon::read_capped(p, 4 << 20),
IconSource::Icns(p) => slicer_icon::icns_png(&slicer_icon::read_capped(p, 32 << 20)?),
IconSource::Exe(p) => slicer_icon::exe_icon(p),
}
}
pub fn path(&self) -> &Path {
match self {
IconSource::Image(p) | IconSource::Icns(p) | IconSource::Exe(p) => p,
}
}
}
struct Known {
id: &'static str,
name: &'static str,
/// Names the application goes by, letters and digits only, lower case; an entry matches when
/// its name starts with one of them.
keys: &'static [&'static str],
/// Program file names.
exes: &'static [&'static str],
}
const KNOWN: &[Known] = &[
Known { id: "prusa", name: "PrusaSlicer", keys: &["prusaslicer"], exes: &["prusa-slicer", "PrusaSlicer", "prusa-slicer.exe"] },
Known { id: "bambu", name: "Bambu Studio", keys: &["bambustudio"], exes: &["bambu-studio", "BambuStudio", "bambu-studio.exe"] },
Known { id: "orca", name: "OrcaSlicer", keys: &["orcaslicer"], exes: &["orca-slicer", "OrcaSlicer", "orca-slicer.exe"] },
Known {
id: "cura",
name: "UltiMaker Cura",
keys: &["ultimakercura", "curaultimaker", "cura"],
exes: &["cura", "UltiMaker-Cura", "UltiMaker-Cura.exe", "Cura.exe"],
},
Known { id: "superslicer", name: "SuperSlicer", keys: &["superslicer"], exes: &["superslicer", "SuperSlicer", "superslicer.exe"] },
Known { id: "creality", name: "Creality Print", keys: &["crealityprint"], exes: &["CrealityPrint", "creality-print", "CrealityPrint.exe"] },
Known { id: "elegoo", name: "ElegooSlicer", keys: &["elegooslicer"], exes: &["elegoo-slicer", "ElegooSlicer", "ElegooSlicer.exe"] },
Known { id: "anycubic", name: "AnycubicSlicerNext", keys: &["anycubicslicer"], exes: &["AnycubicSlicerNext", "AnycubicSlicerNext.exe"] },
Known { id: "ideamaker", name: "ideaMaker", keys: &["ideamaker"], exes: &["ideamaker", "ideaMaker.exe"] },
Known { id: "lychee", name: "Lychee Slicer", keys: &["lycheeslicer"], exes: &["lychee-slicer", "Lychee Slicer.exe"] },
Known { id: "slic3r", name: "Slic3r", keys: &["slic3r"], exes: &["slic3r", "Slic3r", "slic3r.exe"] },
];
/// Lower-case letters and digits only: how names are compared.
fn normalize(s: &str) -> String {
s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect()
}
/// The known slicer an application name (or file name) belongs to.
fn known_for(name: &str) -> Option<&'static Known> {
let n = normalize(name);
// Companion tools (G-code viewers, command-line builds) are not slicers to send a mesh to.
if n.contains("viewer") || n.contains("console") || n.contains("gcode") || n.contains("uninstall") {
return None;
}
KNOWN.iter().find(|k| k.keys.iter().any(|key| n.starts_with(key)))
}
/// Folders and variables detection looks at, so it can be tested without the real machine.
#[derive(Clone, Debug, Default)]
pub struct Env {
/// The user's home folder.
pub home: Option<PathBuf>,
/// `PATH`.
pub path_dirs: Vec<PathBuf>,
/// XDG data folders (each holds `applications/` and `icons/`).
pub data_dirs: Vec<PathBuf>,
/// Folders holding AppImages, loose programs and per-slicer folders (Linux).
pub program_dirs: Vec<PathBuf>,
/// Folders holding `.app` bundles (macOS).
pub app_dirs: Vec<PathBuf>,
/// Folders holding one folder per installed program (Windows).
pub install_dirs: Vec<PathBuf>,
}
impl Env {
/// The running machine.
pub fn current() -> Env {
let var = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()).map(PathBuf::from);
let home = var("HOME").or_else(|| var("USERPROFILE"));
let mut env = Env {
home: home.clone(),
path_dirs: std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect()).unwrap_or_default(),
..Env::default()
};
if cfg!(target_os = "macos") {
env.app_dirs.push("/Applications".into());
env.app_dirs.extend(home.iter().map(|h| h.join("Applications")));
} else if cfg!(target_os = "windows") {
for k in ["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"] {
env.install_dirs.extend(var(k));
}
if let Some(local) = var("LOCALAPPDATA") {
env.install_dirs.push(local.join("Programs"));
env.install_dirs.push(local);
}
} else {
let data_home = var("XDG_DATA_HOME").or_else(|| home.as_ref().map(|h| h.join(".local/share")));
env.data_dirs.extend(data_home.clone());
env.data_dirs.extend(home.iter().map(|h| h.join(".local/share/flatpak/exports/share")));
env.data_dirs.push("/var/lib/flatpak/exports/share".into());
env.data_dirs.push("/var/lib/snapd/desktop".into());
match std::env::var_os("XDG_DATA_DIRS").filter(|v| !v.is_empty()) {
Some(dirs) => env.data_dirs.extend(std::env::split_paths(&dirs)),
None => env.data_dirs.extend(["/usr/local/share".into(), "/usr/share".into()]),
}
for dir in ["Applications", "AppImages", "Apps", ".local/bin", "bin"] {
env.program_dirs.extend(home.iter().map(|h| h.join(dir)));
}
env.program_dirs.extend(["/opt", "/usr/local/bin"].map(PathBuf::from));
}
env
}
}
/// The slicers installed on this machine, known ones in a fixed order. Empty in the browser.
pub fn detect() -> Vec<Slicer> {
if cfg!(target_arch = "wasm32") { Vec::new() } else { detect_in(&Env::current()) }
}
/// [`detect`] over the folders in `env`.
pub fn detect_in(env: &Env) -> Vec<Slicer> {
let mut found: Vec<Slicer> = Vec::new();
let mut add = |s: Slicer| {
if !found.iter().any(|f| f.id == s.id) {
found.push(s);
}
};
desktop_entries(env, &mut add);
path_programs(env, &mut add);
program_folders(env, &mut add);
mac_apps(env, &mut add);
windows_programs(env, &mut add);
let order = |s: &Slicer| KNOWN.iter().position(|k| k.id == s.id).unwrap_or(usize::MAX);
found.sort_by_key(order);
found
}
fn entries(dir: &Path) -> Vec<PathBuf> {
std::fs::read_dir(dir).map(|rd| rd.filter_map(|e| e.ok().map(|e| e.path())).take(MAX_ENTRIES).collect()).unwrap_or_default()
}
fn file_name(p: &Path) -> String {
p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
}
fn is_executable(p: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(p).map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0).unwrap_or(false)
}
#[cfg(not(unix))]
{
p.is_file()
}
}
fn s(p: &Path) -> String {
p.to_string_lossy().into_owned()
}
fn known_slicer(k: &Known, command: Vec<String>, icon: Option<IconSource>) -> Slicer {
Slicer { id: k.id.into(), name: k.name.into(), command, icon }
}
// --- Linux ---
/// A desktop entry's `[Desktop Entry]` keys.
fn parse_desktop(text: &str) -> std::collections::HashMap<String, String> {
let mut out = std::collections::HashMap::new();
let mut inside = false;
for line in text.lines().take(2_000) {
let line = line.trim();
if line.starts_with('[') {
inside = line == "[Desktop Entry]";
} else if inside && let Some((k, v)) = line.split_once('=') {
// Localised keys (`Name[de]`) never replace the plain one.
if !k.contains('[') {
out.entry(k.trim().to_string()).or_insert_with(|| v.trim().to_string());
}
}
}
out
}
/// An `Exec=` line as a command: quotes honoured, field codes (`%F`, …) dropped.
pub fn parse_exec(exec: &str) -> Vec<String> {
let mut args: Vec<String> = Vec::new();
let mut cur = String::new();
let mut quoted = false;
let mut started = false;
let mut chars = exec.chars();
while let Some(c) = chars.next() {
match c {
'"' => {
quoted = !quoted;
started = true;
}
'\\' if quoted => {
if let Some(n) = chars.next() {
cur.push(n);
}
}
c if c.is_whitespace() && !quoted => {
if started {
args.push(std::mem::take(&mut cur));
started = false;
}
}
c => {
cur.push(c);
started = true;
}
}
}
if started {
args.push(cur);
}
args.retain(|a| !(a.len() == 2 && a.starts_with('%')));
args
}
fn desktop_entries(env: &Env, add: &mut impl FnMut(Slicer)) {
for data in &env.data_dirs {
for file in entries(&data.join("applications")) {
if file.extension().is_none_or(|e| e != "desktop") || std::fs::metadata(&file).map(|m| m.len() > MAX_DESKTOP_BYTES).unwrap_or(true) {
continue;
}
let Ok(text) = std::fs::read_to_string(&file) else { continue };
let entry = parse_desktop(&text);
let Some(known) = entry.get("Name").and_then(|n| known_for(n)) else { continue };
if entry.get("Type").is_some_and(|t| t != "Application") {
continue;
}
let command = entry.get("Exec").map(|e| parse_exec(e)).unwrap_or_default();
let Some(program) = command.first() else { continue };
// A stale entry points at a program that is gone.
if Path::new(program).is_absolute() && !Path::new(program).is_file() {
continue;
}
let icon = entry.get("Icon").and_then(|i| theme_icon(env, i));
add(known_slicer(known, command, icon));
}
}
}
/// An icon by theme name (or absolute PNG path) from the icon themes.
fn theme_icon(env: &Env, name: &str) -> Option<IconSource> {
const SIZES: [u32; 7] = [256, 128, 192, 64, 48, 512, 32];
let direct = Path::new(name);
if direct.is_absolute() {
return (direct.extension().is_some_and(|e| e.eq_ignore_ascii_case("png")) && direct.is_file())
.then(|| IconSource::Image(direct.to_path_buf()));
}
if name.is_empty() || name.contains('/') || name.contains("..") {
return None;
}
for data in &env.data_dirs {
for size in SIZES {
let p = data.join(format!("icons/hicolor/{size}x{size}/apps/{name}.png"));
if p.is_file() {
return Some(IconSource::Image(p));
}
}
let p = data.join(format!("pixmaps/{name}.png"));
if p.is_file() {
return Some(IconSource::Image(p));
}
}
None
}
fn path_programs(env: &Env, add: &mut impl FnMut(Slicer)) {
for k in KNOWN {
for dir in &env.path_dirs {
for exe in k.exes {
let p = dir.join(exe);
if is_executable(&p) {
let icon = k.exes.iter().chain([&k.id]).find_map(|n| theme_icon(env, n.trim_end_matches(".exe")));
add(known_slicer(k, vec![s(&p)], icon));
}
}
}
}
}
/// AppImages and per-slicer folders in the usual places.
fn program_folders(env: &Env, add: &mut impl FnMut(Slicer)) {
for dir in &env.program_dirs {
for p in entries(dir) {
let name = file_name(&p);
let Some(known) = known_for(&name) else { continue };
if p.is_dir() {
let inside = known.exes.iter().flat_map(|e| [p.join(e), p.join("bin").join(e)]).find(|c| is_executable(c));
if let Some(exe) = inside {
let icon = theme_icon(env, known.id);
add(known_slicer(known, vec![s(&exe)], icon));
}
} else if name.to_ascii_lowercase().ends_with(".appimage") && is_executable(&p) {
add(known_slicer(known, vec![s(&p)], None));
}
}
}
}
// --- macOS ---
fn mac_apps(env: &Env, add: &mut impl FnMut(Slicer)) {
for dir in &env.app_dirs {
for app in entries(dir) {
let name = file_name(&app);
let Some(stem) = name.strip_suffix(".app") else { continue };
let Some(known) = known_for(stem) else { continue };
add(known_slicer(known, vec!["open".into(), "-a".into(), s(&app)], mac_icon(&app)));
}
}
}
/// The icon of a `.app` bundle: the `.icns` named like the app, else the first one.
fn mac_icon(app: &Path) -> Option<IconSource> {
let icons: Vec<PathBuf> = entries(&app.join("Contents/Resources")).into_iter().filter(|p| p.extension().is_some_and(|e| e == "icns")).collect();
let stem = normalize(&file_name(app).replace(".app", ""));
let pick = icons.iter().find(|p| p.file_stem().is_some_and(|n| normalize(&n.to_string_lossy()) == stem)).or_else(|| icons.iter().min());
pick.cloned().map(IconSource::Icns)
}
// --- Windows ---
fn windows_programs(env: &Env, add: &mut impl FnMut(Slicer)) {
for root in &env.install_dirs {
for dir in entries(root).into_iter().filter(|p| p.is_dir()) {
let Some(known) = known_for(&file_name(&dir)) else { continue };
let exe = known.exes.iter().filter(|e| e.ends_with(".exe")).flat_map(|e| [dir.join(e), dir.join("bin").join(e)]).find(|c| c.is_file());
if let Some(exe) = exe {
add(known_slicer(known, vec![s(&exe)], Some(IconSource::Exe(exe.clone()))));
}
}
}
}
// --- custom programs and launching ---
/// `name` with its first letter in capitals.
fn capitalised(name: &str) -> String {
let mut chars = name.chars();
chars.next().map(|c| c.to_uppercase().chain(chars).collect()).unwrap_or_default()
}
/// A program the user picked: an application bundle, a program file or an AppImage.
pub fn custom(path: &str) -> Option<Slicer> {
let p = Path::new(path);
let name = file_name(p);
let lower = name.to_ascii_lowercase();
let mac_app = lower.ends_with(".app") && p.is_dir();
if !(mac_app || (p.is_file() && is_executable(p))) {
return None;
}
let stem = [".app", ".exe", ".appimage"]
.iter()
.find(|ext| lower.ends_with(**ext))
.and_then(|ext| name.get(..name.len() - ext.len()))
.map_or_else(|| name.clone(), str::to_string);
let name = known_for(&stem).map_or_else(|| capitalised(&stem), |k| k.name.to_string());
let (command, icon) = if mac_app {
(vec!["open".into(), "-a".into(), path.to_string()], mac_icon(p))
} else if lower.ends_with(".exe") {
(vec![path.to_string()], Some(IconSource::Exe(p.to_path_buf())))
} else {
let stem = p.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
(vec![path.to_string()], theme_icon(&Env::current(), &stem))
};
Some(Slicer { id: "custom".into(), name, command, icon })
}
/// A slicer from a command parameter: the id or name of an installed one, else a program path.
pub fn resolve(spec: &str) -> Option<Slicer> {
let wanted = normalize(spec);
if !wanted.is_empty()
&& let Some(found) = detect().into_iter().find(|d| normalize(&d.id) == wanted || normalize(&d.name) == wanted)
{
return Some(found);
}
custom(spec)
}
/// `args` with the file added: at the end, or inside the `@@ … @@` pair of a Flatpak command, which
/// is how Flatpak lets the application see a file (`@@u` would hand it over as a URI).
#[cfg(not(target_arch = "wasm32"))]
fn with_file(args: &[String], path: &str) -> Vec<String> {
let mut out: Vec<String> = args.iter().map(|a| if a == "@@u" { "@@".to_string() } else { a.clone() }).collect();
let marks: Vec<usize> = out.iter().enumerate().filter(|(_, a)| *a == "@@").map(|(i, _)| i).collect();
match marks.as_slice() {
[.., _, last] => out.insert(*last, path.to_string()),
_ => out.push(path.to_string()),
}
out
}
impl Slicer {
/// Start the slicer with `path` loaded; does not wait for it to exit.
pub fn open(&self, path: &str) -> std::result::Result<(), String> {
#[cfg(target_arch = "wasm32")]
{
let _ = path;
Err(format!("{} can't be started from the browser", self.name))
}
#[cfg(not(target_arch = "wasm32"))]
{
use std::process::{Command, Stdio};
let (program, args) = self.command.split_first().ok_or_else(|| format!("{} has no program to start", self.name))?;
Command::new(program)
.args(with_file(args, path))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map(drop)
.map_err(|e| format!("couldn't start {} ({e}); the file is saved at {path}", self.name))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("solidcraft-slicers-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn names_match_known_slicers_but_not_their_companions() {
assert_eq!(known_for("PrusaSlicer").map(|k| k.id), Some("prusa"));
assert_eq!(known_for("Bambu Studio").map(|k| k.id), Some("bambu"));
assert_eq!(known_for("UltiMaker Cura 5.9").map(|k| k.id), Some("cura"));
assert!(known_for("PrusaSlicer G-code Viewer").is_none());
assert!(known_for("Firefox").is_none());
}
#[test]
fn exec_lines_become_commands() {
assert_eq!(parse_exec("prusa-slicer %F"), ["prusa-slicer"]);
assert_eq!(parse_exec(r#""/opt/My Apps/bambu" --x %U"#), ["/opt/My Apps/bambu", "--x"]);
assert_eq!(
parse_exec("flatpak run --branch=stable com.prusa3d.PrusaSlicer @@u %U @@"),
["flatpak", "run", "--branch=stable", "com.prusa3d.PrusaSlicer", "@@u", "@@"]
);
assert!(parse_exec("").is_empty());
}
#[test]
fn the_file_goes_where_the_command_expects_it() {
let v = |s: &str| s.split(' ').map(str::to_string).collect::<Vec<_>>();
assert_eq!(with_file(&v("run --file-forwarding app --x @@u @@"), "/t/a.3mf"), v("run --file-forwarding app --x @@ /t/a.3mf @@"));
assert_eq!(with_file(&v("-a /Applications/X.app"), "/t/a.stl"), v("-a /Applications/X.app /t/a.stl"));
assert_eq!(with_file(&[], "/t/a.stl"), v("/t/a.stl"));
}
#[test]
fn finds_linux_desktop_entries_and_their_icons() {
let root = scratch("linux");
let data = root.join("share");
std::fs::create_dir_all(data.join("applications")).unwrap();
std::fs::create_dir_all(data.join("icons/hicolor/128x128/apps")).unwrap();
std::fs::write(data.join("icons/hicolor/128x128/apps/PrusaSlicer.png"), b"png").unwrap();
std::fs::write(
data.join("applications/a.desktop"),
"[Desktop Entry]\nType=Application\nName=PrusaSlicer\nName[de]=Prusa\nExec=flatpak run com.prusa3d.PrusaSlicer %U\nIcon=PrusaSlicer\n",
)
.unwrap();
std::fs::write(data.join("applications/b.desktop"), "[Desktop Entry]\nName=Firefox\nExec=firefox\n").unwrap();
std::fs::write(data.join("applications/c.desktop"), "[Desktop Entry]\nName=PrusaSlicer G-code Viewer\nExec=prusa-slicer --gcodeviewer\n")
.unwrap();
let found = detect_in(&Env { data_dirs: vec![data.clone()], ..Env::default() });
assert_eq!(found.len(), 1);
assert_eq!(found[0].id, "prusa");
assert_eq!(found[0].command, ["flatpak", "run", "com.prusa3d.PrusaSlicer"]);
assert_eq!(found[0].icon, Some(IconSource::Image(data.join("icons/hicolor/128x128/apps/PrusaSlicer.png"))));
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn finds_mac_bundles_and_windows_folders() {
let root = scratch("mac-win");
let apps = root.join("Applications");
std::fs::create_dir_all(apps.join("BambuStudio.app/Contents/Resources")).unwrap();
std::fs::write(apps.join("BambuStudio.app/Contents/Resources/BambuStudio.icns"), b"x").unwrap();
std::fs::create_dir_all(apps.join("Safari.app")).unwrap();
let prog = root.join("Program Files");
std::fs::create_dir_all(prog.join("Prusa3D")).unwrap();
std::fs::create_dir_all(prog.join("OrcaSlicer")).unwrap();
std::fs::write(prog.join("OrcaSlicer/orca-slicer.exe"), b"x").unwrap();
let found = detect_in(&Env { app_dirs: vec![apps.clone()], install_dirs: vec![prog.clone()], ..Env::default() });
let ids: Vec<&str> = found.iter().map(|f| f.id.as_str()).collect();
assert_eq!(ids, ["bambu", "orca"]);
assert_eq!(found[0].command, ["open", "-a", apps.join("BambuStudio.app").to_str().unwrap()]);
assert!(matches!(found[0].icon, Some(IconSource::Icns(_))));
assert_eq!(found[1].icon, Some(IconSource::Exe(prog.join("OrcaSlicer/orca-slicer.exe"))));
std::fs::remove_dir_all(root).unwrap();
}
#[cfg(unix)]
#[test]
fn finds_programs_on_the_path_and_appimages() {
use std::os::unix::fs::PermissionsExt;
let root = scratch("path");
let bin = root.join("bin");
let apps = root.join("Applications");
std::fs::create_dir_all(&bin).unwrap();
std::fs::create_dir_all(&apps).unwrap();
let exe = bin.join("orca-slicer");
let image = apps.join("PrusaSlicer-2.8.AppImage");
for f in [&exe, &image] {
std::fs::write(f, b"#!/bin/sh\n").unwrap();
std::fs::set_permissions(f, std::fs::Permissions::from_mode(0o755)).unwrap();
}
std::fs::write(bin.join("prusa-slicer"), b"not executable").unwrap();
let found = detect_in(&Env { path_dirs: vec![bin.clone()], program_dirs: vec![apps.clone()], ..Env::default() });
let ids: Vec<&str> = found.iter().map(|f| f.id.as_str()).collect();
assert_eq!(ids, ["prusa", "orca"]);
assert_eq!(found[0].command, [image.to_str().unwrap()]);
// A picked program becomes a custom entry named after it; a missing path is refused.
let c = custom(exe.to_str().unwrap()).unwrap();
assert_eq!((c.id.as_str(), c.name.as_str()), ("custom", "OrcaSlicer"));
assert_eq!(capitalised("mycura"), "Mycura");
assert!(custom(root.join("nope").to_str().unwrap()).is_none());
assert!(custom(root.to_str().unwrap()).is_none());
std::fs::remove_dir_all(root).unwrap();
}
}