272 lines
8.3 KiB
Rust

//! Utilities for working with freedesktop MIME / mimeapps.list.
//!
//! NOTE:
//! `mimeapps.list` is not a strict INI file.
//! We intentionally perform line-based, round-trip edits instead of using
//! an INI parser to preserve comments, ordering, duplicate keys and desktop
//! environment quirks.
use anyhow::Result;
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::PathBuf;
pub fn ensure_mimeapps_entries(desktop_file: &str, schemes: &[&str]) -> Result<()> {
let Some(path) = mimeapps_list_path() else {
return Ok(());
};
if !path.exists() {
return Ok(());
}
let original = fs::read_to_string(&path)?;
let mut changed = false;
let mut output_lines: Vec<String> = Vec::new();
let mut current_section: Option<SectionKind> = None;
let mut section_buffer: Vec<String> = Vec::new();
let mut default_present = false;
for line in original.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
if let Some(kind) = current_section.take() {
flush_section(
&mut output_lines,
&mut section_buffer,
desktop_file,
schemes,
kind,
&mut changed,
);
}
if trimmed.eq_ignore_ascii_case("[Default Applications]") {
default_present = true;
current_section = Some(SectionKind::DefaultApplications);
output_lines.push("[Default Applications]".to_string());
continue;
} else if trimmed.eq_ignore_ascii_case("[Added Associations]") {
current_section = Some(SectionKind::AddedAssociations);
output_lines.push("[Added Associations]".to_string());
continue;
}
}
if current_section.is_some() {
section_buffer.push(line.to_string());
} else {
output_lines.push(line.to_string());
}
}
if let Some(kind) = current_section.take() {
flush_section(
&mut output_lines,
&mut section_buffer,
desktop_file,
schemes,
kind,
&mut changed,
);
}
if !default_present {
changed = true;
if output_lines.last().is_some_and(|line| !line.is_empty()) {
output_lines.push(String::new());
}
output_lines.push("[Default Applications]".to_string());
for &scheme in schemes {
output_lines.push(format!("x-scheme-handler/{scheme}={desktop_file};"));
}
}
if !changed {
return Ok(());
}
let mut new_content = output_lines.join("\n");
if !new_content.ends_with('\n') {
new_content.push('\n');
}
fs::write(path, new_content)?;
Ok(())
}
fn mimeapps_list_path() -> Option<PathBuf> {
let config_path = env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| {
env::var_os("HOME").map(PathBuf::from).map(|mut home| {
home.push(".config");
home
})
})
.map(|mut dir| {
dir.push("mimeapps.list");
dir
});
if config_path.as_ref().is_some_and(|path| path.exists()) {
return config_path;
}
let data_path = env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| {
env::var_os("HOME").map(PathBuf::from).map(|mut home| {
home.push(".local");
home.push("share");
home
})
})
.map(|mut dir| {
dir.push("applications");
dir.push("mimeapps.list");
dir
});
if data_path.as_ref().is_some_and(|path| path.exists()) {
return data_path;
}
config_path
}
#[derive(Clone, Copy)]
enum SectionKind {
DefaultApplications,
AddedAssociations,
}
fn flush_section(
output: &mut Vec<String>,
section: &mut Vec<String>,
desktop_file: &str,
schemes: &[&str],
kind: SectionKind,
changed: &mut bool,
) {
let mut seen: HashMap<&str, usize> = HashMap::new();
let mut processed: Vec<String> = Vec::with_capacity(section.len());
for line in section.drain(..) {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
processed.push(line);
continue;
}
let Some((raw_key, raw_value)) = trimmed.split_once('=') else {
processed.push(line);
continue;
};
if let Some(scheme) = match_scheme(raw_key.trim(), schemes) {
let mut values: Vec<String> = raw_value
.split(';')
.filter_map(|value| {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
})
.collect();
if let Some(&index) = seen.get(scheme) {
let existing_line = &mut processed[index];
let existing_prefix: String = existing_line.chars().take_while(|c| c.is_whitespace()).collect();
let Some((_, existing_raw_value)) = existing_line.trim().split_once('=') else {
processed.push(line);
continue;
};
let mut merged_values: Vec<String> = existing_raw_value
.split(';')
.filter_map(|value| {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
})
.collect();
for value in values {
if !merged_values.iter().any(|existing| existing == &value) {
merged_values.push(value);
}
}
if let Some(pos) = merged_values.iter().position(|value| value == desktop_file) {
if pos != 0 {
let moved = merged_values.remove(pos);
merged_values.insert(0, moved);
}
} else {
merged_values.insert(0, desktop_file.to_string());
}
let mut merged_line = format!("{existing_prefix}x-scheme-handler/{scheme}=");
merged_line.push_str(&merged_values.join(";"));
merged_line.push(';');
if *existing_line != merged_line {
*existing_line = merged_line;
}
// Dropping the duplicate entry alters the section even if nothing new was added.
*changed = true;
continue;
}
if let Some(pos) = values.iter().position(|value| value == desktop_file) {
if pos != 0 {
values.remove(pos);
values.insert(0, desktop_file.to_string());
*changed = true;
}
} else {
values.insert(0, desktop_file.to_string());
*changed = true;
}
let prefix = line.chars().take_while(|c| c.is_whitespace()).collect::<String>();
let mut new_line = format!("{prefix}x-scheme-handler/{scheme}=");
new_line.push_str(&values.join(";"));
new_line.push(';');
if new_line != line {
*changed = true;
}
let index = processed.len();
processed.push(new_line);
seen.insert(scheme, index);
continue;
}
processed.push(line);
}
let ensure_all = matches!(kind, SectionKind::DefaultApplications | SectionKind::AddedAssociations);
if ensure_all {
for &scheme in schemes {
if !seen.contains_key(scheme) {
processed.push(format!("x-scheme-handler/{scheme}={desktop_file};"));
*changed = true;
}
}
}
output.extend(processed);
}
fn match_scheme<'a>(key: &str, schemes: &'a [&str]) -> Option<&'a str> {
if let Some(rest) = key.strip_prefix("x-scheme-handler/") {
return schemes.iter().copied().find(|candidate| *candidate == rest);
}
schemes.iter().copied().find(|candidate| *candidate == key)
}