use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HandoffApp {
pub name: String,
pub command: String,
pub args: String,
pub icon_path: String,
}
pub const KNOWN_HANDOFF_APPS: &[&str] = &[
"tensaku", "satty", "swappy", "flameshot", "ksnip", "shutter", "pinta", "drawing", ];
#[cfg(target_os = "macos")]
pub const KNOWN_HANDOFF_APPS_MACOS: &[&str] = &[
"CleanShot X",
"Shottr",
"Xnapper",
"Monosnap",
"Annotate",
"Skitch",
"Snagit 2025",
"Snagit 2024",
"Snagit",
"Lightshot Screenshot",
"Droplr",
"CloudApp",
"Preview", ];
pub fn find_installed_apps() -> Vec<HandoffApp> {
#[cfg(target_os = "macos")]
{
find_installed_apps_macos()
}
#[cfg(not(target_os = "macos"))]
{
KNOWN_HANDOFF_APPS
.iter()
.filter_map(|name| lookup_for_binary(Path::new(name)))
.collect()
}
}
#[cfg(target_os = "macos")]
fn find_installed_apps_macos() -> Vec<HandoffApp> {
let dirs = macos_application_dirs();
let mut found: Vec<HandoffApp> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for known in KNOWN_HANDOFF_APPS_MACOS {
for dir in &dirs {
let bundle = dir.join(format!("{known}.app"));
if bundle.is_dir() && seen.insert((*known).to_string()) {
found.push(handoff_for_macos_bundle(&bundle, known));
break;
}
}
}
found
}
#[cfg(target_os = "macos")]
fn macos_application_dirs() -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = vec![
PathBuf::from("/Applications"),
PathBuf::from("/Applications/Setapp"),
PathBuf::from("/System/Applications"),
];
if let Some(home) = std::env::var_os("HOME") {
dirs.push(PathBuf::from(home).join("Applications"));
}
dirs
}
#[cfg(target_os = "macos")]
fn handoff_for_macos_bundle(bundle_path: &Path, display_name: &str) -> HandoffApp {
let bundle_str = bundle_path.to_string_lossy().into_owned();
HandoffApp {
name: display_name.to_string(),
command: "/usr/bin/open".to_string(),
args: format!("-a {} {{file}}", shell_quote_for_template(&bundle_str)),
icon_path: bundle_str,
}
}
#[cfg(target_os = "macos")]
fn shell_quote_for_template(s: &str) -> String {
if s.chars().any(|c| c.is_whitespace()) {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
} else {
s.to_string()
}
}
pub fn lookup_for_binary(bin: &Path) -> Option<HandoffApp> {
#[cfg(target_os = "macos")]
{
if bin.extension().and_then(|e| e.to_str()) == Some("app") && bin.is_dir() {
let display = bin
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "App".to_string());
return Some(handoff_for_macos_bundle(bin, &display));
}
}
let resolved = resolve_binary(bin)?;
let basename = bin
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| resolved.to_string_lossy().into_owned());
if let Some(app) = find_desktop_for_binary(&basename, &resolved) {
return Some(app);
}
Some(HandoffApp {
name: basename,
command: resolved.to_string_lossy().into_owned(),
args: "{file}".to_string(),
icon_path: String::new(),
})
}
pub fn render_args(template: &str, file_path: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut current = String::new();
let mut in_quotes = false;
let mut chars = template.chars().peekable();
while let Some(c) = chars.next() {
match c {
'"' => in_quotes = !in_quotes,
'\\' if in_quotes => {
if let Some(next) = chars.next() {
current.push(next);
}
}
' ' | '\t' if !in_quotes => {
if !current.is_empty() {
out.push(std::mem::take(&mut current));
}
}
_ => current.push(c),
}
}
if !current.is_empty() {
out.push(current);
}
out.into_iter()
.map(|t| t.replace("{file}", file_path))
.collect()
}
pub fn resolve_icon(name_or_path: &str) -> String {
if name_or_path.is_empty() {
return String::new();
}
let p = Path::new(name_or_path);
if p.is_absolute() {
return if p.exists() {
name_or_path.to_string()
} else {
String::new()
};
}
let home = std::env::var_os("HOME").map(PathBuf::from);
let mut roots: Vec<PathBuf> = Vec::new();
if let Some(h) = home.as_ref() {
roots.push(h.join(".local/share/icons"));
roots.push(h.join(".icons"));
}
if let Some(extra) = std::env::var_os("XDG_DATA_DIRS") {
for entry in std::env::split_paths(&extra) {
roots.push(entry.join("icons"));
}
} else {
roots.push(PathBuf::from("/usr/local/share/icons"));
roots.push(PathBuf::from("/usr/share/icons"));
}
roots.push(PathBuf::from("/usr/share/pixmaps"));
let sizes = [
"scalable", "512x512", "256x256", "192x192", "128x128", "96x96", "64x64", "48x48",
];
let exts = ["svg", "png"];
for root in &roots {
for size in &sizes {
for ext in &exts {
let p = root
.join("hicolor")
.join(size)
.join("apps")
.join(format!("{name_or_path}.{ext}"));
if p.exists() {
return p.to_string_lossy().into_owned();
}
}
}
for ext in &exts {
let p = root.join(format!("{name_or_path}.{ext}"));
if p.exists() {
return p.to_string_lossy().into_owned();
}
}
}
String::new()
}
fn resolve_binary(bin: &Path) -> Option<PathBuf> {
if bin.is_absolute() {
return bin.exists().then(|| bin.to_path_buf());
}
if let Some(path) = std::env::var_os("PATH") {
for dir in std::env::split_paths(&path) {
let candidate = dir.join(bin);
if candidate.exists() {
return Some(candidate);
}
}
}
None
}
fn xdg_application_dirs() -> Vec<PathBuf> {
let home = std::env::var_os("HOME").map(PathBuf::from);
let xdg_data_home = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| home.as_ref().map(|h| h.join(".local/share")));
let mut roots: Vec<PathBuf> = Vec::new();
if let Some(p) = xdg_data_home {
roots.push(p);
}
if let Some(extra) = std::env::var_os("XDG_DATA_DIRS") {
for entry in std::env::split_paths(&extra) {
roots.push(entry);
}
} else {
roots.push(PathBuf::from("/usr/local/share"));
roots.push(PathBuf::from("/usr/share"));
}
roots.into_iter().map(|r| r.join("applications")).collect()
}
fn find_desktop_for_binary(basename: &str, resolved: &Path) -> Option<HandoffApp> {
for dir in xdg_application_dirs() {
let direct = dir.join(format!("{basename}.desktop"));
if let Some(app) = parse_desktop(&direct, basename, resolved) {
return Some(app);
}
}
for dir in xdg_application_dirs() {
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
let p = entry.path();
if p.extension().and_then(|s| s.to_str()) != Some("desktop") {
continue;
}
if let Some(app) = parse_desktop(&p, basename, resolved) {
return Some(app);
}
}
}
None
}
fn parse_desktop(path: &Path, expected_basename: &str, resolved_bin: &Path) -> Option<HandoffApp> {
let text = std::fs::read_to_string(path).ok()?;
let mut in_entry = false;
let mut name: Option<String> = None;
let mut exec: Option<String> = None;
let mut icon: Option<String> = None;
let mut hidden = false;
for line in text.lines() {
let line = line.trim();
if line.starts_with('[') {
in_entry = line.eq_ignore_ascii_case("[Desktop Entry]");
continue;
}
if !in_entry {
continue;
}
if let Some(rest) = line.strip_prefix("Name=") {
if name.is_none() {
name = Some(rest.to_string());
}
} else if let Some(rest) = line.strip_prefix("Exec=") {
exec = Some(rest.to_string());
} else if let Some(rest) = line.strip_prefix("Icon=") {
icon = Some(rest.to_string());
} else if let Some(rest) = line.strip_prefix("Hidden=") {
hidden = matches!(rest.trim().to_ascii_lowercase().as_str(), "true" | "1");
}
}
if hidden {
return None;
}
let exec = exec?;
let first = exec.split_whitespace().next()?.trim_matches('"');
let first_path = Path::new(first);
let first_base = first_path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| first.to_string());
let matches = first_base == expected_basename || first_path == resolved_bin;
if !matches {
return None;
}
let mut argv: Vec<String> = Vec::new();
for tok in exec.split_whitespace().skip(1) {
match tok {
"%f" | "%F" | "%u" | "%U" => argv.push("{file}".to_string()),
"%i" | "%c" | "%k" => {}
_ => argv.push(tok.to_string()),
}
}
if !argv.iter().any(|a| a == "{file}") {
argv.push("{file}".to_string());
}
let args = argv.join(" ");
let icon_path = icon.map(|n| resolve_icon(&n)).unwrap_or_default();
Some(HandoffApp {
name: name.unwrap_or_else(|| expected_basename.to_string()),
command: resolved_bin.to_string_lossy().into_owned(),
args,
icon_path,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_args_substitutes_file_token() {
let argv = render_args("--filename {file} --output {file}", "/tmp/x.png");
assert_eq!(
argv,
vec!["--filename", "/tmp/x.png", "--output", "/tmp/x.png"]
);
}
#[test]
fn render_args_handles_quoted_tokens() {
let argv = render_args("\"--with space\" {file}", "/tmp/x.png");
assert_eq!(argv, vec!["--with space", "/tmp/x.png"]);
}
}
#[cfg(all(test, target_os = "macos"))]
mod macos_tests {
use super::*;
#[test]
fn lists_installed_macos_apps_includes_preview() {
let apps = find_installed_apps();
assert!(
apps.iter().any(|a| a.name == "Preview"),
"Preview should be detected on macOS; found: {:?}",
apps.iter().map(|a| &a.name).collect::<Vec<_>>()
);
}
#[test]
fn browse_to_app_bundle_yields_open_invocation() {
let preview = Path::new("/System/Applications/Preview.app");
let app = lookup_for_binary(preview).expect("Preview bundle should resolve");
assert_eq!(app.name, "Preview");
assert_eq!(app.command, "/usr/bin/open");
assert!(
app.args.contains("Preview.app") && app.args.contains("{file}"),
"args should reference the bundle and include {{file}}: {}",
app.args
);
}
#[test]
fn shell_quote_wraps_paths_with_spaces() {
assert_eq!(
shell_quote_for_template("/Applications/Shottr.app"),
"/Applications/Shottr.app"
);
assert_eq!(
shell_quote_for_template("/Applications/Setapp/CleanShot X.app"),
"\"/Applications/Setapp/CleanShot X.app\""
);
}
}