Skip to main content

planarian_backend_linux/
logind.rs

1//! `SessionBackend` over logind (`loginctl`) and the kernel's power-supply class.
2
3use crate::cmd::run;
4use planarian_schema::{Outcome, PowerSource, SessionBackend, SessionInfo, Timestamp};
5use std::path::PathBuf;
6use std::time::Duration;
7
8#[derive(Clone, Debug)]
9pub struct Logind {
10    /// Process names that are a compositor (`comm`), for the per-session
11    /// readiness check (§9). Empty: the backend does not judge compositors.
12    pub compositors: Vec<String>,
13    pub cgroup_root: PathBuf,
14}
15
16impl Default for Logind {
17    fn default() -> Logind {
18        Logind { compositors: vec![], cgroup_root: PathBuf::from("/sys/fs/cgroup") }
19    }
20}
21
22impl Logind {
23    /// The `comm` of every process in a session's scope; None if the scope
24    /// cannot be read.
25    fn session_comms(&self, s: &SessionInfo) -> Option<Vec<String>> {
26        let scope = self
27            .cgroup_root
28            .join("user.slice")
29            .join(format!("user-{}.slice", s.uid))
30            .join(format!("session-{}.scope", s.id));
31        let procs = std::fs::read_to_string(scope.join("cgroup.procs")).ok()?;
32        Some(
33            procs
34                .lines()
35                .filter_map(|pid| std::fs::read_to_string(format!("/proc/{}/comm", pid.trim())).ok())
36                .map(|c| c.trim().to_string())
37                .collect(),
38        )
39    }
40}
41
42/// `ClientCount` of Feral GameMode on the user's bus, read through the user
43/// manager (`--machine=<uid>@.host --user`); 0 if GameMode is not there.
44fn gamemode_clients(uid: u32) -> u32 {
45    let m = format!("--machine={uid}@.host");
46    let o = run(
47        &[
48            "busctl",
49            &m,
50            "--user",
51            "get-property",
52            "com.feralinteractive.GameMode",
53            "/com/feralinteractive/GameMode",
54            "com.feralinteractive.GameMode",
55            "ClientCount",
56        ],
57        Duration::from_secs(2),
58    );
59    if !o.ok() {
60        return 0;
61    }
62    // "i 1"
63    o.stdout.split_whitespace().nth(1).and_then(|n| n.parse().ok()).unwrap_or(0)
64}
65
66/// Parse `loginctl show-session` key=value output into a session.
67pub fn parse_session(id: &str, text: &str) -> Option<SessionInfo> {
68    let props = crate::systemd::parse_show(text);
69    let get = |k: &str| props.get(k).cloned().unwrap_or_default();
70    Some(SessionInfo {
71        id: id.into(),
72        uid: get("User").parse().ok()?,
73        class: get("Class"),
74        seat: Some(get("Seat")).filter(|s| !s.is_empty()),
75        remote: get("Remote") == "yes",
76        active: get("State") == "active",
77        idle: get("IdleHint") == "yes",
78        // `Timestamp` is printed; `TimestampMonotonic` is not wall time. Use the
79        // realtime microseconds logind exports in `--value` form when present.
80        created: Timestamp(get("TimestampUSec").parse::<u64>().map(|us| us / 1000).unwrap_or(0)),
81    })
82}
83
84/// One `--property` per property: systemd 260's loginctl prints nothing (and
85/// exits 0) for a comma-separated list, so every session went unseen.
86pub fn show_session_argv(id: &str) -> Vec<String> {
87    let mut a: Vec<String> = ["loginctl", "show-session", id].map(String::from).to_vec();
88    a.extend(["User", "Class", "Seat", "Remote", "State", "IdleHint"].map(|p| format!("--property={p}")));
89    a
90}
91
92/// Session ids from `loginctl list-sessions --no-legend` (first column).
93pub fn parse_list(text: &str) -> Vec<String> {
94    text.lines().filter_map(|l| l.split_whitespace().next()).map(String::from).collect()
95}
96
97fn read_power() -> Option<PowerSource> {
98    let dir = std::fs::read_dir("/sys/class/power_supply").ok()?;
99    let mut saw_mains = false;
100    for e in dir.flatten() {
101        let p = e.path();
102        let kind = std::fs::read_to_string(p.join("type")).unwrap_or_default();
103        if kind.trim() == "Mains" {
104            saw_mains = true;
105            if std::fs::read_to_string(p.join("online")).unwrap_or_default().trim() == "1" {
106                return Some(PowerSource::Ac);
107            }
108        }
109    }
110    saw_mains.then_some(PowerSource::Battery)
111}
112
113impl SessionBackend for Logind {
114    fn sessions(&self) -> Result<Vec<SessionInfo>, Outcome> {
115        let t = Duration::from_secs(5);
116        let list = run(&["loginctl", "list-sessions", "--no-legend", "--no-pager"], t);
117        if !list.ok() {
118            return Err(Outcome::Failed(list.tail()));
119        }
120        let mut out = vec![];
121        for id in parse_list(&list.stdout) {
122            // Creation time is left 0 (unknown): `state` measures the session
123            // deadline from when it first saw the session, at most one tick late,
124            // rather than depend on loginctl's timestamp formatting.
125            let o = run(&show_session_argv(&id).iter().map(String::as_str).collect::<Vec<_>>(), t);
126            if !o.ok() {
127                continue;
128            }
129            if let Some(s) = parse_session(&id, &o.stdout) {
130                out.push(s);
131            }
132        }
133        Ok(out)
134    }
135
136    fn power(&self) -> Option<PowerSource> {
137        read_power()
138    }
139
140    fn compositor_running(&self, s: &SessionInfo) -> Option<bool> {
141        if self.compositors.is_empty() {
142            return None;
143        }
144        let comms = self.session_comms(s)?;
145        Some(comms.iter().any(|c| self.compositors.contains(c)))
146    }
147
148    /// §10 foreground signals: a GameMode client for the uid of an active
149    /// local session, or a gamescope in the session. (The idle-inhibitor and
150    /// Do Not Disturb signals have no session-independent source yet.)
151    fn foreground(&self) -> Vec<String> {
152        let Ok(sessions) = self.sessions() else { return vec![] };
153        sessions
154            .into_iter()
155            .filter(|s| s.establishes() && s.active && s.seat.is_some())
156            .filter(|s| {
157                self.session_comms(s).is_some_and(|c| c.iter().any(|n| n == "gamescope")) || gamemode_clients(s.uid) > 0
158            })
159            .map(|s| s.id)
160            .collect()
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn compositor_in_the_session_scope() {
170        let root = std::env::temp_dir().join(format!("planarian-cg-{}", std::process::id()));
171        let scope = root.join("user.slice/user-1000.slice/session-7.scope");
172        std::fs::create_dir_all(&scope).unwrap();
173        // This test process stands in for the session's only process.
174        std::fs::write(scope.join("cgroup.procs"), format!("{}\n", std::process::id())).unwrap();
175        let me = std::fs::read_to_string("/proc/self/comm").unwrap().trim().to_string();
176        let s = SessionInfo {
177            id: "7".into(),
178            uid: 1000,
179            class: "user".into(),
180            seat: Some("seat0".into()),
181            remote: false,
182            created: Timestamp(0),
183            active: true,
184            idle: false,
185        };
186        let l = Logind { compositors: vec![], cgroup_root: root.clone() };
187        assert_eq!(l.compositor_running(&s), None, "no list: cannot judge");
188        let l = Logind { compositors: vec![me], cgroup_root: root.clone() };
189        assert_eq!(l.compositor_running(&s), Some(true));
190        let l = Logind { compositors: vec!["cosmic-comp".into()], cgroup_root: root.clone() };
191        assert_eq!(l.compositor_running(&s), Some(false));
192        let other = SessionInfo { id: "8".into(), ..s };
193        assert_eq!(l.compositor_running(&other), None, "no such scope");
194        std::fs::remove_dir_all(root).unwrap();
195    }
196
197    #[test]
198    fn one_property_flag_each() {
199        let a = show_session_argv("2");
200        assert!(a.iter().all(|x| !x.starts_with("--property=") || !x.contains(',')), "{a:?}");
201        // What systemd 260 prints for that form, in its own order.
202        let s = parse_session("2", "User=1000\nSeat=seat0\nRemote=no\nClass=user\n").unwrap();
203        assert!(s.establishes());
204    }
205
206    #[test]
207    fn parses() {
208        assert_eq!(parse_list("  2 1000 alice seat0 tty2\nc1 990 greeter seat0 tty1\n"), vec!["2", "c1"]);
209        let s = parse_session("2", "User=1000\nClass=user\nSeat=seat0\nRemote=no\nTimestampUSec=1700000000000000\n")
210            .unwrap();
211        assert!(s.establishes());
212        assert_eq!(s.created, Timestamp(1_700_000_000_000));
213        let g = parse_session("c1", "User=990\nClass=greeter\nSeat=seat0\nRemote=no\n").unwrap();
214        assert!(!g.establishes());
215        let r = parse_session("5", "User=1000\nClass=user\nSeat=\nRemote=yes\n").unwrap();
216        assert!(r.establishes() && r.seat.is_none());
217    }
218}