wallswitch/backends/
detector.rs1use crate::{Config, Desktop, WallSwitchError, WallSwitchResult, exec_cmd, is_installed};
2use std::{fs, path::PathBuf, process::Command};
3
4pub fn detect_monitors(config: &Config) -> WallSwitchResult<Vec<String>> {
10 let mut monitors = Vec::new();
11
12 match config.desktop {
14 Desktop::Niri if is_installed("niri") => {
15 if let Ok(out) = Command::new("niri").args(["msg", "outputs"]).output() {
16 monitors = parse_niri(&String::from_utf8_lossy(&out.stdout));
17 }
18 }
19 Desktop::Hyprland if is_installed("hyprctl") => {
20 if let Ok(out) = Command::new("hyprctl").arg("monitors").output() {
21 monitors = parse_hyprland(&String::from_utf8_lossy(&out.stdout));
22 }
23 }
24 Desktop::Xfce if is_installed("xfconf-query") => {
25 let active_xrandr_monitors = get_active_xrandr_monitors(config);
26
27 prune_stale_xfce_configs(config, &active_xrandr_monitors)?;
29
30 let mut cmd = Command::new("xfconf-query");
31 cmd.args([
32 "--channel",
33 "xfce4-desktop",
34 "--property",
35 "/backdrop",
36 "--list",
37 ]);
38 if let Ok(out) = exec_cmd(&mut cmd, config.verbose, "xfconf-query") {
39 monitors = parse_xfce(
40 &String::from_utf8_lossy(&out.stdout),
41 &active_xrandr_monitors,
42 );
43 }
44 }
45 _ => {}
46 }
47
48 if !monitors.is_empty() {
49 return Ok(monitors);
50 }
51
52 if is_installed("wlr-randr")
54 && let Ok(out) = Command::new("wlr-randr").output()
55 {
56 monitors = parse_wlr_randr(&String::from_utf8_lossy(&out.stdout));
57 if !monitors.is_empty() {
58 return Ok(monitors);
59 }
60 }
61
62 monitors = detect_drm_monitors();
64 if !monitors.is_empty() {
65 return Ok(monitors);
66 }
67
68 Err(WallSwitchError::NoMonitors(
71 "any system tool (X11/Wayland/DRM)".to_string(),
72 ))
73}
74
75pub fn get_active_xrandr_monitors(config: &Config) -> Vec<String> {
79 let mut monitors = Vec::new();
80
81 if is_installed("xrandr") {
82 let mut cmd = Command::new("xrandr");
83 cmd.args(["--listactivemonitors"]);
84 if let Ok(out) = exec_cmd(&mut cmd, config.verbose, "xrandr") {
85 let stdout = String::from_utf8_lossy(&out.stdout);
86 monitors = parse_xrandr(&stdout);
87 }
88 }
89
90 monitors
91}
92
93pub fn parse_xrandr(stdout: &str) -> Vec<String> {
104 stdout
105 .lines()
106 .filter_map(|line| {
107 let tokens: Vec<&str> = line.split_whitespace().collect();
108 if tokens.len() >= 3 {
109 let first_token = tokens[0];
110 if let Some(prefix) = first_token.strip_suffix(':')
111 && prefix.parse::<usize>().is_ok()
112 {
113 return tokens.last().map(|&s| s.to_string());
114 }
115 }
116 None
117 })
118 .collect()
119}
120
121pub fn parse_xfce(stdout: &str, active_monitors: &[String]) -> Vec<String> {
124 let words = ["screen0", "workspace0", "last-image"];
125 let existing_props: Vec<String> = stdout
126 .trim()
127 .split(['\n', ' '])
128 .filter(|out| words.iter().all(|w| out.contains(w)))
129 .map(String::from)
130 .collect();
131
132 if active_monitors.is_empty() {
133 return existing_props;
134 }
135
136 let mut final_properties = Vec::new();
137
138 for m in active_monitors {
139 let prefix_match = format!("/monitor{}/", m);
140 let exact_match = format!("/{}/", m);
141
142 if let Some(prop) = existing_props.iter().find(|p| p.contains(&prefix_match)) {
144 final_properties.push(prop.clone());
145 } else if let Some(prop) = existing_props.iter().find(|p| p.contains(&exact_match)) {
146 final_properties.push(prop.clone());
147 } else {
148 let synthesized = format!("/backdrop/screen0/monitor{}/workspace0/last-image", m);
152 final_properties.push(synthesized);
153 }
154 }
155
156 final_properties.sort();
158 final_properties.dedup();
159 final_properties
160}
161
162pub fn prune_stale_xfce_configs(
163 config: &Config,
164 active_monitors: &[String],
165) -> WallSwitchResult<()> {
166 if active_monitors.is_empty() {
167 return Ok(());
168 }
169
170 let mut cmd = Command::new("xfconf-query");
171 cmd.args([
172 "--channel",
173 "xfce4-desktop",
174 "--property",
175 "/backdrop",
176 "--list",
177 ]);
178
179 if let Ok(output) = cmd.output() {
180 let stdout = String::from_utf8_lossy(&output.stdout);
181 let stale_properties: Vec<String> = stdout
182 .lines()
183 .filter(|prop| prop.contains("workspace0/last-image"))
184 .filter(|prop| {
185 !active_monitors.iter().any(|m| {
186 prop.contains(&format!("/{}/", m)) || prop.contains(&format!("/monitor{}/", m))
187 })
188 })
189 .map(String::from)
190 .collect();
191
192 for property in stale_properties {
193 if let Some(monitor_root) = property.split("/workspace0").next() {
194 if config.verbose {
195 println!("Pruning: {}", monitor_root);
196 }
197 if config.dry_run {
198 println!(
199 "[DRY-RUN] Would reset stale XFCE property: {}",
200 monitor_root
201 );
202 } else {
203 let _ = Command::new("xfconf-query")
204 .args([
205 "--channel",
206 "xfce4-desktop",
207 "--property",
208 monitor_root,
209 "--reset",
210 "--recursive",
211 ])
212 .output();
213 }
214 }
215 }
216 }
217 Ok(())
218}
219
220pub fn parse_niri(stdout: &str) -> Vec<String> {
222 stdout
223 .lines()
224 .filter(|line| line.starts_with("Output"))
225 .filter_map(|line| {
226 let start = line.rfind('(')?;
227 let end = line.rfind(')')?;
228 if start < end {
229 Some(line[start + 1..end].to_string())
230 } else {
231 None
232 }
233 })
234 .collect()
235}
236
237pub fn parse_hyprland(stdout: &str) -> Vec<String> {
239 stdout
240 .lines()
241 .filter(|line| line.starts_with("Monitor"))
242 .filter_map(|line| line.split_whitespace().nth(1).map(String::from))
243 .collect()
244}
245
246pub fn parse_wlr_randr(stdout: &str) -> Vec<String> {
248 stdout
249 .lines()
250 .filter(|line| !line.starts_with(' ') && !line.is_empty())
251 .filter_map(|line| line.split_whitespace().next().map(String::from))
252 .collect()
253}
254
255fn detect_drm_monitors() -> Vec<String> {
257 let mut monitors = Vec::new();
258 let drm_path = PathBuf::from("/sys/class/drm");
259
260 if let Ok(entries) = fs::read_dir(drm_path) {
261 for entry in entries.flatten() {
262 let name = entry.file_name().to_string_lossy().to_string();
263 if name.starts_with("card") && name.contains('-') {
264 let status_path = entry.path().join("status");
265 if let Ok(status) = fs::read_to_string(status_path)
266 && status.trim() == "connected"
267 && let Some(idx) = name.find('-')
268 {
269 monitors.push(name[idx + 1..].to_string());
270 }
271 }
272 }
273 }
274 monitors
275}
276
277#[cfg(test)]
284mod tests_detector {
285 use super::*;
286
287 #[test]
288 fn test_monitor_parsers() {
289 let expected = vec!["DP-1", "DP-2"];
290
291 let xrandr_mock = "\
293Monitors: 2
294 0: +DP-1 3840/621x2160/341+0+0 DP-1
295 Error: bla bla
296 A: +DP-2 3840/621x2160/341+0+0 DP-X
297 1: +DP-2 3840/621x2160/341+3840+0 DP-2";
298 assert_eq!(parse_xrandr(xrandr_mock), expected);
299
300 let niri_mock = "\
302Output eDP-1 (DP-1)
303 Mode: 1920x1080
304Output HDMI-A-1 (DP-2)
305 Mode: 1920x1080";
306 assert_eq!(parse_niri(niri_mock), expected);
307
308 let hypr_mock = "\
310Monitor DP-1 (ID 0):
311 1920x1080@60.00000
312Monitor DP-2 (ID 1):
313 1920x1080@60.00000";
314 assert_eq!(parse_hyprland(hypr_mock), expected);
315
316 let wlr_mock = "\
318DP-1 \"Manufacturer X\"
319 Position: 0,0
320DP-2 \"Manufacturer Y\"
321 Position: 1920,0";
322 assert_eq!(parse_wlr_randr(wlr_mock), expected);
323
324 let xfce_mock = "\
326/backdrop/screen0/monitorASUSPB287Q/workspace0/last-image
327/backdrop/screen0/monitorDP-1/workspace0/last-image
328/backdrop/screen0/DP-2/workspace0/last-image
329/backdrop/screen0/monitorDP-2/workspace0/last-image
330/backdrop/screen0/monitorDP-1/workspace0/color-style";
331
332 let active = vec!["DP-1".to_string(), "DP-2".to_string()];
333
334 let xfce_expected = vec![
336 "/backdrop/screen0/monitorDP-1/workspace0/last-image",
337 "/backdrop/screen0/monitorDP-2/workspace0/last-image",
338 ];
339 assert_eq!(parse_xfce(xfce_mock, &active), xfce_expected);
340
341 let empty_active = vec![];
343 let xfce_expected_fallback = vec![
344 "/backdrop/screen0/monitorASUSPB287Q/workspace0/last-image",
345 "/backdrop/screen0/monitorDP-1/workspace0/last-image",
346 "/backdrop/screen0/DP-2/workspace0/last-image",
347 "/backdrop/screen0/monitorDP-2/workspace0/last-image",
348 ];
349 assert_eq!(parse_xfce(xfce_mock, &empty_active), xfce_expected_fallback);
350 }
351}