feat(firefox): propage le toggle vers les profils Firefox

Module firefox.rs : parse ~/.mozilla/firefox/profiles.ini, écrit un
bloc délimité (BEGIN/END markers) dans user.js de chaque profil avec
widget.gtk.decoration-layout aligné sur le layout COSMIC. Sans toucher
aux préfs existantes du profil.

Limite : Firefox doit être redémarré pour relire user.js. Vérifié sur
2 profils (default et "Profil 1") — boutons positionnés correctement
après restart Firefox.

By Leyoda 2026 — MIT
This commit is contained in:
Votre Nom 2026-05-05 14:07:02 +02:00
parent a61c95040e
commit f33ee1c889
3 changed files with 231 additions and 0 deletions

View file

@ -13,6 +13,9 @@ anyhow = "1"
log = "0.4"
env_logger = "0.11"
[dev-dependencies]
tempfile = "3"
[profile.release]
lto = "thin"
codegen-units = 1

223
src/firefox.rs Normal file
View file

@ -0,0 +1,223 @@
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
const BEGIN_MARKER: &str = "// === BEGIN cosmic-gtk-config ===";
const END_MARKER: &str = "// === END cosmic-gtk-config ===";
pub fn apply(layout: &str) -> Result<()> {
let Some(home) = std::env::var_os("HOME") else {
return Ok(());
};
let firefox_dir = PathBuf::from(home).join(".mozilla/firefox");
let profiles = read_profiles_ini(&firefox_dir);
if profiles.is_empty() {
log::debug!("firefox: no profile detected, skipping");
return Ok(());
}
for profile in profiles {
if let Err(err) = update_user_js(&profile, layout) {
log::warn!("firefox profile {}: {:#}", profile.display(), err);
}
}
Ok(())
}
fn read_profiles_ini(firefox_dir: &Path) -> Vec<PathBuf> {
let Ok(content) = std::fs::read_to_string(firefox_dir.join("profiles.ini")) else {
return Vec::new();
};
let mut profiles = Vec::new();
let mut in_profile = false;
let mut path_value: Option<String> = None;
let mut is_relative = true;
let commit = |relative: bool, path: Option<String>, profiles: &mut Vec<PathBuf>| {
if let Some(p) = path {
let pb = if relative {
firefox_dir.join(&p)
} else {
PathBuf::from(p)
};
profiles.push(pb);
}
};
for line in content.lines() {
let line = line.trim();
if let Some(section) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
if in_profile {
commit(is_relative, path_value.take(), &mut profiles);
}
in_profile = section.starts_with("Profile");
path_value = None;
is_relative = true;
} else if in_profile {
if let Some(value) = line.strip_prefix("Path=") {
path_value = Some(value.to_string());
} else if let Some(value) = line.strip_prefix("IsRelative=") {
is_relative = value != "0";
}
}
}
if in_profile {
commit(is_relative, path_value, &mut profiles);
}
profiles
}
fn update_user_js(profile: &Path, layout: &str) -> Result<()> {
if !profile.is_dir() {
log::debug!("skipping non-existent profile {}", profile.display());
return Ok(());
}
let user_js = profile.join("user.js");
let content = if user_js.exists() {
std::fs::read_to_string(&user_js)
.with_context(|| format!("read {}", user_js.display()))?
} else {
String::new()
};
let new_content = rewrite_user_js(&content, layout);
if new_content == content {
log::debug!("{}: already up-to-date", user_js.display());
return Ok(());
}
let tmp = user_js.with_extension("js.tmp");
std::fs::write(&tmp, &new_content)
.with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, &user_js)
.with_context(|| format!("rename to {}", user_js.display()))?;
log::info!("updated {} (Firefox restart required)", user_js.display());
Ok(())
}
fn rewrite_user_js(content: &str, layout: &str) -> String {
let block = format!(
"{BEGIN_MARKER}\n\
user_pref(\"widget.gtk.non-native-titlebar-buttons.enabled\", false);\n\
user_pref(\"widget.gtk.decoration-layout\", \"{layout}\");\n\
{END_MARKER}\n",
);
if let (Some(begin), Some(end)) = (content.find(BEGIN_MARKER), content.find(END_MARKER)) {
let end_line_end = content[end..]
.find('\n')
.map(|n| end + n + 1)
.unwrap_or(content.len());
let mut out = String::with_capacity(content.len());
out.push_str(&content[..begin]);
out.push_str(&block);
out.push_str(&content[end_line_end..]);
return out;
}
let mut out = String::with_capacity(content.len() + block.len() + 1);
out.push_str(content);
if !content.is_empty() && !content.ends_with('\n') {
out.push('\n');
}
if !content.is_empty() && !content.ends_with("\n\n") {
out.push('\n');
}
out.push_str(&block);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rewrite_appends_to_empty() {
let out = rewrite_user_js("", "close,minimize,maximize:");
assert!(out.starts_with(BEGIN_MARKER));
assert!(out.contains("decoration-layout\", \"close,minimize,maximize:\""));
assert!(out.contains(END_MARKER));
}
#[test]
fn rewrite_replaces_existing_block() {
let original = format!(
"user_pref(\"general.warnOnAboutConfig\", false);\n\n{BEGIN_MARKER}\n\
user_pref(\"widget.gtk.non-native-titlebar-buttons.enabled\", false);\n\
user_pref(\"widget.gtk.decoration-layout\", \"close,minimize,maximize:\");\n\
{END_MARKER}\n\nuser_pref(\"browser.tabs.warnOnClose\", false);\n"
);
let out = rewrite_user_js(&original, "menu:minimize,maximize,close");
assert!(out.contains("decoration-layout\", \"menu:minimize,maximize,close\""));
assert!(!out.contains("decoration-layout\", \"close,minimize,maximize:\""));
assert!(out.contains("user_pref(\"general.warnOnAboutConfig\", false)"));
assert!(out.contains("user_pref(\"browser.tabs.warnOnClose\", false)"));
let begin_count = out.matches(BEGIN_MARKER).count();
let end_count = out.matches(END_MARKER).count();
assert_eq!(begin_count, 1);
assert_eq!(end_count, 1);
}
#[test]
fn rewrite_appends_after_existing_content_with_blank_line() {
let original = "user_pref(\"foo\", true);\n";
let out = rewrite_user_js(original, "close,minimize,maximize:");
assert!(out.contains("user_pref(\"foo\", true);"));
assert!(out.contains(BEGIN_MARKER));
assert!(out.contains("\n\n// === BEGIN"));
}
#[test]
fn rewrite_idempotent() {
let out1 = rewrite_user_js("", "close,minimize,maximize:");
let out2 = rewrite_user_js(&out1, "close,minimize,maximize:");
assert_eq!(out1, out2);
}
#[test]
fn parse_profiles_ini_simple() {
let dir = tempfile::tempdir().unwrap();
let ini = "\
[Install4F96D1932A9F858E]\n\
Default=YyVZB8DZ.Profil 1\n\
Locked=1\n\
\n\
[Profile1]\n\
Name=default\n\
IsRelative=1\n\
Path=p65kjdux.default\n\
Default=1\n\
\n\
[Profile0]\n\
Name=default-release\n\
IsRelative=1\n\
Path=YyVZB8DZ.Profil 1\n\
\n\
[General]\n\
StartWithLastProfile=1\n\
";
std::fs::write(dir.path().join("profiles.ini"), ini).unwrap();
let profiles = read_profiles_ini(dir.path());
assert_eq!(profiles.len(), 2);
assert!(profiles.iter().any(|p| p.ends_with("p65kjdux.default")));
assert!(profiles.iter().any(|p| p.ends_with("YyVZB8DZ.Profil 1")));
}
#[test]
fn parse_profiles_ini_absolute_path() {
let dir = tempfile::tempdir().unwrap();
let ini = "\
[Profile0]\n\
Name=custom\n\
IsRelative=0\n\
Path=/tmp/nowhere/profile\n\
";
std::fs::write(dir.path().join("profiles.ini"), ini).unwrap();
let profiles = read_profiles_ini(dir.path());
assert_eq!(profiles.len(), 1);
assert_eq!(profiles[0], PathBuf::from("/tmp/nowhere/profile"));
}
}

View file

@ -1,3 +1,5 @@
mod firefox;
use std::path::{Path, PathBuf};
use std::process::Command;
@ -113,6 +115,9 @@ fn apply(pos: WindowControlsPosition) -> Result<()> {
update_gtk_ini(&home_path(".config/gtk-3.0/settings.ini"), layout)?;
update_gtk_ini(&home_path(".config/gtk-4.0/settings.ini"), layout)?;
update_gsettings(layout)?;
if let Err(err) = firefox::apply(layout) {
log::warn!("firefox propagation failed: {:#}", err);
}
Ok(())
}