~goorzhel/radm

radm/src/session.rs -rw-r--r-- 7.6 KiB
d11d708e — Antonio Gurgel 0.6.4 30 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
//! Desktop entries.

use std::{
    fmt::{Debug, Display},
    fs::{create_dir_all, read_dir, OpenOptions},
    io::{Read, Write},
    path::Path,
    str::FromStr,
    thread::sleep,
    time::Duration,
};

use anyhow::{anyhow, Context, Error, Result};
use configparser::ini::Ini;
use log::{error, trace, warn};
use xdg::BaseDirectories;

use crate::tui::{prompt, EchoInput};

/// Converts a `Result<T, E>` to an `Option<T>`, and logs `E` if existent.
fn log_if_err<T, E: Display>(result: Result<T, E>, msg: String) -> Option<T> {
    match result {
        Ok(v) => Some(v),
        Err(e) => {
            error!("{}: {}", msg, e);
            None
        }
    }
}

/// Searches directories non-recursively for session files, returning any that parse correctly
/// or an `Err` if none found.
pub fn find_sessions<P: AsRef<Path> + Debug>(dirs: &[P]) -> Result<Vec<Desktop>> {
    let mut out = dirs
        .iter()
        .filter_map(|path| log_if_err(read_dir(path), format!("Couldn't read dir {:?}", path)))
        .flat_map(|dir| {
            dir.filter_map(|entry| log_if_err(entry, "Couldn't read an entry (I/O error)".into()))
        })
        .inspect(|file| trace!("Parsing desktop file {}", file.path().to_string_lossy()))
        .filter_map(|file| {
            let path = file.path();
            log_if_err(
                Desktop::from_path(&path),
                format!("Couldn't parse desktop file {}", path.to_string_lossy()),
            )
        })
        .collect::<Vec<_>>();
    if out.is_empty() {
        return Err(anyhow!("Found no session files in: {:?}", dirs));
    }
    out.sort();
    Ok(out)
}

/// Reads a session name from `$XDG_CACHE_HOME/radm-last`.
///
/// Additionally, creates `$XDG_CACHE_HOME` if it doesn't already exist.
pub fn get_cached_session(xdg: &BaseDirectories) -> Result<String> {
    let mut cache = xdg.get_cache_home();
    if !cache.exists() {
        create_dir_all(&cache).context("Couldn't create XDG_CACHE_HOME")?;
        warn!(
            "XDG_CACHE_HOME ({:?}) nonexistent; created it for next time.",
            cache
        );
        return Ok(String::new());
    }

    cache.push("radm-last");
    let mut file = OpenOptions::new()
        .read(true)
        .write(true)
        .create(true)
        .open(&cache)
        .context("Couldn't open session cache")?;

    let mut cached_name = String::new();
    file.read_to_string(&mut cached_name)
        .context("Couldn't read session cache")?;

    Ok(cached_name)
}

/// Prompts user to choose a session from a numbered list.
///
/// If `sessions` has one item, it will be returned automatically. If zero, an error is returned.
pub fn choose_session(sessions: &[Desktop], xdg: &BaseDirectories) -> Result<Desktop> {
    if sessions.is_empty() {
        return Err(anyhow!("Cannot choose among zero sessions"));
    }

    if sessions.len() == 1 {
        let session = sessions[0].clone();
        println!(
            "Found only one session ({}); starting it now.",
            session.name
        );
        sleep(Duration::from_secs(1));
        return Ok(session);
    }

    let cached_name = get_cached_session(xdg).unwrap_or_default();

    let mut cached_choice: Option<usize> = None;
    for (i, session) in sessions.iter().enumerate().map(|(i, s)| (i + 1, s)) {
        if session.name == cached_name {
            cached_choice = Some(i)
        }
        match &session.comment {
            // I thought about implementing this as Desktop::display, but this use case is too
            // specific to warrant that.
            Some(comment) => {
                println!("{:2}. {:15} {}", i, &session.name, comment)
            }
            None => println!("{:2}. {}", i, &session.name),
        }
    }

    loop {
        let choice = match cached_choice {
            Some(c) => {
                let input = prompt(format!("Desktop choice [{}]", c), EchoInput::Echo)?;
                match input.is_empty() {
                    true => c.to_string(),
                    false => input,
                }
            }
            None => prompt("Desktop choice", EchoInput::Echo)?,
        };
        if let Ok(i) = usize::from_str(&choice) {
            if let Some(session) = sessions.get(i - 1) {
                cache_session(xdg, session)
                    .map_err(|e| {
                        let msg = format!("Couldn't cache session: {}", e);
                        eprintln!("{}", msg);
                        error!("{}", msg);
                    })
                    .ok();

                return Ok(session.clone());
            }
        }
    }
}

/// Writes the name of the chosen session to `$XDG_CACHE_HOME/radm-last`.
fn cache_session(xdg: &BaseDirectories, session: &Desktop) -> Result<()> {
    let mut cache = xdg.get_cache_home();
    cache.push("radm-last");
    let mut file = OpenOptions::new()
        .write(true)
        .create(true)
        .truncate(true)
        .open(&cache)
        .context("Couldn't open cache for writing")?;
    file.write_all(session.name.as_bytes())
        .context("Couldn't write to cache")?;
    Ok(())
}

/// A representation of a [desktop entry](https://specifications.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html) file.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Desktop {
    pub name: String,
    pub comment: Option<String>,
    pub exec: String, // PathBuf was overkill; I spent more time converting it to a String than using its features.
    pub protocol: Protocol,
}

/// The protocol with which to run a desktop environment.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum Protocol {
    Wayland,
    X11,
}

/// Detects the display protocol a desktop file is meant for by inspecting its path.
impl Protocol {
    fn from_path<P: AsRef<Path>>(path: P) -> Result<Self> {
        let path = path.as_ref().to_string_lossy();
        if path.contains("wayland-sessions") {
            return Ok(Self::Wayland);
        }
        if path.contains("xsessions") {
            return Ok(Self::X11);
        }
        Err(anyhow!("Couldn't guess at display protocol of {}", path))
    }
}
impl Desktop {
    /// Reads a desktop file from a path.
    fn from_path<P: AsRef<Path>>(path: P) -> Result<Self> {
        let mut out = Ini::new();
        let path = path.as_ref();

        out.load(path).map_err(Error::msg)?;
        let protocol = Protocol::from_path(path)?;
        let name = out
            .get("desktop entry", "name")
            .unwrap_or_else(|| "<unnamed>".to_string());
        let exec = out
            .get("desktop entry", "exec")
            .ok_or_else(|| anyhow!("No exec defined for this desktop entry"))?;
        let comment = out.get("desktop entry", "comment");
        Ok(Self {
            name,
            exec,
            comment,
            protocol,
        })
    }
}

#[cfg(test)]
mod test {
    use std::path::Path;

    use super::find_sessions;

    const WAYLAND_PATHS: [&str; 2] = [
        "/usr/share/wayland-sessions/",
        "/usr/local/share/wayland-sessions",
    ];
    const BOGUS_PATHS: [&str; 4] = ["\n", "aaaaaa", "🦀/wayland-sessions/", "/"];

    #[test]
    fn find_sessions_finds_sessions() {
        // Accomodate headless builders, etc.
        let a_wayland_path_exists = WAYLAND_PATHS.iter().any(|p| Path::new(p).exists());

        if a_wayland_path_exists {
            let result = find_sessions(&WAYLAND_PATHS);
            assert!(result.is_ok())
        }
    }

    #[test]
    fn find_sessions_bails_when_none_available() {
        let result = find_sessions(&BOGUS_PATHS);
        assert!(result
            .err()
            .unwrap()
            .to_string()
            .starts_with("Found no session files in"));
    }
}