diff --git a/breadgreet.example.toml b/breadgreet.example.toml index 3c86ecc..bf7fab4 100644 --- a/breadgreet.example.toml +++ b/breadgreet.example.toml @@ -21,6 +21,17 @@ 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"] diff --git a/breadgreet/scripts/mock-greetd.py b/breadgreet/scripts/mock-greetd.py new file mode 100755 index 0000000..182f7ed --- /dev/null +++ b/breadgreet/scripts/mock-greetd.py @@ -0,0 +1,114 @@ +#!/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]} [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() diff --git a/breadgreet/scripts/preview.sh b/breadgreet/scripts/preview.sh new file mode 100755 index 0000000..bdeeb57 --- /dev/null +++ b/breadgreet/scripts/preview.sh @@ -0,0 +1,100 @@ +#!/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" <> "$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" diff --git a/breadgreet/src/config.rs b/breadgreet/src/config.rs index 01213f7..0903e5b 100644 --- a/breadgreet/src/config.rs +++ b/breadgreet/src/config.rs @@ -8,6 +8,20 @@ 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)] @@ -34,7 +48,21 @@ 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) -> 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); @@ -64,4 +92,14 @@ 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"); + } } diff --git a/breadgreet/src/main.rs b/breadgreet/src/main.rs index 07aaae1..a41d4b1 100644 --- a/breadgreet/src/main.rs +++ b/breadgreet/src/main.rs @@ -2,6 +2,7 @@ mod config; mod greetd; mod sessions; mod theme; +mod users; use greetd::{AuthPrompt, Outcome}; use gtk4::gdk::Key; @@ -15,13 +16,15 @@ const KENBURNS_ZOOM: f32 = 1.06; #[derive(Debug, Clone)] enum Stage { - /// Waiting for a username in `entry`. + /// Waiting for a username typed into `entry` — only reached when user + /// enumeration is off or found nothing (see [`App::typed_mode`]). 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. + /// race it. Also the initial stage in auto-user mode, while the opening + /// `CreateSession` is in flight. Working, /// `StartSession` has been sent — Escape must not cancel. Starting, @@ -37,6 +40,11 @@ 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, } @@ -49,7 +57,13 @@ struct App { 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, + user_idx: usize, sessions: Vec, selected: usize, clock_format: String, @@ -109,6 +123,24 @@ 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 \ @@ -121,19 +153,33 @@ 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(¤t_time(&config.appearance.clock.date_format)); } let entry = gtk4::Entry::new(); entry.add_css_class("login-entry"); - entry.set_placeholder_text(Some("Username")); 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); { let sender = sender.clone(); entry.connect_activate(move |_| sender.input(AppInput::Submit)); @@ -155,7 +201,6 @@ 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")); @@ -167,7 +212,55 @@ impl SimpleComponent for App { sender.input(AppInput::SessionSelected(dd.selected())); }); } - dropdown.upcast() + // 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() + }; + + // 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 = 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 @@ -179,6 +272,9 @@ impl SimpleComponent for App { let card = gtk4::Box::new(gtk4::Orientation::Vertical, 0); 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); @@ -208,8 +304,16 @@ 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); { @@ -239,15 +343,18 @@ impl SimpleComponent for App { None }; setup_wallpaper(&root, &bg_area, wallpaper_path.as_deref(), ken_burns); - setup_entrance(&root, &widgets.root_box); + // 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. let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); spawn_greetd_actor(cmd_rx, sender.clone()); theme::apply(&config.appearance.font.family); - bread_theme::gtk::bind_window_auto(&root); + theme::bind(&root, &config.appearance.font.family); spawn_clock_ticker(sender.clone()); + let username = users.first().map(|u| u.name.clone()).unwrap_or_default(); let model = App { clock_lbl, date_lbl, @@ -255,8 +362,16 @@ impl SimpleComponent for App { entry, card, spinner, - stage: Stage::Username, - username: String::new(), + // 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, sessions, selected, clock_format: config.appearance.clock.format.clone(), @@ -267,7 +382,9 @@ impl SimpleComponent for App { model .clock_lbl .set_label(¤t_time(&model.clock_format)); - if !model.sessions.is_empty() { + if auto_user { + sender.input(AppInput::AutoStart); + } else if !model.sessions.is_empty() { model.entry.grab_focus(); } @@ -299,6 +416,16 @@ 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(); @@ -388,13 +515,19 @@ 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(message); + self.status_lbl.set_label(""); } 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; @@ -416,6 +549,31 @@ 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 => {} @@ -425,9 +583,9 @@ impl App { Stage::Prompt | Stage::Working => { self.status_lbl.set_label(""); self.status_lbl.remove_css_class("error"); - // `reset_to_username` dispatches the CancelSession itself, so - // we don't double-send it here. - self.reset_to_username(); + // `reset_auth` dispatches the CancelSession itself, so we don't + // double-send it here. + self.reset_auth(); } } } @@ -446,30 +604,53 @@ impl App { self.status_lbl.add_css_class("error"); self.flash_error(); } - self.reset_to_username(); + self.reset_auth(); } - fn reset_to_username(&mut self) { + /// 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) { 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; - // 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() { + + // 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.sessions.is_empty() { - self.entry.grab_focus(); + + 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(); + 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())); + } } } } @@ -553,31 +734,6 @@ 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: >k4::ApplicationWindow, root_box: >k4::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`]. diff --git a/breadgreet/src/theme.rs b/breadgreet/src/theme.rs index 972a136..73ce0fe 100644 --- a/breadgreet/src/theme.rs +++ b/breadgreet/src/theme.rs @@ -1,14 +1,19 @@ //! breadgreet's app stylesheet. //! -//! Layered on top of `bread_theme`'s shared `@define-color` palette -//! (`@surface`, `@overlay`, `@accent`, `@red`, …) via `apply_shared()` + -//! `apply_app_css()`. The visual target is `design/sketch.html`'s -//! `.greetoverlay` section — the same design language as the lock screen: -//! a floating surface card over a cover-fit / Ken-Burns wallpaper, an -//! accent focus ring on the entry, a spinner while a request is in flight, -//! and a shake on a failed attempt. +//! 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 gtk4::CssProvider; use std::cell::RefCell; @@ -27,92 +32,166 @@ fn css_font_family(family: &str) -> String { fn load_css(font_family: &str) -> String { let font = css_font_family(font_family); format!( - // ---- window + wallpaper ---------------------------------------- + // ---- window + wallpaper veil --------------------------------- "window.breadgreet {{ background-color: @bg; color: @on-bg; {font} }}\ .login-veil {{\ - background-image: linear-gradient(to bottom,\ - alpha(black, 0.42) 0%, alpha(black, 0.20) 45%, alpha(black, 0.34) 100%);\ + 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 (over the wallpaper) --------------------- */\ + /* ---- clock cluster ---------------------------------------- */\ .login-clock {{\ - font-size: 46px; font-weight: 700; color: white;\ - text-shadow: 0 2px 12px alpha(black, 0.55);\ + 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: 500; color: alpha(white, 0.82);\ - text-shadow: 0 1px 8px alpha(black, 0.5);\ + 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 --------------------------------------------- */\ + /* ---- the card ------------------------------------------- */\ .login-card {{\ - background: @surface; color: @on-surface;\ - border: 1px solid alpha(@overlay, 0.09);\ - border-radius: 10px; padding: 22px;\ - min-width: 300px;\ - box-shadow: 0 6px 28px alpha(black, 0.5);\ + 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: breadgreet-shake 380ms cubic-bezier(0.36, 0.07, 0.19, 0.97); }}\ + .login-card.shake {{ animation: bg-shake 400ms cubic-bezier(0.36, 0.07, 0.19, 0.97); }}\ \ - /* ---- entry ----------------------------------------------- */\ + /* ---- entry --------------------------------------------- */\ .login-entry {{\ - background: shade(@surface, 1.5); color: @on-surface;\ - border: 1px solid alpha(@overlay, 0.14);\ - border-radius: 7px; padding: 11px 14px; font-size: 14px;\ + 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;\ - transition: border-color 180ms ease, box-shadow 180ms ease;\ + 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.55; }}\ + .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;\ - box-shadow: 0 0 0 2px alpha(@accent, 0.28);\ + 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 --------------------------------------- */\ + /* ---- status line -------------------------------------- */\ .login-status {{\ - font-size: 12px; color: alpha(@on-surface, 0.72);\ - margin-top: 10px; min-height: 1em;\ + font-size: 12px; color: alpha(@on-surface, 0.68);\ + margin-top: 12px; min-height: 1em;\ transition: color 160ms ease;\ }}\ - .login-status.error {{ color: @red; opacity: 1; font-weight: 700; }}\ + /* 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 (shown while a request is in flight) ------ */\ + /* ---- spinner ----------------------------------------- */\ .login-spinner {{\ - min-width: 16px; min-height: 16px;\ - margin-top: 12px;\ - border: 2px solid alpha(@overlay, 0.15);\ + 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;\ + border-radius: 999px; opacity: 0;\ + transition: opacity 160ms ease;\ }}\ - .login-spinner.spinning {{ opacity: 1; animation: breadgreet-spin 800ms linear infinite; }}\ + .login-spinner.spinning {{ opacity: 1; animation: bg-spin 720ms linear infinite; }}\ \ - /* ---- session picker (styled as a surface pill) -------- */\ - .login-session {{\ - margin-top: 14px; font-size: 12px;\ + /* ---- 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;\ }}\ - .login-session > button {{\ - background: shade(@surface, 1.5); color: alpha(@on-surface, 0.9);\ - border: 1px solid alpha(@overlay, 0.14);\ - border-radius: 7px; padding: 9px 12px; min-height: 20px;\ + .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 > button:hover {{ border-color: alpha(@accent, 0.5); }}\ - .login-session > button:focus-within {{\ - border-color: @accent; box-shadow: 0 0 0 2px alpha(@accent, 0.28); outline: none;\ + .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 arrow {{ color: alpha(@on-surface, 0.6); min-height: 16px; min-width: 16px; }}\ + .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.12);\ - border-radius: 8px; padding: 4px;\ + 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: 6px; padding: 6px 10px; }}\ + .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; }}\ \ - /* ---- keyframes --------------------------------------- */\ - @keyframes breadgreet-spin {{ to {{ transform: rotate(360deg); }} }}\ - @keyframes breadgreet-shake {{\ + /* ---- 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); }}\ @@ -132,3 +211,18 @@ 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, font_family: &str) { + let family = font_family.to_string(); + bgtk::bind_window_auto_with_app_css(window, move |_palette| load_css(&family)); +} diff --git a/breadgreet/src/users.rs b/breadgreet/src/users.rs new file mode 100644 index 0000000..9b71cbf --- /dev/null +++ b/breadgreet/src/users.rs @@ -0,0 +1,143 @@ +//! 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 { + 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 { + let (min, max) = uid_bounds(login_defs); + let mut users: Vec = 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)); + } +}