From a61c95040e8e123b746377edb3ce49d50ceddea9 Mon Sep 17 00:00:00 2001 From: Votre Nom Date: Tue, 5 May 2026 12:54:44 +0200 Subject: [PATCH] feat: initial cosmic-gtk-config bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Daemon user-mode qui relie le setting CosmicTk.window_controls_position aux fenêtres non-libcosmic : - met à jour gtk-decoration-layout dans ~/.config/gtk-{3,4}.0/settings.ini - aligne org.gnome.desktop.wm.preferences button-layout via gsettings - abonné via Config::watch (cosmic-config) — propagation à chaud Pendant : kde-gtk-config / Plasma. Limites connues : Firefox + apps Electron qui dessinent leur propre titlebar (phase 2). By Leyoda 2026 — MIT --- .gitignore | 4 + Cargo.toml | 19 +++ LICENSE | 21 ++++ services/cosmic-gtk-config.service | 13 ++ src/main.rs | 192 +++++++++++++++++++++++++++++ 5 files changed, 249 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 services/cosmic-gtk-config.service create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cbd2765 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +Cargo.lock +*.bak +*.tmp diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..15596d1 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "cosmic-gtk-config" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "Bridges the COSMIC window-controls-position setting to GTK3, GTK4 and gsettings, à la kde-gtk-config." +authors = ["Lionel Darnis "] + +[dependencies] +cosmic-config = { path = "../libcosmic/cosmic-config", default-features = false, features = ["macro"] } +serde = { version = "1", features = ["derive"] } +anyhow = "1" +log = "0.4" +env_logger = "0.11" + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..45417c8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Lionel Darnis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/services/cosmic-gtk-config.service b/services/cosmic-gtk-config.service new file mode 100644 index 0000000..c3aaa84 --- /dev/null +++ b/services/cosmic-gtk-config.service @@ -0,0 +1,13 @@ +[Unit] +Description=COSMIC -> GTK config bridge (window controls position, theme...) +PartOf=cosmic-session.target +After=cosmic-session.target + +[Service] +Type=simple +ExecStart=%h/.local/bin/cosmic-gtk-config +Restart=on-failure +RestartSec=2 + +[Install] +WantedBy=cosmic-session.target diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..cebade9 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,192 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result}; +use cosmic_config::{Config, ConfigGet}; +use serde::{Deserialize, Serialize}; + +const COSMIC_TK_ID: &str = "com.system76.CosmicTk"; +const COSMIC_TK_VERSION: u64 = 1; +const KEY: &str = "window_controls_position"; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +enum WindowControlsPosition { + Start, + #[default] + End, +} + +impl WindowControlsPosition { + fn gtk_layout(self) -> &'static str { + match self { + Self::Start => "close,minimize,maximize:", + Self::End => "menu:minimize,maximize,close", + } + } +} + +fn home_path(rel: &str) -> PathBuf { + let home = std::env::var_os("HOME").expect("HOME unset"); + PathBuf::from(home).join(rel) +} + +fn rewrite_decoration_layout(content: &str, layout: &str) -> String { + let mut found = false; + let mut out = String::with_capacity(content.len() + 64); + let trailing_newline = content.ends_with('\n'); + + for line in content.lines() { + if line.starts_with("gtk-decoration-layout=") { + out.push_str("gtk-decoration-layout="); + out.push_str(layout); + out.push('\n'); + found = true; + } else { + out.push_str(line); + out.push('\n'); + } + } + + if !found { + if !out.contains("[Settings]") { + out.push_str("[Settings]\n"); + } + out.push_str("gtk-decoration-layout="); + out.push_str(layout); + out.push('\n'); + } + + if !trailing_newline && !out.is_empty() { + out.pop(); + } + out +} + +fn update_gtk_ini(path: &Path, layout: &str) -> Result<()> { + let content = if path.exists() { + std::fs::read_to_string(path) + .with_context(|| format!("read {}", path.display()))? + } else { + String::from("[Settings]\n") + }; + + let new_content = rewrite_decoration_layout(&content, layout); + if new_content == content { + log::debug!("{}: already up-to-date", path.display()); + return Ok(()); + } + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).ok(); + } + + let tmp = path.with_extension("ini.tmp"); + std::fs::write(&tmp, &new_content) + .with_context(|| format!("write {}", tmp.display()))?; + std::fs::rename(&tmp, path) + .with_context(|| format!("rename to {}", path.display()))?; + + log::info!("updated {}", path.display()); + Ok(()) +} + +fn update_gsettings(layout: &str) -> Result<()> { + let status = Command::new("gsettings") + .args([ + "set", + "org.gnome.desktop.wm.preferences", + "button-layout", + layout, + ]) + .status() + .context("spawn gsettings")?; + if !status.success() { + anyhow::bail!("gsettings exited with {}", status); + } + log::info!("gsettings button-layout = {}", layout); + Ok(()) +} + +fn apply(pos: WindowControlsPosition) -> Result<()> { + let layout = pos.gtk_layout(); + log::info!("applying position={:?} layout={}", pos, layout); + 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)?; + Ok(()) +} + +fn read_pos(config: &Config) -> WindowControlsPosition { + config + .get::(KEY) + .unwrap_or_default() +} + +fn main() -> Result<()> { + env_logger::Builder::from_env( + env_logger::Env::default().default_filter_or("info"), + ) + .init(); + + let config = Config::new(COSMIC_TK_ID, COSMIC_TK_VERSION) + .context("open com.system76.CosmicTk config")?; + + let initial = read_pos(&config); + log::info!("initial position: {:?}", initial); + apply(initial).context("initial apply failed")?; + + let _watcher = config + .watch(|cfg, keys| { + if !keys.iter().any(|k| k == KEY) { + return; + } + let pos = cfg + .get::(KEY) + .unwrap_or_default(); + log::info!("change detected: {:?}", pos); + if let Err(err) = apply(pos) { + log::error!("apply failed: {:#}", err); + } + }) + .context("setup watcher")?; + + log::info!("daemon running, watching {}", COSMIC_TK_ID); + std::thread::park(); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rewrite_replaces_existing_line() { + let input = "[Settings]\ngtk-theme-name=Adwaita\ngtk-decoration-layout=close,minimize,maximize:\ngtk-font-name=Sans 10\n"; + let out = rewrite_decoration_layout(input, "menu:minimize,maximize,close"); + assert!(out.contains("gtk-decoration-layout=menu:minimize,maximize,close\n")); + assert!(!out.contains("close,minimize,maximize:")); + assert!(out.contains("gtk-theme-name=Adwaita")); + } + + #[test] + fn rewrite_appends_when_absent() { + let input = "[Settings]\ngtk-theme-name=Adwaita\n"; + let out = rewrite_decoration_layout(input, "close,minimize,maximize:"); + assert!(out.contains("gtk-decoration-layout=close,minimize,maximize:\n")); + } + + #[test] + fn rewrite_creates_section_when_empty() { + let input = ""; + let out = rewrite_decoration_layout(input, "close,minimize,maximize:"); + assert!(out.contains("[Settings]")); + assert!(out.contains("gtk-decoration-layout=close,minimize,maximize:")); + } + + #[test] + fn rewrite_preserves_no_trailing_newline() { + let input = "[Settings]\ngtk-decoration-layout=:minimize,maximize,close"; + let out = rewrite_decoration_layout(input, "close,minimize,maximize:"); + assert!(!out.ends_with('\n')); + } +}