planarian_backend_linux/
logind.rs1use 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 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 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
42fn 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 o.stdout.split_whitespace().nth(1).and_then(|n| n.parse().ok()).unwrap_or(0)
64}
65
66pub 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 created: Timestamp(get("TimestampUSec").parse::<u64>().map(|us| us / 1000).unwrap_or(0)),
81 })
82}
83
84pub 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
92pub 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 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 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 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 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}