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:
Breadway 2026-08-23 14:21:29 +08:00
parent 6925e132fd
commit 495d7f8446
9 changed files with 763 additions and 168 deletions

View file

@ -9,7 +9,8 @@
mode = "color" mode = "color"
path = "" path = ""
blur = false blur = false
# (Ken Burns is a breadlock-only background effect; not used by the greeter.) # Slow Ken Burns pan on image backgrounds (gentle drift + zoom). Opt-in: the
# background redraws continuously at a low frame rate.
ken_burns = false ken_burns = false
[clock] [clock]

View file

@ -42,12 +42,15 @@ pub fn load() -> Config {
breadlock_ui::config::load_or_default(&xdg_config_path()) breadlock_ui::config::load_or_default(&xdg_config_path())
} }
fn xdg_config_path() -> PathBuf { pub(crate) fn xdg_config_dir() -> PathBuf {
let base = std::env::var_os("XDG_CONFIG_HOME") std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from) .map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
.unwrap_or_else(|| PathBuf::from(".")); .unwrap_or_else(|| PathBuf::from("."))
base.join("breadgreet").join("breadgreet.toml") }
fn xdg_config_path() -> PathBuf {
xdg_config_dir().join("breadgreet").join("breadgreet.toml")
} }
#[cfg(test)] #[cfg(test)]

View file

@ -59,7 +59,7 @@ impl Client {
/// used directly by tests against a mock server so they don't need to /// used directly by tests against a mock server so they don't need to
/// mutate process-global environment state (which parallel `cargo test` /// mutate process-global environment state (which parallel `cargo test`
/// threads would race on). /// threads would race on).
async fn connect_to(path: impl AsRef<std::path::Path>) -> Result<Self, GreetdError> { pub(crate) async fn connect_to(path: impl AsRef<std::path::Path>) -> Result<Self, GreetdError> {
let stream = UnixStream::connect(path) let stream = UnixStream::connect(path)
.await .await
.map_err(GreetdError::Connect)?; .map_err(GreetdError::Connect)?;
@ -149,10 +149,21 @@ mod tests {
//! PAM involved. This is the safe way to test this module: a bug here //! PAM involved. This is the safe way to test this module: a bug here
//! just fails a test, it can never affect a real login. //! just fails a test, it can never affect a real login.
use super::*; use super::*;
use greetd_ipc::codec::TokioCodec;
use greetd_ipc::{AuthMessageType, ErrorType, Request, Response};
use tokio::net::UnixListener; use tokio::net::UnixListener;
async fn mock_server(path: std::path::PathBuf, script: Vec<Response>) { fn bind_socket(name: &str) -> (std::path::PathBuf, UnixListener) {
let path = std::env::temp_dir().join(format!(
"breadgreet-test-{name}-{}.sock",
std::process::id()
));
std::fs::remove_file(&path).ok();
let listener = UnixListener::bind(&path).unwrap(); let listener = UnixListener::bind(&path).unwrap();
(path, listener)
}
async fn serve(listener: UnixListener, script: Vec<Response>) {
let (mut stream, _) = listener.accept().await.unwrap(); let (mut stream, _) = listener.accept().await.unwrap();
for response in script { for response in script {
// Drain the request that prompted this response — we don't need // Drain the request that prompted this response — we don't need
@ -162,21 +173,11 @@ mod tests {
} }
} }
fn socket_path(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"breadgreet-test-{name}-{}.sock",
std::process::id()
))
}
#[tokio::test] #[tokio::test]
async fn create_session_success_flows_straight_through() { async fn create_session_success_flows_straight_through() {
let path = socket_path("success"); let (path, listener) = bind_socket("success");
std::fs::remove_file(&path).ok(); let server = tokio::spawn(serve(listener, vec![Response::Success]));
let server = tokio::spawn(mock_server(path.clone(), vec![Response::Success]));
// Give the listener a moment to bind before connecting.
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
let mut client = Client::connect_to(&path).await.unwrap(); let mut client = Client::connect_to(&path).await.unwrap();
let outcome = client.create_session("bob").await.unwrap(); let outcome = client.create_session("bob").await.unwrap();
assert!(matches!(outcome, Outcome::Success)); assert!(matches!(outcome, Outcome::Success));
@ -187,10 +188,9 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn create_session_prompts_for_password_then_succeeds() { async fn create_session_prompts_for_password_then_succeeds() {
let path = socket_path("prompt"); let (path, listener) = bind_socket("prompt");
std::fs::remove_file(&path).ok(); let server = tokio::spawn(serve(
let server = tokio::spawn(mock_server( listener,
path.clone(),
vec![ vec![
Response::AuthMessage { Response::AuthMessage {
auth_message_type: AuthMessageType::Secret, auth_message_type: AuthMessageType::Secret,
@ -200,7 +200,6 @@ mod tests {
], ],
)); ));
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
let mut client = Client::connect_to(&path).await.unwrap(); let mut client = Client::connect_to(&path).await.unwrap();
let outcome = client.create_session("bob").await.unwrap(); let outcome = client.create_session("bob").await.unwrap();
@ -218,17 +217,15 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn auth_error_is_reported_as_such() { async fn auth_error_is_reported_as_such() {
let path = socket_path("autherr"); let (path, listener) = bind_socket("autherr");
std::fs::remove_file(&path).ok(); let server = tokio::spawn(serve(
let server = tokio::spawn(mock_server( listener,
path.clone(),
vec![Response::Error { vec![Response::Error {
error_type: ErrorType::AuthError, error_type: ErrorType::AuthError,
description: "denied".to_string(), description: "denied".to_string(),
}], }],
)); ));
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
let mut client = Client::connect_to(&path).await.unwrap(); let mut client = Client::connect_to(&path).await.unwrap();
let err = client.create_session("bob").await.unwrap_err(); let err = client.create_session("bob").await.unwrap_err();
@ -242,9 +239,43 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn connect_without_greetd_sock_env_fails_cleanly() { async fn connect_to_missing_socket_fails() {
std::env::remove_var("GREETD_SOCK"); let err = Client::connect_to("/no/such/breadgreet-test.sock")
let err = Client::connect().await.unwrap_err(); .await
assert!(matches!(err, GreetdError::NoSocketEnv)); .unwrap_err();
assert!(matches!(err, GreetdError::Connect(_)));
}
#[tokio::test]
async fn empty_password_is_sent_as_some_empty_string() {
let (path, listener) = bind_socket("empty-pw");
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let _ = Request::read_from(&mut stream).await;
Response::AuthMessage {
auth_message_type: AuthMessageType::Secret,
auth_message: "Password:".to_string(),
}
.write_to(&mut stream)
.await
.unwrap();
let req = Request::read_from(&mut stream).await.unwrap();
match req {
Request::PostAuthMessageResponse { response } => {
assert_eq!(response, Some(String::new()));
}
other => panic!("expected PostAuthMessageResponse, got {other:?}"),
}
Response::Success.write_to(&mut stream).await.unwrap();
});
let mut client = Client::connect_to(&path).await.unwrap();
let outcome = client.create_session("bob").await.unwrap();
assert!(matches!(outcome, Outcome::Prompt(AuthPrompt::Secret(_))));
let outcome = client.respond(Some(String::new())).await.unwrap();
assert!(matches!(outcome, Outcome::Success));
server.await.unwrap();
std::fs::remove_file(&path).ok();
} }
} }

View file

@ -1,3 +1,198 @@
mod client; mod client;
pub use client::{AuthPrompt, Client, Outcome}; pub use client::{AuthPrompt, Client, GreetdError, Outcome};
use std::future::Future;
use tokio::sync::mpsc;
/// Commands sent from the UI thread to the greetd actor, which owns the
/// single stateful connection to `$GREETD_SOCK`.
#[derive(Debug)]
pub enum Command {
CreateSession(String),
Respond(Option<String>),
StartSession { cmd: Vec<String>, env: Vec<String> },
CancelSession,
}
#[derive(Debug)]
pub enum Event {
Outcome(Outcome),
Error(String),
SessionStarted,
}
/// Owns the greetd connection for the life of the greeter. Connect failures
/// and a later-dead socket are reported as [`Event::Error`]; the actor stays
/// alive and reconnects on the next command so the UI cannot freeze with a
/// dropped `cmd_rx`.
pub async fn run_actor<E>(cmd_rx: mpsc::UnboundedReceiver<Command>, emit: E)
where
E: FnMut(Event) + Send + 'static,
{
run_actor_with(cmd_rx, emit, Client::connect).await;
}
async fn run_actor_with<E, C, Fut>(
mut cmd_rx: mpsc::UnboundedReceiver<Command>,
mut emit: E,
mut connect: C,
) where
E: FnMut(Event),
C: FnMut() -> Fut,
Fut: Future<Output = Result<Client, GreetdError>>,
{
let mut client: Option<Client> = match connect().await {
Ok(c) => Some(c),
Err(err) => {
emit(Event::Error(format!("Cannot reach greetd: {err}")));
None
}
};
while let Some(cmd) = cmd_rx.recv().await {
if matches!(cmd, Command::CancelSession) {
if let Some(c) = client.as_mut() {
c.cancel_session().await;
}
continue;
}
if client.is_none() {
match connect().await {
Ok(c) => client = Some(c),
Err(err) => {
emit(Event::Error(format!("Cannot reach greetd: {err}")));
continue;
}
}
}
let result = exec_cmd(client.as_mut().expect("just connected"), cmd).await;
match result {
CmdResult::Idle => {}
CmdResult::Started => emit(Event::SessionStarted),
CmdResult::Roundtrip(Ok(outcome)) => emit(Event::Outcome(outcome)),
CmdResult::Roundtrip(Err(err)) => {
if is_connection_error(&err) {
client = None;
} else if let Some(c) = client.as_mut() {
c.cancel_session().await;
}
emit(Event::Error(err.to_string()));
}
}
}
}
enum CmdResult {
Idle,
Started,
Roundtrip(Result<Outcome, GreetdError>),
}
async fn exec_cmd(client: &mut Client, cmd: Command) -> CmdResult {
match cmd {
Command::CancelSession => {
client.cancel_session().await;
CmdResult::Idle
}
Command::CreateSession(username) => {
CmdResult::Roundtrip(client.create_session(&username).await)
}
Command::Respond(answer) => CmdResult::Roundtrip(client.respond(answer).await),
Command::StartSession { cmd, env } => match client.start_session(cmd, env).await {
Ok(()) => CmdResult::Started,
Err(err) => CmdResult::Roundtrip(Err(err)),
},
}
}
fn is_connection_error(err: &GreetdError) -> bool {
matches!(
err,
GreetdError::Connect(_) | GreetdError::Codec(_) | GreetdError::NoSocketEnv
)
}
#[cfg(test)]
mod tests {
use super::*;
use greetd_ipc::codec::TokioCodec;
use greetd_ipc::{Request, Response};
use tokio::net::UnixListener;
fn sock(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"breadgreet-actor-{name}-{}.sock",
std::process::id()
))
}
#[tokio::test]
async fn connect_failure_does_not_drop_the_actor() {
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
let (ev_tx, mut ev_rx) = mpsc::unbounded_channel();
let path = sock("retry");
std::fs::remove_file(&path).ok();
let attempts = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
let attempts_c = attempts.clone();
let path_c = path.clone();
let actor = tokio::spawn(async move {
run_actor_with(
cmd_rx,
move |ev| {
let _ = ev_tx.send(ev);
},
move || {
let n = attempts_c.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let path = path_c.clone();
async move {
if n == 0 {
Client::connect_to("/no/such/breadgreet-actor.sock").await
} else {
Client::connect_to(&path).await
}
}
},
)
.await;
});
let ev = ev_rx.recv().await.expect("startup connect error");
match ev {
Event::Error(msg) => assert!(
msg.contains("Cannot reach greetd"),
"unexpected error: {msg}"
),
other => panic!("expected Error, got {other:?}"),
}
let listener = UnixListener::bind(&path).unwrap();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let _ = Request::read_from(&mut stream).await;
Response::Success.write_to(&mut stream).await.unwrap();
});
cmd_tx
.send(Command::CreateSession("bob".into()))
.unwrap();
let ev = ev_rx.recv().await.expect("actor should retry after bind");
match ev {
Event::Outcome(Outcome::Success) => {}
other => panic!("expected Success, got {other:?}"),
}
drop(cmd_tx);
server.await.unwrap();
actor.await.unwrap();
assert!(
attempts.load(std::sync::atomic::Ordering::SeqCst) >= 2,
"actor should reconnect after the first failed connect"
);
std::fs::remove_file(&path).ok();
}
}

View file

@ -3,19 +3,15 @@ mod greetd;
mod sessions; mod sessions;
mod theme; mod theme;
use greetd::{AuthPrompt, Client, Outcome}; use greetd::{AuthPrompt, Outcome};
use gtk4::gdk::Key;
use gtk4::glib::Propagation;
use gtk4::prelude::*; use gtk4::prelude::*;
use relm4::prelude::*; use relm4::prelude::*;
use tokio::sync::mpsc; use tokio::sync::mpsc;
/// Commands sent from the UI thread to the greetd actor task (see /// Extra zoom beyond plain cover-fit — matches breadlock's `KENBURNS_ZOOM`.
/// [`spawn_greetd_actor`]), which owns the single stateful connection to const KENBURNS_ZOOM: f32 = 1.06;
/// `$GREETD_SOCK` for the lifetime of one login attempt.
enum GreetdCommand {
CreateSession(String),
Respond(Option<String>),
StartSession { cmd: Vec<String>, env: Vec<String> },
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
enum Stage { enum Stage {
@ -27,6 +23,8 @@ enum Stage {
/// A request is in flight — input is disabled so a second Enter can't /// A request is in flight — input is disabled so a second Enter can't
/// race it. /// race it.
Working, Working,
/// `StartSession` has been sent — Escape must not cancel.
Starting,
} }
#[derive(Debug)] #[derive(Debug)]
@ -39,10 +37,13 @@ enum AppInput {
SessionStarted, SessionStarted,
/// Picker changed; `u32::MAX` (`INVALID_LIST_POSITION`) is ignored. /// Picker changed; `u32::MAX` (`INVALID_LIST_POSITION`) is ignored.
SessionSelected(u32), SessionSelected(u32),
/// Escape — abort the in-progress PAM conversation.
Cancel,
} }
struct App { struct App {
clock_lbl: gtk4::Label, clock_lbl: gtk4::Label,
date_lbl: gtk4::Label,
status_lbl: gtk4::Label, status_lbl: gtk4::Label,
entry: gtk4::Entry, entry: gtk4::Entry,
stage: Stage, stage: Stage,
@ -50,7 +51,11 @@ struct App {
sessions: Vec<sessions::Session>, sessions: Vec<sessions::Session>,
selected: usize, selected: usize,
clock_format: String, clock_format: String,
cmd_tx: mpsc::UnboundedSender<GreetdCommand>, date_format: String,
/// Last status line was a PAM Info/Error — keep it when the next
/// Secret/Visible prompt arrives.
pam_status_held: bool,
cmd_tx: mpsc::UnboundedSender<greetd::Command>,
} }
#[relm4::component] #[relm4::component]
@ -102,9 +107,27 @@ impl SimpleComponent for App {
.and_then(|chosen| sessions.iter().position(|s| s.stem == chosen.stem)) .and_then(|chosen| sessions.iter().position(|s| s.stem == chosen.stem))
.unwrap_or(0); .unwrap_or(0);
if config.appearance.background.blur {
tracing::warn!(
"background.blur is not implemented yet (planned v2 feature, needs a wlr-screencopy \
capture) showing the configured background unblurred"
);
}
let clock_lbl = gtk4::Label::new(None); let clock_lbl = gtk4::Label::new(None);
clock_lbl.add_css_class("login-clock"); clock_lbl.add_css_class("login-clock");
let date_lbl = gtk4::Label::new(None);
date_lbl.add_css_class("login-date");
if config.appearance.clock.date_format.is_empty() {
date_lbl.set_visible(false);
clock_lbl.set_margin_bottom(20);
} else {
clock_lbl.set_margin_bottom(4);
date_lbl.set_margin_bottom(16);
date_lbl.set_label(&current_time(&config.appearance.clock.date_format));
}
let entry = gtk4::Entry::new(); let entry = gtk4::Entry::new();
entry.add_css_class("login-entry"); entry.add_css_class("login-entry");
entry.set_placeholder_text(Some("Username")); entry.set_placeholder_text(Some("Username"));
@ -117,6 +140,12 @@ impl SimpleComponent for App {
let status_lbl = gtk4::Label::new(None); let status_lbl = gtk4::Label::new(None);
status_lbl.add_css_class("login-status"); status_lbl.add_css_class("login-status");
if sessions.is_empty() {
entry.set_sensitive(false);
status_lbl.set_label("No session found — cannot log in");
status_lbl.add_css_class("error");
}
let session_widget: gtk4::Widget = if sessions.is_empty() { let session_widget: gtk4::Widget = if sessions.is_empty() {
let session_lbl = gtk4::Label::new(Some("No session found — cannot log in")); let session_lbl = gtk4::Label::new(Some("No session found — cannot log in"));
session_lbl.add_css_class("login-session"); session_lbl.add_css_class("login-session");
@ -170,8 +199,24 @@ impl SimpleComponent for App {
widgets.overlay.add_overlay(&widgets.root_box); widgets.overlay.add_overlay(&widgets.root_box);
widgets.root_box.append(&clock_lbl); widgets.root_box.append(&clock_lbl);
widgets.root_box.append(&date_lbl);
widgets.root_box.append(&card); widgets.root_box.append(&card);
{
let tx = sender.input_sender().clone();
let key = gtk4::EventControllerKey::new();
key.set_propagation_phase(gtk4::PropagationPhase::Capture);
key.connect_key_pressed(move |_, keyval, _, _| {
if keyval == Key::Escape {
let _ = tx.send(AppInput::Cancel);
Propagation::Stop
} else {
Propagation::Proceed
}
});
root.add_controller(key);
}
// Wallpaper behind the card: cover-fit, Ken Burns pan when enabled // Wallpaper behind the card: cover-fit, Ken Burns pan when enabled
// (driven by a frame-clock tick callback), plus an entrance fade+rise. // (driven by a frame-clock tick callback), plus an entrance fade+rise.
let ken_burns = config.appearance.background.ken_burns; let ken_burns = config.appearance.background.ken_burns;
@ -189,12 +234,13 @@ impl SimpleComponent for App {
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
spawn_greetd_actor(cmd_rx, sender.clone()); spawn_greetd_actor(cmd_rx, sender.clone());
theme::apply(); theme::apply(&config.appearance.font.family);
bread_theme::gtk::bind_window_auto(&root); bread_theme::gtk::bind_window_auto(&root);
spawn_clock_ticker(sender.clone()); spawn_clock_ticker(sender.clone());
let model = App { let model = App {
clock_lbl, clock_lbl,
date_lbl,
status_lbl, status_lbl,
entry, entry,
stage: Stage::Username, stage: Stage::Username,
@ -202,12 +248,16 @@ impl SimpleComponent for App {
sessions, sessions,
selected, selected,
clock_format: config.appearance.clock.format.clone(), clock_format: config.appearance.clock.format.clone(),
date_format: config.appearance.clock.date_format.clone(),
pam_status_held: false,
cmd_tx, cmd_tx,
}; };
model model
.clock_lbl .clock_lbl
.set_label(&current_time(&model.clock_format)); .set_label(&current_time(&model.clock_format));
if !model.sessions.is_empty() {
model.entry.grab_focus(); model.entry.grab_focus();
}
ComponentParts { model, widgets } ComponentParts { model, widgets }
} }
@ -216,24 +266,20 @@ impl SimpleComponent for App {
match msg { match msg {
AppInput::ClockTick => { AppInput::ClockTick => {
self.clock_lbl.set_label(&current_time(&self.clock_format)); self.clock_lbl.set_label(&current_time(&self.clock_format));
if !self.date_format.is_empty() {
self.date_lbl.set_label(&current_time(&self.date_format));
}
} }
AppInput::Submit => self.handle_submit(), AppInput::Submit => self.handle_submit(),
AppInput::Outcome(Outcome::Success) => self.start_session(), AppInput::Outcome(Outcome::Success) => self.start_session(),
AppInput::Outcome(Outcome::Prompt(prompt)) => self.handle_prompt(prompt), AppInput::Outcome(Outcome::Prompt(prompt)) => self.handle_prompt(prompt),
AppInput::Error(description) => { AppInput::Error(description) => self.show_error(&description),
self.status_lbl.set_label(&description);
self.status_lbl.add_css_class("error");
self.entry.set_text("");
self.entry.set_visibility(true);
self.entry.set_placeholder_text(Some("Username"));
self.entry.set_sensitive(true);
self.stage = Stage::Username;
self.username.clear();
}
AppInput::SessionStarted => { AppInput::SessionStarted => {
// greetd now owns the VT switch to the started session — // greetd waits for this process to exit before exec'ing the
// nothing left for the greeter to do. // session (cage + gtkgreet/tuigreet all quit here).
self.status_lbl.set_label("Starting session…"); self.status_lbl.set_label("Starting session…");
relm4::main_application().quit();
std::process::exit(0);
} }
AppInput::SessionSelected(idx) => { AppInput::SessionSelected(idx) => {
let idx = idx as usize; let idx = idx as usize;
@ -241,19 +287,24 @@ impl SimpleComponent for App {
self.selected = idx; self.selected = idx;
} }
} }
AppInput::Cancel => self.cancel_auth(),
} }
} }
} }
impl App { impl App {
fn handle_submit(&mut self) { fn handle_submit(&mut self) {
if matches!(self.stage, Stage::Working) { if matches!(self.stage, Stage::Working | Stage::Starting) {
return; return;
} }
let text = self.entry.text().to_string(); let text = self.entry.text().to_string();
match &self.stage { match &self.stage {
Stage::Username => { Stage::Username => {
if self.sessions.is_empty() {
self.show_error("No session found — cannot log in");
return;
}
if text.is_empty() { if text.is_empty() {
return; return;
} }
@ -261,63 +312,122 @@ impl App {
self.entry.set_text(""); self.entry.set_text("");
self.entry.set_sensitive(false); self.entry.set_sensitive(false);
self.stage = Stage::Working; self.stage = Stage::Working;
let _ = self self.status_lbl.set_label("");
.cmd_tx self.status_lbl.remove_css_class("error");
.send(GreetdCommand::CreateSession(self.username.clone())); self.pam_status_held = false;
self.dispatch(greetd::Command::CreateSession(self.username.clone()));
} }
Stage::Prompt => { Stage::Prompt => {
self.entry.set_text(""); self.entry.set_text("");
self.entry.set_sensitive(false); self.entry.set_sensitive(false);
self.stage = Stage::Working; self.stage = Stage::Working;
let answer = if text.is_empty() { None } else { Some(text) }; self.dispatch(greetd::Command::Respond(prompt_answer(text)));
let _ = self.cmd_tx.send(GreetdCommand::Respond(answer));
} }
Stage::Working => {} Stage::Working | Stage::Starting => {}
} }
} }
fn handle_prompt(&mut self, prompt: AuthPrompt) { fn handle_prompt(&mut self, prompt: AuthPrompt) {
self.status_lbl.remove_css_class("error");
match prompt { match prompt {
AuthPrompt::Info(message) | AuthPrompt::Error(message) => { AuthPrompt::Info(message) => {
// No answer needed — display and immediately continue the self.status_lbl.remove_css_class("error");
// conversation with an empty response.
self.status_lbl.set_label(&message); self.status_lbl.set_label(&message);
let _ = self.cmd_tx.send(GreetdCommand::Respond(None)); self.pam_status_held = true;
self.dispatch(greetd::Command::Respond(None));
} }
AuthPrompt::Visible(message) => { AuthPrompt::Error(message) => {
self.status_lbl.add_css_class("error");
self.status_lbl.set_label(&message); self.status_lbl.set_label(&message);
self.entry.set_visibility(true); self.pam_status_held = true;
self.entry.set_placeholder_text(Some(&message)); self.dispatch(greetd::Command::Respond(None));
}
AuthPrompt::Visible(message) => self.show_auth_entry(&message, true),
AuthPrompt::Secret(message) => self.show_auth_entry(&message, false),
}
}
fn show_auth_entry(&mut self, message: &str, visible: bool) {
if !self.pam_status_held {
self.status_lbl.remove_css_class("error");
self.status_lbl.set_label(message);
}
self.pam_status_held = false;
self.entry.set_visibility(visible);
self.entry.set_placeholder_text(Some(message));
self.entry.set_sensitive(true); self.entry.set_sensitive(true);
self.entry.grab_focus(); self.entry.grab_focus();
self.stage = Stage::Prompt; self.stage = Stage::Prompt;
} }
AuthPrompt::Secret(message) => {
self.status_lbl.set_label(&message); fn start_session(&mut self) {
self.entry.set_visibility(false); let (cmd, env) = match self.sessions.get(self.selected) {
self.entry.set_placeholder_text(Some(&message)); Some(session) => (session.exec.clone(), session.start_env()),
self.entry.set_sensitive(true); None => {
self.entry.grab_focus(); self.dispatch(greetd::Command::CancelSession);
self.stage = Stage::Prompt; self.show_error("No session available to start");
return;
}
};
self.status_lbl.remove_css_class("error");
self.status_lbl.set_label("Starting session…");
self.entry.set_sensitive(false);
self.stage = Stage::Starting;
self.dispatch(greetd::Command::StartSession { cmd, env });
}
fn cancel_auth(&mut self) {
match self.stage {
Stage::Starting => {}
Stage::Username => {
self.entry.set_text("");
}
Stage::Prompt | Stage::Working => {
self.status_lbl.set_label("");
self.status_lbl.remove_css_class("error");
self.reset_to_username();
if self.cmd_tx.send(greetd::Command::CancelSession).is_err() {
self.show_error("Cannot reach greetd");
}
} }
} }
} }
fn start_session(&mut self) { fn dispatch(&mut self, cmd: greetd::Command) {
let Some(session) = self.sessions.get(self.selected) else { if self.cmd_tx.send(cmd).is_err() {
self.status_lbl.set_label("No session available to start"); self.show_error("Cannot reach greetd");
self.status_lbl.add_css_class("error");
return;
};
self.status_lbl.set_label("Starting session…");
let _ = self.cmd_tx.send(GreetdCommand::StartSession {
cmd: session.exec.clone(),
env: Vec::new(),
});
} }
} }
fn show_error(&mut self, description: &str) {
self.status_lbl.set_label(description);
if description.is_empty() {
self.status_lbl.remove_css_class("error");
} else {
self.status_lbl.add_css_class("error");
}
self.reset_to_username();
}
fn reset_to_username(&mut self) {
self.entry.set_text("");
self.entry.set_visibility(true);
self.entry.set_placeholder_text(Some("Username"));
self.entry.set_sensitive(!self.sessions.is_empty());
self.stage = Stage::Username;
self.username.clear();
self.pam_status_held = false;
if !self.sessions.is_empty() {
self.entry.grab_focus();
}
}
}
/// Secret/Visible answers are always `Some`, including the empty string.
/// greetd/PAM treat `None` as a conversation cancel.
fn prompt_answer(text: String) -> Option<String> {
Some(text)
}
/// Paints the configured wallpaper full-screen behind the login card. The /// Paints the configured wallpaper full-screen behind the login card. The
/// image is loaded once as a `gdk_pixbuf::Pixbuf` and drawn by a /// image is loaded once as a `gdk_pixbuf::Pixbuf` and drawn by a
/// `GtkDrawingArea` draw callback, so the pan costs no layout passes — the /// `GtkDrawingArea` draw callback, so the pan costs no layout passes — the
@ -357,7 +467,11 @@ fn setup_wallpaper(
} }
// Cover scale, then the Ken Burns oversize (leaves room to pan). // Cover scale, then the Ken Burns oversize (leaves room to pan).
let cover = (w / iw).max(h / ih); let cover = (w / iw).max(h / ih);
let scale = if ken_burns { cover * 1.08 } else { cover }; let scale = if ken_burns {
cover * KENBURNS_ZOOM
} else {
cover
};
let dw = iw * scale; let dw = iw * scale;
let dh = ih * scale; let dh = ih * scale;
// Pan within the oversize margin (0..dw-w, 0..dh-h). // Pan within the oversize margin (0..dw-w, 0..dh-h).
@ -391,9 +505,12 @@ fn setup_wallpaper(
/// over ~600ms (ease-out), matching the lock screen's appear motion. /// over ~600ms (ease-out), matching the lock screen's appear motion.
fn setup_entrance(window: &gtk4::ApplicationWindow, root_box: &gtk4::Box) { fn setup_entrance(window: &gtk4::ApplicationWindow, root_box: &gtk4::Box) {
let root_box = root_box.clone(); let root_box = root_box.clone();
let start = std::time::Instant::now();
const DURATION_MS: f32 = 600.0; const DURATION_MS: f32 = 600.0;
const RISE_PX: f32 = 24.0; const RISE_PX: f32 = 24.0;
// First mapped frame must not be fully opaque — start hidden, then tick.
root_box.set_opacity(0.0);
root_box.set_margin_top(RISE_PX as i32);
let start = std::time::Instant::now();
window.add_tick_callback(move |_w, _frame_clock| { window.add_tick_callback(move |_w, _frame_clock| {
let t = (start.elapsed().as_secs_f32() * 1000.0) / DURATION_MS; let t = (start.elapsed().as_secs_f32() * 1000.0) / DURATION_MS;
let t = t.clamp(0.0, 1.0); let t = t.clamp(0.0, 1.0);
@ -409,54 +526,35 @@ fn setup_entrance(window: &gtk4::ApplicationWindow, root_box: &gtk4::Box) {
}); });
} }
/// Owns the single stateful connection to `$GREETD_SOCK` for one login /// Owns the single stateful connection to `$GREETD_SOCK` and translates the
/// attempt and translates the UI's [`GreetdCommand`]s into greetd IPC /// UI's [`greetd::Command`]s into greetd IPC round-trips, forwarding each
/// round-trips, forwarding each outcome back as an [`AppInput`]. /// outcome back as an [`AppInput`].
fn spawn_greetd_actor( fn spawn_greetd_actor(
mut cmd_rx: mpsc::UnboundedReceiver<GreetdCommand>, cmd_rx: mpsc::UnboundedReceiver<greetd::Command>,
sender: ComponentSender<App>, sender: ComponentSender<App>,
) { ) {
let input = sender.input_sender().clone();
relm4::spawn(async move { relm4::spawn(async move {
let mut client = match Client::connect().await { greetd::run_actor(cmd_rx, move |event| {
Ok(client) => client, let msg = match event {
Err(err) => { greetd::Event::Outcome(outcome) => AppInput::Outcome(outcome),
sender.input(AppInput::Error(format!("Cannot reach greetd: {err}"))); greetd::Event::Error(description) => AppInput::Error(description),
return; greetd::Event::SessionStarted => AppInput::SessionStarted,
}
}; };
let _ = input.send(msg);
while let Some(cmd) = cmd_rx.recv().await { })
let result = match cmd { .await;
GreetdCommand::CreateSession(username) => client.create_session(&username).await,
GreetdCommand::Respond(answer) => client.respond(answer).await,
GreetdCommand::StartSession { cmd, env } => {
match client.start_session(cmd, env).await {
Ok(()) => {
sender.input(AppInput::SessionStarted);
continue;
}
Err(err) => Err(err),
}
}
};
match result {
Ok(outcome) => sender.input(AppInput::Outcome(outcome)),
Err(err) => {
tracing::warn!(%err, "greetd reported an error");
client.cancel_session().await;
sender.input(AppInput::Error(err.to_string()));
}
}
}
}); });
} }
fn spawn_clock_ticker(sender: ComponentSender<App>) { fn spawn_clock_ticker(sender: ComponentSender<App>) {
let tx = sender.input_sender().clone();
relm4::spawn(async move { relm4::spawn(async move {
loop { loop {
tokio::time::sleep(std::time::Duration::from_secs(1)).await; tokio::time::sleep(std::time::Duration::from_secs(1)).await;
sender.input(AppInput::ClockTick); if tx.send(AppInput::ClockTick).is_err() {
break;
}
} }
}); });
} }
@ -473,3 +571,14 @@ fn main() {
let app = RelmApp::new("sh.breadway.breadgreet"); let app = RelmApp::new("sh.breadway.breadgreet");
app.run::<App>(()); app.run::<App>(());
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_password_is_some_empty_string() {
assert_eq!(prompt_answer(String::new()), Some(String::new()));
assert_eq!(prompt_answer("hunter2".into()), Some("hunter2".into()));
}
}

View file

@ -9,6 +9,12 @@
use breadlock_ui::desktop_entry::scan_dir; use breadlock_ui::desktop_entry::scan_dir;
use std::path::Path; use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionKind {
Wayland,
X11,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Session { pub struct Session {
/// `.desktop` file stem (`bos` for `bos.desktop`) — used to match /// `.desktop` file stem (`bos` for `bos.desktop`) — used to match
@ -16,22 +22,50 @@ pub struct Session {
pub stem: String, pub stem: String,
pub name: String, pub name: String,
pub exec: Vec<String>, pub exec: Vec<String>,
/// Which directory list this entry came from — drives `XDG_SESSION_TYPE`.
pub kind: SessionKind,
}
impl Session {
/// Environment greetd should apply to the started session.
pub fn start_env(&self) -> Vec<String> {
let session_type = match self.kind {
SessionKind::Wayland => "wayland",
SessionKind::X11 => "x11",
};
let desktop = if self.stem.is_empty() {
self.name.as_str()
} else {
self.stem.as_str()
};
vec![
format!("XDG_SESSION_TYPE={session_type}"),
format!("XDG_SESSION_DESKTOP={desktop}"),
format!("XDG_CURRENT_DESKTOP={desktop}"),
]
}
} }
/// Every installed session, `wayland_dirs` first then `xsessions_dirs`. /// Every installed session, `wayland_dirs` first then `xsessions_dirs`.
/// Each directory is sorted by stem (see [`scan_dir`]). /// Each directory is sorted by stem (see [`scan_dir`]).
pub fn list(wayland_dirs: &[String], xsessions_dirs: &[String]) -> Vec<Session> { pub fn list(wayland_dirs: &[String], xsessions_dirs: &[String]) -> Vec<Session> {
let mut all = Vec::new(); let mut all = Vec::new();
for dir in wayland_dirs.iter().chain(xsessions_dirs) { collect_into(&mut all, wayland_dirs, SessionKind::Wayland);
collect_into(&mut all, xsessions_dirs, SessionKind::X11);
all
}
fn collect_into(all: &mut Vec<Session>, dirs: &[String], kind: SessionKind) {
for dir in dirs {
for (stem, entry) in scan_dir(Path::new(dir)) { for (stem, entry) in scan_dir(Path::new(dir)) {
all.push(Session { all.push(Session {
stem, stem,
name: entry.name, name: entry.name,
exec: split_exec(&entry.exec), exec: split_exec(&entry.exec),
kind,
}); });
} }
} }
all
} }
/// Index of the configured default stem, or `0` if it is absent. Callers /// Index of the configured default stem, or `0` if it is absent. Callers
@ -54,18 +88,67 @@ pub fn discover(
all.into_iter().nth(idx) all.into_iter().nth(idx)
} }
/// Splits a `.desktop` `Exec=` line into an argv. Only handles plain /// Splits a `.desktop` `Exec=` line into an argv. Double-quoted arguments
/// whitespace-separated commands (BOS's own `hyprland.desktop` is /// are one token (Freedesktop Exec quoting). Whole-argument field codes
/// `Exec=Hyprland`) — full field-code (`%f`, `%u`, …) and quoting support /// (`%f`, `%F`, …) are dropped; `%%` is a literal `%`.
/// isn't needed for a greeter that never launches file-manager-style
/// entries.
fn split_exec(exec: &str) -> Vec<String> { fn split_exec(exec: &str) -> Vec<String> {
exec.split_whitespace() tokenize_exec(exec)
.filter(|arg| !arg.starts_with('%')) .into_iter()
.map(str::to_string) .filter(|arg| !is_field_code(arg))
.map(|arg| unescape_percent(&arg))
.filter(|arg| !arg.is_empty())
.collect() .collect()
} }
fn tokenize_exec(exec: &str) -> Vec<String> {
let mut args = Vec::new();
let mut current = String::new();
let mut in_quote = false;
let mut chars = exec.chars().peekable();
while let Some(c) = chars.next() {
match c {
'"' => in_quote = !in_quote,
'\\' if in_quote => {
if let Some(n) = chars.next() {
current.push(n);
}
}
c if c.is_whitespace() && !in_quote => {
if !current.is_empty() {
args.push(std::mem::take(&mut current));
}
}
_ => current.push(c),
}
}
if !current.is_empty() {
args.push(current);
}
args
}
fn is_field_code(arg: &str) -> bool {
matches!(
arg,
"%f" | "%F" | "%u" | "%U" | "%d" | "%D" | "%n" | "%N" | "%i" | "%c" | "%k" | "%v" | "%m"
)
}
fn unescape_percent(arg: &str) -> String {
let mut out = String::with_capacity(arg.len());
let mut chars = arg.chars().peekable();
while let Some(c) = chars.next() {
if c == '%' && chars.peek() == Some(&'%') {
chars.next();
out.push('%');
} else {
out.push(c);
}
}
out
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -76,6 +159,20 @@ mod tests {
assert_eq!(split_exec("gnome-session %U"), vec!["gnome-session"]); assert_eq!(split_exec("gnome-session %U"), vec!["gnome-session"]);
} }
#[test]
fn split_exec_quoted_arguments() {
assert_eq!(
split_exec(r#"wrapper "my session" --flag"#),
vec!["wrapper", "my session", "--flag"]
);
}
#[test]
fn split_exec_double_percent_is_literal() {
assert_eq!(split_exec("echo %%"), vec!["echo", "%"]);
assert_eq!(split_exec(r#"echo "100%%""#), vec!["echo", "100%"]);
}
#[test] #[test]
fn discover_returns_none_when_no_directories_exist() { fn discover_returns_none_when_no_directories_exist() {
assert!(discover( assert!(discover(
@ -109,6 +206,7 @@ mod tests {
assert_eq!(session.stem, "hyprland"); assert_eq!(session.stem, "hyprland");
assert_eq!(session.name, "Hyprland"); assert_eq!(session.name, "Hyprland");
assert_eq!(session.exec, vec!["Hyprland"]); assert_eq!(session.exec, vec!["Hyprland"]);
assert_eq!(session.kind, SessionKind::Wayland);
std::fs::remove_dir_all(&dir).ok(); std::fs::remove_dir_all(&dir).ok();
} }
@ -131,6 +229,23 @@ mod tests {
let stems: Vec<&str> = listed.iter().map(|s| s.stem.as_str()).collect(); let stems: Vec<&str> = listed.iter().map(|s| s.stem.as_str()).collect();
assert_eq!(stems, vec!["bos", "hyprland", "openbox"]); assert_eq!(stems, vec!["bos", "hyprland", "openbox"]);
assert_eq!(listed[0].exec, vec!["/usr/local/bin/bos-session"]); assert_eq!(listed[0].exec, vec!["/usr/local/bin/bos-session"]);
assert_eq!(listed[0].kind, SessionKind::Wayland);
assert_eq!(listed[2].kind, SessionKind::X11);
assert!(
listed[2]
.start_env()
.contains(&"XDG_SESSION_TYPE=x11".to_string())
);
assert!(
listed[0]
.start_env()
.contains(&"XDG_SESSION_TYPE=wayland".to_string())
);
assert!(
listed[0]
.start_env()
.contains(&"XDG_SESSION_DESKTOP=bos".to_string())
);
std::fs::remove_dir_all(&wayland).ok(); std::fs::remove_dir_all(&wayland).ok();
std::fs::remove_dir_all(&x11).ok(); std::fs::remove_dir_all(&x11).ok();
@ -143,11 +258,13 @@ mod tests {
stem: "aaa".into(), stem: "aaa".into(),
name: "A".into(), name: "A".into(),
exec: vec!["a".into()], exec: vec!["a".into()],
kind: SessionKind::Wayland,
}, },
Session { Session {
stem: "bos".into(), stem: "bos".into(),
name: "BOS".into(), name: "BOS".into(),
exec: vec!["bos-session".into()], exec: vec!["bos-session".into()],
kind: SessionKind::Wayland,
}, },
]; ];
assert_eq!(default_index(&sessions, "bos"), 1); assert_eq!(default_index(&sessions, "bos"), 1);

View file

@ -6,13 +6,23 @@ thread_local! {
static USER_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) }; static USER_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
} }
fn load_css() -> String { fn css_font_family(family: &str) -> String {
if family.is_empty() {
return String::new();
}
let escaped = family.replace('\\', "\\\\").replace('"', "\\\"");
format!("font-family: \"{escaped}\";")
}
fn load_css(font_family: &str) -> String {
let p = load_palette(); let p = load_palette();
let font = css_font_family(font_family);
format!( format!(
"window.breadgreet {{ background-color: {bg}; color: {on_bg}; }}\ "window.breadgreet {{ background-color: {bg}; color: {on_bg}; {font} }}\
.login-card {{ background: {surface}; color: {on_surface}; border-radius: 8px;\ .login-card {{ background: {surface}; color: {on_surface}; border-radius: 8px;\
padding: 20px; min-width: 320px; }}\ padding: 20px; min-width: 320px; }}\
.login-clock {{ font-size: 48px; font-weight: bold; margin-bottom: 20px; }}\ .login-clock {{ font-size: 48px; font-weight: bold; }}\
.login-date {{ font-size: 18px; font-weight: 500; opacity: 0.8; }}\
.login-entry {{ font-size: 14px; }}\ .login-entry {{ font-size: 14px; }}\
.login-status {{ font-size: 12px; opacity: 0.75; margin-top: 8px; }}\ .login-status {{ font-size: 12px; opacity: 0.75; margin-top: 8px; }}\
.login-status.error {{ color: {red}; opacity: 1; }}\ .login-status.error {{ color: {red}; opacity: 1; }}\
@ -24,14 +34,17 @@ fn load_css() -> String {
red = p.color1, red = p.color1,
on_bg = ink_on(&p.background), on_bg = ink_on(&p.background),
on_surface = ink_on(&p.color0), on_surface = ink_on(&p.color0),
font = font,
) )
} }
pub fn apply() { pub fn apply(font_family: &str) {
bgtk::apply_shared(); bgtk::apply_shared();
bgtk::apply_app_css(load_css); let family = font_family.to_string();
bgtk::apply_app_css(move || load_css(&family));
let home = std::env::var("HOME").unwrap_or_default(); let user_path = crate::config::xdg_config_dir()
let user_path = std::path::PathBuf::from(format!("{home}/.config/breadgreet/style.css")); .join("breadgreet")
.join("style.css");
USER_PROVIDER.with(|cell| bgtk::apply_user_css(&user_path, cell)); USER_PROVIDER.with(|cell| bgtk::apply_user_css(&user_path, cell));
} }

View file

@ -80,14 +80,23 @@ impl Default for Font {
} }
} }
/// Reads and parses a TOML config file, falling back to `T::default()` if the /// Reads and parses a TOML config file. A missing file is a silent
/// file is missing or malformed — every bread* app runs with sensible /// `T::default()`; a present but malformed file prints a warning (with the
/// defaults and no required config. /// path) and also falls back to `T::default()`.
pub fn load_or_default<T: serde::de::DeserializeOwned + Default>(path: &Path) -> T { pub fn load_or_default<T: serde::de::DeserializeOwned + Default>(path: &Path) -> T {
std::fs::read_to_string(path) match std::fs::read_to_string(path) {
.ok() Ok(s) => match toml::from_str(&s) {
.and_then(|s| toml::from_str(&s).ok()) Ok(parsed) => parsed,
.unwrap_or_default() Err(err) => {
eprintln!(
"warning: failed to parse {}: {err} — using defaults",
path.display()
);
T::default()
}
},
Err(_) => T::default(),
}
} }
#[cfg(test)] #[cfg(test)]
@ -112,11 +121,27 @@ mod tests {
#[test] #[test]
fn parses_partial_toml_with_defaults_for_rest() { fn parses_partial_toml_with_defaults_for_rest() {
let dir = std::env::temp_dir().join("breadlock-ui-test-partial.toml"); let path = std::env::temp_dir().join(format!(
std::fs::write(&dir, "[clock]\nformat = \"%I:%M %p\"\n").unwrap(); "breadlock-ui-test-partial-{}.toml",
let a: Appearance = load_or_default(&dir); 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.clock.format, "%I:%M %p");
assert_eq!(a.background.mode, BackgroundMode::Color); 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();
} }
} }

View file

@ -1,9 +1,8 @@
//! Minimal freedesktop `.desktop` entry parsing — just enough to discover //! Minimal freedesktop `.desktop` entry parsing — just enough to discover
//! session launchers (`Name=`, `Exec=`, `Type=`) under //! session launchers (`Name=`, `Exec=`, `Type=`) under
//! `/usr/share/wayland-sessions` and `/usr/share/xsessions`. BOS only ships //! `/usr/share/wayland-sessions` and `/usr/share/xsessions`. Also honours
//! one session today, so this deliberately doesn't handle the full spec //! `Hidden=` / `NoDisplay=` / `TryExec=` so we don't offer sessions that
//! (localized `Name[xx]=`, `Exec=` quoting/field codes, `Actions=`, etc.) — //! menus would skip. Localized `Name[xx]=` and `Actions=` are out of scope.
//! only the three keys a greeter needs to list and launch a session.
use std::path::Path; use std::path::Path;
@ -12,14 +11,21 @@ pub struct DesktopEntry {
pub name: String, pub name: String,
pub exec: String, pub exec: String,
pub entry_type: 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. /// 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> { pub fn parse(contents: &str) -> Option<DesktopEntry> {
let mut name = None; let mut name = None;
let mut exec = None; let mut exec = None;
let mut entry_type = 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; let mut in_desktop_entry = false;
for line in contents.lines() { for line in contents.lines() {
@ -39,21 +45,39 @@ pub fn parse(contents: &str) -> Option<DesktopEntry> {
"Name" => name = Some(value.trim().to_string()), "Name" => name = Some(value.trim().to_string()),
"Exec" => exec = Some(value.trim().to_string()), "Exec" => exec = Some(value.trim().to_string()),
"Type" => entry_type = 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 { Some(DesktopEntry {
name: name?, name: name?,
exec: exec?, exec: exec?,
entry_type: entry_type.unwrap_or_else(|| "Application".to_string()), 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)` /// Scans a directory for `*.desktop` files, returning `(file stem, entry)`
/// pairs. Unreadable directories and unparsable entries are silently skipped /// pairs. Unreadable directories and unparsable entries are silently skipped
/// — a missing session directory is normal (e.g. no X11 sessions installed). /// — 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)> { pub fn scan_dir(dir: &Path) -> Vec<(String, DesktopEntry)> {
let Ok(read_dir) = std::fs::read_dir(dir) else { let Ok(read_dir) = std::fs::read_dir(dir) else {
return Vec::new(); return Vec::new();
@ -65,7 +89,13 @@ pub fn scan_dir(dir: &Path) -> Vec<(String, DesktopEntry)> {
.filter_map(|e| { .filter_map(|e| {
let stem = e.path().file_stem()?.to_str()?.to_string(); let stem = e.path().file_stem()?.to_str()?.to_string();
let contents = std::fs::read_to_string(e.path()).ok()?; 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(); .collect();
@ -73,6 +103,25 @@ pub fn scan_dir(dir: &Path) -> Vec<(String, DesktopEntry)> {
entries 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -83,12 +132,26 @@ mod tests {
Exec=Hyprland\n\ Exec=Hyprland\n\
Type=Application\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] #[test]
fn parses_name_exec_type() { fn parses_name_exec_type() {
let e = parse(HYPRLAND_DESKTOP).unwrap(); let e = parse(HYPRLAND_DESKTOP).unwrap();
assert_eq!(e.name, "Hyprland"); assert_eq!(e.name, "Hyprland");
assert_eq!(e.exec, "Hyprland"); assert_eq!(e.exec, "Hyprland");
assert_eq!(e.entry_type, "Application"); assert_eq!(e.entry_type, "Application");
assert_eq!(e.try_exec, None);
} }
#[test] #[test]
@ -111,6 +174,14 @@ mod tests {
assert_eq!(e.entry_type, "Application"); 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] #[test]
fn scan_dir_on_missing_directory_returns_empty() { fn scan_dir_on_missing_directory_returns_empty() {
assert!(scan_dir(Path::new("/nonexistent/wayland-sessions")).is_empty()); assert!(scan_dir(Path::new("/nonexistent/wayland-sessions")).is_empty());
@ -118,8 +189,7 @@ mod tests {
#[test] #[test]
fn scan_dir_finds_and_sorts_desktop_files() { fn scan_dir_finds_and_sorts_desktop_files() {
let dir = std::env::temp_dir().join("breadlock-ui-test-sessions"); let dir = unique_temp_dir("scan");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("zzz.desktop"), HYPRLAND_DESKTOP).unwrap(); 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("aaa.desktop"), "[Desktop Entry]\nName=A\nExec=a\n").unwrap();
std::fs::write(dir.join("not-a-session.txt"), "ignored").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(); 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();
}
} }