breadgreet: bring the login UI up to the lock screen's polish #4
7 changed files with 858 additions and 76 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
114
breadgreet/scripts/mock-greetd.py
Executable file
114
breadgreet/scripts/mock-greetd.py
Executable file
|
|
@ -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]} <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()
|
||||
100
breadgreet/scripts/preview.sh
Executable file
100
breadgreet/scripts/preview.sh
Executable file
|
|
@ -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" <<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"
|
||||
|
|
@ -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<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);
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -46,8 +54,16 @@ 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,
|
||||
|
|
@ -107,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 \
|
||||
|
|
@ -119,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));
|
||||
|
|
@ -153,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"));
|
||||
|
|
@ -165,13 +212,72 @@ 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()
|
||||
};
|
||||
|
||||
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 8);
|
||||
// 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);
|
||||
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!();
|
||||
|
|
@ -198,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);
|
||||
|
||||
{
|
||||
|
|
@ -229,22 +343,35 @@ 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,
|
||||
status_lbl,
|
||||
entry,
|
||||
stage: Stage::Username,
|
||||
username: String::new(),
|
||||
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,
|
||||
sessions,
|
||||
selected,
|
||||
clock_format: config.appearance.clock.format.clone(),
|
||||
|
|
@ -255,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();
|
||||
}
|
||||
|
||||
|
|
@ -287,12 +416,50 @@ 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;
|
||||
|
|
@ -338,6 +505,7 @@ 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));
|
||||
}
|
||||
|
|
@ -347,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;
|
||||
|
|
@ -375,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 => {}
|
||||
|
|
@ -384,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -403,31 +602,55 @@ impl App {
|
|||
self.status_lbl.remove_css_class("error");
|
||||
} else {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -511,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`].
|
||||
|
|
|
|||
|
|
@ -1,4 +1,19 @@
|
|||
use bread_theme::{gtk as bgtk, ink_on, load_palette};
|
||||
//! 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 gtk4::CssProvider;
|
||||
use std::cell::RefCell;
|
||||
|
||||
|
|
@ -15,25 +30,173 @@ 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.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),
|
||||
// ---- 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); }}\
|
||||
}}",
|
||||
font = font,
|
||||
)
|
||||
}
|
||||
|
|
@ -48,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<gtk4::Native>, font_family: &str) {
|
||||
let family = font_family.to_string();
|
||||
bgtk::bind_window_auto_with_app_css(window, move |_palette| load_css(&family));
|
||||
}
|
||||
|
|
|
|||
143
breadgreet/src/users.rs
Normal file
143
breadgreet/src/users.rs
Normal file
|
|
@ -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<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));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue