diff --git a/breadgreet.example.toml b/breadgreet.example.toml index 150d830..3c86ecc 100644 --- a/breadgreet.example.toml +++ b/breadgreet.example.toml @@ -9,7 +9,8 @@ mode = "color" path = "" 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 [clock] diff --git a/breadgreet/src/config.rs b/breadgreet/src/config.rs index bd9aa32..01213f7 100644 --- a/breadgreet/src/config.rs +++ b/breadgreet/src/config.rs @@ -42,12 +42,15 @@ pub fn load() -> Config { breadlock_ui::config::load_or_default(&xdg_config_path()) } -fn xdg_config_path() -> PathBuf { - let base = std::env::var_os("XDG_CONFIG_HOME") +pub(crate) fn xdg_config_dir() -> PathBuf { + std::env::var_os("XDG_CONFIG_HOME") .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) - .unwrap_or_else(|| PathBuf::from(".")); - base.join("breadgreet").join("breadgreet.toml") + .unwrap_or_else(|| PathBuf::from(".")) +} + +fn xdg_config_path() -> PathBuf { + xdg_config_dir().join("breadgreet").join("breadgreet.toml") } #[cfg(test)] diff --git a/breadgreet/src/greetd/client.rs b/breadgreet/src/greetd/client.rs index 721acd3..a58a51c 100644 --- a/breadgreet/src/greetd/client.rs +++ b/breadgreet/src/greetd/client.rs @@ -59,7 +59,7 @@ impl Client { /// used directly by tests against a mock server so they don't need to /// mutate process-global environment state (which parallel `cargo test` /// threads would race on). - async fn connect_to(path: impl AsRef) -> Result { + pub(crate) async fn connect_to(path: impl AsRef) -> Result { let stream = UnixStream::connect(path) .await .map_err(GreetdError::Connect)?; @@ -149,10 +149,21 @@ mod tests { //! 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. use super::*; + use greetd_ipc::codec::TokioCodec; + use greetd_ipc::{AuthMessageType, ErrorType, Request, Response}; use tokio::net::UnixListener; - async fn mock_server(path: std::path::PathBuf, script: Vec) { + 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(); + (path, listener) + } + + async fn serve(listener: UnixListener, script: Vec) { let (mut stream, _) = listener.accept().await.unwrap(); for response in script { // 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] async fn create_session_success_flows_straight_through() { - let path = socket_path("success"); - std::fs::remove_file(&path).ok(); - let server = tokio::spawn(mock_server(path.clone(), vec![Response::Success])); + let (path, listener) = bind_socket("success"); + let server = tokio::spawn(serve(listener, 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 outcome = client.create_session("bob").await.unwrap(); assert!(matches!(outcome, Outcome::Success)); @@ -187,10 +188,9 @@ mod tests { #[tokio::test] async fn create_session_prompts_for_password_then_succeeds() { - let path = socket_path("prompt"); - std::fs::remove_file(&path).ok(); - let server = tokio::spawn(mock_server( - path.clone(), + let (path, listener) = bind_socket("prompt"); + let server = tokio::spawn(serve( + listener, vec![ Response::AuthMessage { 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 outcome = client.create_session("bob").await.unwrap(); @@ -218,17 +217,15 @@ mod tests { #[tokio::test] async fn auth_error_is_reported_as_such() { - let path = socket_path("autherr"); - std::fs::remove_file(&path).ok(); - let server = tokio::spawn(mock_server( - path.clone(), + let (path, listener) = bind_socket("autherr"); + let server = tokio::spawn(serve( + listener, vec![Response::Error { error_type: ErrorType::AuthError, description: "denied".to_string(), }], )); - tokio::time::sleep(std::time::Duration::from_millis(20)).await; let mut client = Client::connect_to(&path).await.unwrap(); let err = client.create_session("bob").await.unwrap_err(); @@ -242,9 +239,43 @@ mod tests { } #[tokio::test] - async fn connect_without_greetd_sock_env_fails_cleanly() { - std::env::remove_var("GREETD_SOCK"); - let err = Client::connect().await.unwrap_err(); - assert!(matches!(err, GreetdError::NoSocketEnv)); + async fn connect_to_missing_socket_fails() { + let err = Client::connect_to("/no/such/breadgreet-test.sock") + .await + .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(); } } diff --git a/breadgreet/src/greetd/mod.rs b/breadgreet/src/greetd/mod.rs index 01b15c6..81a8d7c 100644 --- a/breadgreet/src/greetd/mod.rs +++ b/breadgreet/src/greetd/mod.rs @@ -1,3 +1,198 @@ 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), + StartSession { cmd: Vec, env: Vec }, + 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(cmd_rx: mpsc::UnboundedReceiver, emit: E) +where + E: FnMut(Event) + Send + 'static, +{ + run_actor_with(cmd_rx, emit, Client::connect).await; +} + +async fn run_actor_with( + mut cmd_rx: mpsc::UnboundedReceiver, + mut emit: E, + mut connect: C, +) where + E: FnMut(Event), + C: FnMut() -> Fut, + Fut: Future>, +{ + let mut client: Option = 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), +} + +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(); + } +} diff --git a/breadgreet/src/main.rs b/breadgreet/src/main.rs index 4d75265..146f0c2 100644 --- a/breadgreet/src/main.rs +++ b/breadgreet/src/main.rs @@ -3,19 +3,15 @@ mod greetd; mod sessions; mod theme; -use greetd::{AuthPrompt, Client, Outcome}; +use greetd::{AuthPrompt, Outcome}; +use gtk4::gdk::Key; +use gtk4::glib::Propagation; use gtk4::prelude::*; use relm4::prelude::*; use tokio::sync::mpsc; -/// Commands sent from the UI thread to the greetd actor task (see -/// [`spawn_greetd_actor`]), which owns the single stateful connection to -/// `$GREETD_SOCK` for the lifetime of one login attempt. -enum GreetdCommand { - CreateSession(String), - Respond(Option), - StartSession { cmd: Vec, env: Vec }, -} +/// Extra zoom beyond plain cover-fit — matches breadlock's `KENBURNS_ZOOM`. +const KENBURNS_ZOOM: f32 = 1.06; #[derive(Debug, Clone)] enum Stage { @@ -27,6 +23,8 @@ enum Stage { /// A request is in flight — input is disabled so a second Enter can't /// race it. Working, + /// `StartSession` has been sent — Escape must not cancel. + Starting, } #[derive(Debug)] @@ -39,10 +37,13 @@ enum AppInput { SessionStarted, /// Picker changed; `u32::MAX` (`INVALID_LIST_POSITION`) is ignored. SessionSelected(u32), + /// Escape — abort the in-progress PAM conversation. + Cancel, } struct App { clock_lbl: gtk4::Label, + date_lbl: gtk4::Label, status_lbl: gtk4::Label, entry: gtk4::Entry, stage: Stage, @@ -50,7 +51,11 @@ struct App { sessions: Vec, selected: usize, clock_format: String, - cmd_tx: mpsc::UnboundedSender, + 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, } #[relm4::component] @@ -102,9 +107,27 @@ impl SimpleComponent for App { .and_then(|chosen| sessions.iter().position(|s| s.stem == chosen.stem)) .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); 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(¤t_time(&config.appearance.clock.date_format)); + } + let entry = gtk4::Entry::new(); entry.add_css_class("login-entry"); entry.set_placeholder_text(Some("Username")); @@ -117,6 +140,12 @@ impl SimpleComponent for App { let status_lbl = gtk4::Label::new(None); 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_lbl = gtk4::Label::new(Some("No session found — cannot log in")); session_lbl.add_css_class("login-session"); @@ -170,8 +199,24 @@ impl SimpleComponent for App { widgets.overlay.add_overlay(&widgets.root_box); widgets.root_box.append(&clock_lbl); + widgets.root_box.append(&date_lbl); 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 // (driven by a frame-clock tick callback), plus an entrance fade+rise. let ken_burns = config.appearance.background.ken_burns; @@ -189,12 +234,13 @@ impl SimpleComponent for App { let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); spawn_greetd_actor(cmd_rx, sender.clone()); - theme::apply(); + theme::apply(&config.appearance.font.family); bread_theme::gtk::bind_window_auto(&root); spawn_clock_ticker(sender.clone()); let model = App { clock_lbl, + date_lbl, status_lbl, entry, stage: Stage::Username, @@ -202,12 +248,16 @@ impl SimpleComponent for App { sessions, selected, clock_format: config.appearance.clock.format.clone(), + date_format: config.appearance.clock.date_format.clone(), + pam_status_held: false, cmd_tx, }; model .clock_lbl .set_label(¤t_time(&model.clock_format)); - model.entry.grab_focus(); + if !model.sessions.is_empty() { + model.entry.grab_focus(); + } ComponentParts { model, widgets } } @@ -216,24 +266,20 @@ impl SimpleComponent for App { match msg { AppInput::ClockTick => { self.clock_lbl.set_label(¤t_time(&self.clock_format)); + if !self.date_format.is_empty() { + self.date_lbl.set_label(¤t_time(&self.date_format)); + } } AppInput::Submit => self.handle_submit(), AppInput::Outcome(Outcome::Success) => self.start_session(), AppInput::Outcome(Outcome::Prompt(prompt)) => self.handle_prompt(prompt), - AppInput::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::Error(description) => self.show_error(&description), AppInput::SessionStarted => { - // greetd now owns the VT switch to the started session — - // nothing left for the greeter to do. + // greetd waits for this process to exit before exec'ing the + // session (cage + gtkgreet/tuigreet all quit here). self.status_lbl.set_label("Starting session…"); + relm4::main_application().quit(); + std::process::exit(0); } AppInput::SessionSelected(idx) => { let idx = idx as usize; @@ -241,19 +287,24 @@ impl SimpleComponent for App { self.selected = idx; } } + AppInput::Cancel => self.cancel_auth(), } } } impl App { fn handle_submit(&mut self) { - if matches!(self.stage, Stage::Working) { + if matches!(self.stage, Stage::Working | Stage::Starting) { return; } let text = self.entry.text().to_string(); match &self.stage { Stage::Username => { + if self.sessions.is_empty() { + self.show_error("No session found — cannot log in"); + return; + } if text.is_empty() { return; } @@ -261,61 +312,120 @@ impl App { self.entry.set_text(""); self.entry.set_sensitive(false); self.stage = Stage::Working; - let _ = self - .cmd_tx - .send(GreetdCommand::CreateSession(self.username.clone())); + self.status_lbl.set_label(""); + self.status_lbl.remove_css_class("error"); + self.pam_status_held = false; + self.dispatch(greetd::Command::CreateSession(self.username.clone())); } Stage::Prompt => { self.entry.set_text(""); self.entry.set_sensitive(false); self.stage = Stage::Working; - let answer = if text.is_empty() { None } else { Some(text) }; - let _ = self.cmd_tx.send(GreetdCommand::Respond(answer)); + self.dispatch(greetd::Command::Respond(prompt_answer(text))); } - Stage::Working => {} + Stage::Working | Stage::Starting => {} } } fn handle_prompt(&mut self, prompt: AuthPrompt) { - self.status_lbl.remove_css_class("error"); match prompt { - AuthPrompt::Info(message) | AuthPrompt::Error(message) => { - // No answer needed — display and immediately continue the - // conversation with an empty response. + AuthPrompt::Info(message) => { + self.status_lbl.remove_css_class("error"); 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.entry.set_visibility(true); - self.entry.set_placeholder_text(Some(&message)); - self.entry.set_sensitive(true); - self.entry.grab_focus(); - self.stage = Stage::Prompt; + self.pam_status_held = true; + self.dispatch(greetd::Command::Respond(None)); } - AuthPrompt::Secret(message) => { - self.status_lbl.set_label(&message); - self.entry.set_visibility(false); - self.entry.set_placeholder_text(Some(&message)); - self.entry.set_sensitive(true); - self.entry.grab_focus(); - self.stage = Stage::Prompt; + 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.grab_focus(); + self.stage = Stage::Prompt; + } + + fn start_session(&mut self) { + let (cmd, env) = match self.sessions.get(self.selected) { + Some(session) => (session.exec.clone(), session.start_env()), + None => { + self.dispatch(greetd::Command::CancelSession); + 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) { - let Some(session) = self.sessions.get(self.selected) else { - self.status_lbl.set_label("No session available to start"); - 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 dispatch(&mut self, cmd: greetd::Command) { + if self.cmd_tx.send(cmd).is_err() { + self.show_error("Cannot reach greetd"); + } } + + 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 { + Some(text) } /// Paints the configured wallpaper full-screen behind the login card. The @@ -357,7 +467,11 @@ fn setup_wallpaper( } // Cover scale, then the Ken Burns oversize (leaves room to pan). 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 dh = ih * scale; // 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. fn setup_entrance(window: >k4::ApplicationWindow, root_box: >k4::Box) { let root_box = root_box.clone(); - let start = std::time::Instant::now(); const DURATION_MS: f32 = 600.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| { let t = (start.elapsed().as_secs_f32() * 1000.0) / DURATION_MS; let t = t.clamp(0.0, 1.0); @@ -409,54 +526,35 @@ fn setup_entrance(window: >k4::ApplicationWindow, root_box: >k4::Box) { }); } -/// Owns the single stateful connection to `$GREETD_SOCK` for one login -/// attempt and translates the UI's [`GreetdCommand`]s into greetd IPC -/// round-trips, forwarding each outcome back as an [`AppInput`]. +/// Owns the single stateful connection to `$GREETD_SOCK` and translates the +/// UI's [`greetd::Command`]s into greetd IPC round-trips, forwarding each +/// outcome back as an [`AppInput`]. fn spawn_greetd_actor( - mut cmd_rx: mpsc::UnboundedReceiver, + cmd_rx: mpsc::UnboundedReceiver, sender: ComponentSender, ) { + let input = sender.input_sender().clone(); relm4::spawn(async move { - let mut client = match Client::connect().await { - Ok(client) => client, - Err(err) => { - sender.input(AppInput::Error(format!("Cannot reach greetd: {err}"))); - return; - } - }; - - while let Some(cmd) = cmd_rx.recv().await { - let result = match cmd { - 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), - } - } + greetd::run_actor(cmd_rx, move |event| { + let msg = match event { + greetd::Event::Outcome(outcome) => AppInput::Outcome(outcome), + greetd::Event::Error(description) => AppInput::Error(description), + greetd::Event::SessionStarted => AppInput::SessionStarted, }; - - 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())); - } - } - } + let _ = input.send(msg); + }) + .await; }); } fn spawn_clock_ticker(sender: ComponentSender) { + let tx = sender.input_sender().clone(); relm4::spawn(async move { loop { 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"); app.run::(()); } + +#[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())); + } +} diff --git a/breadgreet/src/sessions.rs b/breadgreet/src/sessions.rs index 3fe4d72..be39dc9 100644 --- a/breadgreet/src/sessions.rs +++ b/breadgreet/src/sessions.rs @@ -9,6 +9,12 @@ use breadlock_ui::desktop_entry::scan_dir; use std::path::Path; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionKind { + Wayland, + X11, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct Session { /// `.desktop` file stem (`bos` for `bos.desktop`) — used to match @@ -16,22 +22,50 @@ pub struct Session { pub stem: String, pub name: String, pub exec: Vec, + /// 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 { + 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`. /// Each directory is sorted by stem (see [`scan_dir`]). pub fn list(wayland_dirs: &[String], xsessions_dirs: &[String]) -> Vec { 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, dirs: &[String], kind: SessionKind) { + for dir in dirs { for (stem, entry) in scan_dir(Path::new(dir)) { all.push(Session { stem, name: entry.name, exec: split_exec(&entry.exec), + kind, }); } } - all } /// 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) } -/// Splits a `.desktop` `Exec=` line into an argv. Only handles plain -/// whitespace-separated commands (BOS's own `hyprland.desktop` is -/// `Exec=Hyprland`) — full field-code (`%f`, `%u`, …) and quoting support -/// isn't needed for a greeter that never launches file-manager-style -/// entries. +/// Splits a `.desktop` `Exec=` line into an argv. Double-quoted arguments +/// are one token (Freedesktop Exec quoting). Whole-argument field codes +/// (`%f`, `%F`, …) are dropped; `%%` is a literal `%`. fn split_exec(exec: &str) -> Vec { - exec.split_whitespace() - .filter(|arg| !arg.starts_with('%')) - .map(str::to_string) + tokenize_exec(exec) + .into_iter() + .filter(|arg| !is_field_code(arg)) + .map(|arg| unescape_percent(&arg)) + .filter(|arg| !arg.is_empty()) .collect() } +fn tokenize_exec(exec: &str) -> Vec { + 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)] mod tests { use super::*; @@ -76,6 +159,20 @@ mod tests { 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] fn discover_returns_none_when_no_directories_exist() { assert!(discover( @@ -109,6 +206,7 @@ mod tests { assert_eq!(session.stem, "hyprland"); assert_eq!(session.name, "Hyprland"); assert_eq!(session.exec, vec!["Hyprland"]); + assert_eq!(session.kind, SessionKind::Wayland); 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(); assert_eq!(stems, vec!["bos", "hyprland", "openbox"]); 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(&x11).ok(); @@ -143,11 +258,13 @@ mod tests { stem: "aaa".into(), name: "A".into(), exec: vec!["a".into()], + kind: SessionKind::Wayland, }, Session { stem: "bos".into(), name: "BOS".into(), exec: vec!["bos-session".into()], + kind: SessionKind::Wayland, }, ]; assert_eq!(default_index(&sessions, "bos"), 1); diff --git a/breadgreet/src/theme.rs b/breadgreet/src/theme.rs index 3265b69..96b4360 100644 --- a/breadgreet/src/theme.rs +++ b/breadgreet/src/theme.rs @@ -6,13 +6,23 @@ thread_local! { static USER_PROVIDER: RefCell> = 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 font = css_font_family(font_family); 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;\ 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-status {{ font-size: 12px; opacity: 0.75; margin-top: 8px; }}\ .login-status.error {{ color: {red}; opacity: 1; }}\ @@ -24,14 +34,17 @@ fn load_css() -> String { red = p.color1, on_bg = ink_on(&p.background), on_surface = ink_on(&p.color0), + font = font, ) } -pub fn apply() { +pub fn apply(font_family: &str) { 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 = std::path::PathBuf::from(format!("{home}/.config/breadgreet/style.css")); + let user_path = crate::config::xdg_config_dir() + .join("breadgreet") + .join("style.css"); USER_PROVIDER.with(|cell| bgtk::apply_user_css(&user_path, cell)); } diff --git a/breadlock-ui/src/config.rs b/breadlock-ui/src/config.rs index 179a5f7..bce474b 100644 --- a/breadlock-ui/src/config.rs +++ b/breadlock-ui/src/config.rs @@ -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(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(); } } diff --git a/breadlock-ui/src/desktop_entry.rs b/breadlock-ui/src/desktop_entry.rs index 8c66c78..927b67e 100644 --- a/breadlock-ui/src/desktop_entry.rs +++ b/breadlock-ui/src/desktop_entry.rs @@ -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, } /// 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 { 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 { "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(); + } }