app_lib/
open_with_app.rs

1//! Cross-platform **open file with named application** for context menus and the command palette.
2//!
3//! macOS uses `open -a`. Windows and Linux resolve the same human-readable labels the UI uses
4//! (often macOS-oriented) to real executables, then spawn `program path/to/file`.
5
6use std::path::Path;
7
8#[cfg(any(target_os = "windows", target_os = "linux"))]
9use std::path::PathBuf;
10
11/// Opens an existing file with a named application.
12pub fn open_with_application(file_path: &Path, app_name: &str) -> Result<(), String> {
13    let app = app_name.trim();
14    if app.is_empty() {
15        return Err("application name is empty".into());
16    }
17    if !file_path.exists() {
18        return Err(format!("File not found: {}", file_path.display()));
19    }
20
21    #[cfg(target_os = "macos")]
22    {
23        return open_macos(file_path, app);
24    }
25    #[cfg(target_os = "windows")]
26    {
27        return open_windows(file_path, app);
28    }
29    #[cfg(target_os = "linux")]
30    {
31        return open_linux(file_path, app);
32    }
33    #[cfg(not(any(
34        target_os = "macos",
35        target_os = "linux",
36        target_os = "windows"
37    )))]
38    {
39        let _ = (file_path, app);
40        Err("open_with_app is not supported on this platform".into())
41    }
42}
43
44#[cfg(target_os = "macos")]
45fn open_macos(file_path: &Path, app_name: &str) -> Result<(), String> {
46    let output = std::process::Command::new("open")
47        .args(["-a", app_name, &file_path.to_string_lossy()])
48        .output()
49        .map_err(|e| e.to_string())?;
50    if !output.status.success() {
51        let stderr = String::from_utf8_lossy(&output.stderr);
52        return Err(format!(
53            "Could not open with {}: {}",
54            app_name,
55            stderr.trim()
56        ));
57    }
58    Ok(())
59}
60
61#[cfg(target_os = "windows")]
62fn open_windows(file_path: &Path, app_name: &str) -> Result<(), String> {
63    if matches!(app_name, "GarageBand" | "Logic Pro") {
64        return Err(format!("{app_name} is only available on macOS"));
65    }
66    if app_name == "Preview" {
67        return opener::open(file_path).map_err(|e| e.to_string());
68    }
69    let exe = resolve_windows_executable(app_name)?;
70    std::process::Command::new(&exe)
71        .arg(file_path)
72        .spawn()
73        .map_err(|e| format!("failed to start {}: {e}", exe.display()))?;
74    Ok(())
75}
76
77#[cfg(target_os = "windows")]
78fn resolve_windows_executable(app_name: &str) -> Result<PathBuf, String> {
79    let p = Path::new(app_name);
80    if p.is_file() {
81        return Ok(p.to_path_buf());
82    }
83    if app_name.contains('/') || app_name.contains('\\') {
84        if p.exists() {
85            return Ok(p.to_path_buf());
86        }
87        return Err(format!("application path not found: {app_name}"));
88    }
89
90    match app_name {
91        "TextEdit" => {
92            return where_win("notepad.exe").ok_or_else(|| {
93                "notepad.exe not found (expected on Windows)".into()
94            });
95        }
96        "Music" => {
97            return first_existing_file(&[
98                r"C:\Program Files\Windows Media Player\wmplayer.exe",
99            ])
100            .or_else(|_| vlc_windows());
101        }
102        "QuickTime Player" => return vlc_windows(),
103        "Audacity" => {
104            return where_win("audacity.exe").ok_or_else(|| {
105                "Audacity not found in PATH (install Audacity or pass the full path to Audacity.exe)"
106                    .into()
107            });
108        }
109        "Ableton Live 12 Standard" | "Ableton Live 11 Suite" | "Ableton Live 12 Suite" => {
110            if let Some(exe) = find_ableton_live_exe() {
111                return Ok(exe);
112            }
113            return Err(
114                "Ableton Live executable not found under Program Files or ProgramData".into(),
115            );
116        }
117        "Adobe Acrobat" => return find_adobe_acrobat_windows(),
118        _ => {}
119    }
120
121    if let Some(pb) = where_win(app_name) {
122        return Ok(pb);
123    }
124    if let Some(pb) = where_win(&format!("{app_name}.exe")) {
125        return Ok(pb);
126    }
127
128    Err(format!(
129        "Could not resolve application {app_name:?}. Install the app, add it to PATH, or pass the full path to the .exe."
130    ))
131}
132
133#[cfg(target_os = "windows")]
134fn where_win(name: &str) -> Option<PathBuf> {
135    let output = std::process::Command::new("where").arg(name).output().ok()?;
136    if !output.status.success() {
137        return None;
138    }
139    let stdout = String::from_utf8_lossy(&output.stdout);
140    let line = stdout.lines().next()?.trim();
141    if line.is_empty() {
142        return None;
143    }
144    let pb = PathBuf::from(line);
145    pb.is_file().then_some(pb)
146}
147
148#[cfg(target_os = "windows")]
149fn first_existing_file(paths: &[&str]) -> Result<PathBuf, String> {
150    for s in paths {
151        let p = PathBuf::from(s);
152        if p.is_file() {
153            return Ok(p);
154        }
155    }
156    Err("no candidate executable found".into())
157}
158
159#[cfg(target_os = "windows")]
160fn vlc_windows() -> Result<PathBuf, String> {
161    where_win("vlc.exe").or_else(|| {
162        first_existing_file(&[
163            r"C:\Program Files\VideoLAN\VLC\vlc.exe",
164            r"C:\Program Files (x86)\VideoLAN\VLC\vlc.exe",
165        ])
166        .ok()
167    })
168    .ok_or_else(|| "VLC not found (install VLC or map QuickTime to another player)".into())
169}
170
171#[cfg(target_os = "windows")]
172fn find_ableton_live_exe() -> Option<PathBuf> {
173    let roots = [
174        std::env::var_os("ProgramData").map(PathBuf::from),
175        std::env::var_os("ProgramFiles").map(PathBuf::from),
176    ];
177    for opt in roots.into_iter().flatten() {
178        let ableton = opt.join("Ableton");
179        let Ok(entries) = std::fs::read_dir(&ableton) else {
180            continue;
181        };
182        for e in entries.flatten() {
183            let dir = e.path();
184            if !dir.is_dir() {
185                continue;
186            }
187            let name = dir.file_name()?.to_string_lossy();
188            if !name.contains("Live") {
189                continue;
190            }
191            let program = dir.join("Program");
192            let Ok(files) = std::fs::read_dir(&program) else {
193                continue;
194            };
195            for f in files.flatten() {
196                let path = f.path();
197                if path.is_file() && path.extension().is_some_and(|x| x == "exe") {
198                    let stem = path.file_name()?.to_string_lossy();
199                    if stem.contains("Live") {
200                        return Some(path);
201                    }
202                }
203            }
204        }
205    }
206    None
207}
208
209#[cfg(target_os = "windows")]
210fn find_adobe_acrobat_windows() -> Result<PathBuf, String> {
211    let roots = [
212        std::env::var_os("ProgramFiles").map(PathBuf::from),
213        std::env::var_os("ProgramFiles(x86)").map(PathBuf::from),
214    ];
215    for opt in roots.into_iter().flatten() {
216        let adobe = opt.join("Adobe");
217        let Ok(entries) = std::fs::read_dir(&adobe) else {
218            continue;
219        };
220        for e in entries.flatten() {
221            let dir = e.path();
222            if !dir.is_dir() {
223                continue;
224            }
225            let name = dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
226            if !name.starts_with("Acrobat") && !name.starts_with("Adobe Acrobat") {
227                continue;
228            }
229            let exe = dir.join("Acrobat").join("AcroRd32.exe");
230            if exe.is_file() {
231                return Ok(exe);
232            }
233        }
234    }
235    where_win("AcroRd32.exe").ok_or_else(|| {
236        "Adobe Acrobat Reader not found under Program Files or PATH".into()
237    })
238}
239
240#[cfg(target_os = "linux")]
241fn open_linux(file_path: &Path, app_name: &str) -> Result<(), String> {
242    if matches!(app_name, "GarageBand" | "Logic Pro") {
243        return Err(format!("{app_name} is only available on macOS"));
244    }
245    if app_name == "Preview" {
246        return opener::open(file_path).map_err(|e| e.to_string());
247    }
248    let exe = resolve_linux_executable(app_name)?;
249    std::process::Command::new(&exe)
250        .arg(file_path)
251        .spawn()
252        .map_err(|e| format!("failed to start {}: {e}", exe.display()))?;
253    Ok(())
254}
255
256#[cfg(target_os = "linux")]
257fn which_linux(candidates: &[&str]) -> Option<PathBuf> {
258    for c in candidates {
259        let output = std::process::Command::new("which").arg(c).output().ok()?;
260        if !output.status.success() {
261            continue;
262        }
263        let stdout = String::from_utf8_lossy(&output.stdout);
264        let line = stdout.lines().next()?.trim();
265        if line.is_empty() {
266            continue;
267        }
268        let pb = PathBuf::from(line);
269        if pb.is_file() {
270            return Some(pb);
271        }
272    }
273    None
274}
275
276#[cfg(target_os = "linux")]
277fn resolve_linux_executable(app_name: &str) -> Result<PathBuf, String> {
278    let p = Path::new(app_name);
279    if p.is_file() {
280        return Ok(p.to_path_buf());
281    }
282    if app_name.contains('/') {
283        if p.exists() {
284            return Ok(p.to_path_buf());
285        }
286        return Err(format!("application path not found: {app_name}"));
287    }
288
289    match app_name {
290        "TextEdit" => which_linux(&[
291            "gnome-text-editor",
292            "gedit",
293            "kwrite",
294            "mousepad",
295            "xed",
296            "pluma",
297            "leafpad",
298        ])
299        .ok_or_else(|| {
300            "No text editor found in PATH (install gedit, gnome-text-editor, or similar)".into()
301        }),
302        "Music" => which_linux(&["vlc", "mpv", "audacious", "rhythmbox"]).ok_or_else(|| {
303            "No music player found in PATH (install vlc or mpv)".into()
304        }),
305        "QuickTime Player" => which_linux(&["vlc", "mpv", "totem", "celluloid"]).ok_or_else(|| {
306            "No video player found in PATH (install vlc or mpv)".into()
307        }),
308        "Audacity" => which_linux(&["audacity"]).ok_or_else(|| {
309            "Audacity not found in PATH (install Audacity or pass the full path)".into()
310        }),
311        "Ableton Live 12 Standard" | "Ableton Live 11 Suite" | "Ableton Live 12 Suite" => {
312            which_linux(&["Live", "live", "ableton"]).ok_or_else(|| {
313                "Ableton Live not found in PATH (install Ableton or pass the full path to the Live binary)".into()
314            })
315        }
316        "Adobe Acrobat" => which_linux(&["acroread", "evince", "okular", "atril", "xreader"])
317            .ok_or_else(|| {
318                "PDF viewer not found in PATH (install evince, okular, or Adobe Reader)".into()
319            }),
320        _ => {
321            if let Some(pb) = which_linux(&[app_name]) {
322                return Ok(pb);
323            }
324            Err(format!(
325                "Could not resolve application {app_name:?} in PATH. Pass a full path to the binary."
326            ))
327        }
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[test]
336    fn open_with_errors_on_empty_app_name() {
337        let tmp = std::env::temp_dir().join("audio_haxor_open_with_test_file");
338        let _ = std::fs::write(&tmp, b"x");
339        let r = open_with_application(&tmp, "  ");
340        let _ = std::fs::remove_file(&tmp);
341        assert!(r.is_err());
342    }
343}