Skip to main content

helixforge_engine/
desktop.rs

1//! The desktop environment manager: which desktop, session and display
2//! manager this is, its appearance settings (read with the desktop's own
3//! tools), saved looks to put back, and who else sets those settings: Home
4//! Manager, its Plasma and dconf modules, Stylix. Reading changes nothing;
5//! applying a look is a plan of the desktop's own commands, confirmed first.
6
7use crate::{Plan, Step, doctor::System, quote};
8use helixforge_core::{Risk, printable};
9use serde::{Deserialize, Serialize};
10use std::{
11    collections::BTreeMap,
12    path::{Path, PathBuf},
13};
14
15/// The desktop environments HelixForge knows the settings of.
16#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
17#[serde(rename_all = "lowercase")]
18pub enum Kind {
19    Kde,
20    Gnome,
21    Xfce,
22    Cinnamon,
23    Mate,
24    Hyprland,
25    Sway,
26    Other,
27}
28
29impl Kind {
30    pub fn id(self) -> &'static str {
31        match self {
32            Kind::Kde => "kde",
33            Kind::Gnome => "gnome",
34            Kind::Xfce => "xfce",
35            Kind::Cinnamon => "cinnamon",
36            Kind::Mate => "mate",
37            Kind::Hyprland => "hyprland",
38            Kind::Sway => "sway",
39            Kind::Other => "other",
40        }
41    }
42
43    pub fn label(self) -> &'static str {
44        match self {
45            Kind::Kde => "KDE Plasma",
46            Kind::Gnome => "GNOME",
47            Kind::Xfce => "Xfce",
48            Kind::Cinnamon => "Cinnamon",
49            Kind::Mate => "MATE",
50            Kind::Hyprland => "Hyprland",
51            Kind::Sway => "Sway",
52            Kind::Other => "another desktop",
53        }
54    }
55
56    /// From `XDG_CURRENT_DESKTOP`, which may list several names.
57    pub fn from_desktop(value: &str) -> Option<Self> {
58        value
59            .split(':')
60            .find_map(|name| match name.to_ascii_lowercase().as_str() {
61                "kde" | "plasma" => Some(Kind::Kde),
62                "gnome" | "gnome-classic" | "ubuntu" | "pop" => Some(Kind::Gnome),
63                "xfce" => Some(Kind::Xfce),
64                "x-cinnamon" | "cinnamon" => Some(Kind::Cinnamon),
65                "mate" => Some(Kind::Mate),
66                "hyprland" => Some(Kind::Hyprland),
67                "sway" => Some(Kind::Sway),
68                _ => None,
69            })
70    }
71
72    /// The process that shows this desktop is running.
73    fn process(self) -> &'static str {
74        match self {
75            Kind::Kde => "plasmashell",
76            Kind::Gnome => "gnome-shell",
77            Kind::Xfce => "xfce4-session",
78            Kind::Cinnamon => "cinnamon",
79            Kind::Mate => "mate-session",
80            Kind::Hyprland => "Hyprland",
81            Kind::Sway => "sway",
82            Kind::Other => "",
83        }
84    }
85}
86
87/// One appearance setting: what it is, how it is read and set.
88struct Setting {
89    key: &'static str,
90    label: &'static str,
91    read: &'static [&'static str],
92    /// The command that sets it, before the value.
93    apply: &'static [&'static str],
94}
95
96const KDE: &[Setting] = &[
97    Setting {
98        key: "color-scheme",
99        label: "Color scheme",
100        read: &[
101            "kreadconfig6",
102            "--file",
103            "kdeglobals",
104            "--group",
105            "General",
106            "--key",
107            "ColorScheme",
108        ],
109        apply: &["plasma-apply-colorscheme"],
110    },
111    Setting {
112        key: "plasma-theme",
113        label: "Plasma style",
114        read: &[
115            "kreadconfig6",
116            "--file",
117            "plasmarc",
118            "--group",
119            "Theme",
120            "--key",
121            "name",
122        ],
123        apply: &["plasma-apply-desktoptheme"],
124    },
125    Setting {
126        key: "icons",
127        label: "Icons",
128        read: &[
129            "kreadconfig6",
130            "--file",
131            "kdeglobals",
132            "--group",
133            "Icons",
134            "--key",
135            "Theme",
136        ],
137        apply: &[
138            "kwriteconfig6",
139            "--file",
140            "kdeglobals",
141            "--group",
142            "Icons",
143            "--key",
144            "Theme",
145        ],
146    },
147    Setting {
148        key: "cursor",
149        label: "Cursor",
150        read: &[
151            "kreadconfig6",
152            "--file",
153            "kcminputrc",
154            "--group",
155            "Mouse",
156            "--key",
157            "cursorTheme",
158        ],
159        apply: &["plasma-apply-cursortheme"],
160    },
161    Setting {
162        key: "global-theme",
163        label: "Global theme",
164        read: &[
165            "kreadconfig6",
166            "--file",
167            "kdeglobals",
168            "--group",
169            "KDE",
170            "--key",
171            "LookAndFeelPackage",
172        ],
173        apply: &["plasma-apply-lookandfeel", "--apply"],
174    },
175    Setting {
176        key: "font",
177        label: "Font",
178        read: &[
179            "kreadconfig6",
180            "--file",
181            "kdeglobals",
182            "--group",
183            "General",
184            "--key",
185            "font",
186        ],
187        apply: &[
188            "kwriteconfig6",
189            "--file",
190            "kdeglobals",
191            "--group",
192            "General",
193            "--key",
194            "font",
195        ],
196    },
197];
198
199const GNOME: &[Setting] = &[
200    Setting {
201        key: "gtk-theme",
202        label: "Applications",
203        read: &[
204            "gsettings",
205            "get",
206            "org.gnome.desktop.interface",
207            "gtk-theme",
208        ],
209        apply: &[
210            "gsettings",
211            "set",
212            "org.gnome.desktop.interface",
213            "gtk-theme",
214        ],
215    },
216    Setting {
217        key: "color-scheme",
218        label: "Style",
219        read: &[
220            "gsettings",
221            "get",
222            "org.gnome.desktop.interface",
223            "color-scheme",
224        ],
225        apply: &[
226            "gsettings",
227            "set",
228            "org.gnome.desktop.interface",
229            "color-scheme",
230        ],
231    },
232    Setting {
233        key: "icons",
234        label: "Icons",
235        read: &[
236            "gsettings",
237            "get",
238            "org.gnome.desktop.interface",
239            "icon-theme",
240        ],
241        apply: &[
242            "gsettings",
243            "set",
244            "org.gnome.desktop.interface",
245            "icon-theme",
246        ],
247    },
248    Setting {
249        key: "cursor",
250        label: "Cursor",
251        read: &[
252            "gsettings",
253            "get",
254            "org.gnome.desktop.interface",
255            "cursor-theme",
256        ],
257        apply: &[
258            "gsettings",
259            "set",
260            "org.gnome.desktop.interface",
261            "cursor-theme",
262        ],
263    },
264    Setting {
265        key: "font",
266        label: "Font",
267        read: &[
268            "gsettings",
269            "get",
270            "org.gnome.desktop.interface",
271            "font-name",
272        ],
273        apply: &[
274            "gsettings",
275            "set",
276            "org.gnome.desktop.interface",
277            "font-name",
278        ],
279    },
280];
281
282const CINNAMON: &[Setting] = &[
283    Setting {
284        key: "gtk-theme",
285        label: "Applications",
286        read: &[
287            "gsettings",
288            "get",
289            "org.cinnamon.desktop.interface",
290            "gtk-theme",
291        ],
292        apply: &[
293            "gsettings",
294            "set",
295            "org.cinnamon.desktop.interface",
296            "gtk-theme",
297        ],
298    },
299    Setting {
300        key: "desktop-theme",
301        label: "Desktop",
302        read: &["gsettings", "get", "org.cinnamon.theme", "name"],
303        apply: &["gsettings", "set", "org.cinnamon.theme", "name"],
304    },
305    Setting {
306        key: "icons",
307        label: "Icons",
308        read: &[
309            "gsettings",
310            "get",
311            "org.cinnamon.desktop.interface",
312            "icon-theme",
313        ],
314        apply: &[
315            "gsettings",
316            "set",
317            "org.cinnamon.desktop.interface",
318            "icon-theme",
319        ],
320    },
321    Setting {
322        key: "cursor",
323        label: "Cursor",
324        read: &[
325            "gsettings",
326            "get",
327            "org.cinnamon.desktop.interface",
328            "cursor-theme",
329        ],
330        apply: &[
331            "gsettings",
332            "set",
333            "org.cinnamon.desktop.interface",
334            "cursor-theme",
335        ],
336    },
337];
338
339const MATE: &[Setting] = &[
340    Setting {
341        key: "gtk-theme",
342        label: "Applications",
343        read: &["gsettings", "get", "org.mate.interface", "gtk-theme"],
344        apply: &["gsettings", "set", "org.mate.interface", "gtk-theme"],
345    },
346    Setting {
347        key: "icons",
348        label: "Icons",
349        read: &["gsettings", "get", "org.mate.interface", "icon-theme"],
350        apply: &["gsettings", "set", "org.mate.interface", "icon-theme"],
351    },
352    Setting {
353        key: "cursor",
354        label: "Cursor",
355        read: &[
356            "gsettings",
357            "get",
358            "org.mate.peripherals-mouse",
359            "cursor-theme",
360        ],
361        apply: &[
362            "gsettings",
363            "set",
364            "org.mate.peripherals-mouse",
365            "cursor-theme",
366        ],
367    },
368];
369
370const XFCE: &[Setting] = &[
371    Setting {
372        key: "gtk-theme",
373        label: "Style",
374        read: &["xfconf-query", "-c", "xsettings", "-p", "/Net/ThemeName"],
375        apply: &[
376            "xfconf-query",
377            "-c",
378            "xsettings",
379            "-p",
380            "/Net/ThemeName",
381            "-s",
382        ],
383    },
384    Setting {
385        key: "window-theme",
386        label: "Window manager",
387        read: &["xfconf-query", "-c", "xfwm4", "-p", "/general/theme"],
388        apply: &["xfconf-query", "-c", "xfwm4", "-p", "/general/theme", "-s"],
389    },
390    Setting {
391        key: "icons",
392        label: "Icons",
393        read: &[
394            "xfconf-query",
395            "-c",
396            "xsettings",
397            "-p",
398            "/Net/IconThemeName",
399        ],
400        apply: &[
401            "xfconf-query",
402            "-c",
403            "xsettings",
404            "-p",
405            "/Net/IconThemeName",
406            "-s",
407        ],
408    },
409    Setting {
410        key: "cursor",
411        label: "Cursor",
412        read: &[
413            "xfconf-query",
414            "-c",
415            "xsettings",
416            "-p",
417            "/Gtk/CursorThemeName",
418        ],
419        apply: &[
420            "xfconf-query",
421            "-c",
422            "xsettings",
423            "-p",
424            "/Gtk/CursorThemeName",
425            "-s",
426        ],
427    },
428    Setting {
429        key: "font",
430        label: "Font",
431        read: &["xfconf-query", "-c", "xsettings", "-p", "/Gtk/FontName"],
432        apply: &[
433            "xfconf-query",
434            "-c",
435            "xsettings",
436            "-p",
437            "/Gtk/FontName",
438            "-s",
439        ],
440    },
441];
442
443fn settings_of(kind: Kind) -> &'static [Setting] {
444    match kind {
445        Kind::Kde => KDE,
446        Kind::Gnome => GNOME,
447        Kind::Cinnamon => CINNAMON,
448        Kind::Mate => MATE,
449        Kind::Xfce => XFCE,
450        Kind::Hyprland | Kind::Sway | Kind::Other => &[],
451    }
452}
453
454/// What the machine's desktop is.
455#[derive(Clone, Debug, Default, Serialize)]
456pub struct Desktop {
457    pub kind: Option<Kind>,
458    /// "KDE Plasma 6.8.0".
459    pub name: Option<String>,
460    /// wayland or x11.
461    pub session: Option<String>,
462    pub display_manager: Option<DisplayManager>,
463    /// The appearance settings, in the desktop's order, with their values.
464    pub appearance: Vec<Value>,
465    /// Who else sets these settings.
466    pub owners: Vec<String>,
467    /// Whether HelixForge would refuse to change the settings, and why.
468    pub blocked: Option<String>,
469}
470
471#[derive(Clone, Debug, Default, Serialize)]
472pub struct Value {
473    pub key: String,
474    pub label: String,
475    pub value: Option<String>,
476}
477
478#[derive(Clone, Debug, Default, Serialize)]
479pub struct DisplayManager {
480    /// sddm, gdm, lightdm.
481    pub name: String,
482    /// Its configuration files that exist here.
483    pub files: Vec<String>,
484    /// The login screen theme, where it can be read.
485    pub theme: Option<String>,
486}
487
488/// Read the desktop. `environment` answers for `XDG_*` variables.
489pub fn detect(system: &dyn System, environment: &dyn Fn(&str) -> Option<String>) -> Desktop {
490    let processes = system.processes();
491    let kind = environment("XDG_CURRENT_DESKTOP")
492        .and_then(|value| Kind::from_desktop(&value))
493        .or_else(|| {
494            [
495                Kind::Kde,
496                Kind::Gnome,
497                Kind::Xfce,
498                Kind::Cinnamon,
499                Kind::Mate,
500                Kind::Hyprland,
501                Kind::Sway,
502            ]
503            .into_iter()
504            .find(|kind| processes.iter().any(|name| name == kind.process()))
505        });
506    let version = |command: &[&str]| -> Option<String> {
507        let (true, text) = system.run(command)? else {
508            return None;
509        };
510        text.split_whitespace()
511            .find(|word| word.chars().next().is_some_and(|c| c.is_ascii_digit()))
512            .map(printable)
513    };
514    let name = kind.map(|kind| {
515        let found = match kind {
516            Kind::Kde => version(&["plasmashell", "--version"]),
517            Kind::Gnome => version(&["gnome-shell", "--version"]),
518            Kind::Xfce => version(&["xfce4-session", "--version"]),
519            Kind::Cinnamon => version(&["cinnamon", "--version"]),
520            Kind::Mate => version(&["mate-session", "--version"]),
521            _ => None,
522        };
523        match found {
524            Some(version) => format!("{} {version}", kind.label()),
525            None => kind.label().to_string(),
526        }
527    });
528    let appearance = kind
529        .map(|kind| {
530            settings_of(kind)
531                .iter()
532                .map(|setting| Value {
533                    key: setting.key.to_string(),
534                    label: setting.label.to_string(),
535                    value: system
536                        .run(setting.read)
537                        .filter(|(ok, _)| *ok)
538                        .map(|(_, text)| unquote(text.trim()))
539                        .filter(|text| !text.is_empty()),
540                })
541                .collect()
542        })
543        .unwrap_or_default();
544    let (owners, blocked) = owners(system, kind);
545    Desktop {
546        kind,
547        name,
548        session: environment("XDG_SESSION_TYPE").map(|value| printable(&value)),
549        display_manager: display_manager(system),
550        appearance,
551        owners,
552        blocked,
553    }
554}
555
556/// gsettings prints strings in quotes.
557fn unquote(text: &str) -> String {
558    printable(
559        text.strip_prefix('\'')
560            .and_then(|rest| rest.strip_suffix('\''))
561            .unwrap_or(text),
562    )
563}
564
565fn display_manager(system: &dyn System) -> Option<DisplayManager> {
566    let target = system.link(Path::new("/etc/systemd/system/display-manager.service"))?;
567    let name = target.file_stem()?.to_string_lossy().into_owned();
568    let mut files: Vec<String> = match name.as_str() {
569        "sddm" => {
570            let mut files = vec!["/etc/sddm.conf".to_string()];
571            files.extend(
572                system
573                    .list(Path::new("/etc/sddm.conf.d"))
574                    .into_iter()
575                    .map(|file| format!("/etc/sddm.conf.d/{file}")),
576            );
577            files
578        }
579        "gdm" | "gdm3" => vec![
580            "/etc/gdm/custom.conf".into(),
581            "/etc/gdm3/custom.conf".into(),
582        ],
583        "lightdm" => vec![
584            "/etc/lightdm/lightdm.conf".into(),
585            "/etc/lightdm/lightdm-gtk-greeter.conf".into(),
586        ],
587        _ => Vec::new(),
588    };
589    files.retain(|file| system.exists(Path::new(file)));
590    // SDDM's theme: the last Current= in the files, read in order.
591    let theme = (name == "sddm")
592        .then(|| {
593            files
594                .iter()
595                .filter_map(|file| system.read(Path::new(file)))
596                .flat_map(|text| {
597                    text.lines()
598                        .filter_map(|line| line.trim().strip_prefix("Current="))
599                        .map(|value| printable(value.trim()))
600                        .collect::<Vec<_>>()
601                })
602                .last()
603        })
604        .flatten();
605    Some(DisplayManager {
606        name: printable(&name),
607        files: files.iter().map(|file| printable(file)).collect(),
608        theme,
609    })
610}
611
612/// Who else sets the desktop's appearance, and whether that rules out
613/// changing it here: a settings file that is a Home Manager link would be
614/// written through into the Nix store, or replaced at the next switch.
615fn owners(system: &dyn System, kind: Option<Kind>) -> (Vec<String>, Option<String>) {
616    let (_, _, home) = system.user();
617    let mut owners = Vec::new();
618    let mut blocked = None;
619    let files: &[&str] = match kind {
620        Some(Kind::Kde) => &[
621            ".config/kdeglobals",
622            ".config/plasmarc",
623            ".config/kcminputrc",
624        ],
625        Some(Kind::Xfce) => &[".config/xfce4/xfconf/xfce-perchannel-xml/xsettings.xml"],
626        Some(Kind::Gnome | Kind::Cinnamon | Kind::Mate) => &[".config/dconf/user"],
627        _ => &[],
628    };
629    for file in files {
630        let path = home.join(file);
631        if let Some(target) = system.link(&path)
632            && target.starts_with("/nix/store")
633        {
634            blocked = Some(format!(
635                "~/{file} is a Home Manager link into the Nix store; change the look in your Home Manager configuration instead"
636            ));
637        }
638    }
639    let state = home.join(".local/state/home-manager/gcroots/current-home");
640    if let Some(generation) = system.canonical(&state)
641        && let Some(activate) = system.read(&generation.join("activate"))
642    {
643        let script = activate.to_lowercase();
644        if script.contains("stylix") {
645            owners.push(
646                "Stylix themes this desktop through Home Manager at every switch".to_string(),
647            );
648        }
649        match kind {
650            Some(Kind::Kde) if script.contains("plasma") => owners.push(
651                "Home Manager runs a Plasma configuration step at every switch (plasma-manager or your own), which may set these again".to_string(),
652            ),
653            Some(Kind::Gnome | Kind::Cinnamon | Kind::Mate) if script.contains("dconf") => owners.push(
654                "Home Manager loads dconf settings at every switch, which may set these again".to_string(),
655            ),
656            _ => {}
657        }
658    }
659    (owners, blocked)
660}
661
662/// A saved look: a desktop's appearance settings, to put back later.
663#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
664pub struct Look {
665    pub name: String,
666    pub desktop: String,
667    #[serde(default)]
668    pub settings: BTreeMap<String, String>,
669}
670
671/// Why a look cannot be used.
672#[derive(Debug, thiserror::Error)]
673pub enum LookError {
674    #[error("cannot read {0}: {1}")]
675    Read(PathBuf, String),
676    #[error("{0} is not a HelixForge look: {1}")]
677    Format(PathBuf, String),
678    #[error(
679        "the look has a name or value HelixForge does not set: control characters, a leading dash or too long"
680    )]
681    Invalid,
682    #[error("the name {0:?} gives no usable file name; use letters or digits")]
683    Name(String),
684    #[error("cannot write {0}: {1}")]
685    Write(PathBuf, String),
686}
687
688/// `$XDG_CONFIG_HOME/helixforge/looks`.
689pub fn dir() -> Option<PathBuf> {
690    crate::profiles::dir().and_then(|profiles| Some(profiles.parent()?.join("looks")))
691}
692
693/// The current appearance as a look.
694pub fn capture(name: &str, desktop: &Desktop) -> Option<Look> {
695    let kind = desktop.kind?;
696    let settings: BTreeMap<String, String> = desktop
697        .appearance
698        .iter()
699        .filter_map(|value| Some((value.key.clone(), value.value.clone()?)))
700        .collect();
701    (!settings.is_empty()).then(|| Look {
702        name: name.trim().to_string(),
703        desktop: kind.id().to_string(),
704        settings,
705    })
706}
707
708/// A value HelixForge passes to a desktop's tool: plain text that cannot be
709/// taken for an option.
710fn valid_value(value: &str) -> bool {
711    !value.is_empty()
712        && value.len() <= 200
713        && !value.starts_with('-')
714        && !value.chars().any(char::is_control)
715}
716
717pub fn check(look: &Look) -> Result<(), LookError> {
718    let plain = look.name.len() <= 100 && !look.name.chars().any(char::is_control);
719    let keys = look.settings.keys().all(|key| {
720        key.len() <= 32
721            && key
722                .bytes()
723                .all(|byte| byte.is_ascii_lowercase() || byte == b'-')
724    });
725    if plain
726        && keys
727        && look.settings.values().all(|value| valid_value(value))
728        && helixforge_core::valid_id(&look.desktop)
729    {
730        Ok(())
731    } else {
732        Err(LookError::Invalid)
733    }
734}
735
736pub fn read(path: &Path) -> Result<Look, LookError> {
737    let text = std::fs::read_to_string(path)
738        .map_err(|error| LookError::Read(path.to_path_buf(), error.to_string()))?;
739    let look: Look = toml::from_str(&text)
740        .map_err(|error| LookError::Format(path.to_path_buf(), printable(error.message())))?;
741    check(&look)?;
742    Ok(look)
743}
744
745pub fn save(directory: &Path, look: &Look) -> Result<PathBuf, LookError> {
746    check(look)?;
747    let slug =
748        crate::profiles::slug(&look.name).ok_or_else(|| LookError::Name(look.name.clone()))?;
749    let path = directory.join(format!("{slug}.toml"));
750    let failed = |error: String| LookError::Write(path.clone(), error);
751    std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
752    let text = toml::to_string(look).map_err(|error| failed(error.to_string()))?;
753    let staging = path.with_extension("toml.new");
754    std::fs::write(
755        &staging,
756        format!("# A HelixForge desktop look: helixforge desktop apply <name>\n{text}"),
757    )
758    .map_err(|error| failed(error.to_string()))?;
759    std::fs::rename(&staging, &path).map_err(|error| failed(error.to_string()))?;
760    Ok(path)
761}
762
763/// The saved looks, by name, and the files that could not be read.
764pub fn load_all(directory: &Path) -> (Vec<(String, Look)>, Vec<String>) {
765    let mut found = Vec::new();
766    let mut problems = Vec::new();
767    let Ok(listing) = std::fs::read_dir(directory) else {
768        return (found, problems);
769    };
770    let mut paths: Vec<PathBuf> = listing
771        .flatten()
772        .map(|item| item.path())
773        .filter(|path| {
774            path.extension()
775                .is_some_and(|extension| extension == "toml")
776        })
777        .collect();
778    paths.sort();
779    for path in paths {
780        let Some(slug) = path
781            .file_stem()
782            .and_then(|stem| crate::profiles::slug(&stem.to_string_lossy()))
783        else {
784            continue;
785        };
786        match read(&path) {
787            Ok(look) => found.push((slug, look)),
788            Err(error) => problems.push(error.to_string()),
789        }
790    }
791    (found, problems)
792}
793
794/// A plan that puts a look back with the desktop's own tools, the settings
795/// that differ only. Refused for another desktop, or where Home Manager owns
796/// the settings files.
797pub fn plan_apply(look: &Look, desktop: &Desktop) -> Result<Plan, String> {
798    check(look).map_err(|error| error.to_string())?;
799    let Some(kind) = desktop.kind else {
800        return Err("no desktop environment is running here".to_string());
801    };
802    if look.desktop != kind.id() {
803        return Err(format!(
804            "the look was saved on {}; this is {}",
805            printable(&look.desktop),
806            kind.label()
807        ));
808    }
809    if let Some(why) = &desktop.blocked {
810        return Err(why.clone());
811    }
812    let mut lines = Vec::new();
813    let mut commands = Vec::new();
814    let mut shown = Vec::new();
815    let mut requires: Vec<String> = Vec::new();
816    for setting in settings_of(kind) {
817        let Some(value) = look.settings.get(setting.key) else {
818            continue;
819        };
820        let now = desktop
821            .appearance
822            .iter()
823            .find(|current| current.key == setting.key)
824            .and_then(|current| current.value.as_deref());
825        if now == Some(value.as_str()) {
826            continue;
827        }
828        let mut words: Vec<String> = setting.apply.iter().map(|word| word.to_string()).collect();
829        // gsettings takes a GVariant: a string in quotes.
830        words.push(if setting.apply[0] == "gsettings" {
831            format!("'{}'", value.replace('\'', ""))
832        } else {
833            value.clone()
834        });
835        lines.push(
836            words
837                .iter()
838                .map(|word| quote(word))
839                .collect::<Vec<_>>()
840                .join(" "),
841        );
842        commands.push(printable(&words.join(" ")));
843        shown.push(printable(&format!(
844            "{}: {} → {value}",
845            setting.label,
846            now.unwrap_or("unset")
847        )));
848        if !requires.contains(&words[0]) {
849            requires.push(words[0].clone());
850        }
851    }
852    let mut plan = Plan::default();
853    if lines.is_empty() {
854        plan.notes.push(format!(
855            "{}: the desktop already looks like this",
856            printable(&look.name)
857        ));
858        return Ok(plan);
859    }
860    requires.sort();
861    plan.steps.push(Step::Shell {
862        id: "desktop-look".to_string(),
863        name: format!("Apply the look {}", printable(&look.name)),
864        program: lines.join(" &&\n"),
865        display: commands.join(" && "),
866        requires: requires.clone(),
867        changes: Vec::new(),
868    });
869    plan.notes.extend(shown);
870    plan.notes
871        .extend(desktop.owners.iter().map(|owner| format!("Note: {owner}")));
872    plan.requirements = requires;
873    plan.risk = Risk::Low;
874    plan.depends = vec![Vec::new()];
875    Ok(plan)
876}
877
878#[cfg(test)]
879mod tests {
880    use super::*;
881    use std::collections::HashMap;
882
883    #[derive(Default)]
884    struct Fake {
885        commands: HashMap<String, String>,
886        links: HashMap<PathBuf, PathBuf>,
887        files: HashMap<PathBuf, String>,
888        processes: Vec<String>,
889    }
890
891    impl System for Fake {
892        fn read(&self, path: &Path) -> Option<String> {
893            self.files.get(path).cloned()
894        }
895        fn exists(&self, path: &Path) -> bool {
896            self.files.contains_key(path)
897        }
898        fn list(&self, path: &Path) -> Vec<String> {
899            self.files
900                .keys()
901                .filter(|known| known.parent() == Some(path))
902                .filter_map(|known| known.file_name())
903                .map(|name| name.to_string_lossy().into_owned())
904                .collect()
905        }
906        fn link(&self, path: &Path) -> Option<PathBuf> {
907            self.links.get(path).cloned()
908        }
909        fn run(&self, command: &[&str]) -> Option<(bool, String)> {
910            self.commands
911                .get(&command.join(" "))
912                .map(|text| (true, text.clone()))
913        }
914        fn resolve(&self, _: &str) -> Result<Vec<String>, String> {
915            Err("offline".into())
916        }
917        fn space(&self, _: &Path) -> Option<(u64, u64)> {
918            None
919        }
920        fn owner(&self, _: &Path) -> Option<u32> {
921            None
922        }
923        fn is_link(&self, path: &Path) -> bool {
924            self.links.contains_key(path)
925        }
926        fn canonical(&self, path: &Path) -> Option<PathBuf> {
927            self.links.get(path).cloned()
928        }
929        fn user(&self) -> (u32, u32, PathBuf) {
930            (1000, 1000, PathBuf::from("/home/me"))
931        }
932        fn on_path(&self, _: &str) -> bool {
933            true
934        }
935        fn processes(&self) -> Vec<String> {
936            self.processes.clone()
937        }
938    }
939
940    fn plasma() -> Fake {
941        let mut fake = Fake::default();
942        fake.commands
943            .insert("plasmashell --version".into(), "plasmashell 6.8.0\n".into());
944        fake.commands.insert(
945            "kreadconfig6 --file kdeglobals --group General --key ColorScheme".into(),
946            "BreezeDark\n".into(),
947        );
948        fake.commands.insert(
949            "kreadconfig6 --file kdeglobals --group Icons --key Theme".into(),
950            "breeze-dark\n".into(),
951        );
952        fake.links.insert(
953            PathBuf::from("/etc/systemd/system/display-manager.service"),
954            PathBuf::from("/usr/lib/systemd/system/sddm.service"),
955        );
956        fake.files
957            .insert(PathBuf::from("/etc/sddm.conf"), "".into());
958        fake.files.insert(
959            PathBuf::from("/etc/sddm.conf.d/zz-commander-theme.conf"),
960            "[Theme]\nCurrent=silent\n".into(),
961        );
962        fake
963    }
964
965    fn environment(name: &str) -> Option<String> {
966        match name {
967            "XDG_CURRENT_DESKTOP" => Some("KDE".into()),
968            "XDG_SESSION_TYPE" => Some("wayland".into()),
969            _ => None,
970        }
971    }
972
973    #[test]
974    fn plasma_is_read_with_its_own_tools() {
975        let found = detect(&plasma(), &environment);
976        assert_eq!(found.kind, Some(Kind::Kde));
977        assert_eq!(found.name.as_deref(), Some("KDE Plasma 6.8.0"));
978        assert_eq!(found.session.as_deref(), Some("wayland"));
979        let dm = found.display_manager.as_ref().unwrap();
980        assert_eq!(dm.name, "sddm");
981        assert_eq!(dm.theme.as_deref(), Some("silent"));
982        assert_eq!(found.appearance[0].value.as_deref(), Some("BreezeDark"));
983        assert!(found.appearance.iter().any(|value| value.value.is_none()));
984        assert!(found.owners.is_empty());
985        assert!(found.blocked.is_none());
986        // Without the variable, the running shell tells.
987        let mut fake = plasma();
988        fake.processes = vec!["plasmashell".into()];
989        assert_eq!(detect(&fake, &|_| None).kind, Some(Kind::Kde));
990        assert_eq!(Kind::from_desktop("ubuntu:GNOME"), Some(Kind::Gnome));
991    }
992
993    #[test]
994    fn a_look_is_saved_and_put_back_with_only_what_differs() {
995        let desktop = detect(&plasma(), &environment);
996        let mut look = capture("Dark", &desktop).unwrap();
997        assert_eq!(look.desktop, "kde");
998        assert_eq!(look.settings.len(), 2);
999        let directory = tempfile::tempdir().unwrap();
1000        let path = save(directory.path(), &look).unwrap();
1001        assert_eq!(read(&path).unwrap(), look);
1002        assert_eq!(load_all(directory.path()).0.len(), 1);
1003        // The same look changes nothing.
1004        let planned = plan_apply(&look, &desktop).unwrap();
1005        assert!(planned.steps.is_empty());
1006        look.settings.insert("color-scheme".into(), "Sweet".into());
1007        let planned = plan_apply(&look, &desktop).unwrap();
1008        assert_eq!(planned.steps.len(), 1);
1009        assert_eq!(planned.steps[0].display(), "plasma-apply-colorscheme Sweet");
1010        assert_eq!(planned.notes[0], "Color scheme: BreezeDark → Sweet");
1011        assert!(
1012            planned
1013                .requirements
1014                .contains(&"plasma-apply-colorscheme".to_string())
1015        );
1016        // Another desktop's look, or an option smuggled in, is refused.
1017        let gnome = Look {
1018            desktop: "gnome".into(),
1019            ..look.clone()
1020        };
1021        assert!(
1022            plan_apply(&gnome, &desktop)
1023                .unwrap_err()
1024                .contains("saved on gnome; this is KDE Plasma")
1025        );
1026        look.settings.insert("icons".into(), "--help".into());
1027        assert!(plan_apply(&look, &desktop).is_err());
1028    }
1029
1030    #[test]
1031    fn home_manager_owners_are_named_and_its_links_block_changes() {
1032        let mut fake = plasma();
1033        fake.links.insert(
1034            PathBuf::from("/home/me/.local/state/home-manager/gcroots/current-home"),
1035            PathBuf::from("/nix/store/abc-home-manager-generation"),
1036        );
1037        fake.files.insert(
1038            PathBuf::from("/nix/store/abc-home-manager-generation/activate"),
1039            "_iNote \"Activating %s\" \"configure-plasma\"\n".into(),
1040        );
1041        let found = detect(&fake, &environment);
1042        assert_eq!(found.owners.len(), 1);
1043        assert!(found.owners[0].contains("Plasma"));
1044        assert!(found.blocked.is_none());
1045        fake.links.insert(
1046            PathBuf::from("/home/me/.config/kdeglobals"),
1047            PathBuf::from("/nix/store/xyz-home-manager-files/.config/kdeglobals"),
1048        );
1049        let found = detect(&fake, &environment);
1050        assert!(found.blocked.as_ref().unwrap().contains("kdeglobals"));
1051        let look = Look {
1052            name: "x".into(),
1053            desktop: "kde".into(),
1054            settings: [("color-scheme".to_string(), "Sweet".to_string())].into(),
1055        };
1056        assert!(
1057            plan_apply(&look, &found)
1058                .unwrap_err()
1059                .contains("Home Manager")
1060        );
1061    }
1062}