diff --git a/breadgreet.example.toml b/breadgreet.example.toml index bf7fab4..3c86ecc 100644 --- a/breadgreet.example.toml +++ b/breadgreet.example.toml @@ -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"] diff --git a/breadgreet/scripts/mock-greetd.py b/breadgreet/scripts/mock-greetd.py deleted file mode 100755 index 182f7ed..0000000 --- a/breadgreet/scripts/mock-greetd.py +++ /dev/null @@ -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]} [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 deleted file mode 100755 index bdeeb57..0000000 --- a/breadgreet/scripts/preview.sh +++ /dev/null @@ -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" <> "$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 0903e5b..01213f7 100644 --- a/breadgreet/src/config.rs +++ b/breadgreet/src/config.rs @@ -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) -> 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"); - } } diff --git a/breadgreet/src/main.rs b/breadgreet/src/main.rs index a41d4b1..935ad44 100644 --- a/breadgreet/src/main.rs +++ b/breadgreet/src/main.rs @@ -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, - user_idx: usize, sessions: Vec, 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(¤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)); @@ -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 = 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(¤t_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,55 +403,31 @@ 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.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; - - // 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 { + // 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.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())); - } + if !self.sessions.is_empty() { + self.entry.grab_focus(); } } } @@ -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: >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 73ce0fe..96b4360 100644 --- a/breadgreet/src/theme.rs +++ b/breadgreet/src/theme.rs @@ -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, 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 deleted file mode 100644 index 9b71cbf..0000000 --- a/breadgreet/src/users.rs +++ /dev/null @@ -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 { - 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)); - } -}