feat: initial cosmic-gtk-config bridge

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
This commit is contained in:
Votre Nom 2026-05-05 12:54:44 +02:00
commit a61c95040e
5 changed files with 249 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/target
Cargo.lock
*.bak
*.tmp

19
Cargo.toml Normal file
View file

@ -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 <ldarnis@gmail.com>"]
[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

21
LICENSE Normal file
View file

@ -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.

View file

@ -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

192
src/main.rs Normal file
View file

@ -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::<WindowControlsPosition>(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::<WindowControlsPosition>(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'));
}
}