feat: Open multiple files with one/multiple apps

Opening multiple selected paths should:
* Launch one app that supports all of the file paths e.g. an image
  viewer that allows cycling through the selected images
* Launch multiple instances of an app if the app doesn't handle multiple
  URLs, such as nsxiv
* Launch different apps for a heterogeneous mix of URLs while taking
  into account the two rules above. For example, launching a mix of
  paths of images, videos, and text should open the correct apps
This commit is contained in:
Josh Megnauth 2025-03-22 02:37:54 -04:00
parent a89ffb0693
commit 42cad87825
No known key found for this signature in database
GPG key ID: 70813183462EFAD3
3 changed files with 271 additions and 137 deletions

View file

@ -6,32 +6,123 @@ use cosmic::desktop;
use cosmic::widget;
pub use mime_guess::Mime;
use std::{
cmp::Ordering, collections::HashMap, env, ffi::OsString, fs, io, path::PathBuf, process,
cmp::Ordering,
collections::HashMap,
env,
ffi::OsStr,
fs, io,
path::{Path, PathBuf},
process,
time::Instant,
};
pub fn exec_to_command(exec: &str, path_opt: Option<OsString>) -> Option<process::Command> {
let args_vec: Vec<String> = shlex::split(exec)?;
let mut args = args_vec.iter();
let mut command = process::Command::new(args.next()?);
for arg in args {
if arg.starts_with('%') {
match arg.as_str() {
"%f" | "%F" | "%u" | "%U" => {
if let Some(path) = &path_opt {
command.arg(path);
}
// Supported exec key field codes
const EXEC_HANDLERS: [&str; 4] = ["%f", "%F", "%u", "%U"];
// Deprecated field codes. The spec advises to ignore these handlers.
const DEPRECATED_HANDLERS: [&str; 6] = ["%d", "%D", "%n", "%N", "%v", "%m"];
pub fn exec_to_command(
exec: &str,
path_opt: &[impl AsRef<OsStr>],
) -> Option<Vec<process::Command>> {
let args_vec = shlex::split(exec)?;
let program = args_vec.first()?;
// Base Command instance(s)
// 1. We may need to launch multiple of the same process.
// 2. Each of those processes will need to be passed args from exec.
// 3. Each of those args may appear in any order.
//
// So, we'll go through exec in two passes. The first pass handles paths (%f etc) while the
// second passes extra, non-% args to each processes.
//
// While it'd be marginally faster to process everything in one pass, that's problematic:
// 1. path_opt may need to be cloned because it may be moved on each iteration (borrowck
// doesn't know we'll only use it once)
// 2. We have to keep track of which modifier (%f etc) we've used/seen already
// 3. We have to keep track of which processes received non-modifier args which gets messy fast
// 4. `exec` is likely small so looping over it twice is not a big deal
let args_handler = args_vec
.iter()
.find(|arg| EXEC_HANDLERS.contains(&arg.as_str()));
// msrv
// .inspect(|handler| log::trace!("Found paths handler: {handler} for exec: {exec}"));
let mut processes = match args_handler.map(|s| s.as_str()) {
Some("%f") => {
let mut processes = Vec::with_capacity(path_opt.len());
for path in path_opt.iter().map(AsRef::as_ref) {
// TODO: %f and %F need to handle non-file URLs (see spec)
if from_file_or_dir(path).is_none() {
log::warn!("Desktop file expects a file path instead of a URL: {path:?}");
}
_ => {
log::warn!("unsupported Exec code {:?} in {:?}", arg, exec);
return None;
// Passing multiple paths to %f should open an instance per path
let mut process = process::Command::new(program);
process.arg(path);
processes.push(process);
}
processes
}
Some("%F") => {
// TODO: %f and %F need to handle non-file URLs (see spec)
for invalid in path_opt
.iter()
.map(AsRef::as_ref)
.filter(|path| from_file_or_dir(path).is_none())
{
log::warn!("Desktop file expects a file path instead of a URL: {invalid:?}");
}
// Launch one instance with all args
let mut process = process::Command::new(program);
process.args(path_opt);
vec![process]
}
Some("%u") => path_opt
.iter()
.map(|path| {
let mut process = process::Command::new(program);
process.arg(path);
process
})
.collect(),
Some("%U") => {
let mut process = process::Command::new(program);
process.args(path_opt);
vec![process]
}
Some(invalid) => unreachable!("All valid variants were checked; got: {invalid}"),
None => vec![process::Command::new(program)],
};
// Pass 2: Add every argument that's not % to each process
for arg in args_vec.into_iter().skip(1) {
match arg.as_str() {
unsupported
if arg.starts_with('%')
&& !EXEC_HANDLERS.contains(&unsupported)
&& !DEPRECATED_HANDLERS.contains(&unsupported) =>
{
log::warn!("unsupported Exec code {:?} in {:?}", unsupported, exec);
return None;
}
arg => {
for process in &mut processes {
process.arg(arg);
}
}
} else {
command.arg(arg);
}
}
Some(command)
Some(processes)
}
fn from_file_or_dir(path: impl AsRef<Path>) -> Option<url::Url> {
url::Url::from_file_path(&path)
.ok()
.or_else(|| url::Url::from_directory_path(&path).ok())
}
#[derive(Clone, Debug)]
@ -46,7 +137,7 @@ pub struct MimeApp {
impl MimeApp {
//TODO: move to libcosmic, support multiple files
pub fn command(&self, path_opt: Option<OsString>) -> Option<process::Command> {
pub fn command<O: AsRef<OsStr>>(&self, path_opt: &[O]) -> Option<Vec<process::Command>> {
exec_to_command(self.exec.as_deref()?, path_opt)
}
}