Initial commit

This commit is contained in:
Breadway 2026-07-03 13:37:31 +08:00
commit 9f97f2c989
37 changed files with 4871 additions and 0 deletions

63
breadgreet/src/config.rs Normal file
View 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");
}
}

View 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));
}
}

View file

@ -0,0 +1,3 @@
mod client;
pub use client::{AuthPrompt, Client, Outcome};

304
breadgreet/src/main.rs Normal file
View 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(&current_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(&current_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>(());
}

View 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
View 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));
}