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