Skip to main content

wallswitch/backends/
detector.rs

1use crate::{Config, Desktop, WallSwitchError, WallSwitchResult, exec_cmd, is_installed};
2use std::{fs, path::PathBuf, process::Command};
3
4/// Detects active outputs (monitors) using a robust fallback chain.
5/// Returns a WallSwitchResult wrapping a Vector of monitor property strings.
6///
7/// If all detection methods fail, it returns a formal WallSwitchError instead of
8/// relying on hardcoded defaults.
9pub fn detect_monitors(config: &Config) -> WallSwitchResult<Vec<String>> {
10    let mut monitors = Vec::new();
11
12    // 1. Try Desktop-specific tools first
13    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            // Self-healing: remove orphan properties before detection
28            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    // 2. Generic Wayland fallback (wlr-randr)
53    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    // 3. Hardware fallback (DRM Sysfs - Linux Kernel)
63    monitors = detect_drm_monitors();
64    if !monitors.is_empty() {
65        return Ok(monitors);
66    }
67
68    // 4. Fatal Error: If we reached this point, no active monitors were found.
69    // Instead of using a fake default, we raise a NoMonitors error.
70    Err(WallSwitchError::NoMonitors(
71        "any system tool (X11/Wayland/DRM)".to_string(),
72    ))
73}
74
75/// Get active X11 monitors via xrandr to filter out stale configurations.
76///
77/// Runs `xrandr --listactivemonitors` and safely parses the output.
78pub 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
93/*
94┌─[claudio@manjaro] - [~/Documents/Rust/projects/wallswitch] - [seg mai 18, 10:16]
95└─[$] <git:(master*)> xrandr --listactivemonitors
96Monitors: 2
97 0: +DP-2 3840/621x2160/341+0+0  DP-2
98 1: +DP-0 3840/621x2160/341+3840+0  DP-0
99┌─[claudio@manjaro] - [~/Documents/Rust/projects/wallswitch] - [seg mai 18, 10:16]
100└─[$] <git:(master*)>
101*/
102
103pub 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
121/// XFCE Logic: Matches active hardware (xrandr) with XFCE properties.
122/// If a monitor is active but has no XFCE property yet, we synthesize one.
123pub 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        // Check if XFCE already has a property for this monitor
143        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            // CRITICAL FIX: If monitor exists in xrandr but NOT in xfconf,
149            // we create the expected path based on XFCE 4.18+ standards.
150            // This allows the program to "force-set" the wallpaper on new monitors.
151            let synthesized = format!("/backdrop/screen0/monitor{}/workspace0/last-image", m);
152            final_properties.push(synthesized);
153        }
154    }
155
156    // Deduplicate in case multiple matches occurred
157    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
220/// Pure parser for Niri output
221pub 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
237/// Pure parser for Hyprland output
238pub 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
246/// Pure parser for wlr-randr output
247pub 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
255/// Hardware DRM parser
256fn 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//----------------------------------------------------------------------------//
278//                                   Tests                                    //
279//----------------------------------------------------------------------------//
280
281/// Run tests with:
282/// cargo test -- --show-output tests_detector
283#[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        // Mocked xrandr output with malicious/unexpected edge cases
292        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        // Mocked Niri output
301        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        // Mocked Hyprland output
309        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        // Mocked wlr-randr output
317        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        // Mocked XFCE output with stale/duplicate entries
325        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        // It MUST prioritize prefix_match (/monitorDP-2/) over exact_match (/DP-2/)
335        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        // Test fallback if xrandr doesn't match anything
342        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}