Files
proton-drive-linux/crates/pdfs-core/src/opener.rs
T
nvrl 48c8910e7b fix(gui,core): ask the mount off the main thread when opening, reap children
Opening a file or a location stat'ed the mount on the GTK main thread, so
a hung mount hung the app. The browser now uses the launcher's bounded
worker check (`present_within`, 2 s), and opening a location runs on a
thread. Every child the GUI starts is also waited for on a thread, so it
no longer stays a zombie (B243).
2026-10-16 12:07:48 +02:00

644 lines
22 KiB
Rust

//! How a file chosen in the prompt (or the browser) is actually opened.
//!
//! The default is `xdg-open`, which routes through the desktop's MIME
//! associations. That is the right answer for a GNOME session and the wrong one
//! for a tiling-WM setup where "open a text file" means "run `nvim` inside my
//! terminal" — there is no desktop file for that, and pointing `xdg-open` at a
//! terminal editor globally breaks every graphical caller.
//!
//! So the rules live in `config.json` under `open_with`: an ordered list of
//! name/class patterns mapped to argv, each optionally wrapped in a terminal.
//! First match wins; anything unmatched falls through to `default` (itself
//! `xdg-open` unless overridden). Nothing here is required — an absent
//! `open_with` reproduces the previous behaviour exactly.
//!
//! ```json
//! "open_with": {
//! "terminal": ["alacritty", "-e"],
//! "rules": [
//! { "match": ["@text", "*.rs", "*.toml"], "command": ["nvim"], "terminal": true },
//! { "match": ["*.png"], "command": ["imv"] }
//! ]
//! }
//! ```
use std::ffi::OsStr;
use std::os::unix::process::CommandExt;
use std::path::Path;
use std::process::{Command, Stdio};
use serde::{Deserialize, Serialize};
/// Environment override for the terminal used by `"terminal": true` rules.
/// Checked before `$TERMINAL` so a support case can redirect it without editing
/// config.
pub const TERMINAL_ENV: &str = "PDFS_TERMINAL";
/// Terminals we know how to hand a command line to, most-preferred first, with
/// the argument that means "run this, don't parse it as options". `kitty` and
/// `wezterm start --` take the command positionally.
const KNOWN_TERMINALS: [(&str, &[&str]); 11] = [
("alacritty", &["-e"]),
("foot", &["-e"]),
("kitty", &[]),
("ghostty", &["-e"]),
("wezterm", &["start", "--"]),
("gnome-terminal", &["--"]),
("konsole", &["-e"]),
("xfce4-terminal", &["-x"]),
("urxvt", &["-e"]),
("st", &["-e"]),
("xterm", &["-e"]),
];
/// The opener policy as it appears in `config.json`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OpenWith {
/// Terminal argv used by rules with `"terminal": true`, e.g.
/// `["alacritty", "-e"]`. Unset means "resolve one" — see
/// [`resolve_terminal`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub terminal: Option<Vec<String>>,
/// What to run when no rule matches. Unset means `xdg-open`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<Vec<String>>,
/// Ordered; first match wins.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rules: Vec<OpenRule>,
}
/// One `pattern set -> command` mapping.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OpenRule {
/// Patterns this rule answers to. Either a shell-style glob against the file
/// name (`*.md`, `notes-*.txt`) or one of the classes `@dir`, `@text`,
/// `@document`, `@image`, `@media`, `@any`. Matching is case-insensitive.
#[serde(rename = "match", default)]
pub patterns: Vec<String>,
/// argv to run. A `{}` token is replaced by the path; without one the path
/// is appended. `$VAR`/`${VAR}` tokens expand from the environment, so
/// `["$EDITOR"]` follows the user's editor.
#[serde(default)]
pub command: Vec<String>,
/// Wrap [`command`](Self::command) in the terminal emulator.
#[serde(default)]
pub terminal: bool,
}
impl OpenWith {
/// The argv that opens `path`, terminal wrapping included. Never fails: an
/// unmatched path — or a rule whose terminal cannot be resolved — degrades
/// to the default opener rather than doing nothing.
pub fn command_for(&self, path: &Path, is_dir: bool) -> Vec<String> {
let name = path
.file_name()
.and_then(OsStr::to_str)
.unwrap_or_default()
.to_string();
self.command_for_named(path, &name, is_dir)
}
/// [`command_for`](Self::command_for) with the rules matched against `name`
/// instead of the last component of `path`.
///
/// A materialised Drive file lives in the content cache under its content
/// hash — no extension, no original name — so matching the on-disk path
/// classifies every download as "unknown" and hands it to `xdg-open`, which
/// sniffs the bytes and picks whatever claims `text/plain` (LibreOffice, for
/// a Markdown file). The rules are about *what the user opened*, so the
/// caller passes that name and the argv still gets the real path.
pub fn command_for_named(&self, path: &Path, name: &str, is_dir: bool) -> Vec<String> {
for rule in &self.rules {
if rule.command.is_empty() {
continue;
}
if !rule.patterns.iter().any(|p| matches(p, name, is_dir)) {
continue;
}
let argv = expand(&rule.command, path);
if !rule.terminal {
return argv;
}
match self.terminal_argv() {
Some(mut terminal) => {
terminal.extend(argv);
return terminal;
}
// A terminal rule with no terminal to run it in would silently
// launch a TUI with no tty. Fall through to the desktop instead.
None => {
tracing::warn!(
"no terminal found for a terminal open rule; using the default opener"
);
break;
}
}
}
let fallback = self
.default
.clone()
.unwrap_or_else(|| vec!["xdg-open".to_string()]);
expand(&fallback, path)
}
/// The configured terminal, or a resolved one.
fn terminal_argv(&self) -> Option<Vec<String>> {
match &self.terminal {
Some(argv) if !argv.is_empty() => Some(argv.clone()),
_ => resolve_terminal(),
}
}
}
/// Launch `path` under `policy`. Fire-and-forget: the child outlives us, and a
/// spawn failure is logged rather than propagated — every caller is a UI action
/// with nowhere useful to report to.
pub fn open(policy: &OpenWith, path: &Path, is_dir: bool) {
let argv = policy.command_for(path, is_dir);
spawn(&argv, path);
}
/// [`open`] for a path whose own file name says nothing about its type — see
/// [`OpenWith::command_for_named`].
pub fn open_named(policy: &OpenWith, path: &Path, name: &str, is_dir: bool) {
let argv = policy.command_for_named(path, name, is_dir);
spawn(&argv, path);
}
/// Launch `argv`, detached from whatever is opening it.
///
/// The child gets its own process group and no inherited stdio, because the
/// caller may be about to exit and take its terminal with it: `pdfs-prompt
/// --fzf` runs inside a terminal it spawned for itself, and that terminal
/// closes the moment the selection is made. A child left in the terminal's
/// foreground process group is sent `SIGHUP` when the pty hangs up, so
/// "open this folder in a terminal" would open a window that dies immediately
/// — which is exactly what it did. Nothing here needs the caller's stdio in the
/// first place: every `argv` is another application, and a terminal one is
/// wrapped in its own emulator by the `terminal` rule flag.
fn spawn(argv: &[String], path: &Path) {
let Some((program, args)) = argv.split_first() else {
return;
};
tracing::info!(path = %path.display(), command = ?argv, "opening");
let spawned = Command::new(program)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0)
.spawn();
match spawned {
Ok(child) => reap(child),
Err(e) => tracing::error!(command = ?argv, "open failed: {e}"),
}
}
/// Wait for `child` on a thread of its own. A child nobody waits for stays a
/// zombie for as long as its parent lives, which for the app and the tray is
/// the whole session.
pub fn reap(mut child: std::process::Child) {
std::thread::spawn(move || {
let _ = child.wait();
});
}
/// [`open`] with the policy from the user's `config.json`, read once per
/// process. For callers that open the occasional path and have no reason to
/// carry a policy around; long-lived windows should hold their own copy.
pub fn open_default(path: &Path, is_dir: bool) {
open(default_policy(), path, is_dir);
}
/// [`open_default`] matching the rules against `name` — see
/// [`OpenWith::command_for_named`].
pub fn open_default_named(path: &Path, name: &str, is_dir: bool) {
open_named(default_policy(), path, name, is_dir);
}
fn default_policy() -> &'static OpenWith {
static POLICY: std::sync::OnceLock<OpenWith> = std::sync::OnceLock::new();
POLICY.get_or_init(|| {
crate::config::AppDirs::new()
.map(|dirs| dirs.load_config().resolved_open_with())
.unwrap_or_default()
})
}
/// `$PDFS_TERMINAL`, then `$TERMINAL`, then the first [`KNOWN_TERMINALS`] entry
/// on `PATH`. An env value may be a full command line (`"foot -e"`), in which
/// case it is used verbatim; a bare program name gains that terminal's "run
/// this" argument so `TERMINAL=alacritty` does the obvious thing.
pub fn resolve_terminal() -> Option<Vec<String>> {
for key in [TERMINAL_ENV, "TERMINAL"] {
let Ok(value) = std::env::var(key) else {
continue;
};
let argv: Vec<String> = value.split_whitespace().map(str::to_string).collect();
match argv.len() {
0 => continue,
1 => return Some(with_exec_flag(&argv[0])),
_ => return Some(argv),
}
}
KNOWN_TERMINALS
.iter()
.find(|(program, _)| on_path(program))
.map(|(program, _)| with_exec_flag(program))
}
/// A bare terminal name plus whatever it needs before a command.
fn with_exec_flag(program: &str) -> Vec<String> {
let base = Path::new(program)
.file_name()
.and_then(OsStr::to_str)
.unwrap_or(program);
let mut argv = vec![program.to_string()];
let flags = KNOWN_TERMINALS
.iter()
.find(|(known, _)| *known == base)
.map_or(&["-e"][..], |(_, flags)| *flags);
argv.extend(flags.iter().map(|f| (*f).to_string()));
argv
}
fn on_path(program: &str) -> bool {
let Some(path) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&path).any(|dir| dir.join(program).is_file())
}
/// Substitute `{}` (or append) the path, and expand `$VAR` tokens. Tokens that
/// expand to nothing are dropped, so `["$EDITOR"]` with no `EDITOR` set leaves
/// an empty argv and falls back at the call site.
fn expand(argv: &[String], path: &Path) -> Vec<String> {
let target = path.display().to_string();
let mut out = Vec::with_capacity(argv.len() + 1);
let mut substituted = false;
for token in argv {
if token.contains("{}") {
out.push(token.replace("{}", &target));
substituted = true;
continue;
}
match expand_env(token) {
Some(value) => out.push(value),
None => continue,
}
}
if out.is_empty() {
return vec!["xdg-open".to_string(), target];
}
if !substituted {
out.push(target);
}
out
}
/// `$VAR` / `${VAR}` for a whole token, `None` when the variable is unset or
/// empty so the token drops out. A token that is not a variable reference is
/// returned unchanged; this is deliberately not a shell — no word splitting, no
/// quoting rules.
fn expand_env(token: &str) -> Option<String> {
let Some(name) = token
.strip_prefix("${")
.and_then(|rest| rest.strip_suffix('}'))
.or_else(|| token.strip_prefix('$'))
else {
return Some(token.to_string());
};
if name.is_empty() {
return Some(token.to_string());
}
// Follow `$EDITOR`'s convention of preferring the graphical-capable one.
let value = if name == "EDITOR" {
std::env::var("VISUAL")
.ok()
.filter(|v| !v.is_empty())
.or_else(|| std::env::var("EDITOR").ok())
} else {
std::env::var(name).ok()
};
value.filter(|v| !v.is_empty())
}
/// Whether `pattern` — a glob or an `@class` — matches this name.
fn matches(pattern: &str, name: &str, is_dir: bool) -> bool {
let name_lower = name.to_lowercase();
match pattern.to_lowercase().as_str() {
"@any" => true,
"@dir" => is_dir,
"@text" => !is_dir && class_text(&name_lower),
"@document" => !is_dir && class_document(&name_lower),
"@image" => !is_dir && class_image(&name_lower),
"@media" => !is_dir && class_media(&name_lower),
other if other.starts_with('@') => false,
other => glob_match(other, &name_lower),
}
}
fn extension(name: &str) -> &str {
Path::new(name)
.extension()
.and_then(OsStr::to_str)
.unwrap_or_default()
}
/// Plain-text-ish files a terminal editor is a sensible default for. Extensions
/// only: the daemon's hits may name files that are not on this machine yet, so
/// there is nothing to sniff.
fn class_text(name: &str) -> bool {
matches!(
extension(name),
"txt"
| "md"
| "markdown"
| "rst"
| "org"
| "log"
| "conf"
| "cfg"
| "ini"
| "toml"
| "yaml"
| "yml"
| "json"
| "xml"
| "csv"
| "tsv"
| "env"
| "sh"
| "bash"
| "zsh"
| "fish"
| "rs"
| "py"
| "go"
| "c"
| "h"
| "cpp"
| "hpp"
| "js"
| "ts"
| "tsx"
| "jsx"
| "css"
| "html"
| "sql"
| "lua"
| "vim"
| "nix"
| "patch"
| "diff"
) || name == "makefile"
|| name == "dockerfile"
}
fn class_document(name: &str) -> bool {
matches!(
extension(name),
"pdf"
| "doc"
| "docx"
| "odt"
| "rtf"
| "xls"
| "xlsx"
| "ods"
| "ppt"
| "pptx"
| "odp"
| "epub"
) || class_text(name)
}
fn class_image(name: &str) -> bool {
matches!(
extension(name),
"png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "svg" | "avif" | "heic" | "tiff"
)
}
fn class_media(name: &str) -> bool {
matches!(
extension(name),
"mp4"
| "mkv"
| "webm"
| "mov"
| "avi"
| "mp3"
| "flac"
| "wav"
| "ogg"
| "opus"
| "m4a"
| "aac"
)
}
/// `*` and `?` globbing, no character classes and no path semantics — patterns
/// are matched against a bare file name. Iterative with backtracking so a
/// pathological pattern cannot blow the stack.
fn glob_match(pattern: &str, name: &str) -> bool {
let pattern: Vec<char> = pattern.chars().collect();
let name: Vec<char> = name.chars().collect();
let (mut p, mut n) = (0usize, 0usize);
// Where to resume if the current `*` turns out to have matched too little.
let (mut star, mut resume) = (None, 0usize);
while n < name.len() {
match pattern.get(p) {
Some('*') => {
star = Some(p);
resume = n;
p += 1;
}
Some('?') => {
p += 1;
n += 1;
}
Some(c) if *c == name[n] => {
p += 1;
n += 1;
}
_ => match star {
Some(s) => {
p = s + 1;
resume += 1;
n = resume;
}
None => return false,
},
}
}
pattern[p..].iter().all(|c| *c == '*')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_reaped_child_does_not_stay_a_zombie() {
let child = std::process::Command::new("true").spawn().unwrap();
let proc_entry = std::path::PathBuf::from(format!("/proc/{}", child.id()));
reap(child);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while proc_entry.exists() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(
!proc_entry.exists(),
"the child is still in the process table"
);
}
fn rule(patterns: &[&str], command: &[&str], terminal: bool) -> OpenRule {
OpenRule {
patterns: patterns.iter().map(|p| (*p).to_string()).collect(),
command: command.iter().map(|c| (*c).to_string()).collect(),
terminal,
}
}
#[test]
fn a_cache_blob_is_classified_by_the_name_it_was_opened_as() {
let policy = OpenWith {
terminal: Some(vec!["alacritty".into(), "-e".into()]),
default: None,
rules: vec![rule(&["@text"], &["nvim"], true)],
};
// What the daemon hands back: content-hash name, no extension. Matched
// on its own it is "unknown" and would land in xdg-open's lap.
let blob = Path::new("/home/me/.cache/proton-drive-linux/content/364d1d8cc8e6");
assert_eq!(
policy.command_for(blob, false),
vec![
"xdg-open",
"/home/me/.cache/proton-drive-linux/content/364d1d8cc8e6"
]
);
assert_eq!(
policy.command_for_named(blob, "ONBOARDING.md", false),
vec![
"alacritty",
"-e",
"nvim",
"/home/me/.cache/proton-drive-linux/content/364d1d8cc8e6"
]
);
}
#[test]
fn an_empty_policy_is_xdg_open() {
let policy = OpenWith::default();
assert_eq!(
policy.command_for(Path::new("/tmp/a.md"), false),
vec!["xdg-open".to_string(), "/tmp/a.md".to_string()]
);
}
#[test]
fn a_terminal_rule_wraps_the_command() {
let policy = OpenWith {
terminal: Some(vec!["alacritty".into(), "-e".into()]),
default: None,
rules: vec![rule(&["@text"], &["nvim"], true)],
};
assert_eq!(
policy.command_for(Path::new("/tmp/notes.md"), false),
vec!["alacritty", "-e", "nvim", "/tmp/notes.md"]
);
}
#[test]
fn the_first_matching_rule_wins_and_globs_are_case_insensitive() {
let policy = OpenWith {
terminal: None,
default: None,
rules: vec![
rule(&["*.PNG"], &["imv"], false),
rule(&["@image"], &["eog"], false),
],
};
assert_eq!(
policy.command_for(Path::new("/tmp/shot.png"), false),
vec!["imv", "/tmp/shot.png"]
);
}
#[test]
fn a_placeholder_positions_the_path_instead_of_appending_it() {
let policy = OpenWith {
terminal: None,
default: None,
rules: vec![rule(&["@any"], &["sh", "-c", "cat {} | less"], false)],
};
assert_eq!(
policy.command_for(Path::new("/tmp/a.log"), false),
vec!["sh", "-c", "cat /tmp/a.log | less"]
);
}
#[test]
fn directories_only_match_the_dir_class() {
let policy = OpenWith {
terminal: None,
default: None,
rules: vec![
rule(&["@text"], &["nvim"], false),
rule(&["@dir"], &["nautilus"], false),
],
};
assert_eq!(
policy.command_for(Path::new("/tmp/notes.md"), true),
vec!["nautilus", "/tmp/notes.md"]
);
}
#[test]
fn a_rule_that_cannot_find_a_terminal_falls_back_to_the_default() {
// A terminal that is spelled out in config is always honoured; this is
// the other path — resolution must not invent one from the test host.
let policy = OpenWith {
terminal: Some(Vec::new()),
default: Some(vec!["gio".into(), "open".into()]),
rules: vec![rule(&["@text"], &["nvim"], true)],
};
let argv = policy.command_for(Path::new("/tmp/a.md"), false);
// Either a terminal exists on this machine (wrapped nvim) or it does
// not (the default). Both are correct; what must not happen is a bare
// `nvim` with no tty.
assert!(
argv == vec!["gio", "open", "/tmp/a.md"]
|| argv.ends_with(&["nvim".to_string(), "/tmp/a.md".to_string()])
);
}
#[test]
fn globs_backtrack() {
assert!(glob_match("*.md", "notes.md"));
assert!(glob_match("*notes*.md", "my-notes-2024.md"));
assert!(glob_match("a?c.txt", "abc.txt"));
assert!(!glob_match("*.md", "notes.mdx"));
assert!(!glob_match("a?c.txt", "ac.txt"));
assert!(glob_match("*", "anything"));
}
#[test]
fn a_bare_terminal_name_gains_its_exec_flag() {
assert_eq!(with_exec_flag("alacritty"), vec!["alacritty", "-e"]);
assert_eq!(with_exec_flag("kitty"), vec!["kitty"]);
assert_eq!(
with_exec_flag("/usr/bin/wezterm"),
vec!["/usr/bin/wezterm", "start", "--"]
);
// Unknown terminals get the near-universal `-e`.
assert_eq!(with_exec_flag("mystery-term"), vec!["mystery-term", "-e"]);
}
}