Compare commits

..

No commits in common. "feature/breadgreet-polish" and "main" have entirely different histories.

7 changed files with 76 additions and 858 deletions

View file

@ -21,17 +21,6 @@ date_format = "%A · %b %d"
[font]
family = "Varela Round"
[user]
# By default breadgreet enumerates the system's human accounts (/etc/passwd,
# UID_MIN..UID_MAX from /etc/login.defs) and skips the username field: one
# account goes straight to the password prompt, several show a picker. These
# keys override that.
#
# Pin one account and don't enumerate (empty = auto-detect):
name = ""
# Always ask for the username by hand (the pre-enumeration behaviour):
prompt = false
[sessions]
# Directories scanned for .desktop session entries, in order.
wayland_dirs = ["/usr/share/wayland-sessions"]

View file

@ -1,114 +0,0 @@
#!/usr/bin/env python3
"""A stand-in greetd for previewing breadgreet without a real session.
Speaks the greetd IPC wire format (`u32` native-endian length prefix + JSON
body, see the `greetd_ipc` crate) on a Unix socket. It never touches PAM and
never starts anything `start_session` just acknowledges and the greeter
exits, exactly as it would on a real login.
./mock-greetd.py /run/user/1000/breadgreet-preview.sock [password]
Default password is "bread"; any other answer gets the auth-error path so you
can see the shake + red status line.
"""
import json
import os
import socket
import struct
import sys
PASSWORD = sys.argv[2] if len(sys.argv) > 2 else "bread"
def read_frame(conn):
hdr = b""
while len(hdr) < 4:
chunk = conn.recv(4 - len(hdr))
if not chunk:
return None
hdr += chunk
(length,) = struct.unpack("=I", hdr)
body = b""
while len(body) < length:
chunk = conn.recv(length - len(body))
if not chunk:
return None
body += chunk
return json.loads(body)
def send(conn, obj):
body = json.dumps(obj).encode()
conn.sendall(struct.pack("=I", len(body)) + body)
def handle(conn):
while True:
req = read_frame(conn)
if req is None:
return
kind = req.get("type")
if kind == "create_session":
print(f" create_session username={req.get('username')!r}")
send(conn, {
"type": "auth_message",
"auth_message_type": "secret",
"auth_message": "Password: ",
})
elif kind == "post_auth_message_response":
if req.get("response") == PASSWORD:
print(" auth ok -> success")
send(conn, {"type": "success"})
else:
print(" auth bad -> auth_error")
send(conn, {
"type": "error",
"error_type": "auth_error",
"description": "Login incorrect",
})
elif kind == "start_session":
print(f" start_session cmd={req.get('cmd')}")
send(conn, {"type": "success"})
elif kind == "cancel_session":
print(" cancel_session")
send(conn, {"type": "success"})
else:
print(f" ?? {req}")
send(conn, {
"type": "error",
"error_type": "error",
"description": f"mock-greetd: unknown request {kind}",
})
def main():
sys.stdout.reconfigure(line_buffering=True)
if len(sys.argv) < 2:
sys.exit(f"usage: {sys.argv[0]} <socket-path> [password]")
path = sys.argv[1]
try:
os.unlink(path)
except FileNotFoundError:
pass
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
srv.bind(path)
srv.listen(1)
print(f"mock-greetd listening on {path} (password: {PASSWORD!r})")
try:
while True:
conn, _ = srv.accept()
with conn:
handle(conn)
except KeyboardInterrupt:
pass
finally:
srv.close()
try:
os.unlink(path)
except FileNotFoundError:
pass
if __name__ == "__main__":
main()

View file

@ -1,100 +0,0 @@
#!/usr/bin/env bash
# Run breadgreet in a nested compositor window on your current desktop so you
# can actually drive the UI — type a username, a password, watch the spinner,
# get the shake on a wrong password — without touching your real greeter or
# rebooting.
#
# breadgreet/scripts/preview.sh # cairo renderer (safe everywhere)
# breadgreet/scripts/preview.sh --gpu # your default GSK renderer
# breadgreet/scripts/preview.sh --typed # force the old type-the-username flow
#
# By default breadgreet enumerates your /etc/passwd users and skips straight to
# the password prompt. Password is "bread"; any other password exercises the
# auth-error path (shake + red status line). A correct login makes breadgreet
# exit, as it would for real — that ends the script and closes the window.
# Ctrl-C in this terminal tears everything down at any point.
#
# The nested compositor opens as an ordinary window; float / resize it with
# your WM as you like (it fills whatever size it gets).
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "$here/../.." && pwd)"
cd "$repo"
renderer=cairo
typed=0
for arg in "$@"; do
case "$arg" in
--gpu) renderer="" ;;
--typed) typed=1 ;;
*) echo "unknown flag: $arg" >&2; exit 1 ;;
esac
done
if [[ -z "${WAYLAND_DISPLAY:-}" ]]; then
echo "no WAYLAND_DISPLAY — run this from inside your Wayland session" >&2
exit 1
fi
: "${XDG_RUNTIME_DIR:=/run/user/$(id -u)}"
if ! command -v weston >/dev/null; then
echo "need 'weston' for the nested compositor (pacman -S weston)" >&2
exit 1
fi
echo ">> building breadgreet (debug)"
cargo build -p breadgreet
work="$(mktemp -d /tmp/breadgreet-preview.XXXXXX)"
sock="$work/greetd.sock"
conf="$work/breadgreet.toml"
# A preview config (loaded via $BREADGREET_CONFIG, so the real
# /etc/greetd/breadgreet.toml is left untouched). BOS ships breadgreet with a
# flat colour background; this points at the BOS wallpaper + Ken Burns so you
# can also see how a wallpapered greeter would look.
wallpaper="$repo/../bos/iso/airootfs/usr/share/backgrounds/bos/bread-background.png"
cat > "$conf" <<EOF
[background]
mode = "$([[ -f "$wallpaper" ]] && echo image || echo color)"
path = "$wallpaper"
ken_burns = true
[clock]
format = "%H:%M"
date_format = "%A, %B %-d"
[font]
family = "Varela Round"
EOF
[[ "$typed" == 1 ]] && printf '\n[user]\nprompt = true\n' >> "$conf"
wl_sock="breadgreet-preview-$$"
pids=()
cleanup() {
for p in "${pids[@]:-}"; do kill "$p" 2>/dev/null || true; done
sleep 0.3
for p in "${pids[@]:-}"; do kill -9 "$p" 2>/dev/null || true; done
rm -rf "$work"
}
trap cleanup EXIT INT TERM
echo ">> starting mock greetd"
python3 "$here/mock-greetd.py" "$sock" bread &
pids+=($!)
for _ in $(seq 1 40); do [[ -S "$sock" ]] && break; sleep 0.1; done
echo ">> starting nested compositor"
weston --width=1400 --height=900 --socket="$wl_sock" >"$work/weston.log" 2>&1 &
pids+=($!)
for _ in $(seq 1 60); do [[ -S "$XDG_RUNTIME_DIR/$wl_sock" ]] && break; sleep 0.1; done
echo ">> launching breadgreet (password: bread)"
[[ -n "$renderer" ]] && export GSK_RENDERER="$renderer"
WAYLAND_DISPLAY="$wl_sock" \
BREADGREET_CONFIG="$conf" \
GREETD_SOCK="$sock" \
"$repo/target/debug/breadgreet" || true
echo ">> breadgreet exited"

View file

@ -8,20 +8,6 @@ pub struct Config {
#[serde(flatten)]
pub appearance: Appearance,
pub sessions: Sessions,
pub user: User,
}
/// Who the greeter logs in. By default breadgreet enumerates the system's
/// human accounts (`/etc/passwd`) and skips the username field: one account
/// goes straight to the password prompt, several offer a picker. These keys
/// override that.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct User {
/// Force this login name and don't enumerate. Empty = auto-detect.
pub name: String,
/// Always ask for the username by hand (the pre-enumeration behaviour).
pub prompt: bool,
}
#[derive(Debug, Clone, Deserialize)]
@ -48,21 +34,7 @@ impl Default for Sessions {
/// 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.
///
/// `$BREADGREET_CONFIG` overrides both — an explicit file to load, used by
/// `scripts/preview.sh` so a preview doesn't have to touch the real
/// `/etc/greetd/breadgreet.toml`.
pub fn load() -> Config {
load_with_override(std::env::var_os("BREADGREET_CONFIG"))
}
/// [`load`] with the `$BREADGREET_CONFIG` value passed in explicitly, so the
/// resolution order is testable without mutating process-global env state
/// (which parallel `cargo test` threads race on).
fn load_with_override(explicit: Option<std::ffi::OsString>) -> Config {
if let Some(explicit) = explicit {
return breadlock_ui::config::load_or_default(std::path::Path::new(&explicit));
}
let system_path = std::path::Path::new("/etc/greetd/breadgreet.toml");
if system_path.exists() {
return breadlock_ui::config::load_or_default(system_path);
@ -92,14 +64,4 @@ mod tests {
assert_eq!(s.xsessions_dirs, vec!["/usr/share/xsessions"]);
assert_eq!(s.default, "bos");
}
#[test]
fn breadgreet_config_override_wins_over_the_search_path() {
let path =
std::env::temp_dir().join(format!("breadgreet-cfg-env-{}.toml", std::process::id()));
std::fs::write(&path, "[clock]\nformat = \"%I:%M %p\"\n").unwrap();
let cfg = load_with_override(Some(path.clone().into_os_string()));
std::fs::remove_file(&path).ok();
assert_eq!(cfg.appearance.clock.format, "%I:%M %p");
}
}

View file

@ -2,7 +2,6 @@ mod config;
mod greetd;
mod sessions;
mod theme;
mod users;
use greetd::{AuthPrompt, Outcome};
use gtk4::gdk::Key;
@ -16,15 +15,13 @@ const KENBURNS_ZOOM: f32 = 1.06;
#[derive(Debug, Clone)]
enum Stage {
/// Waiting for a username typed into `entry` — only reached when user
/// enumeration is off or found nothing (see [`App::typed_mode`]).
/// 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. Also the initial stage in auto-user mode, while the opening
/// `CreateSession` is in flight.
/// race it.
Working,
/// `StartSession` has been sent — Escape must not cancel.
Starting,
@ -40,11 +37,6 @@ enum AppInput {
SessionStarted,
/// Picker changed; `u32::MAX` (`INVALID_LIST_POSITION`) is ignored.
SessionSelected(u32),
/// User picker changed (multi-user auto mode) — restart auth for that user.
UserSelected(u32),
/// Post-init kick in auto-user mode: open the greetd conversation for the
/// resolved user so the greeter lands straight on the password prompt.
AutoStart,
/// Escape — abort the in-progress PAM conversation.
Cancel,
}
@ -54,16 +46,8 @@ struct App {
date_lbl: gtk4::Label,
status_lbl: gtk4::Label,
entry: gtk4::Entry,
card: gtk4::Box,
spinner: gtk4::Box,
stage: Stage,
/// The name currently being authenticated — typed in [`Stage::Username`],
/// or the resolved account in auto-user mode.
username: String,
/// Enumerated login accounts. Empty ⇒ typed-username mode; one ⇒ straight
/// to the password prompt; several ⇒ `user_idx` selects among them.
users: Vec<users::User>,
user_idx: usize,
sessions: Vec<sessions::Session>,
selected: usize,
clock_format: String,
@ -123,24 +107,6 @@ impl SimpleComponent for App {
.and_then(|chosen| sessions.iter().position(|s| s.stem == chosen.stem))
.unwrap_or(0);
// Who's logging in. `[user] prompt` keeps the old type-it flow; a set
// `[user] name` pins one account; otherwise enumerate `/etc/passwd`.
// A non-empty list means "auto mode": no username field, straight to
// the password prompt (with a picker if there's more than one).
let users = if config.user.prompt {
Vec::new()
} else if !config.user.name.trim().is_empty() {
let name = config.user.name.trim().to_string();
vec![users::User {
display: name.clone(),
name,
uid: 0,
}]
} else {
users::list()
};
let auto_user = !users.is_empty();
if config.appearance.background.blur {
tracing::warn!(
"background.blur is not implemented yet (planned v2 feature, needs a wlr-screencopy \
@ -153,33 +119,19 @@ impl SimpleComponent for App {
let date_lbl = gtk4::Label::new(None);
date_lbl.add_css_class("login-date");
// Spacing below the clock cluster is the accent rule's own top/bottom
// margin (see `.login-rule` CSS); only a hair of clock→date gap here.
if config.appearance.clock.date_format.is_empty() {
date_lbl.set_visible(false);
clock_lbl.set_margin_bottom(20);
} else {
clock_lbl.set_margin_bottom(4);
date_lbl.set_margin_bottom(16);
date_lbl.set_label(&current_time(&config.appearance.clock.date_format));
}
let entry = gtk4::Entry::new();
entry.add_css_class("login-entry");
entry.set_width_chars(24);
// A leading glyph that swaps person → key when PAM asks for a secret
// (kept in sync in show_auth_entry / reset_auth). In auto-user mode the
// entry is only ever the password field, and starts disabled until the
// opening `CreateSession` produces a prompt.
if auto_user {
entry.set_placeholder_text(Some("Password"));
entry.set_primary_icon_name(Some("dialog-password-symbolic"));
entry.set_visibility(false);
entry.set_sensitive(false);
} else {
entry.set_placeholder_text(Some("Username"));
entry.set_primary_icon_name(Some("avatar-default-symbolic"));
}
entry.set_primary_icon_activatable(false);
entry.set_primary_icon_sensitive(false);
entry.set_width_chars(24);
{
let sender = sender.clone();
entry.connect_activate(move |_| sender.input(AppInput::Submit));
@ -201,6 +153,7 @@ impl SimpleComponent for App {
} else {
let names: Vec<&str> = sessions.iter().map(|s| s.name.as_str()).collect();
let dropdown = gtk4::DropDown::from_strings(&names);
dropdown.add_css_class("login-session");
dropdown.set_hexpand(true);
dropdown.set_focusable(true);
dropdown.set_tooltip_text(Some("Session"));
@ -212,72 +165,13 @@ impl SimpleComponent for App {
sender.input(AppInput::SessionSelected(dd.selected()));
});
}
// Wrap the dropdown in the design sketch's `.srow`: a surface pill
// with a gradient session glyph on the left (drawn purely in CSS).
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
row.add_css_class("login-session");
row.add_css_class("session-row");
let glyph = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
glyph.add_css_class("session-icon");
glyph.set_valign(gtk4::Align::Center);
row.append(&glyph);
row.append(&dropdown);
row.upcast()
dropdown.upcast()
};
// The "who's logging in" row, shown above the password field in auto
// mode: a gradient avatar glyph + either the account name (one user) or
// a picker (several). Nothing in typed mode — the entry is the field.
let user_widget: Option<gtk4::Widget> = if !auto_user {
None
} else {
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
row.add_css_class("login-user");
row.add_css_class("user-row");
let glyph = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
glyph.add_css_class("user-icon");
glyph.set_valign(gtk4::Align::Center);
row.append(&glyph);
if users.len() == 1 {
let name = gtk4::Label::new(Some(&users[0].display));
name.add_css_class("user-name");
name.set_halign(gtk4::Align::Start);
name.set_hexpand(true);
name.set_ellipsize(gtk4::pango::EllipsizeMode::End);
row.append(&name);
} else {
let labels: Vec<&str> = users.iter().map(|u| u.display.as_str()).collect();
let dropdown = gtk4::DropDown::from_strings(&labels);
dropdown.set_hexpand(true);
dropdown.set_focusable(true);
dropdown.set_tooltip_text(Some("User"));
dropdown.update_property(&[gtk4::accessible::Property::Label("User")]);
{
let sender = sender.clone();
dropdown.connect_selected_notify(move |dd| {
sender.input(AppInput::UserSelected(dd.selected()));
});
}
row.append(&dropdown);
}
Some(row.upcast())
};
// A CSS-animated ring (not GtkSpinner) so it matches the design
// sketch exactly: 2px track, accent top, shown only while a greetd
// request is in flight (see `set_busy`).
let spinner = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
spinner.add_css_class("login-spinner");
spinner.set_halign(gtk4::Align::Center);
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 8);
card.add_css_class("login-card");
if let Some(w) = &user_widget {
card.append(w);
}
card.append(&entry);
card.append(&status_lbl);
card.append(&spinner);
card.append(&session_widget);
let widgets = view_output!();
@ -304,16 +198,8 @@ impl SimpleComponent for App {
widgets.overlay.add_overlay(&veil);
widgets.overlay.add_overlay(&widgets.root_box);
// Thin accent rule between the clock cluster and the card — a
// signature detail shared with the lock screen; it draws itself in
// (scaleX 0→1) via `.login-rule`.
let rule = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
rule.add_css_class("login-rule");
rule.set_halign(gtk4::Align::Center);
widgets.root_box.append(&clock_lbl);
widgets.root_box.append(&date_lbl);
widgets.root_box.append(&rule);
widgets.root_box.append(&card);
{
@ -343,35 +229,22 @@ impl SimpleComponent for App {
None
};
setup_wallpaper(&root, &bg_area, wallpaper_path.as_deref(), ken_burns);
// Entrance motion (clock rise, rule draw, card pop-in + idle breathe)
// is CSS `@keyframes` in theme.rs — GTK4 plays `animation` when a
// widget's style is first computed on show.
setup_entrance(&root, &widgets.root_box);
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
spawn_greetd_actor(cmd_rx, sender.clone());
theme::apply(&config.appearance.font.family);
theme::bind(&root, &config.appearance.font.family);
bread_theme::gtk::bind_window_auto(&root);
spawn_clock_ticker(sender.clone());
let username = users.first().map(|u| u.name.clone()).unwrap_or_default();
let model = App {
clock_lbl,
date_lbl,
status_lbl,
entry,
card,
spinner,
// Auto mode opens in `Working` — the greeter is already waiting on
// greetd for the first prompt (kicked by `AutoStart` below).
stage: if auto_user {
Stage::Working
} else {
Stage::Username
},
username,
users,
user_idx: 0,
stage: Stage::Username,
username: String::new(),
sessions,
selected,
clock_format: config.appearance.clock.format.clone(),
@ -382,9 +255,7 @@ impl SimpleComponent for App {
model
.clock_lbl
.set_label(&current_time(&model.clock_format));
if auto_user {
sender.input(AppInput::AutoStart);
} else if !model.sessions.is_empty() {
if !model.sessions.is_empty() {
model.entry.grab_focus();
}
@ -416,50 +287,12 @@ impl SimpleComponent for App {
self.selected = idx;
}
}
AppInput::UserSelected(idx) => self.switch_user(idx as usize),
AppInput::AutoStart => {
if self.sessions.is_empty() {
// Nothing to log into — leave the "no session" error up.
self.stage = Stage::Username;
} else {
self.status_lbl.set_label("");
self.dispatch(greetd::Command::CreateSession(self.username.clone()));
}
}
AppInput::Cancel => self.cancel_auth(),
}
self.sync_busy();
}
}
impl App {
/// Spinner runs exactly while a greetd request is in flight. Driven from
/// one place (the end of `update`) so every path — submit, prompt,
/// cancel, error — stays in sync with `self.stage`.
fn sync_busy(&self) {
let busy = matches!(self.stage, Stage::Working | Stage::Starting);
if busy {
self.spinner.add_css_class("spinning");
} else {
self.spinner.remove_css_class("spinning");
}
}
/// One-shot shake on the card, matching the lock screen's wrong-password
/// motion. Re-armed each call by dropping the class first (a running
/// animation won't restart just from re-adding it).
fn flash_error(&self) {
self.card.remove_css_class("shake");
let card = self.card.clone();
gtk4::glib::timeout_add_local_once(std::time::Duration::from_millis(10), move || {
card.add_css_class("shake");
let card2 = card.clone();
gtk4::glib::timeout_add_local_once(std::time::Duration::from_millis(420), move || {
card2.remove_css_class("shake");
});
});
}
fn handle_submit(&mut self) {
if matches!(self.stage, Stage::Working | Stage::Starting) {
return;
@ -505,7 +338,6 @@ impl App {
AuthPrompt::Error(message) => {
self.status_lbl.add_css_class("error");
self.status_lbl.set_label(&message);
self.flash_error();
self.pam_status_held = true;
self.dispatch(greetd::Command::Respond(None));
}
@ -515,19 +347,13 @@ impl App {
}
fn show_auth_entry(&mut self, message: &str, visible: bool) {
// The prompt text goes in the placeholder; the status line stays for
// Info/Error messages only (a preceding one is kept via
// `pam_status_held`, otherwise it's cleared — no echoing "Password:"
// both in the field and under it).
if !self.pam_status_held {
self.status_lbl.remove_css_class("error");
self.status_lbl.set_label("");
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_primary_icon_name(Some("dialog-password-symbolic"));
self.entry.set_sensitive(true);
self.entry.grab_focus();
self.stage = Stage::Prompt;
@ -549,31 +375,6 @@ impl App {
self.dispatch(greetd::Command::StartSession { cmd, env });
}
/// No account resolved up front — the greeter asks for the username.
fn typed_mode(&self) -> bool {
self.users.is_empty()
}
/// Multi-user auto mode: the picker changed. Tear down the current greetd
/// conversation and open a fresh one for the newly selected account.
fn switch_user(&mut self, idx: usize) {
if matches!(self.stage, Stage::Starting) || idx >= self.users.len() || idx == self.user_idx
{
return;
}
self.user_idx = idx;
self.username = self.users[idx].name.clone();
self.status_lbl.set_label("");
self.status_lbl.remove_css_class("error");
self.pam_status_held = false;
self.entry.set_text("");
self.entry.set_sensitive(false);
self.entry.set_placeholder_text(Some("Password"));
self.stage = Stage::Working;
let _ = self.cmd_tx.send(greetd::Command::CancelSession);
self.dispatch(greetd::Command::CreateSession(self.username.clone()));
}
fn cancel_auth(&mut self) {
match self.stage {
Stage::Starting => {}
@ -583,9 +384,9 @@ impl App {
Stage::Prompt | Stage::Working => {
self.status_lbl.set_label("");
self.status_lbl.remove_css_class("error");
// `reset_auth` dispatches the CancelSession itself, so we don't
// double-send it here.
self.reset_auth();
// `reset_to_username` dispatches the CancelSession itself, so
// we don't double-send it here.
self.reset_to_username();
}
}
}
@ -602,56 +403,32 @@ impl App {
self.status_lbl.remove_css_class("error");
} else {
self.status_lbl.add_css_class("error");
self.flash_error();
}
self.reset_auth();
self.reset_to_username();
}
/// Return to the start of the auth flow after an error or an Escape.
///
/// Typed mode goes back to the username field; auto mode re-opens the
/// password prompt for the same account (there's no username step to
/// return to). Either way the stale greetd conversation is cancelled
/// first, so the next `CreateSession` doesn't stack on a half-done one.
fn reset_auth(&mut self) {
fn reset_to_username(&mut self) {
self.entry.set_text("");
self.entry.set_visibility(true);
self.pam_status_held = false;
// On a broken channel set the failure label directly rather than
// recursing through `show_error` (which calls back here forever).
let channel_ok = self.cmd_tx.send(greetd::Command::CancelSession).is_ok();
if !channel_ok {
self.status_lbl.set_label("Cannot reach greetd");
self.status_lbl.add_css_class("error");
}
if self.typed_mode() {
self.entry.set_placeholder_text(Some("Username"));
self.entry
.set_primary_icon_name(Some("avatar-default-symbolic"));
self.entry.set_sensitive(!self.sessions.is_empty());
self.stage = Stage::Username;
self.username.clear();
self.pam_status_held = false;
// Abort any greetd conversation still open server-side. Without this,
// the error/`show_error` reset path returns to the username entry but
// leaves greetd holding a half-done PAM conversation, so the next
// login attempt's CreateSession stacks on a stale session. On a
// broken channel we set the failure label directly rather than
// recursing into `show_error`, which would call back into
// `reset_to_username` forever.
if self.cmd_tx.send(greetd::Command::CancelSession).is_err() {
self.status_lbl.set_label("Cannot reach greetd");
self.status_lbl.add_css_class("error");
}
if !self.sessions.is_empty() {
self.entry.grab_focus();
}
} else {
// Auto mode: straight back to a fresh password prompt. The entry
// stays disabled until the reopened conversation's Secret prompt
// arrives (see `show_auth_entry`).
self.entry.set_visibility(false);
self.entry.set_placeholder_text(Some("Password"));
self.entry
.set_primary_icon_name(Some("dialog-password-symbolic"));
self.entry.set_sensitive(false);
self.stage = Stage::Working;
if channel_ok && !self.sessions.is_empty() {
let _ = self
.cmd_tx
.send(greetd::Command::CreateSession(self.username.clone()));
}
}
}
}
@ -734,6 +511,31 @@ fn setup_wallpaper(
});
}
/// Entrance animation: the clock + card cluster fades in and rises ~24px
/// over ~600ms (ease-out), matching the lock screen's appear motion.
fn setup_entrance(window: &gtk4::ApplicationWindow, root_box: &gtk4::Box) {
let root_box = root_box.clone();
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);
// Ease-out cubic.
let e = 1.0 - (1.0 - t).powi(3);
root_box.set_opacity(e as f64);
root_box.set_margin_top((RISE_PX * (1.0 - e)) as i32);
if t >= 1.0 {
gtk4::glib::ControlFlow::Break
} else {
gtk4::glib::ControlFlow::Continue
}
});
}
/// 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`].

View file

@ -1,19 +1,4 @@
//! breadgreet's app stylesheet.
//!
//! Layered on `bread_theme`'s shared `@define-color` palette (`@surface`,
//! `@overlay`, `@accent`, `@teal`, `@red`, …) via `apply_shared()` +
//! `apply_app_css()`. Visual target: `design/sketch.html`'s `.greetoverlay`
//! and the lock screen's motion vocabulary — a hero clock over an accent
//! rule, a floating surface card with a staggered pop-in, an entry with a
//! live accent focus glow, and per-state motion (spinner, shake).
//!
//! Entrance / idle motion is CSS `@keyframes` (GTK4 runs `animation` when a
//! widget's style is first computed, i.e. on show). Wallpaper Ken Burns
//! stays in Rust (`setup_wallpaper`) since it's a continuous frame-clock
//! pan, not a one-shot.
use bread_theme::gtk as bgtk;
use gtk4::prelude::*;
use bread_theme::{gtk as bgtk, ink_on, load_palette};
use gtk4::CssProvider;
use std::cell::RefCell;
@ -30,173 +15,25 @@ fn css_font_family(family: &str) -> String {
}
fn load_css(font_family: &str) -> String {
let p = load_palette();
let font = css_font_family(font_family);
format!(
// ---- window + wallpaper veil ---------------------------------
"window.breadgreet {{ background-color: @bg; color: @on-bg; {font} }}\
.login-veil {{\
background-image:\
radial-gradient(ellipse 90% 90% at 50% 45%, alpha(black, 0.0) 35%, alpha(black, 0.44) 100%),\
linear-gradient(to bottom, alpha(black, 0.40) 0%, alpha(black, 0.16) 42%, alpha(black, 0.36) 100%);\
}}\
\
/* ---- clock cluster ---------------------------------------- */\
.login-clock {{\
font-size: 68px; font-weight: 300; color: white; letter-spacing: 1px;\
text-shadow: 0 3px 22px alpha(black, 0.6);\
animation: bg-rise 520ms cubic-bezier(0.16, 1, 0.3, 1) both;\
}}\
.login-date {{\
font-size: 14px; font-weight: 600; color: alpha(white, 0.8);\
letter-spacing: 1.5px;\
text-shadow: 0 1px 10px alpha(black, 0.55);\
animation: bg-rise 520ms cubic-bezier(0.16, 1, 0.3, 1) 80ms both;\
}}\
/* accent rule between the clock and the card — draws in */\
.login-rule {{\
min-height: 3px; min-width: 88px; margin: 15px 0 22px;\
border-radius: 3px;\
background-image: linear-gradient(90deg, alpha(@accent, 0.0), @accent 42%, @teal 100%);\
box-shadow: 0 0 14px alpha(@accent, 0.5), 0 0 3px alpha(@teal, 0.4);\
animation: bg-draw 620ms cubic-bezier(0.16, 1, 0.3, 1) 140ms both;\
}}\
\
/* ---- the card ------------------------------------------- */\
.login-card {{\
background-image: linear-gradient(to bottom, shade(@surface, 1.06), @surface);\
color: @on-surface;\
border: 1px solid alpha(@overlay, 0.10);\
border-top: 1px solid alpha(white, 0.06);\
border-radius: 14px; padding: 24px 22px; min-width: 320px;\
box-shadow: 0 20px 48px alpha(black, 0.55), 0 2px 8px alpha(black, 0.4);\
animation: bg-pop 480ms cubic-bezier(0.34, 1.56, 0.64, 1) 170ms both,\
bg-breathe 5s ease-in-out 1400ms infinite;\
}}\
.login-card.shake {{ animation: bg-shake 400ms cubic-bezier(0.36, 0.07, 0.19, 0.97); }}\
\
/* ---- entry --------------------------------------------- */\
.login-entry {{\
background-image: linear-gradient(to bottom, shade(@surface, 1.42), shade(@surface, 1.58));\
color: @on-surface;\
border: 1px solid alpha(@overlay, 0.13);\
border-radius: 9px; padding: 12px 14px; font-size: 15px;\
caret-color: @accent;\
box-shadow: inset 0 1px 2px alpha(black, 0.28);\
transition: border-color 180ms ease, box-shadow 200ms ease, background-image 180ms ease;\
}}\
.login-entry:disabled {{ opacity: 0.5; }}\
.login-entry > text {{ background: transparent; }}\
.login-entry image {{ color: alpha(@on-surface, 0.55); margin-right: 6px; }}\
.login-entry:focus-within {{\
border-color: @accent;\
background-image: linear-gradient(to bottom, shade(@surface, 1.5), shade(@surface, 1.66));\
box-shadow: inset 0 1px 2px alpha(black, 0.2),\
0 0 0 3px alpha(@accent, 0.22),\
0 6px 22px alpha(@accent, 0.14);\
outline: none;\
}}\
.login-entry:focus-within image {{ color: @accent; }}\
\
/* ---- status line -------------------------------------- */\
.login-status {{\
font-size: 12px; color: alpha(@on-surface, 0.68);\
margin-top: 12px; min-height: 1em;\
transition: color 160ms ease;\
}}\
/* errors must read as errors regardless of the wallpaper palette\
(BOS's default `@red` slot is a warm ochre, not a warning red) */\
.login-status.error {{ color: #ff6b6b; opacity: 1; font-weight: 700; }}\
\
/* ---- spinner ----------------------------------------- */\
.login-spinner {{\
min-width: 16px; min-height: 16px; margin-top: 14px;\
border: 2px solid alpha(@overlay, 0.16);\
border-top: 2px solid @accent;\
border-radius: 999px; opacity: 0;\
transition: opacity 160ms ease;\
}}\
.login-spinner.spinning {{ opacity: 1; animation: bg-spin 720ms linear infinite; }}\
\
/* ---- session picker (design sketch .srow) ------------ */\
.login-session {{ margin-top: 16px; }}\
.session-row {{\
background-image: linear-gradient(to bottom, shade(@surface, 1.4), shade(@surface, 1.52));\
border: 1px solid alpha(@overlay, 0.12);\
border-radius: 9px; padding: 7px 10px;\
transition: border-color 160ms ease;\
}}\
.session-row:focus-within {{ border-color: alpha(@accent, 0.6); }}\
.session-icon {{\
min-width: 22px; min-height: 22px; border-radius: 6px;\
background-image: linear-gradient(135deg, @accent, @teal);\
box-shadow: 0 1px 4px alpha(@accent, 0.35);\
}}\
.login-session dropdown {{ background: transparent; border: none; box-shadow: none; padding: 0; }}\
.login-session dropdown > button {{\
background: transparent; border: none; box-shadow: none; outline: none;\
padding: 2px 4px; min-height: 22px; color: alpha(@on-surface, 0.92);\
font-size: 13px;\
}}\
.login-session dropdown > button:hover {{ background: transparent; }}\
.login-session dropdown arrow {{ color: alpha(@on-surface, 0.55); min-height: 14px; min-width: 14px; }}\
.login-session popover > contents {{\
background: @surface; border: 1px solid alpha(@overlay, 0.14);\
border-radius: 10px; padding: 5px;\
box-shadow: 0 12px 32px alpha(black, 0.5);\
}}\
.login-session popover row {{ border-radius: 7px; padding: 7px 11px; font-size: 13px; }}\
.login-session popover row:selected {{ background: alpha(@accent, 0.22); color: @on-surface; }}\
\
/* ---- who's logging in (auto-user mode) -------------- */\
.login-user {{ margin-bottom: 12px; }}\
.user-row {{\
background-image: linear-gradient(to bottom, shade(@surface, 1.4), shade(@surface, 1.52));\
border: 1px solid alpha(@overlay, 0.12);\
border-radius: 9px; padding: 7px 10px;\
transition: border-color 160ms ease;\
}}\
.user-row:focus-within {{ border-color: alpha(@accent, 0.6); }}\
.user-icon {{\
min-width: 24px; min-height: 24px; border-radius: 999px;\
background-image: linear-gradient(135deg, @accent, @teal);\
box-shadow: 0 1px 5px alpha(@accent, 0.4), inset 0 1px 1px alpha(white, 0.2);\
}}\
.user-name {{ font-size: 14px; font-weight: 600; color: alpha(@on-surface, 0.95); }}\
.login-user dropdown {{ background: transparent; border: none; box-shadow: none; padding: 0; }}\
.login-user dropdown > button {{\
background: transparent; border: none; box-shadow: none; outline: none;\
padding: 2px 4px; min-height: 24px; color: alpha(@on-surface, 0.95);\
font-size: 14px; font-weight: 600;\
}}\
.login-user dropdown > button:hover {{ background: transparent; }}\
.login-user dropdown arrow {{ color: alpha(@on-surface, 0.55); min-height: 14px; min-width: 14px; }}\
.login-user popover > contents {{\
background: @surface; border: 1px solid alpha(@overlay, 0.14);\
border-radius: 10px; padding: 5px;\
box-shadow: 0 12px 32px alpha(black, 0.5);\
}}\
.login-user popover row {{ border-radius: 7px; padding: 7px 11px; font-size: 13px; }}\
.login-user popover row:selected {{ background: alpha(@accent, 0.22); color: @on-surface; }}\
\
/* ---- keyframes -------------------------------------- */\
@keyframes bg-rise {{ from {{ opacity: 0; transform: translateY(20px); }} to {{ opacity: 1; transform: none; }} }}\
@keyframes bg-pop {{\
from {{ opacity: 0; transform: scale(0.94) translateY(14px); }}\
70% {{ transform: scale(1.015) translateY(0); }}\
to {{ opacity: 1; transform: scale(1) translateY(0); }}\
}}\
@keyframes bg-draw {{ from {{ opacity: 0; transform: scaleX(0); }} to {{ opacity: 1; transform: scaleX(1); }} }}\
@keyframes bg-spin {{ to {{ transform: rotate(360deg); }} }}\
@keyframes bg-breathe {{\
0%, 100% {{ box-shadow: 0 20px 48px alpha(black, 0.55), 0 2px 8px alpha(black, 0.4); }}\
50% {{ box-shadow: 0 24px 60px alpha(black, 0.62), 0 0 0 1px alpha(@accent, 0.10), 0 2px 8px alpha(black, 0.4); }}\
}}\
@keyframes bg-shake {{\
10%, 90% {{ transform: translateX(-2px); }}\
20%, 80% {{ transform: translateX(4px); }}\
30%, 50%, 70% {{ transform: translateX(-7px); }}\
40%, 60% {{ transform: translateX(7px); }}\
}}",
"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; }}\
.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; }}\
.login-session {{ font-size: 12px; opacity: 0.85; margin-top: 12px; }}\
dropdown.login-session {{ min-height: 32px; }}\
.login-veil {{ background-image: linear-gradient(to bottom, rgba(0,0,0,0.34) 0%, rgba(0,0,0,0.16) 100%); }}",
bg = p.background,
surface = p.color0,
red = p.color1,
on_bg = ink_on(&p.background),
on_surface = ink_on(&p.color0),
font = font,
)
}
@ -211,18 +48,3 @@ pub fn apply(font_family: &str) {
.join("style.css");
USER_PROVIDER.with(|cell| bgtk::apply_user_css(&user_path, cell));
}
/// Bind the greeter window to its output's wallpaper palette *and* re-apply
/// breadgreet's own sheet as a widget-tree provider.
///
/// [`apply`]'s `apply_app_css` loads at APPLICATION priority, but
/// `bind_window_auto` re-broadcasts the shared component sheet — including its
/// `* { font-size }` base rule — at `USER - 10`, which outranks APPLICATION
/// regardless of selector specificity. So the hero clock, the date, every
/// typographic override here would silently collapse back to the base size.
/// Riding our sheet at `USER - 9` (what `_with_app_css` does) puts it back on
/// top. `@accent`/`@surface`/… tokens are inlined against the same palette.
pub fn bind(window: &impl IsA<gtk4::Native>, font_family: &str) {
let family = font_family.to_string();
bgtk::bind_window_auto_with_app_css(window, move |_palette| load_css(&family));
}

View file

@ -1,143 +0,0 @@
//! Enumerating the human users greetd could log in.
//!
//! On a typical single-user desktop this lets the greeter skip the "type your
//! username" step entirely: one human account → go straight to the password
//! prompt; several → offer a picker. The list comes from `/etc/passwd`
//! (world-readable, so this works as the unprivileged `greeter` user),
//! filtered to the login-user UID range from `/etc/login.defs`.
/// A local account the greeter can offer as a login target.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct User {
/// The login name (what greetd's `CreateSession` wants).
pub name: String,
/// The GECOS full name if the account has one, else `name` — this is what
/// the picker shows.
pub display: String,
pub uid: u32,
}
/// Human users on this system, sorted by UID. Empty if `/etc/passwd` can't be
/// read or holds no login accounts (the greeter then falls back to a typed
/// username).
pub fn list() -> Vec<User> {
let passwd = std::fs::read_to_string("/etc/passwd").unwrap_or_default();
let login_defs = std::fs::read_to_string("/etc/login.defs").ok();
parse(&passwd, login_defs.as_deref())
}
/// `(UID_MIN, UID_MAX)` from `/etc/login.defs`, or shadow's defaults.
fn uid_bounds(login_defs: Option<&str>) -> (u32, u32) {
let (mut min, mut max) = (1000u32, 60000u32);
for line in login_defs.unwrap_or_default().lines() {
let mut it = line.split_whitespace();
match it.next() {
Some("UID_MIN") => {
if let Some(Ok(n)) = it.next().map(str::parse) {
min = n;
}
}
Some("UID_MAX") => {
if let Some(Ok(n)) = it.next().map(str::parse) {
max = n;
}
}
_ => {}
}
}
(min, max)
}
/// A shell that actually lets someone log in — excludes the `nologin` / `false`
/// placeholders system accounts use.
fn is_login_shell(shell: &str) -> bool {
!shell.is_empty() && !shell.ends_with("nologin") && !shell.ends_with("/false")
}
fn parse(passwd: &str, login_defs: Option<&str>) -> Vec<User> {
let (min, max) = uid_bounds(login_defs);
let mut users: Vec<User> = passwd
.lines()
.filter_map(|line| {
// name:passwd:uid:gid:gecos:home:shell
let mut f = line.split(':');
let name = f.next()?;
let _passwd = f.next()?;
let uid: u32 = f.next()?.parse().ok()?;
let _gid = f.next()?;
let gecos = f.next().unwrap_or("");
let _home = f.next()?;
let shell = f.next().unwrap_or("");
if uid < min || uid > max || name == "nobody" || !is_login_shell(shell) {
return None;
}
let full = gecos.split(',').next().unwrap_or("").trim();
let display = if full.is_empty() { name } else { full }.to_string();
Some(User {
name: name.to_string(),
display,
uid,
})
})
.collect();
users.sort_by(|a, b| a.uid.cmp(&b.uid).then_with(|| a.name.cmp(&b.name)));
users.dedup_by(|a, b| a.name == b.name);
users
}
#[cfg(test)]
mod tests {
use super::*;
const PASSWD: &str = "\
root:x:0:0:root:/root:/bin/bash
bin:x:1:1::/:/usr/bin/nologin
nobody:x:65534:65534:Nobody:/:/usr/bin/nologin
riley:x:1000:1000:Riley Horsham,,,:/home/riley:/bin/zsh
guest:x:1001:1001::/home/guest:/bin/bash
svc:x:850:850:some service:/var/lib/svc:/bin/bash
noshell:x:1002:1002::/home/noshell:/usr/sbin/nologin
falseshell:x:1003:1003::/home/f:/bin/false
";
#[test]
fn keeps_only_human_login_accounts() {
let users = parse(PASSWD, Some("UID_MIN 1000\nUID_MAX 60000\n"));
let names: Vec<&str> = users.iter().map(|u| u.name.as_str()).collect();
assert_eq!(names, ["riley", "guest"]);
}
#[test]
fn gecos_full_name_becomes_the_display_name() {
let users = parse(PASSWD, None);
let riley = users.iter().find(|u| u.name == "riley").unwrap();
assert_eq!(riley.display, "Riley Horsham");
let guest = users.iter().find(|u| u.name == "guest").unwrap();
assert_eq!(guest.display, "guest"); // no GECOS -> falls back to name
}
#[test]
fn respects_uid_min_from_login_defs() {
// Lowering UID_MIN pulls the service account (uid 850) into range;
// it still has a real shell so it now counts.
let users = parse(PASSWD, Some("UID_MIN 500\nUID_MAX 60000\n"));
let names: Vec<&str> = users.iter().map(|u| u.name.as_str()).collect();
assert_eq!(names, ["svc", "riley", "guest"]);
}
#[test]
fn sorted_by_uid() {
let users = parse(PASSWD, Some("UID_MIN 500\n"));
assert!(users.windows(2).all(|w| w[0].uid <= w[1].uid));
}
#[test]
fn empty_passwd_yields_nothing() {
assert!(parse("", None).is_empty());
}
#[test]
fn defaults_when_login_defs_absent() {
assert_eq!(uid_bounds(None), (1000, 60000));
}
}