Initial commit
This commit is contained in:
commit
9f97f2c989
37 changed files with 4871 additions and 0 deletions
25
breadgreet/Cargo.toml
Normal file
25
breadgreet/Cargo.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
[package]
|
||||
name = "breadgreet"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["Breadway <rileyhorsham@gmail.com>"]
|
||||
description = "Graphical greetd greeter for Hyprland / Wayland"
|
||||
|
||||
[[bin]]
|
||||
name = "breadgreet"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
breadlock-ui = { path = "../breadlock-ui" }
|
||||
bread-theme = { workspace = true, features = ["gtk"] }
|
||||
gtk4 = { version = "0.11", features = ["v4_12"] }
|
||||
relm4 = { version = "0.11", features = ["macros"] }
|
||||
greetd_ipc = { version = "0.10", default-features = false, features = ["tokio-codec"] }
|
||||
chrono = "0.4"
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
toml.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
thiserror.workspace = true
|
||||
63
breadgreet/src/config.rs
Normal file
63
breadgreet/src/config.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
use breadlock_ui::config::Appearance;
|
||||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
#[serde(flatten)]
|
||||
pub appearance: Appearance,
|
||||
pub sessions: Sessions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Sessions {
|
||||
pub wayland_dirs: Vec<String>,
|
||||
pub xsessions_dirs: Vec<String>,
|
||||
/// `.desktop` file stem (without extension) to auto-select.
|
||||
pub default: String,
|
||||
}
|
||||
|
||||
impl Default for Sessions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
wayland_dirs: vec!["/usr/share/wayland-sessions".to_string()],
|
||||
xsessions_dirs: vec!["/usr/share/xsessions".to_string()],
|
||||
default: "hyprland".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `breadgreet` commonly runs as the dedicated `greeter` system user (per
|
||||
/// BOS's `/etc/greetd/config.toml` `user = "greeter"`), so a fixed system
|
||||
/// path is checked first; XDG is the fallback for local dev/testing under a
|
||||
/// normal user session.
|
||||
pub fn load() -> Config {
|
||||
let system_path = std::path::Path::new("/etc/greetd/breadgreet.toml");
|
||||
if system_path.exists() {
|
||||
return breadlock_ui::config::load_or_default(system_path);
|
||||
}
|
||||
breadlock_ui::config::load_or_default(&xdg_config_path())
|
||||
}
|
||||
|
||||
fn xdg_config_path() -> PathBuf {
|
||||
let base = 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")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_sessions_match_standard_greetd_dirs() {
|
||||
let s = Sessions::default();
|
||||
assert_eq!(s.wayland_dirs, vec!["/usr/share/wayland-sessions"]);
|
||||
assert_eq!(s.xsessions_dirs, vec!["/usr/share/xsessions"]);
|
||||
assert_eq!(s.default, "hyprland");
|
||||
}
|
||||
}
|
||||
250
breadgreet/src/greetd/client.rs
Normal file
250
breadgreet/src/greetd/client.rs
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
//! Thin async wrapper around `greetd_ipc`'s wire protocol — connects to
|
||||
//! `$GREETD_SOCK`, and turns greetd's `Request`/`Response` enums into a
|
||||
//! small state machine ([`Outcome`]) the UI drives.
|
||||
|
||||
use greetd_ipc::codec::TokioCodec;
|
||||
use greetd_ipc::{AuthMessageType, ErrorType, Request, Response};
|
||||
use tokio::net::UnixStream;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GreetdError {
|
||||
#[error("$GREETD_SOCK is not set — breadgreet must be launched by greetd")]
|
||||
NoSocketEnv,
|
||||
#[error("failed to connect to greetd socket: {0}")]
|
||||
Connect(#[source] std::io::Error),
|
||||
#[error("greetd IPC error: {0}")]
|
||||
Codec(#[from] greetd_ipc::codec::Error),
|
||||
#[error("{description}")]
|
||||
Greetd {
|
||||
description: String,
|
||||
is_auth_error: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// One step of a PAM conversation, as relayed by greetd.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AuthPrompt {
|
||||
/// Answer should be shown as typed (e.g. a username confirmation).
|
||||
Visible(String),
|
||||
/// Answer should be masked (a password).
|
||||
Secret(String),
|
||||
/// Informational message — no answer needed, just display and continue.
|
||||
Info(String),
|
||||
/// Non-fatal error message from a PAM module — display and continue.
|
||||
Error(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Outcome {
|
||||
/// Login flow complete — call [`Client::start_session`] next.
|
||||
Success,
|
||||
/// Another prompt to answer via [`Client::respond`].
|
||||
Prompt(AuthPrompt),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Client {
|
||||
stream: UnixStream,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Connects to the Unix socket greetd set in `$GREETD_SOCK` when it
|
||||
/// launched this process.
|
||||
pub async fn connect() -> Result<Self, GreetdError> {
|
||||
let path = std::env::var_os("GREETD_SOCK").ok_or(GreetdError::NoSocketEnv)?;
|
||||
Self::connect_to(path).await
|
||||
}
|
||||
|
||||
/// Connects to an explicit socket path, bypassing `$GREETD_SOCK` —
|
||||
/// 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> {
|
||||
let stream = UnixStream::connect(path)
|
||||
.await
|
||||
.map_err(GreetdError::Connect)?;
|
||||
Ok(Self { stream })
|
||||
}
|
||||
|
||||
/// Starts a login attempt for `username`. Answer any resulting
|
||||
/// [`Outcome::Prompt`] via [`Self::respond`] until [`Outcome::Success`].
|
||||
pub async fn create_session(&mut self, username: &str) -> Result<Outcome, GreetdError> {
|
||||
let req = Request::CreateSession {
|
||||
username: username.to_string(),
|
||||
};
|
||||
self.roundtrip(req).await
|
||||
}
|
||||
|
||||
/// Answers the most recent [`AuthPrompt`]. `None` for prompts that don't
|
||||
/// need an answer (info/error messages).
|
||||
pub async fn respond(&mut self, answer: Option<String>) -> Result<Outcome, GreetdError> {
|
||||
self.roundtrip(Request::PostAuthMessageResponse { response: answer })
|
||||
.await
|
||||
}
|
||||
|
||||
/// Hands the chosen session off to greetd, which execs it and owns the
|
||||
/// VT switch away from the greeter. Only valid after an [`Outcome::Success`].
|
||||
pub async fn start_session(
|
||||
&mut self,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
) -> Result<(), GreetdError> {
|
||||
Request::StartSession { cmd, env }
|
||||
.write_to(&mut self.stream)
|
||||
.await?;
|
||||
match Response::read_from(&mut self.stream).await? {
|
||||
Response::Success => Ok(()),
|
||||
Response::Error {
|
||||
error_type,
|
||||
description,
|
||||
} => Err(GreetdError::Greetd {
|
||||
description,
|
||||
is_auth_error: matches!(error_type, ErrorType::AuthError),
|
||||
}),
|
||||
Response::AuthMessage { .. } => Err(GreetdError::Greetd {
|
||||
description: "greetd sent an unexpected auth message after StartSession"
|
||||
.to_string(),
|
||||
is_auth_error: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Aborts an in-progress login flow (e.g. the user hit Escape). Per the
|
||||
/// protocol this can only be called before `StartSession` — best-effort,
|
||||
/// errors here don't matter to the caller since the flow is being torn
|
||||
/// down either way.
|
||||
pub async fn cancel_session(&mut self) {
|
||||
let _ = Request::CancelSession.write_to(&mut self.stream).await;
|
||||
let _ = Response::read_from(&mut self.stream).await;
|
||||
}
|
||||
|
||||
async fn roundtrip(&mut self, req: Request) -> Result<Outcome, GreetdError> {
|
||||
req.write_to(&mut self.stream).await?;
|
||||
match Response::read_from(&mut self.stream).await? {
|
||||
Response::Success => Ok(Outcome::Success),
|
||||
Response::AuthMessage {
|
||||
auth_message_type,
|
||||
auth_message,
|
||||
} => Ok(Outcome::Prompt(match auth_message_type {
|
||||
AuthMessageType::Visible => AuthPrompt::Visible(auth_message),
|
||||
AuthMessageType::Secret => AuthPrompt::Secret(auth_message),
|
||||
AuthMessageType::Info => AuthPrompt::Info(auth_message),
|
||||
AuthMessageType::Error => AuthPrompt::Error(auth_message),
|
||||
})),
|
||||
Response::Error {
|
||||
error_type,
|
||||
description,
|
||||
} => Err(GreetdError::Greetd {
|
||||
description,
|
||||
is_auth_error: matches!(error_type, ErrorType::AuthError),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Exercises the framing/state-machine logic against a mock Unix-socket
|
||||
//! server speaking the real greetd_ipc wire format — no real greetd or
|
||||
//! 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 tokio::net::UnixListener;
|
||||
|
||||
async fn mock_server(path: std::path::PathBuf, script: Vec<Response>) {
|
||||
let listener = UnixListener::bind(&path).unwrap();
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
for response in script {
|
||||
// Drain the request that prompted this response — we don't need
|
||||
// to inspect it, just keep the framing in lockstep.
|
||||
let _ = Request::read_from(&mut stream).await;
|
||||
response.write_to(&mut stream).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
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]));
|
||||
|
||||
// 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));
|
||||
|
||||
server.await.unwrap();
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[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(),
|
||||
vec![
|
||||
Response::AuthMessage {
|
||||
auth_message_type: AuthMessageType::Secret,
|
||||
auth_message: "Password:".to_string(),
|
||||
},
|
||||
Response::Success,
|
||||
],
|
||||
));
|
||||
|
||||
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();
|
||||
let Outcome::Prompt(AuthPrompt::Secret(msg)) = outcome else {
|
||||
panic!("expected a secret prompt, got {outcome:?}");
|
||||
};
|
||||
assert_eq!(msg, "Password:");
|
||||
|
||||
let outcome = client.respond(Some("hunter2".to_string())).await.unwrap();
|
||||
assert!(matches!(outcome, Outcome::Success));
|
||||
|
||||
server.await.unwrap();
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[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(),
|
||||
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();
|
||||
match err {
|
||||
GreetdError::Greetd { is_auth_error, .. } => assert!(is_auth_error),
|
||||
other => panic!("expected a greetd auth error, got {other:?}"),
|
||||
}
|
||||
|
||||
server.await.unwrap();
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
3
breadgreet/src/greetd/mod.rs
Normal file
3
breadgreet/src/greetd/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
mod client;
|
||||
|
||||
pub use client::{AuthPrompt, Client, Outcome};
|
||||
304
breadgreet/src/main.rs
Normal file
304
breadgreet/src/main.rs
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
mod config;
|
||||
mod greetd;
|
||||
mod sessions;
|
||||
mod theme;
|
||||
|
||||
use greetd::{AuthPrompt, Client, Outcome};
|
||||
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> },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Stage {
|
||||
/// Waiting for a username in `entry`.
|
||||
Username,
|
||||
/// greetd/PAM asked a question; `entry` holds the answer (masking is
|
||||
/// applied imperatively on the entry widget when the prompt arrives).
|
||||
Prompt,
|
||||
/// A request is in flight — input is disabled so a second Enter can't
|
||||
/// race it.
|
||||
Working,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum AppInput {
|
||||
ClockTick,
|
||||
/// Enter pressed in the entry — behavior depends on `Stage`.
|
||||
Submit,
|
||||
Outcome(Outcome),
|
||||
Error(String),
|
||||
SessionStarted,
|
||||
}
|
||||
|
||||
struct App {
|
||||
clock_lbl: gtk4::Label,
|
||||
status_lbl: gtk4::Label,
|
||||
entry: gtk4::Entry,
|
||||
stage: Stage,
|
||||
username: String,
|
||||
session: Option<sessions::Session>,
|
||||
clock_format: String,
|
||||
cmd_tx: mpsc::UnboundedSender<GreetdCommand>,
|
||||
}
|
||||
|
||||
#[relm4::component]
|
||||
impl SimpleComponent for App {
|
||||
type Init = ();
|
||||
type Input = AppInput;
|
||||
type Output = ();
|
||||
|
||||
view! {
|
||||
gtk4::ApplicationWindow {
|
||||
add_css_class: "breadgreet",
|
||||
set_title: Some("breadgreet"),
|
||||
|
||||
#[name = "root_box"]
|
||||
gtk4::Box {
|
||||
set_orientation: gtk4::Orientation::Vertical,
|
||||
set_halign: gtk4::Align::Center,
|
||||
set_valign: gtk4::Align::Center,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn init(
|
||||
_init: Self::Init,
|
||||
root: Self::Root,
|
||||
sender: ComponentSender<Self>,
|
||||
) -> ComponentParts<Self> {
|
||||
root.fullscreen();
|
||||
|
||||
let config = config::load();
|
||||
let session = sessions::discover(
|
||||
&config.sessions.wayland_dirs,
|
||||
&config.sessions.xsessions_dirs,
|
||||
&config.sessions.default,
|
||||
);
|
||||
|
||||
let clock_lbl = gtk4::Label::new(None);
|
||||
clock_lbl.add_css_class("login-clock");
|
||||
|
||||
let entry = gtk4::Entry::new();
|
||||
entry.add_css_class("login-entry");
|
||||
entry.set_placeholder_text(Some("Username"));
|
||||
entry.set_width_chars(24);
|
||||
{
|
||||
let sender = sender.clone();
|
||||
entry.connect_activate(move |_| sender.input(AppInput::Submit));
|
||||
}
|
||||
|
||||
let status_lbl = gtk4::Label::new(None);
|
||||
status_lbl.add_css_class("login-status");
|
||||
|
||||
let session_lbl = gtk4::Label::new(session.as_ref().map(|s| s.name.as_str()));
|
||||
session_lbl.add_css_class("login-session");
|
||||
if session.is_none() {
|
||||
session_lbl.set_label("No session found — cannot log in");
|
||||
}
|
||||
|
||||
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 8);
|
||||
card.add_css_class("login-card");
|
||||
card.append(&entry);
|
||||
card.append(&status_lbl);
|
||||
card.append(&session_lbl);
|
||||
|
||||
let widgets = view_output!();
|
||||
widgets.root_box.append(&clock_lbl);
|
||||
widgets.root_box.append(&card);
|
||||
|
||||
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
|
||||
spawn_greetd_actor(cmd_rx, sender.clone());
|
||||
|
||||
theme::apply();
|
||||
spawn_clock_ticker(sender.clone());
|
||||
|
||||
let model = App {
|
||||
clock_lbl,
|
||||
status_lbl,
|
||||
entry,
|
||||
stage: Stage::Username,
|
||||
username: String::new(),
|
||||
session,
|
||||
clock_format: config.appearance.clock.format.clone(),
|
||||
cmd_tx,
|
||||
};
|
||||
model
|
||||
.clock_lbl
|
||||
.set_label(¤t_time(&model.clock_format));
|
||||
|
||||
ComponentParts { model, widgets }
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Input, _sender: ComponentSender<Self>) {
|
||||
match msg {
|
||||
AppInput::ClockTick => {
|
||||
self.clock_lbl.set_label(¤t_time(&self.clock_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::SessionStarted => {
|
||||
// greetd now owns the VT switch to the started session —
|
||||
// nothing left for the greeter to do.
|
||||
self.status_lbl.set_label("Starting session…");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn handle_submit(&mut self) {
|
||||
if matches!(self.stage, Stage::Working) {
|
||||
return;
|
||||
}
|
||||
let text = self.entry.text().to_string();
|
||||
|
||||
match &self.stage {
|
||||
Stage::Username => {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.username = text;
|
||||
self.entry.set_text("");
|
||||
self.entry.set_sensitive(false);
|
||||
self.stage = Stage::Working;
|
||||
let _ = self
|
||||
.cmd_tx
|
||||
.send(GreetdCommand::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));
|
||||
}
|
||||
Stage::Working => {}
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
self.status_lbl.set_label(&message);
|
||||
let _ = self.cmd_tx.send(GreetdCommand::Respond(None));
|
||||
}
|
||||
AuthPrompt::Visible(message) => {
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_session(&mut self) {
|
||||
let Some(session) = &self.session 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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 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`].
|
||||
fn spawn_greetd_actor(
|
||||
mut cmd_rx: mpsc::UnboundedReceiver<GreetdCommand>,
|
||||
sender: ComponentSender<App>,
|
||||
) {
|
||||
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),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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>) {
|
||||
relm4::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
sender.input(AppInput::ClockTick);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn current_time(format: &str) -> String {
|
||||
chrono::Local::now().format(format).to_string()
|
||||
}
|
||||
|
||||
fn main() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
let app = RelmApp::new("sh.breadway.breadgreet");
|
||||
app.run::<App>(());
|
||||
}
|
||||
94
breadgreet/src/sessions.rs
Normal file
94
breadgreet/src/sessions.rs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
//! Session discovery: scans the standard greetd-greeter session directories
|
||||
//! for `.desktop` entries. BOS effectively ships one session (Hyprland via
|
||||
//! `bos-session`), so v1 has no picker UI — it just auto-selects the
|
||||
//! configured default (or the only entry found) and resolves its `Exec=`
|
||||
//! line to hand to `greetd`'s `StartSession`.
|
||||
|
||||
use breadlock_ui::desktop_entry::{scan_dir, DesktopEntry};
|
||||
use std::path::Path;
|
||||
|
||||
pub struct Session {
|
||||
pub name: String,
|
||||
pub exec: Vec<String>,
|
||||
}
|
||||
|
||||
/// Scans `wayland_dirs` then `xsessions_dirs` (in that order) and returns
|
||||
/// the entry matching `default` (by `.desktop` file stem), falling back to
|
||||
/// the first entry found in either directory. `None` if nothing is
|
||||
/// installed — the greeter has no session to offer.
|
||||
pub fn discover(
|
||||
wayland_dirs: &[String],
|
||||
xsessions_dirs: &[String],
|
||||
default: &str,
|
||||
) -> Option<Session> {
|
||||
let mut all: Vec<(String, DesktopEntry)> = Vec::new();
|
||||
for dir in wayland_dirs.iter().chain(xsessions_dirs) {
|
||||
all.extend(scan_dir(Path::new(dir)));
|
||||
}
|
||||
|
||||
let chosen = all
|
||||
.iter()
|
||||
.find(|(stem, _)| stem == default)
|
||||
.or_else(|| all.first())?;
|
||||
|
||||
Some(Session {
|
||||
name: chosen.1.name.clone(),
|
||||
exec: split_exec(&chosen.1.exec),
|
||||
})
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn split_exec(exec: &str) -> Vec<String> {
|
||||
exec.split_whitespace()
|
||||
.filter(|arg| !arg.starts_with('%'))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn split_exec_drops_field_codes() {
|
||||
assert_eq!(split_exec("Hyprland"), vec!["Hyprland"]);
|
||||
assert_eq!(split_exec("gnome-session %U"), vec!["gnome-session"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_returns_none_when_no_directories_exist() {
|
||||
assert!(discover(
|
||||
&["/nonexistent/a".to_string()],
|
||||
&["/nonexistent/b".to_string()],
|
||||
"hyprland"
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_prefers_configured_default_over_first_entry() {
|
||||
let dir = std::env::temp_dir().join("breadgreet-test-sessions-discover");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("aaa.desktop"),
|
||||
"[Desktop Entry]\nName=A\nExec=a-cmd\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.join("hyprland.desktop"),
|
||||
"[Desktop Entry]\nName=Hyprland\nExec=Hyprland\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let dir_str = dir.to_str().unwrap().to_string();
|
||||
let session = discover(&[dir_str], &[], "hyprland").unwrap();
|
||||
assert_eq!(session.name, "Hyprland");
|
||||
assert_eq!(session.exec, vec!["Hyprland"]);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
35
breadgreet/src/theme.rs
Normal file
35
breadgreet/src/theme.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
use bread_theme::{gtk as bgtk, ink_on, load_palette};
|
||||
use gtk4::CssProvider;
|
||||
use std::cell::RefCell;
|
||||
|
||||
thread_local! {
|
||||
static USER_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
fn load_css() -> String {
|
||||
let p = load_palette();
|
||||
format!(
|
||||
"window.breadgreet {{ background-color: {bg}; color: {on_bg}; }}\
|
||||
.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-entry {{ font-size: 14px; }}\
|
||||
.login-status {{ font-size: 12px; opacity: 0.75; margin-top: 8px; }}\
|
||||
.login-status.error {{ color: {red}; opacity: 1; }}\
|
||||
.login-session {{ font-size: 12px; opacity: 0.6; margin-top: 12px; }}",
|
||||
bg = p.background,
|
||||
surface = p.color0,
|
||||
red = p.color1,
|
||||
on_bg = ink_on(&p.background),
|
||||
on_surface = ink_on(&p.color0),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn apply() {
|
||||
bgtk::apply_shared();
|
||||
bgtk::apply_app_css(load_css);
|
||||
|
||||
let home = std::env::var("HOME").unwrap_or_default();
|
||||
let user_path = std::path::PathBuf::from(format!("{home}/.config/breadgreet/style.css"));
|
||||
USER_PROVIDER.with(|cell| bgtk::apply_user_css(&user_path, cell));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue