use std::path::Path;
use anyhow::{Context, Result};
use serde_json::{Map, Value};
fn chromium_pref_values(dir: &str) -> Vec<(&'static str, Value)> {
vec![
("download.prompt_for_download", Value::Bool(false)),
("download.default_directory", Value::String(dir.to_string())),
("download.directory_upgrade", Value::Bool(true)),
("savefile.default_directory", Value::String(dir.to_string())),
(
"profile.default_content_setting_values.automatic_downloads",
Value::from(1),
),
("download_bubble.partial_view_enabled", Value::Bool(false)),
]
}
pub fn apply_chromium(profile_dir: &Path, download_dir: &Path) -> Result<()> {
let default_dir = profile_dir.join("Default");
std::fs::create_dir_all(&default_dir)
.with_context(|| format!("creating {}", default_dir.display()))?;
let path = default_dir.join("Preferences");
let mut root = match std::fs::read(&path) {
Ok(b) => serde_json::from_slice::<Value>(&b)
.ok()
.filter(Value::is_object)
.with_context(|| {
format!(
"{} is not a JSON object; leaving it unchanged",
path.display()
)
})?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Value::Object(Map::new()),
Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
};
let dir = download_dir.to_string_lossy();
for (dotted, value) in chromium_pref_values(&dir) {
set_dotted(&mut root, dotted, value);
}
let bytes = serde_json::to_vec(&root).context("serializing Preferences")?;
write_atomic(&path, &bytes)
}
fn set_dotted(root: &mut Value, dotted: &str, value: Value) {
let mut cur = root;
let mut parts = dotted.split('.').peekable();
while let Some(part) = parts.next() {
if !cur.is_object() {
*cur = Value::Object(Map::new());
}
let obj = cur.as_object_mut().expect("just ensured object");
if parts.peek().is_none() {
obj.insert(part.to_string(), value);
return;
}
cur = obj
.entry(part.to_string())
.or_insert_with(|| Value::Object(Map::new()));
}
}
fn firefox_pref_values(dir: &str) -> Vec<(&'static str, String)> {
vec![
("browser.download.folderList", "2".into()),
("browser.download.dir", js_string(dir)),
("browser.download.useDownloadDir", "true".into()),
(
"browser.download.always_ask_before_handling_new_types",
"false".into(),
),
("browser.download.alwaysOpenPanel", "false".into()),
("browser.download.manager.showWhenStarting", "false".into()),
]
}
fn js_string(s: &str) -> String {
serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into())
}
pub fn apply_firefox(profile_dir: &Path, download_dir: &Path) -> Result<()> {
std::fs::create_dir_all(profile_dir)
.with_context(|| format!("creating {}", profile_dir.display()))?;
let path = profile_dir.join("user.js");
let existing = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
};
let prefs = firefox_pref_values(&download_dir.to_string_lossy());
let mut out: Vec<String> = existing
.lines()
.filter(|line| {
!prefs
.iter()
.any(|(k, _)| line.contains(&format!("user_pref(\"{k}\"")))
})
.map(String::from)
.collect();
for (k, v) in &prefs {
out.push(format!("user_pref(\"{k}\", {v});"));
}
let mut text = out.join("\n");
text.push('\n');
write_atomic(&path, text.as_bytes())
}
fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
let tmp = path.with_extension(format!("browser-control-{}.tmp", std::process::id()));
std::fs::write(&tmp, bytes).with_context(|| format!("writing {}", tmp.display()))?;
std::fs::rename(&tmp, path).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
anyhow::Error::new(e).context(format!("replacing {}", path.display()))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chromium_prefs_merge_into_existing_file() {
let tmp = tempfile::TempDir::new().unwrap();
let prof = tmp.path();
std::fs::create_dir_all(prof.join("Default")).unwrap();
std::fs::write(
prof.join("Default/Preferences"),
r#"{"download":{"prompt_for_download":true,"other":1},"keep":"me"}"#,
)
.unwrap();
let dl = tmp.path().join("dl dir/ΓΌ");
apply_chromium(prof, &dl).unwrap();
let v: Value =
serde_json::from_slice(&std::fs::read(prof.join("Default/Preferences")).unwrap())
.unwrap();
assert_eq!(v["download"]["prompt_for_download"], false);
assert_eq!(v["download"]["other"], 1);
assert_eq!(v["download"]["directory_upgrade"], true);
assert_eq!(
v["download"]["default_directory"],
dl.to_string_lossy().as_ref()
);
assert_eq!(
v["profile"]["default_content_setting_values"]["automatic_downloads"],
1
);
assert_eq!(v["keep"], "me");
}
#[test]
fn chromium_prefs_create_missing_and_keep_corrupt_file() {
let tmp = tempfile::TempDir::new().unwrap();
apply_chromium(tmp.path(), Path::new("/x")).unwrap();
let prefs = tmp.path().join("Default/Preferences");
let v: Value = serde_json::from_slice(&std::fs::read(&prefs).unwrap()).unwrap();
assert_eq!(v["download"]["default_directory"], "/x");
std::fs::write(&prefs, b"{not json").unwrap();
assert!(apply_chromium(tmp.path(), Path::new("/y")).is_err());
assert_eq!(std::fs::read(&prefs).unwrap(), b"{not json");
}
#[test]
fn firefox_user_js_with_invalid_utf8_is_left_alone() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("user.js");
let original: &[u8] = b"// caf\xe9\nuser_pref(\"x\", 1);\n";
std::fs::write(&path, original).unwrap();
assert!(apply_firefox(tmp.path(), Path::new("/d")).is_err());
assert_eq!(std::fs::read(&path).unwrap(), original);
}
#[test]
fn firefox_user_js_replaces_managed_lines_and_keeps_others() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(
tmp.path().join("user.js"),
"user_pref(\"browser.download.dir\", \"/old\");\nuser_pref(\"other.pref\", 1);\n",
)
.unwrap();
apply_firefox(tmp.path(), Path::new("/new \"dir\"")).unwrap();
apply_firefox(tmp.path(), Path::new("/new \"dir\"")).unwrap();
let s = std::fs::read_to_string(tmp.path().join("user.js")).unwrap();
assert!(s.contains("user_pref(\"other.pref\", 1);"));
assert!(!s.contains("/old"));
assert_eq!(s.matches("browser.download.dir\"").count(), 1);
assert!(s.contains(r#"user_pref("browser.download.dir", "/new \"dir\"");"#));
assert!(s.contains("user_pref(\"browser.download.folderList\", 2);"));
}
}