Fix greeter session start and login flow
greetd only launches the session after the greeter process exits. Quit
on successful StartSession instead of sitting on "Starting session…"
until SIGTERM.
Empty Secret/Visible answers are Some("") (not PAM cancel). The greetd
actor stays up across connect failure and reconnects. Escape cancels
the conversation. Session env sets XDG_SESSION_TYPE/DESKTOP. .desktop
parsing honors Hidden/NoDisplay/TryExec and quoted Exec=. Invalid TOML
warns instead of failing silent. Font, date, and Ken Burns config match
what the greeter actually draws.
This commit is contained in:
parent
6925e132fd
commit
495d7f8446
9 changed files with 763 additions and 168 deletions
|
|
@ -80,14 +80,23 @@ impl Default for Font {
|
|||
}
|
||||
}
|
||||
|
||||
/// Reads and parses a TOML config file, falling back to `T::default()` if the
|
||||
/// file is missing or malformed — every bread* app runs with sensible
|
||||
/// defaults and no required config.
|
||||
/// Reads and parses a TOML config file. A missing file is a silent
|
||||
/// `T::default()`; a present but malformed file prints a warning (with the
|
||||
/// path) and also falls back to `T::default()`.
|
||||
pub fn load_or_default<T: serde::de::DeserializeOwned + Default>(path: &Path) -> T {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|s| toml::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(s) => match toml::from_str(&s) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"warning: failed to parse {}: {err} — using defaults",
|
||||
path.display()
|
||||
);
|
||||
T::default()
|
||||
}
|
||||
},
|
||||
Err(_) => T::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -112,11 +121,27 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn parses_partial_toml_with_defaults_for_rest() {
|
||||
let dir = std::env::temp_dir().join("breadlock-ui-test-partial.toml");
|
||||
std::fs::write(&dir, "[clock]\nformat = \"%I:%M %p\"\n").unwrap();
|
||||
let a: Appearance = load_or_default(&dir);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"breadlock-ui-test-partial-{}.toml",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::write(&path, "[clock]\nformat = \"%I:%M %p\"\n").unwrap();
|
||||
let a: Appearance = load_or_default(&path);
|
||||
assert_eq!(a.clock.format, "%I:%M %p");
|
||||
assert_eq!(a.background.mode, BackgroundMode::Color);
|
||||
std::fs::remove_file(&dir).ok();
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_toml_falls_back_to_default() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"breadlock-ui-test-invalid-{}.toml",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::write(&path, "this is not = toml [[[").unwrap();
|
||||
let a: Appearance = load_or_default(&path);
|
||||
assert_eq!(a.clock.format, "%H:%M");
|
||||
assert_eq!(a.font.family, "Varela Round");
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
//! Minimal freedesktop `.desktop` entry parsing — just enough to discover
|
||||
//! session launchers (`Name=`, `Exec=`, `Type=`) under
|
||||
//! `/usr/share/wayland-sessions` and `/usr/share/xsessions`. BOS only ships
|
||||
//! one session today, so this deliberately doesn't handle the full spec
|
||||
//! (localized `Name[xx]=`, `Exec=` quoting/field codes, `Actions=`, etc.) —
|
||||
//! only the three keys a greeter needs to list and launch a session.
|
||||
//! `/usr/share/wayland-sessions` and `/usr/share/xsessions`. Also honours
|
||||
//! `Hidden=` / `NoDisplay=` / `TryExec=` so we don't offer sessions that
|
||||
//! menus would skip. Localized `Name[xx]=` and `Actions=` are out of scope.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
|
|
@ -12,14 +11,21 @@ pub struct DesktopEntry {
|
|||
pub name: String,
|
||||
pub exec: String,
|
||||
pub entry_type: String,
|
||||
/// `TryExec=` if present — [`scan_dir`] skips the entry when this
|
||||
/// binary is missing from disk/`PATH`.
|
||||
pub try_exec: Option<String>,
|
||||
}
|
||||
|
||||
/// Parses the `[Desktop Entry]` section of a `.desktop` file's contents.
|
||||
/// Returns `None` if `Name=` or `Exec=` is missing.
|
||||
/// Returns `None` if `Name=` or `Exec=` is missing, or if `Hidden=true` /
|
||||
/// `NoDisplay=true`.
|
||||
pub fn parse(contents: &str) -> Option<DesktopEntry> {
|
||||
let mut name = None;
|
||||
let mut exec = None;
|
||||
let mut entry_type = None;
|
||||
let mut try_exec = None;
|
||||
let mut hidden = false;
|
||||
let mut no_display = false;
|
||||
let mut in_desktop_entry = false;
|
||||
|
||||
for line in contents.lines() {
|
||||
|
|
@ -39,21 +45,39 @@ pub fn parse(contents: &str) -> Option<DesktopEntry> {
|
|||
"Name" => name = Some(value.trim().to_string()),
|
||||
"Exec" => exec = Some(value.trim().to_string()),
|
||||
"Type" => entry_type = Some(value.trim().to_string()),
|
||||
"TryExec" => {
|
||||
let v = value.trim();
|
||||
if !v.is_empty() {
|
||||
try_exec = Some(v.to_string());
|
||||
}
|
||||
}
|
||||
"Hidden" => hidden = is_desktop_true(value),
|
||||
"NoDisplay" => no_display = is_desktop_true(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hidden || no_display {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(DesktopEntry {
|
||||
name: name?,
|
||||
exec: exec?,
|
||||
entry_type: entry_type.unwrap_or_else(|| "Application".to_string()),
|
||||
try_exec,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_desktop_true(value: &str) -> bool {
|
||||
value.trim().eq_ignore_ascii_case("true")
|
||||
}
|
||||
|
||||
/// Scans a directory for `*.desktop` files, returning `(file stem, entry)`
|
||||
/// pairs. Unreadable directories and unparsable entries are silently skipped
|
||||
/// — a missing session directory is normal (e.g. no X11 sessions installed).
|
||||
/// Entries whose `TryExec=` binary is missing are skipped too.
|
||||
pub fn scan_dir(dir: &Path) -> Vec<(String, DesktopEntry)> {
|
||||
let Ok(read_dir) = std::fs::read_dir(dir) else {
|
||||
return Vec::new();
|
||||
|
|
@ -65,7 +89,13 @@ pub fn scan_dir(dir: &Path) -> Vec<(String, DesktopEntry)> {
|
|||
.filter_map(|e| {
|
||||
let stem = e.path().file_stem()?.to_str()?.to_string();
|
||||
let contents = std::fs::read_to_string(e.path()).ok()?;
|
||||
Some((stem, parse(&contents)?))
|
||||
let entry = parse(&contents)?;
|
||||
if let Some(ref te) = entry.try_exec {
|
||||
if !command_exists(te) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some((stem, entry))
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
|
@ -73,6 +103,25 @@ pub fn scan_dir(dir: &Path) -> Vec<(String, DesktopEntry)> {
|
|||
entries
|
||||
}
|
||||
|
||||
fn command_exists(cmd: &str) -> bool {
|
||||
if cmd.contains('/') {
|
||||
is_runnable(Path::new(cmd))
|
||||
} else {
|
||||
match std::env::var_os("PATH") {
|
||||
Some(paths) => std::env::split_paths(&paths).any(|dir| is_runnable(&dir.join(cmd))),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_runnable(path: &Path) -> bool {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let Ok(meta) = std::fs::metadata(path) else {
|
||||
return false;
|
||||
};
|
||||
meta.is_file() && meta.permissions().mode() & 0o111 != 0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -83,12 +132,26 @@ mod tests {
|
|||
Exec=Hyprland\n\
|
||||
Type=Application\n";
|
||||
|
||||
fn unique_temp_dir(name: &str) -> std::path::PathBuf {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"breadlock-ui-test-sessions-{name}-{}-{}",
|
||||
std::process::id(),
|
||||
SEQ.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_name_exec_type() {
|
||||
let e = parse(HYPRLAND_DESKTOP).unwrap();
|
||||
assert_eq!(e.name, "Hyprland");
|
||||
assert_eq!(e.exec, "Hyprland");
|
||||
assert_eq!(e.entry_type, "Application");
|
||||
assert_eq!(e.try_exec, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -111,6 +174,14 @@ mod tests {
|
|||
assert_eq!(e.entry_type, "Application");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_or_nodisplay_returns_none() {
|
||||
assert!(parse("[Desktop Entry]\nName=X\nExec=x\nHidden=true\n").is_none());
|
||||
assert!(parse("[Desktop Entry]\nName=X\nExec=x\nNoDisplay=true\n").is_none());
|
||||
assert!(parse("[Desktop Entry]\nName=X\nExec=x\nHidden=false\n").is_some());
|
||||
assert!(parse("[Desktop Entry]\nName=X\nExec=x\nNoDisplay=false\n").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_dir_on_missing_directory_returns_empty() {
|
||||
assert!(scan_dir(Path::new("/nonexistent/wayland-sessions")).is_empty());
|
||||
|
|
@ -118,8 +189,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn scan_dir_finds_and_sorts_desktop_files() {
|
||||
let dir = std::env::temp_dir().join("breadlock-ui-test-sessions");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let dir = unique_temp_dir("scan");
|
||||
std::fs::write(dir.join("zzz.desktop"), HYPRLAND_DESKTOP).unwrap();
|
||||
std::fs::write(dir.join("aaa.desktop"), "[Desktop Entry]\nName=A\nExec=a\n").unwrap();
|
||||
std::fs::write(dir.join("not-a-session.txt"), "ignored").unwrap();
|
||||
|
|
@ -131,4 +201,35 @@ mod tests {
|
|||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_dir_skips_hidden_nodisplay_and_missing_tryexec() {
|
||||
let dir = unique_temp_dir("skip");
|
||||
std::fs::write(
|
||||
dir.join("hidden.desktop"),
|
||||
"[Desktop Entry]\nName=Hidden\nExec=hidden\nHidden=true\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.join("nodisp.desktop"),
|
||||
"[Desktop Entry]\nName=NoDisp\nExec=nodisp\nNoDisplay=true\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.join("gone.desktop"),
|
||||
"[Desktop Entry]\nName=Gone\nExec=gone\nTryExec=/no/such/breadlock-tryexec\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.join("ok.desktop"),
|
||||
"[Desktop Entry]\nName=Ok\nExec=ok\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let found = scan_dir(&dir);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].0, "ok");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue