Compare commits

...

12 commits
v0.2.1 ... main

Author SHA1 Message Date
Breadway
94289865c0 Fail-secure bread.command.lock.unlock: never loginctl
Bus clients must not unlock a locked session. Already-unlocked acks
.unlock.done; a running locker emits .failed (PAM only). Super+L /
hypridle remain loginctl lock-session.
2026-08-23 14:38:50 +08:00
Breadway
4f513c9926 Document the hybrid GPU renderer and fix packaging
README claimed fully software / no EGL; the locker is GLES2 wallpaper
plus software chrome, with opt-in Ken Burns. Building needs git and
pkg-config. PKGBUILD pkgver matches 0.2.0, backs up /etc/pam.d/breadlock,
and notes both dev bins. Release CI runs check() instead of --nocheck.
2026-08-23 14:21:39 +08:00
Breadway
495d7f8446 Fix greeter session start and login flow
greetd only launches the session after the greeter process exits. Quit
on successful StartSession instead of sitting on "Starting session…"
until SIGTERM.

Empty Secret/Visible answers are Some("") (not PAM cancel). The greetd
actor stays up across connect failure and reconnects. Escape cancels
the conversation. Session env sets XDG_SESSION_TYPE/DESKTOP. .desktop
parsing honors Hidden/NoDisplay/TryExec and quoted Exec=. Invalid TOML
warns instead of failing silent. Font, date, and Ken Burns config match
what the greeter actually draws.
2026-08-23 14:21:29 +08:00
Breadway
6925e132fd Add lock-screen status line and harden the locker
Show MPRIS now-playing and upower battery under the clock, polled on a
background thread (zbus). Flags gate both drawing and D-Bus; desktops
without a battery stay blank; titles are truncated.

Also the locker audit pass that landed in the same files:
- PAM authenticates getuid()/getpwuid_r, not $USER, and missing
  username still takes the lock
- one lock surface per output, generation-gated fail timers, PAM
  timeout, Tab-reveal off by default and cleared on keyboard leave
- GPU chrome is a dirty-rect quad with EGL cleanup; shm reuses a
  SlotPool; appear/shake/flash/veil/clock-crossfade bugs
- hold-to-reveal, checking ellipsis, and password display length

Live-checked on hermes (GPU path, PAM unlock, Tab-hold).
2026-08-23 14:21:19 +08:00
Breadway
5475e4da05 breadgreet: GPU wallpaper with Ken Burns pan + entrance animation
The greeter previously ignored the configured background image and
rendered a static palette screen. Now the wallpaper is painted behind
the login card via a GtkDrawingArea draw callback (GTK4's GL pipeline),
with a Ken Burns pan driven by a tick callback and a veil gradient
matching breadlock's software renderer. The clock + card cluster fades
in and rises on entry.

Key bug avoided: the first attempt panned by resizing a GtkPicture,
which fed back into the window's minimum size and grew the window
unboundedly (17k x 11k). The draw-callback approach has no layout
feedback.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-08-21 20:30:28 +08:00
Breadway
0b378e84d0 Add caps-lock chip, hold-to-reveal, idle auto-dim, and attempt counter
Four lock-screen niceties:
- Caps Lock / layout chip: a small pill above the password pill shows
  when Caps Lock is on or a non-default layout is selected, so all-caps
  input never mystifies.
- Hold-to-reveal: holding Tab renders the plain password instead of
  dots (Tab produces no utf8, so it can never be part of the password).
- Idle auto-dim: after `animation.idle_dim_after_secs` of no keystrokes
  the dim veil deepens past the base gradient (background-only alpha can
  legally exceed 1.0), ramping over a few seconds for OLED/burn-in
  comfort. Shared `veil_alpha` keeps software and GPU paths identical.
- Attempt counter: repeat failures show "Wrong password — N failed
  attempts" so a stuck locker reads differently from ordinary typos.

All four render paths covered by tests (veil math, reveal truncation,
chip/reveal compose) and live-verified in nested Hyprland on the GPU
path; idle dim confirmed to deepen the veil (84.5->75.8 top).
2026-08-21 15:50:44 +08:00
Breadway
55cf57a8ba Fix scrambled GPU chrome upload and pin veil gradient direction
glTexSubImage2D has no row-stride concept on GLES2, so uploading the
chrome sub-rect directly from the full-surface pixmap shifted every row
after the first by (w - rw) pixels — the clock/pill rendered as garbage.
Pack the dirty rows into a tightly-strided buffer before upload instead
(GL_UNPACK_ROW_LENGTH is ES 3.0-only).

Add a regression test for the pack, plus one pinning the background
shader's veil gradient to the software dim_rows (gl_FragCoord.y is 0 at
the bottom of the framebuffer, which is easy to get backwards).
2026-08-21 15:35:10 +08:00
Breadway
3d2e35e003 GPU background rendering (EGL/GLES2) with software chrome, plus expanded tests
The Ken Burns wallpaper pan and dim veil move to the GPU: the lock
surface is wrapped in a wl_egl_window, the wallpaper is a textured quad
whose shader applies the pan transform and veil (GPU bilinear makes
sub-pixel panning free — the ~19ms/frame software bilinear is gone,
idle CPU drops from 40% to ~0%), and the clock/pill/status chrome is
still composed with tiny-skia and composited over. EGL init failure
falls back to the fully-software path unchanged.

Notable fixes along the way:
- Text blending is now destination-aware: the old blend forced output
  alpha to 255, which is correct over the opaque background but made
  every glyph opaque in the chrome-only pixmap the GPU path composites.
- wayland-client switches to its C backend for raw surface pointers,
  and khronos-egl/glow replace a hand-rolled EGL loader.

Tests grew from 72 to 88: GPU pan_region parity with the software path
(and edge/bounds/corner invariants), shader-uniform/EGL-attrib sanity,
chrome bounding-rect tracking, and a pixel-level parity test proving
the GPU split stays within double-rounding ULP range of the single-pass
compose.
2026-08-21 15:02:09 +08:00
Breadway
f0b66cd791 Breadlock UI update: motion system, depth polish, and new animations
Adds a full motion system to the lock screen (staggered entrance, dot pop
+ caret, wrong-password shake, success flash, clock crossfade), depth
polish (gradient veil, pill shadow + hairline border, "Enter password"
hint, responsive typography), and a second wave of animations (idle
breathing, success dot cascade, status slide-in, parallax unlock drift,
animated checking ellipsis, opt-in Ken Burns wallpaper pan). Includes a
dev-only breadlock-preview harness that renders every state to PNGs with
no Wayland involved, and a design/sketch.html prototype.

Fixes text alpha being dropped by cosmic-text's glyph-mask path (all text
fades now work) and double-drifting that made the date/status overlap the
clock/pill during unlock.
2026-08-21 13:12:31 +08:00
Breadway
647a211a93 can't be bothered writing a commit message 2026-08-16 14:48:34 +08:00
ba51f52660 Merge pull request 'can't be bothered writing a commit message' (#2) from feature/liquid-motion into main
Reviewed-on: #2
2026-08-16 14:45:02 +08:00
Breadway
46ecea18ac can't be bothered writing a commit message 2026-08-16 14:44:07 +08:00
38 changed files with 7824 additions and 677 deletions

View file

@ -34,8 +34,7 @@ jobs:
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD
sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD
chown -R builder:builder /home/builder/src
# --nocheck: packaging builds the artifact; tests belong in a CI job.
su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck"
su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm"
PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1)
mkdir -p /tmp/breadlock-pkg
cp "$PKG" /tmp/breadlock-pkg/

3
.gitignore vendored
View file

@ -25,3 +25,6 @@ CLAUDE.md
# graphify knowledge-graph output (local tool cache, not for commit)
graphify-out/
# breadlock-preview PNG output (dev-only animation harness)
preview/

960
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@ members = ["breadlock-ui", "breadlock", "breadgreet"]
resolver = "2"
[workspace.dependencies]
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4" }
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View file

@ -27,25 +27,26 @@ from session lock/unlock.
| Event | Data | When |
|-------|------|------|
| `bread.lock.locked` | `{}` | The compositor accepted the `ext-session-lock-v1` request (`SessionLockHandler::locked`). Not emitted merely because breadlock started or asked to lock. |
| `bread.lock.unlocked` | `{}` | PAM authenticated successfully and breadlock sent `unlock` to the compositor. Not emitted on a compositor-ended lock (`finished`), a dispatch-error exit (fail-secure: the session stays locked), or a failed/typo password. |
| `bread.lock.unlocked` | `{}` | PAM authenticated successfully and breadlock sent `unlock` to the compositor, **or** the compositor ended an already-active lock (`SessionLockHandler::finished` after `locked` — breadlock sends `unlock_and_destroy` then emits this). Not emitted when the lock was never acquired (`finished` before `locked`), on a dispatch-error exit (fail-secure: the session stays locked), or a failed/typo password. |
| `bread.lock.lock.done` | `{}` | `bread.command.lock.lock` was honored: the locker was already running, or a locker process was started (same no-args invocation as hypridle's `lock_cmd = breadlock`). This is the command confirmation, not compositor proof — wait on `bread.lock.locked` if you need the session-lock protocol to have completed. |
| `bread.lock.lock.failed` | `{ "error": "<message>" }` | `bread.command.lock.lock` was received but the locker could not be started (e.g. this binary is missing from disk). |
| `bread.lock.unlock.done` | `{}` | `bread.command.lock.unlock` was honored: no locker was running (already unlocked), or `loginctl unlock-session` was invoked for this session. This is the command confirmation, not compositor proof — wait on `bread.lock.unlocked` if you need PAM + `ext-session-lock-v1` unlock. |
| `bread.lock.unlock.failed` | `{ "error": "<message>" }` | `bread.command.lock.unlock` was received but `loginctl unlock-session` could not be run (binary missing, non-zero exit). |
| `bread.lock.unlock.done` | `{}` | `bread.command.lock.unlock` was honored because no locker was running (session already unlocked). This is **not** passwordless compositor unlock and is **not** emitted merely because a bus client asked to unlock. For PAM + `ext-session-lock-v1` unlock, wait on `bread.lock.unlocked`. |
| `bread.lock.unlock.failed` | `{ "error": "<message>" }` | `bread.command.lock.unlock` was received while the locker is running. The bus cannot bypass PAM; authenticate at the lock screen. |
## Commands honored (`bread.command.lock.*`)
| Verb | Effect |
|------|--------|
| `lock` | If a locker is already running, emit `bread.lock.lock.done` and do nothing else. Otherwise start `breadlock` the same way hypridle does (`lock_cmd = breadlock`: this binary, no args) and emit `done` or `failed`. |
| `unlock` | If no locker is running, emit `bread.lock.unlock.done` (already unlocked). Otherwise run `loginctl unlock-session` on the caller's session and emit `done` or `failed`. This is session-level (logind), not a passwordless PAM bypass: breadlock does not call compositor `unlock()` for this verb. |
| `unlock` | If no locker is running, emit `bread.lock.unlock.done` (already unlocked) and do **not** call loginctl. If the locker is running, emit `bread.lock.unlock.failed` — bus clients must never trigger unlock. Compositor `unlock()` stays on the PAM path only. |
A Lua workflow that wants the session locked should `bread.wait` /
`bread.wait_any` on `bread.lock.lock.done` (or `.failed`) with a timeout.
To know the compositor actually locked, wait on `bread.lock.locked`.
The same pattern applies to unlock: wait on `bread.lock.unlock.done` /
`.failed` for the command, and on `bread.lock.unlocked` for PAM +
compositor unlock.
Unlock from the bus is not a substitute for PAM: wait on
`bread.lock.unlock.done` / `.failed` for the command ack (`.done` only
means already unlocked), and on `bread.lock.unlocked` for a typed
password + compositor unlock.
### Who is listening
@ -60,8 +61,8 @@ rule). Two subscribers exist:
idempotent `done`.
2. **The locker process** — always subscribes once the lock screen is
up, so `lock` during an active lock is an idempotent `done`, and
`unlock` runs `loginctl unlock-session` rather than compositor
`unlock()`.
`unlock` is `.failed` (cannot bypass PAM). Never compositor
`unlock()`, never `loginctl unlock-session`.
### Session-level equivalent
@ -72,11 +73,11 @@ bread command bus. It is the session-level equivalent of
same `ext-session-lock-v1` request. Prefer `loginctl lock-session`
from a keybind; prefer the bus command from a Lua workflow.
`loginctl unlock-session` is the matching session-level unlock. The
bus verb invokes that same command. Compositor unlock after a typed
password is still PAM on this process (`bread.lock.unlocked`); a
dispatch-error or crash path still does **not** call compositor
`unlock()` (fail-secure).
The bus unlock verb does **not** call `loginctl unlock-session` and
does **not** replace PAM. Super+L / hypridle remain `loginctl
lock-session`. Compositor unlock after a typed password is still PAM
on this process (`bread.lock.unlocked`); a dispatch-error or crash
path still does **not** call compositor `unlock()` (fail-secure).
### Not implemented: `pin` / `blur`

View file

@ -17,11 +17,13 @@ honors `bread.command.lock.lock` / `bread.command.lock.unlock` (emits
`bread.lock.lock.done` / `.failed` and `bread.lock.unlock.done` /
`.failed`). Run `breadlock listen` so both commands work while
unlocked; the locker also subscribes while the session is locked.
Unlock is `loginctl unlock-session` at the session level — not a
passwordless compositor `unlock()`. Super+L remains
`loginctl lock-session` (hypridle then runs `breadlock`). See
[EVENTS.md](EVENTS.md). `breadgreet` is not on the bus. There is no
`bakery.toml` (PAM / pacman exception).
Unlock is fail-secure: already-unlocked acks `bread.lock.unlock.done`;
a running locker refuses with `bread.lock.unlock.failed` (only PAM at
the lock screen unlocks). The bus never calls compositor `unlock()` or
`loginctl unlock-session`. Super+L remains `loginctl lock-session`
(hypridle then runs `breadlock`). See [EVENTS.md](EVENTS.md).
`breadgreet` is not on the bus. There is no `bakery.toml` (PAM /
pacman exception).
## Architecture
@ -30,15 +32,15 @@ breadlock/
├── breadlock-ui/ shared: bread-theme wrapper, TOML config, .desktop parsing,
│ software-rendering primitives (tiny-skia + cosmic-text,
│ behind the "paint" feature — only breadlock needs them)
├── breadlock/ the locker (SCTK + PAM)
├── breadlock/ the locker (SCTK + PAM; EGL wallpaper + software chrome)
└── breadgreet/ the greeter (GTK4 + relm4 + greetd_ipc)
```
### breadlock
- **Protocol**: `ext-session-lock-v1` via [`smithay-client-toolkit`](https://docs.rs/smithay-client-toolkit) — GTK has no session-lock support, so this is a raw Wayland client, not a layer-shell surface like breadbar.
- **Rendering**: fully software — `tiny-skia` composites each frame (background, rounded password pill, clock, status line) into a `wl_shm` buffer; `cosmic-text` shapes and rasterizes text (loads "Varela Round" by family name). No EGL/GL.
- **Background**: a solid palette color or a static PNG (cover-fit). `background.blur` is **not implemented** — the key is accepted and logs a warning; the surface is drawn unblurred. Live blur-of-desktop (hyprlock-style) would need a `wlr-screencopy` capture.
- **Rendering**: hybrid — wallpaper via EGL/GLES2 (`wl_egl_window` wrapping the lock surface); chrome (password pill, clock, status line) is still software (`tiny-skia` + `cosmic-text`, "Varela Round" by family name) and blitted over the GPU frame. If EGL init fails, the locker falls back to a fully-software `wl_shm` path.
- **Background**: a solid palette color or a PNG (cover-fit). Ken Burns (`background.ken_burns`) is opt-in: a slow pan+zoom on image backgrounds — cheap on the GPU path, a continuous software redraw if EGL is unavailable. `background.blur` is **not implemented** — the key is accepted and logs a warning; the surface is drawn unblurred. Live blur-of-desktop (hyprlock-style) would need a `wlr-screencopy` capture.
- **Auth**: [`pam-client2`](https://crates.io/crates/pam-client2) against the `breadlock` PAM service (`packaging/pam.d/breadlock`, installed to `/etc/pam.d/breadlock` by the package). Runs on its own OS thread — libpam's conversation callback is blocking FFI — and reports back through a `calloop::channel` registered on the render loop.
### breadgreet
@ -51,6 +53,8 @@ breadlock/
Copy [`breadlock.example.toml`](breadlock.example.toml) to `~/.config/breadlock/breadlock.toml` and [`breadgreet.example.toml`](breadgreet.example.toml) to `/etc/greetd/breadgreet.toml` (or `~/.config/breadgreet/breadgreet.toml` for local testing under a normal session — `breadgreet` checks the system path first since it typically runs as the dedicated `greeter` user). Every field is optional; both binaries run with sensible defaults and no config at all.
`breadlock.toml`'s `[status]` table (both flags default on) shows now-playing (MPRIS) and battery (upower) as a small line under the clock. Polled on a background thread; degrades silently if D-Bus or the service is missing.
## Building
```sh
@ -58,13 +62,13 @@ cargo build --release --bin breadlock --bin breadgreet
cargo test --workspace
```
Requires GTK4 (≥ 4.12), `libxkbcommon`, and PAM development headers. On Arch:
Requires GTK4 (≥ 4.12), `libxkbcommon`, PAM development headers, `git` (workspace crates `bread-theme` / `bread-utils` are git deps), and `pkg-config` (gtk4-rs; also provided by `base-devel`). On Arch:
```sh
sudo pacman -S gtk4 wayland libxkbcommon pam rust cargo
sudo pacman -S gtk4 wayland libxkbcommon pam rust cargo git pkg-config
```
`breadlock-auth-check` is a third, dev-only binary in the `breadlock` package (see Verification below) — not installed by the package, build it explicitly with `cargo build --bin breadlock-auth-check` if you need it.
`breadlock-auth-check` and `breadlock-preview` are extra, dev-only binaries in the `breadlock` package (see Verification below) — not installed by the package. Build them explicitly with `cargo build --bin breadlock-auth-check` or `--bin breadlock-preview` if you need them.
## Packaging
@ -87,8 +91,10 @@ lock_cmd = breadlock
`bread.command.lock.lock` and `bread.command.lock.unlock`. It is not
started by hypridle; add it to session startup
(`exec-once = breadlock listen`) if a Lua workflow should be able to
lock or unlock the session while it is unlocked. Session-level unlock
is `loginctl unlock-session` (the bus verb runs that).
lock the session while it is unlocked, or to ack already-unlocked.
`bread.command.lock.unlock` does not replace PAM and does not run
`loginctl unlock-session`. Super+L / hypridle remain
`loginctl lock-session`.
## Verification (why this is safe to test without a lockout risk)

View file

@ -9,9 +9,14 @@
mode = "color"
path = ""
blur = false
# Slow Ken Burns pan on image backgrounds (gentle drift + zoom). Opt-in: the
# background redraws continuously at a low frame rate.
ken_burns = false
[clock]
format = "%H:%M"
# strftime format for the date line under the clock; empty string hides it
date_format = "%A · %b %d"
[font]
family = "Varela Round"

View file

@ -42,12 +42,15 @@ pub fn load() -> Config {
breadlock_ui::config::load_or_default(&xdg_config_path())
}
fn xdg_config_path() -> PathBuf {
let base = std::env::var_os("XDG_CONFIG_HOME")
pub(crate) fn xdg_config_dir() -> PathBuf {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
.unwrap_or_else(|| PathBuf::from("."));
base.join("breadgreet").join("breadgreet.toml")
.unwrap_or_else(|| PathBuf::from("."))
}
fn xdg_config_path() -> PathBuf {
xdg_config_dir().join("breadgreet").join("breadgreet.toml")
}
#[cfg(test)]

View file

@ -59,7 +59,7 @@ impl Client {
/// used directly by tests against a mock server so they don't need to
/// mutate process-global environment state (which parallel `cargo test`
/// threads would race on).
async fn connect_to(path: impl AsRef<std::path::Path>) -> Result<Self, GreetdError> {
pub(crate) async fn connect_to(path: impl AsRef<std::path::Path>) -> Result<Self, GreetdError> {
let stream = UnixStream::connect(path)
.await
.map_err(GreetdError::Connect)?;
@ -149,10 +149,21 @@ mod tests {
//! PAM involved. This is the safe way to test this module: a bug here
//! just fails a test, it can never affect a real login.
use super::*;
use greetd_ipc::codec::TokioCodec;
use greetd_ipc::{AuthMessageType, ErrorType, Request, Response};
use tokio::net::UnixListener;
async fn mock_server(path: std::path::PathBuf, script: Vec<Response>) {
fn bind_socket(name: &str) -> (std::path::PathBuf, UnixListener) {
let path = std::env::temp_dir().join(format!(
"breadgreet-test-{name}-{}.sock",
std::process::id()
));
std::fs::remove_file(&path).ok();
let listener = UnixListener::bind(&path).unwrap();
(path, listener)
}
async fn serve(listener: UnixListener, script: Vec<Response>) {
let (mut stream, _) = listener.accept().await.unwrap();
for response in script {
// Drain the request that prompted this response — we don't need
@ -162,21 +173,11 @@ mod tests {
}
}
fn socket_path(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"breadgreet-test-{name}-{}.sock",
std::process::id()
))
}
#[tokio::test]
async fn create_session_success_flows_straight_through() {
let path = socket_path("success");
std::fs::remove_file(&path).ok();
let server = tokio::spawn(mock_server(path.clone(), vec![Response::Success]));
let (path, listener) = bind_socket("success");
let server = tokio::spawn(serve(listener, vec![Response::Success]));
// Give the listener a moment to bind before connecting.
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
let mut client = Client::connect_to(&path).await.unwrap();
let outcome = client.create_session("bob").await.unwrap();
assert!(matches!(outcome, Outcome::Success));
@ -187,10 +188,9 @@ mod tests {
#[tokio::test]
async fn create_session_prompts_for_password_then_succeeds() {
let path = socket_path("prompt");
std::fs::remove_file(&path).ok();
let server = tokio::spawn(mock_server(
path.clone(),
let (path, listener) = bind_socket("prompt");
let server = tokio::spawn(serve(
listener,
vec![
Response::AuthMessage {
auth_message_type: AuthMessageType::Secret,
@ -200,7 +200,6 @@ mod tests {
],
));
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
let mut client = Client::connect_to(&path).await.unwrap();
let outcome = client.create_session("bob").await.unwrap();
@ -218,17 +217,15 @@ mod tests {
#[tokio::test]
async fn auth_error_is_reported_as_such() {
let path = socket_path("autherr");
std::fs::remove_file(&path).ok();
let server = tokio::spawn(mock_server(
path.clone(),
let (path, listener) = bind_socket("autherr");
let server = tokio::spawn(serve(
listener,
vec![Response::Error {
error_type: ErrorType::AuthError,
description: "denied".to_string(),
}],
));
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
let mut client = Client::connect_to(&path).await.unwrap();
let err = client.create_session("bob").await.unwrap_err();
@ -242,9 +239,43 @@ mod tests {
}
#[tokio::test]
async fn connect_without_greetd_sock_env_fails_cleanly() {
std::env::remove_var("GREETD_SOCK");
let err = Client::connect().await.unwrap_err();
assert!(matches!(err, GreetdError::NoSocketEnv));
async fn connect_to_missing_socket_fails() {
let err = Client::connect_to("/no/such/breadgreet-test.sock")
.await
.unwrap_err();
assert!(matches!(err, GreetdError::Connect(_)));
}
#[tokio::test]
async fn empty_password_is_sent_as_some_empty_string() {
let (path, listener) = bind_socket("empty-pw");
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let _ = Request::read_from(&mut stream).await;
Response::AuthMessage {
auth_message_type: AuthMessageType::Secret,
auth_message: "Password:".to_string(),
}
.write_to(&mut stream)
.await
.unwrap();
let req = Request::read_from(&mut stream).await.unwrap();
match req {
Request::PostAuthMessageResponse { response } => {
assert_eq!(response, Some(String::new()));
}
other => panic!("expected PostAuthMessageResponse, got {other:?}"),
}
Response::Success.write_to(&mut stream).await.unwrap();
});
let mut client = Client::connect_to(&path).await.unwrap();
let outcome = client.create_session("bob").await.unwrap();
assert!(matches!(outcome, Outcome::Prompt(AuthPrompt::Secret(_))));
let outcome = client.respond(Some(String::new())).await.unwrap();
assert!(matches!(outcome, Outcome::Success));
server.await.unwrap();
std::fs::remove_file(&path).ok();
}
}

View file

@ -1,3 +1,198 @@
mod client;
pub use client::{AuthPrompt, Client, Outcome};
pub use client::{AuthPrompt, Client, GreetdError, Outcome};
use std::future::Future;
use tokio::sync::mpsc;
/// Commands sent from the UI thread to the greetd actor, which owns the
/// single stateful connection to `$GREETD_SOCK`.
#[derive(Debug)]
pub enum Command {
CreateSession(String),
Respond(Option<String>),
StartSession { cmd: Vec<String>, env: Vec<String> },
CancelSession,
}
#[derive(Debug)]
pub enum Event {
Outcome(Outcome),
Error(String),
SessionStarted,
}
/// Owns the greetd connection for the life of the greeter. Connect failures
/// and a later-dead socket are reported as [`Event::Error`]; the actor stays
/// alive and reconnects on the next command so the UI cannot freeze with a
/// dropped `cmd_rx`.
pub async fn run_actor<E>(cmd_rx: mpsc::UnboundedReceiver<Command>, emit: E)
where
E: FnMut(Event) + Send + 'static,
{
run_actor_with(cmd_rx, emit, Client::connect).await;
}
async fn run_actor_with<E, C, Fut>(
mut cmd_rx: mpsc::UnboundedReceiver<Command>,
mut emit: E,
mut connect: C,
) where
E: FnMut(Event),
C: FnMut() -> Fut,
Fut: Future<Output = Result<Client, GreetdError>>,
{
let mut client: Option<Client> = match connect().await {
Ok(c) => Some(c),
Err(err) => {
emit(Event::Error(format!("Cannot reach greetd: {err}")));
None
}
};
while let Some(cmd) = cmd_rx.recv().await {
if matches!(cmd, Command::CancelSession) {
if let Some(c) = client.as_mut() {
c.cancel_session().await;
}
continue;
}
if client.is_none() {
match connect().await {
Ok(c) => client = Some(c),
Err(err) => {
emit(Event::Error(format!("Cannot reach greetd: {err}")));
continue;
}
}
}
let result = exec_cmd(client.as_mut().expect("just connected"), cmd).await;
match result {
CmdResult::Idle => {}
CmdResult::Started => emit(Event::SessionStarted),
CmdResult::Roundtrip(Ok(outcome)) => emit(Event::Outcome(outcome)),
CmdResult::Roundtrip(Err(err)) => {
if is_connection_error(&err) {
client = None;
} else if let Some(c) = client.as_mut() {
c.cancel_session().await;
}
emit(Event::Error(err.to_string()));
}
}
}
}
enum CmdResult {
Idle,
Started,
Roundtrip(Result<Outcome, GreetdError>),
}
async fn exec_cmd(client: &mut Client, cmd: Command) -> CmdResult {
match cmd {
Command::CancelSession => {
client.cancel_session().await;
CmdResult::Idle
}
Command::CreateSession(username) => {
CmdResult::Roundtrip(client.create_session(&username).await)
}
Command::Respond(answer) => CmdResult::Roundtrip(client.respond(answer).await),
Command::StartSession { cmd, env } => match client.start_session(cmd, env).await {
Ok(()) => CmdResult::Started,
Err(err) => CmdResult::Roundtrip(Err(err)),
},
}
}
fn is_connection_error(err: &GreetdError) -> bool {
matches!(
err,
GreetdError::Connect(_) | GreetdError::Codec(_) | GreetdError::NoSocketEnv
)
}
#[cfg(test)]
mod tests {
use super::*;
use greetd_ipc::codec::TokioCodec;
use greetd_ipc::{Request, Response};
use tokio::net::UnixListener;
fn sock(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"breadgreet-actor-{name}-{}.sock",
std::process::id()
))
}
#[tokio::test]
async fn connect_failure_does_not_drop_the_actor() {
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
let (ev_tx, mut ev_rx) = mpsc::unbounded_channel();
let path = sock("retry");
std::fs::remove_file(&path).ok();
let attempts = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
let attempts_c = attempts.clone();
let path_c = path.clone();
let actor = tokio::spawn(async move {
run_actor_with(
cmd_rx,
move |ev| {
let _ = ev_tx.send(ev);
},
move || {
let n = attempts_c.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let path = path_c.clone();
async move {
if n == 0 {
Client::connect_to("/no/such/breadgreet-actor.sock").await
} else {
Client::connect_to(&path).await
}
}
},
)
.await;
});
let ev = ev_rx.recv().await.expect("startup connect error");
match ev {
Event::Error(msg) => assert!(
msg.contains("Cannot reach greetd"),
"unexpected error: {msg}"
),
other => panic!("expected Error, got {other:?}"),
}
let listener = UnixListener::bind(&path).unwrap();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let _ = Request::read_from(&mut stream).await;
Response::Success.write_to(&mut stream).await.unwrap();
});
cmd_tx
.send(Command::CreateSession("bob".into()))
.unwrap();
let ev = ev_rx.recv().await.expect("actor should retry after bind");
match ev {
Event::Outcome(Outcome::Success) => {}
other => panic!("expected Success, got {other:?}"),
}
drop(cmd_tx);
server.await.unwrap();
actor.await.unwrap();
assert!(
attempts.load(std::sync::atomic::Ordering::SeqCst) >= 2,
"actor should reconnect after the first failed connect"
);
std::fs::remove_file(&path).ok();
}
}

View file

@ -3,19 +3,15 @@ mod greetd;
mod sessions;
mod theme;
use greetd::{AuthPrompt, Client, Outcome};
use greetd::{AuthPrompt, Outcome};
use gtk4::gdk::Key;
use gtk4::glib::Propagation;
use gtk4::prelude::*;
use relm4::prelude::*;
use tokio::sync::mpsc;
/// Commands sent from the UI thread to the greetd actor task (see
/// [`spawn_greetd_actor`]), which owns the single stateful connection to
/// `$GREETD_SOCK` for the lifetime of one login attempt.
enum GreetdCommand {
CreateSession(String),
Respond(Option<String>),
StartSession { cmd: Vec<String>, env: Vec<String> },
}
/// Extra zoom beyond plain cover-fit — matches breadlock's `KENBURNS_ZOOM`.
const KENBURNS_ZOOM: f32 = 1.06;
#[derive(Debug, Clone)]
enum Stage {
@ -27,6 +23,8 @@ enum Stage {
/// A request is in flight — input is disabled so a second Enter can't
/// race it.
Working,
/// `StartSession` has been sent — Escape must not cancel.
Starting,
}
#[derive(Debug)]
@ -39,10 +37,13 @@ enum AppInput {
SessionStarted,
/// Picker changed; `u32::MAX` (`INVALID_LIST_POSITION`) is ignored.
SessionSelected(u32),
/// Escape — abort the in-progress PAM conversation.
Cancel,
}
struct App {
clock_lbl: gtk4::Label,
date_lbl: gtk4::Label,
status_lbl: gtk4::Label,
entry: gtk4::Entry,
stage: Stage,
@ -50,7 +51,11 @@ struct App {
sessions: Vec<sessions::Session>,
selected: usize,
clock_format: String,
cmd_tx: mpsc::UnboundedSender<GreetdCommand>,
date_format: String,
/// Last status line was a PAM Info/Error — keep it when the next
/// Secret/Visible prompt arrives.
pam_status_held: bool,
cmd_tx: mpsc::UnboundedSender<greetd::Command>,
}
#[relm4::component]
@ -64,6 +69,12 @@ impl SimpleComponent for App {
add_css_class: "breadgreet",
set_title: Some("breadgreet"),
#[name = "overlay"]
gtk4::Overlay {
// The relm4 view macro supports a single `set_child` per
// widget, so `root_box` is declared as the overlay's child
// here; the wallpaper (main child) and veil layers are
// stacked in `init` via `set_child` + `add_overlay`.
#[name = "root_box"]
gtk4::Box {
set_orientation: gtk4::Orientation::Vertical,
@ -72,6 +83,7 @@ impl SimpleComponent for App {
}
}
}
}
fn init(
_init: Self::Init,
@ -95,9 +107,27 @@ impl SimpleComponent for App {
.and_then(|chosen| sessions.iter().position(|s| s.stem == chosen.stem))
.unwrap_or(0);
if config.appearance.background.blur {
tracing::warn!(
"background.blur is not implemented yet (planned v2 feature, needs a wlr-screencopy \
capture) showing the configured background unblurred"
);
}
let clock_lbl = gtk4::Label::new(None);
clock_lbl.add_css_class("login-clock");
let date_lbl = gtk4::Label::new(None);
date_lbl.add_css_class("login-date");
if config.appearance.clock.date_format.is_empty() {
date_lbl.set_visible(false);
clock_lbl.set_margin_bottom(20);
} else {
clock_lbl.set_margin_bottom(4);
date_lbl.set_margin_bottom(16);
date_lbl.set_label(&current_time(&config.appearance.clock.date_format));
}
let entry = gtk4::Entry::new();
entry.add_css_class("login-entry");
entry.set_placeholder_text(Some("Username"));
@ -110,6 +140,12 @@ impl SimpleComponent for App {
let status_lbl = gtk4::Label::new(None);
status_lbl.add_css_class("login-status");
if sessions.is_empty() {
entry.set_sensitive(false);
status_lbl.set_label("No session found — cannot log in");
status_lbl.add_css_class("error");
}
let session_widget: gtk4::Widget = if sessions.is_empty() {
let session_lbl = gtk4::Label::new(Some("No session found — cannot log in"));
session_lbl.add_css_class("login-session");
@ -139,17 +175,72 @@ impl SimpleComponent for App {
card.append(&session_widget);
let widgets = view_output!();
// Layer the window: wallpaper (main child, bottom) → dim veil → the
// clock+card cluster (top). Overlay children stack above the main
// child in `add_overlay` order, so the card ends up on top.
let bg_area = gtk4::DrawingArea::new();
bg_area.set_hexpand(true);
bg_area.set_vexpand(true);
let veil = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
veil.set_hexpand(true);
veil.set_vexpand(true);
veil.set_halign(gtk4::Align::Fill);
veil.set_valign(gtk4::Align::Fill);
veil.set_can_focus(false);
veil.add_css_class("login-veil");
widgets.overlay.set_child(Some(&bg_area));
// Overlay children stack above the main child in `add_overlay`
// order; the last one added is topmost. So the veil goes in first,
// then the clock+card cluster on top of it.
widgets.overlay.add_overlay(&veil);
widgets.overlay.add_overlay(&widgets.root_box);
widgets.root_box.append(&clock_lbl);
widgets.root_box.append(&date_lbl);
widgets.root_box.append(&card);
{
let tx = sender.input_sender().clone();
let key = gtk4::EventControllerKey::new();
key.set_propagation_phase(gtk4::PropagationPhase::Capture);
key.connect_key_pressed(move |_, keyval, _, _| {
if keyval == Key::Escape {
let _ = tx.send(AppInput::Cancel);
Propagation::Stop
} else {
Propagation::Proceed
}
});
root.add_controller(key);
}
// Wallpaper behind the card: cover-fit, Ken Burns pan when enabled
// (driven by a frame-clock tick callback), plus an entrance fade+rise.
let ken_burns = config.appearance.background.ken_burns;
let wallpaper_path = if config.appearance.background.mode
== breadlock_ui::config::BackgroundMode::Image
&& !config.appearance.background.path.is_empty()
{
Some(config.appearance.background.path.clone())
} else {
None
};
setup_wallpaper(&root, &bg_area, wallpaper_path.as_deref(), ken_burns);
setup_entrance(&root, &widgets.root_box);
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
spawn_greetd_actor(cmd_rx, sender.clone());
theme::apply();
theme::apply(&config.appearance.font.family);
bread_theme::gtk::bind_window_auto(&root);
spawn_clock_ticker(sender.clone());
let model = App {
clock_lbl,
date_lbl,
status_lbl,
entry,
stage: Stage::Username,
@ -157,12 +248,16 @@ impl SimpleComponent for App {
sessions,
selected,
clock_format: config.appearance.clock.format.clone(),
date_format: config.appearance.clock.date_format.clone(),
pam_status_held: false,
cmd_tx,
};
model
.clock_lbl
.set_label(&current_time(&model.clock_format));
if !model.sessions.is_empty() {
model.entry.grab_focus();
}
ComponentParts { model, widgets }
}
@ -171,24 +266,20 @@ impl SimpleComponent for App {
match msg {
AppInput::ClockTick => {
self.clock_lbl.set_label(&current_time(&self.clock_format));
if !self.date_format.is_empty() {
self.date_lbl.set_label(&current_time(&self.date_format));
}
}
AppInput::Submit => self.handle_submit(),
AppInput::Outcome(Outcome::Success) => self.start_session(),
AppInput::Outcome(Outcome::Prompt(prompt)) => self.handle_prompt(prompt),
AppInput::Error(description) => {
self.status_lbl.set_label(&description);
self.status_lbl.add_css_class("error");
self.entry.set_text("");
self.entry.set_visibility(true);
self.entry.set_placeholder_text(Some("Username"));
self.entry.set_sensitive(true);
self.stage = Stage::Username;
self.username.clear();
}
AppInput::Error(description) => self.show_error(&description),
AppInput::SessionStarted => {
// greetd now owns the VT switch to the started session —
// nothing left for the greeter to do.
// greetd waits for this process to exit before exec'ing the
// session (cage + gtkgreet/tuigreet all quit here).
self.status_lbl.set_label("Starting session…");
relm4::main_application().quit();
std::process::exit(0);
}
AppInput::SessionSelected(idx) => {
let idx = idx as usize;
@ -196,19 +287,24 @@ impl SimpleComponent for App {
self.selected = idx;
}
}
AppInput::Cancel => self.cancel_auth(),
}
}
}
impl App {
fn handle_submit(&mut self) {
if matches!(self.stage, Stage::Working) {
if matches!(self.stage, Stage::Working | Stage::Starting) {
return;
}
let text = self.entry.text().to_string();
match &self.stage {
Stage::Username => {
if self.sessions.is_empty() {
self.show_error("No session found — cannot log in");
return;
}
if text.is_empty() {
return;
}
@ -216,111 +312,249 @@ impl App {
self.entry.set_text("");
self.entry.set_sensitive(false);
self.stage = Stage::Working;
let _ = self
.cmd_tx
.send(GreetdCommand::CreateSession(self.username.clone()));
self.status_lbl.set_label("");
self.status_lbl.remove_css_class("error");
self.pam_status_held = false;
self.dispatch(greetd::Command::CreateSession(self.username.clone()));
}
Stage::Prompt => {
self.entry.set_text("");
self.entry.set_sensitive(false);
self.stage = Stage::Working;
let answer = if text.is_empty() { None } else { Some(text) };
let _ = self.cmd_tx.send(GreetdCommand::Respond(answer));
self.dispatch(greetd::Command::Respond(prompt_answer(text)));
}
Stage::Working => {}
Stage::Working | Stage::Starting => {}
}
}
fn handle_prompt(&mut self, prompt: AuthPrompt) {
self.status_lbl.remove_css_class("error");
match prompt {
AuthPrompt::Info(message) | AuthPrompt::Error(message) => {
// No answer needed — display and immediately continue the
// conversation with an empty response.
AuthPrompt::Info(message) => {
self.status_lbl.remove_css_class("error");
self.status_lbl.set_label(&message);
let _ = self.cmd_tx.send(GreetdCommand::Respond(None));
self.pam_status_held = true;
self.dispatch(greetd::Command::Respond(None));
}
AuthPrompt::Visible(message) => {
AuthPrompt::Error(message) => {
self.status_lbl.add_css_class("error");
self.status_lbl.set_label(&message);
self.entry.set_visibility(true);
self.entry.set_placeholder_text(Some(&message));
self.pam_status_held = true;
self.dispatch(greetd::Command::Respond(None));
}
AuthPrompt::Visible(message) => self.show_auth_entry(&message, true),
AuthPrompt::Secret(message) => self.show_auth_entry(&message, false),
}
}
fn show_auth_entry(&mut self, message: &str, visible: bool) {
if !self.pam_status_held {
self.status_lbl.remove_css_class("error");
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_sensitive(true);
self.entry.grab_focus();
self.stage = Stage::Prompt;
}
AuthPrompt::Secret(message) => {
self.status_lbl.set_label(&message);
self.entry.set_visibility(false);
self.entry.set_placeholder_text(Some(&message));
self.entry.set_sensitive(true);
self.entry.grab_focus();
self.stage = Stage::Prompt;
}
}
}
fn start_session(&mut self) {
let Some(session) = self.sessions.get(self.selected) else {
self.status_lbl.set_label("No session available to start");
self.status_lbl.add_css_class("error");
let (cmd, env) = match self.sessions.get(self.selected) {
Some(session) => (session.exec.clone(), session.start_env()),
None => {
self.dispatch(greetd::Command::CancelSession);
self.show_error("No session available to start");
return;
}
};
self.status_lbl.remove_css_class("error");
self.status_lbl.set_label("Starting session…");
let _ = self.cmd_tx.send(GreetdCommand::StartSession {
cmd: session.exec.clone(),
env: Vec::new(),
self.entry.set_sensitive(false);
self.stage = Stage::Starting;
self.dispatch(greetd::Command::StartSession { cmd, env });
}
fn cancel_auth(&mut self) {
match self.stage {
Stage::Starting => {}
Stage::Username => {
self.entry.set_text("");
}
Stage::Prompt | Stage::Working => {
self.status_lbl.set_label("");
self.status_lbl.remove_css_class("error");
self.reset_to_username();
if self.cmd_tx.send(greetd::Command::CancelSession).is_err() {
self.show_error("Cannot reach greetd");
}
}
}
}
fn dispatch(&mut self, cmd: greetd::Command) {
if self.cmd_tx.send(cmd).is_err() {
self.show_error("Cannot reach greetd");
}
}
fn show_error(&mut self, description: &str) {
self.status_lbl.set_label(description);
if description.is_empty() {
self.status_lbl.remove_css_class("error");
} else {
self.status_lbl.add_css_class("error");
}
self.reset_to_username();
}
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;
if !self.sessions.is_empty() {
self.entry.grab_focus();
}
}
}
/// Secret/Visible answers are always `Some`, including the empty string.
/// greetd/PAM treat `None` as a conversation cancel.
fn prompt_answer(text: String) -> Option<String> {
Some(text)
}
/// Paints the configured wallpaper full-screen behind the login card. The
/// image is loaded once as a `gdk_pixbuf::Pixbuf` and drawn by a
/// `GtkDrawingArea` draw callback, so the pan costs no layout passes — the
/// drawing area fills the window and the draw callback applies the cover
/// scale + Ken Burns offset itself. A missing/unreadable file or a non-image
/// background leaves the card on the palette background color.
fn setup_wallpaper(
window: &gtk4::ApplicationWindow,
bg_area: &gtk4::DrawingArea,
path: Option<&str>,
ken_burns: bool,
) {
let Some(path) = path else { return };
let pixbuf = match gtk4::gdk_pixbuf::Pixbuf::from_file(path) {
Ok(pixbuf) => pixbuf,
Err(err) => {
tracing::warn!(%err, "failed to load wallpaper");
return;
}
};
let (iw, ih) = (pixbuf.width() as f32, pixbuf.height() as f32);
if iw <= 0.0 || ih <= 0.0 {
return;
}
// Shared pan phase: the tick callback advances it, the draw callback
// reads it. Using a draw callback (rather than a moving widget) means
// the wallpaper never feeds the window's minimum size.
let phase = std::rc::Rc::new(std::cell::Cell::new(0.0f64));
let draw_pixbuf = pixbuf.clone();
let draw_phase = phase.clone();
bg_area.set_draw_func(move |_area, cr, w, h| {
let (w, h) = (w as f32, h as f32);
if w <= 0.0 || h <= 0.0 {
return;
}
// Cover scale, then the Ken Burns oversize (leaves room to pan).
let cover = (w / iw).max(h / ih);
let scale = if ken_burns {
cover * KENBURNS_ZOOM
} else {
cover
};
let dw = iw * scale;
let dh = ih * scale;
// Pan within the oversize margin (0..dw-w, 0..dh-h).
let phase = draw_phase.get();
let max_x = (dw - w).max(0.0);
let max_y = (dh - h).max(0.0);
let x = max_x * (0.5 + 0.5 * phase.sin() as f32);
let y = max_y * (0.5 + 0.5 * (phase * 0.7).cos() as f32);
cr.translate(-x as f64, -y as f64);
cr.scale(scale as f64, scale as f64);
cr.set_source_pixbuf(&draw_pixbuf, 0.0, 0.0);
let _ = cr.paint();
});
if !ken_burns {
return;
}
let area = bg_area.clone();
let start = std::time::Instant::now();
window.add_tick_callback(move |_w, _frame_clock| {
let elapsed = start.elapsed().as_secs_f64();
phase.set(elapsed * std::f64::consts::TAU / 90.0);
area.queue_draw();
gtk4::glib::ControlFlow::Continue
});
}
/// Entrance animation: the clock + card cluster fades in and rises ~24px
/// over ~600ms (ease-out), matching the lock screen's appear motion.
fn setup_entrance(window: &gtk4::ApplicationWindow, root_box: &gtk4::Box) {
let root_box = root_box.clone();
const DURATION_MS: f32 = 600.0;
const RISE_PX: f32 = 24.0;
// First mapped frame must not be fully opaque — start hidden, then tick.
root_box.set_opacity(0.0);
root_box.set_margin_top(RISE_PX as i32);
let start = std::time::Instant::now();
window.add_tick_callback(move |_w, _frame_clock| {
let t = (start.elapsed().as_secs_f32() * 1000.0) / DURATION_MS;
let t = t.clamp(0.0, 1.0);
// Ease-out cubic.
let e = 1.0 - (1.0 - t).powi(3);
root_box.set_opacity(e as f64);
root_box.set_margin_top((RISE_PX * (1.0 - e)) as i32);
if t >= 1.0 {
gtk4::glib::ControlFlow::Break
} else {
gtk4::glib::ControlFlow::Continue
}
});
}
/// Owns the single stateful connection to `$GREETD_SOCK` for one login
/// attempt and translates the UI's [`GreetdCommand`]s into greetd IPC
/// round-trips, forwarding each outcome back as an [`AppInput`].
/// 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`].
fn spawn_greetd_actor(
mut cmd_rx: mpsc::UnboundedReceiver<GreetdCommand>,
cmd_rx: mpsc::UnboundedReceiver<greetd::Command>,
sender: ComponentSender<App>,
) {
let input = sender.input_sender().clone();
relm4::spawn(async move {
let mut client = match Client::connect().await {
Ok(client) => client,
Err(err) => {
sender.input(AppInput::Error(format!("Cannot reach greetd: {err}")));
return;
}
greetd::run_actor(cmd_rx, move |event| {
let msg = match event {
greetd::Event::Outcome(outcome) => AppInput::Outcome(outcome),
greetd::Event::Error(description) => AppInput::Error(description),
greetd::Event::SessionStarted => AppInput::SessionStarted,
};
while let Some(cmd) = cmd_rx.recv().await {
let result = match cmd {
GreetdCommand::CreateSession(username) => client.create_session(&username).await,
GreetdCommand::Respond(answer) => client.respond(answer).await,
GreetdCommand::StartSession { cmd, env } => {
match client.start_session(cmd, env).await {
Ok(()) => {
sender.input(AppInput::SessionStarted);
continue;
}
Err(err) => Err(err),
}
}
};
match result {
Ok(outcome) => sender.input(AppInput::Outcome(outcome)),
Err(err) => {
tracing::warn!(%err, "greetd reported an error");
client.cancel_session().await;
sender.input(AppInput::Error(err.to_string()));
}
}
}
let _ = input.send(msg);
})
.await;
});
}
fn spawn_clock_ticker(sender: ComponentSender<App>) {
let tx = sender.input_sender().clone();
relm4::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
sender.input(AppInput::ClockTick);
if tx.send(AppInput::ClockTick).is_err() {
break;
}
}
});
}
@ -337,3 +571,14 @@ fn main() {
let app = RelmApp::new("sh.breadway.breadgreet");
app.run::<App>(());
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_password_is_some_empty_string() {
assert_eq!(prompt_answer(String::new()), Some(String::new()));
assert_eq!(prompt_answer("hunter2".into()), Some("hunter2".into()));
}
}

View file

@ -9,6 +9,12 @@
use breadlock_ui::desktop_entry::scan_dir;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionKind {
Wayland,
X11,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Session {
/// `.desktop` file stem (`bos` for `bos.desktop`) — used to match
@ -16,22 +22,50 @@ pub struct Session {
pub stem: String,
pub name: String,
pub exec: Vec<String>,
/// Which directory list this entry came from — drives `XDG_SESSION_TYPE`.
pub kind: SessionKind,
}
impl Session {
/// Environment greetd should apply to the started session.
pub fn start_env(&self) -> Vec<String> {
let session_type = match self.kind {
SessionKind::Wayland => "wayland",
SessionKind::X11 => "x11",
};
let desktop = if self.stem.is_empty() {
self.name.as_str()
} else {
self.stem.as_str()
};
vec![
format!("XDG_SESSION_TYPE={session_type}"),
format!("XDG_SESSION_DESKTOP={desktop}"),
format!("XDG_CURRENT_DESKTOP={desktop}"),
]
}
}
/// Every installed session, `wayland_dirs` first then `xsessions_dirs`.
/// Each directory is sorted by stem (see [`scan_dir`]).
pub fn list(wayland_dirs: &[String], xsessions_dirs: &[String]) -> Vec<Session> {
let mut all = Vec::new();
for dir in wayland_dirs.iter().chain(xsessions_dirs) {
collect_into(&mut all, wayland_dirs, SessionKind::Wayland);
collect_into(&mut all, xsessions_dirs, SessionKind::X11);
all
}
fn collect_into(all: &mut Vec<Session>, dirs: &[String], kind: SessionKind) {
for dir in dirs {
for (stem, entry) in scan_dir(Path::new(dir)) {
all.push(Session {
stem,
name: entry.name,
exec: split_exec(&entry.exec),
kind,
});
}
}
all
}
/// Index of the configured default stem, or `0` if it is absent. Callers
@ -54,18 +88,67 @@ pub fn discover(
all.into_iter().nth(idx)
}
/// Splits a `.desktop` `Exec=` line into an argv. Only handles plain
/// whitespace-separated commands (BOS's own `hyprland.desktop` is
/// `Exec=Hyprland`) — full field-code (`%f`, `%u`, …) and quoting support
/// isn't needed for a greeter that never launches file-manager-style
/// entries.
/// Splits a `.desktop` `Exec=` line into an argv. Double-quoted arguments
/// are one token (Freedesktop Exec quoting). Whole-argument field codes
/// (`%f`, `%F`, …) are dropped; `%%` is a literal `%`.
fn split_exec(exec: &str) -> Vec<String> {
exec.split_whitespace()
.filter(|arg| !arg.starts_with('%'))
.map(str::to_string)
tokenize_exec(exec)
.into_iter()
.filter(|arg| !is_field_code(arg))
.map(|arg| unescape_percent(&arg))
.filter(|arg| !arg.is_empty())
.collect()
}
fn tokenize_exec(exec: &str) -> Vec<String> {
let mut args = Vec::new();
let mut current = String::new();
let mut in_quote = false;
let mut chars = exec.chars().peekable();
while let Some(c) = chars.next() {
match c {
'"' => in_quote = !in_quote,
'\\' if in_quote => {
if let Some(n) = chars.next() {
current.push(n);
}
}
c if c.is_whitespace() && !in_quote => {
if !current.is_empty() {
args.push(std::mem::take(&mut current));
}
}
_ => current.push(c),
}
}
if !current.is_empty() {
args.push(current);
}
args
}
fn is_field_code(arg: &str) -> bool {
matches!(
arg,
"%f" | "%F" | "%u" | "%U" | "%d" | "%D" | "%n" | "%N" | "%i" | "%c" | "%k" | "%v" | "%m"
)
}
fn unescape_percent(arg: &str) -> String {
let mut out = String::with_capacity(arg.len());
let mut chars = arg.chars().peekable();
while let Some(c) = chars.next() {
if c == '%' && chars.peek() == Some(&'%') {
chars.next();
out.push('%');
} else {
out.push(c);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
@ -76,6 +159,20 @@ mod tests {
assert_eq!(split_exec("gnome-session %U"), vec!["gnome-session"]);
}
#[test]
fn split_exec_quoted_arguments() {
assert_eq!(
split_exec(r#"wrapper "my session" --flag"#),
vec!["wrapper", "my session", "--flag"]
);
}
#[test]
fn split_exec_double_percent_is_literal() {
assert_eq!(split_exec("echo %%"), vec!["echo", "%"]);
assert_eq!(split_exec(r#"echo "100%%""#), vec!["echo", "100%"]);
}
#[test]
fn discover_returns_none_when_no_directories_exist() {
assert!(discover(
@ -109,6 +206,7 @@ mod tests {
assert_eq!(session.stem, "hyprland");
assert_eq!(session.name, "Hyprland");
assert_eq!(session.exec, vec!["Hyprland"]);
assert_eq!(session.kind, SessionKind::Wayland);
std::fs::remove_dir_all(&dir).ok();
}
@ -131,6 +229,23 @@ mod tests {
let stems: Vec<&str> = listed.iter().map(|s| s.stem.as_str()).collect();
assert_eq!(stems, vec!["bos", "hyprland", "openbox"]);
assert_eq!(listed[0].exec, vec!["/usr/local/bin/bos-session"]);
assert_eq!(listed[0].kind, SessionKind::Wayland);
assert_eq!(listed[2].kind, SessionKind::X11);
assert!(
listed[2]
.start_env()
.contains(&"XDG_SESSION_TYPE=x11".to_string())
);
assert!(
listed[0]
.start_env()
.contains(&"XDG_SESSION_TYPE=wayland".to_string())
);
assert!(
listed[0]
.start_env()
.contains(&"XDG_SESSION_DESKTOP=bos".to_string())
);
std::fs::remove_dir_all(&wayland).ok();
std::fs::remove_dir_all(&x11).ok();
@ -143,11 +258,13 @@ mod tests {
stem: "aaa".into(),
name: "A".into(),
exec: vec!["a".into()],
kind: SessionKind::Wayland,
},
Session {
stem: "bos".into(),
name: "BOS".into(),
exec: vec!["bos-session".into()],
kind: SessionKind::Wayland,
},
];
assert_eq!(default_index(&sessions, "bos"), 1);

View file

@ -6,31 +6,45 @@ thread_local! {
static USER_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
}
fn load_css() -> String {
fn css_font_family(family: &str) -> String {
if family.is_empty() {
return String::new();
}
let escaped = family.replace('\\', "\\\\").replace('"', "\\\"");
format!("font-family: \"{escaped}\";")
}
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}; }}\
"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; margin-bottom: 20px; }}\
.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; }}",
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,
)
}
pub fn apply() {
pub fn apply(font_family: &str) {
bgtk::apply_shared();
bgtk::apply_app_css(load_css);
let family = font_family.to_string();
bgtk::apply_app_css(move || load_css(&family));
let home = std::env::var("HOME").unwrap_or_default();
let user_path = std::path::PathBuf::from(format!("{home}/.config/breadgreet/style.css"));
let user_path = crate::config::xdg_config_dir()
.join("breadgreet")
.join("style.css");
USER_PROVIDER.with(|cell| bgtk::apply_user_css(&user_path, cell));
}

View file

@ -14,7 +14,6 @@ toml.workspace = true
# instead, so it builds without pulling these in).
tiny-skia = { version = "0.12", optional = true }
cosmic-text = { version = "0.14", optional = true }
chrono = { version = "0.4", optional = true }
[features]
paint = ["dep:tiny-skia", "dep:cosmic-text", "dep:chrono"]
paint = ["dep:tiny-skia", "dep:cosmic-text"]

View file

@ -26,6 +26,10 @@ pub struct Background {
/// v2 feature flag — no-op (with a warning) in v1, which only supports a
/// static color or image background.
pub blur: bool,
/// Slow Ken Burns pan on image backgrounds (a gentle drift + zoom instead
/// of a static image). CPU cost: the background redraws continuously at a
/// low frame rate while locked, so this is opt-in.
pub ken_burns: bool,
}
impl Default for Background {
@ -34,6 +38,7 @@ impl Default for Background {
mode: BackgroundMode::Color,
path: String::new(),
blur: false,
ken_burns: false,
}
}
}
@ -42,12 +47,16 @@ impl Default for Background {
#[serde(default)]
pub struct Clock {
pub format: String,
/// strftime format for the date line under the clock. Empty string hides
/// the date. `%A` = full weekday, `%b` = abbreviated month, `%d` = day.
pub date_format: String,
}
impl Default for Clock {
fn default() -> Self {
Self {
format: "%H:%M".to_string(),
date_format: "%A · %b %d".to_string(),
}
}
}
@ -71,14 +80,23 @@ impl Default for Font {
}
}
/// Reads and parses a TOML config file, falling back to `T::default()` if the
/// file is missing or malformed — every bread* app runs with sensible
/// defaults and no required config.
/// Reads and parses a TOML config file. A missing file is a silent
/// `T::default()`; a present but malformed file prints a warning (with the
/// path) and also falls back to `T::default()`.
pub fn load_or_default<T: serde::de::DeserializeOwned + Default>(path: &Path) -> T {
std::fs::read_to_string(path)
.ok()
.and_then(|s| toml::from_str(&s).ok())
.unwrap_or_default()
match std::fs::read_to_string(path) {
Ok(s) => match toml::from_str(&s) {
Ok(parsed) => parsed,
Err(err) => {
eprintln!(
"warning: failed to parse {}: {err} — using defaults",
path.display()
);
T::default()
}
},
Err(_) => T::default(),
}
}
#[cfg(test)]
@ -89,7 +107,9 @@ mod tests {
fn defaults_match_design_system() {
let a = Appearance::default();
assert_eq!(a.background.mode, BackgroundMode::Color);
assert!(!a.background.ken_burns, "Ken Burns must be opt-in (CPU cost)");
assert_eq!(a.clock.format, "%H:%M");
assert_eq!(a.clock.date_format, "%A · %b %d");
assert_eq!(a.font.family, "Varela Round");
}
@ -101,11 +121,27 @@ mod tests {
#[test]
fn parses_partial_toml_with_defaults_for_rest() {
let dir = std::env::temp_dir().join("breadlock-ui-test-partial.toml");
std::fs::write(&dir, "[clock]\nformat = \"%I:%M %p\"\n").unwrap();
let a: Appearance = load_or_default(&dir);
let path = std::env::temp_dir().join(format!(
"breadlock-ui-test-partial-{}.toml",
std::process::id()
));
std::fs::write(&path, "[clock]\nformat = \"%I:%M %p\"\n").unwrap();
let a: Appearance = load_or_default(&path);
assert_eq!(a.clock.format, "%I:%M %p");
assert_eq!(a.background.mode, BackgroundMode::Color);
std::fs::remove_file(&dir).ok();
std::fs::remove_file(&path).ok();
}
#[test]
fn invalid_toml_falls_back_to_default() {
let path = std::env::temp_dir().join(format!(
"breadlock-ui-test-invalid-{}.toml",
std::process::id()
));
std::fs::write(&path, "this is not = toml [[[").unwrap();
let a: Appearance = load_or_default(&path);
assert_eq!(a.clock.format, "%H:%M");
assert_eq!(a.font.family, "Varela Round");
std::fs::remove_file(&path).ok();
}
}

View file

@ -1,9 +1,8 @@
//! Minimal freedesktop `.desktop` entry parsing — just enough to discover
//! session launchers (`Name=`, `Exec=`, `Type=`) under
//! `/usr/share/wayland-sessions` and `/usr/share/xsessions`. BOS only ships
//! one session today, so this deliberately doesn't handle the full spec
//! (localized `Name[xx]=`, `Exec=` quoting/field codes, `Actions=`, etc.) —
//! only the three keys a greeter needs to list and launch a session.
//! `/usr/share/wayland-sessions` and `/usr/share/xsessions`. Also honours
//! `Hidden=` / `NoDisplay=` / `TryExec=` so we don't offer sessions that
//! menus would skip. Localized `Name[xx]=` and `Actions=` are out of scope.
use std::path::Path;
@ -12,14 +11,21 @@ pub struct DesktopEntry {
pub name: String,
pub exec: String,
pub entry_type: String,
/// `TryExec=` if present — [`scan_dir`] skips the entry when this
/// binary is missing from disk/`PATH`.
pub try_exec: Option<String>,
}
/// Parses the `[Desktop Entry]` section of a `.desktop` file's contents.
/// Returns `None` if `Name=` or `Exec=` is missing.
/// Returns `None` if `Name=` or `Exec=` is missing, or if `Hidden=true` /
/// `NoDisplay=true`.
pub fn parse(contents: &str) -> Option<DesktopEntry> {
let mut name = None;
let mut exec = None;
let mut entry_type = None;
let mut try_exec = None;
let mut hidden = false;
let mut no_display = false;
let mut in_desktop_entry = false;
for line in contents.lines() {
@ -39,21 +45,39 @@ pub fn parse(contents: &str) -> Option<DesktopEntry> {
"Name" => name = Some(value.trim().to_string()),
"Exec" => exec = Some(value.trim().to_string()),
"Type" => entry_type = Some(value.trim().to_string()),
"TryExec" => {
let v = value.trim();
if !v.is_empty() {
try_exec = Some(v.to_string());
}
}
"Hidden" => hidden = is_desktop_true(value),
"NoDisplay" => no_display = is_desktop_true(value),
_ => {}
}
}
}
if hidden || no_display {
return None;
}
Some(DesktopEntry {
name: name?,
exec: exec?,
entry_type: entry_type.unwrap_or_else(|| "Application".to_string()),
try_exec,
})
}
fn is_desktop_true(value: &str) -> bool {
value.trim().eq_ignore_ascii_case("true")
}
/// Scans a directory for `*.desktop` files, returning `(file stem, entry)`
/// pairs. Unreadable directories and unparsable entries are silently skipped
/// — a missing session directory is normal (e.g. no X11 sessions installed).
/// Entries whose `TryExec=` binary is missing are skipped too.
pub fn scan_dir(dir: &Path) -> Vec<(String, DesktopEntry)> {
let Ok(read_dir) = std::fs::read_dir(dir) else {
return Vec::new();
@ -65,7 +89,13 @@ pub fn scan_dir(dir: &Path) -> Vec<(String, DesktopEntry)> {
.filter_map(|e| {
let stem = e.path().file_stem()?.to_str()?.to_string();
let contents = std::fs::read_to_string(e.path()).ok()?;
Some((stem, parse(&contents)?))
let entry = parse(&contents)?;
if let Some(ref te) = entry.try_exec {
if !command_exists(te) {
return None;
}
}
Some((stem, entry))
})
.collect();
@ -73,6 +103,25 @@ pub fn scan_dir(dir: &Path) -> Vec<(String, DesktopEntry)> {
entries
}
fn command_exists(cmd: &str) -> bool {
if cmd.contains('/') {
is_runnable(Path::new(cmd))
} else {
match std::env::var_os("PATH") {
Some(paths) => std::env::split_paths(&paths).any(|dir| is_runnable(&dir.join(cmd))),
None => false,
}
}
}
fn is_runnable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
let Ok(meta) = std::fs::metadata(path) else {
return false;
};
meta.is_file() && meta.permissions().mode() & 0o111 != 0
}
#[cfg(test)]
mod tests {
use super::*;
@ -83,12 +132,26 @@ mod tests {
Exec=Hyprland\n\
Type=Application\n";
fn unique_temp_dir(name: &str) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let dir = std::env::temp_dir().join(format!(
"breadlock-ui-test-sessions-{name}-{}-{}",
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed)
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn parses_name_exec_type() {
let e = parse(HYPRLAND_DESKTOP).unwrap();
assert_eq!(e.name, "Hyprland");
assert_eq!(e.exec, "Hyprland");
assert_eq!(e.entry_type, "Application");
assert_eq!(e.try_exec, None);
}
#[test]
@ -111,6 +174,14 @@ mod tests {
assert_eq!(e.entry_type, "Application");
}
#[test]
fn hidden_or_nodisplay_returns_none() {
assert!(parse("[Desktop Entry]\nName=X\nExec=x\nHidden=true\n").is_none());
assert!(parse("[Desktop Entry]\nName=X\nExec=x\nNoDisplay=true\n").is_none());
assert!(parse("[Desktop Entry]\nName=X\nExec=x\nHidden=false\n").is_some());
assert!(parse("[Desktop Entry]\nName=X\nExec=x\nNoDisplay=false\n").is_some());
}
#[test]
fn scan_dir_on_missing_directory_returns_empty() {
assert!(scan_dir(Path::new("/nonexistent/wayland-sessions")).is_empty());
@ -118,8 +189,7 @@ mod tests {
#[test]
fn scan_dir_finds_and_sorts_desktop_files() {
let dir = std::env::temp_dir().join("breadlock-ui-test-sessions");
std::fs::create_dir_all(&dir).unwrap();
let dir = unique_temp_dir("scan");
std::fs::write(dir.join("zzz.desktop"), HYPRLAND_DESKTOP).unwrap();
std::fs::write(dir.join("aaa.desktop"), "[Desktop Entry]\nName=A\nExec=a\n").unwrap();
std::fs::write(dir.join("not-a-session.txt"), "ignored").unwrap();
@ -131,4 +201,35 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn scan_dir_skips_hidden_nodisplay_and_missing_tryexec() {
let dir = unique_temp_dir("skip");
std::fs::write(
dir.join("hidden.desktop"),
"[Desktop Entry]\nName=Hidden\nExec=hidden\nHidden=true\n",
)
.unwrap();
std::fs::write(
dir.join("nodisp.desktop"),
"[Desktop Entry]\nName=NoDisp\nExec=nodisp\nNoDisplay=true\n",
)
.unwrap();
std::fs::write(
dir.join("gone.desktop"),
"[Desktop Entry]\nName=Gone\nExec=gone\nTryExec=/no/such/breadlock-tryexec\n",
)
.unwrap();
std::fs::write(
dir.join("ok.desktop"),
"[Desktop Entry]\nName=Ok\nExec=ok\n",
)
.unwrap();
let found = scan_dir(&dir);
assert_eq!(found.len(), 1);
assert_eq!(found[0].0, "ok");
std::fs::remove_dir_all(&dir).ok();
}
}

View file

@ -5,7 +5,9 @@
//! instead and doesn't need a font-shaping stack.
pub use bread_theme::tokens;
pub use cosmic_text::Weight;
use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping, SwashCache};
use std::collections::HashMap;
use tiny_skia::{Path, PathBuilder, Pixmap, PremultipliedColorU8};
/// Builds a rounded-rectangle path. `radius` is clamped so it never exceeds
@ -32,6 +34,14 @@ pub fn rounded_rect(x: f32, y: f32, w: f32, h: f32, radius: f32) -> Option<Path>
pub struct TextRenderer {
font_system: FontSystem,
swash_cache: SwashCache,
/// Exact glyph-pixel span `(top, height)` per unique `(text, family,
/// size, weight)` — see [`Self::measure_box`]. Keyed by size in
/// centipixels so fractional sizes don't thrash the cache.
boxes: HashMap<(String, String, u32, u16), (f32, f32)>,
/// Whether `Family::Name(family)` resolved to an installed face. Missing
/// families fall back to `Family::SansSerif` instead of panicking or
/// drawing tofu; the result is cached so we don't scan fontdb every frame.
family_ok: HashMap<String, bool>,
}
impl Default for TextRenderer {
@ -45,23 +55,141 @@ impl TextRenderer {
Self {
font_system: FontSystem::new(),
swash_cache: SwashCache::new(),
boxes: HashMap::new(),
family_ok: HashMap::new(),
}
}
fn shape_line(&mut self, text: &str, family: &str, size_px: f32, max_width: f32) -> Buffer {
/// `Family::Name` if `family` is installed, otherwise the generic
/// sans-serif. Never panics on a missing configured font.
fn resolve_family<'a>(&mut self, family: &'a str) -> Family<'a> {
if family.is_empty() || family.eq_ignore_ascii_case("sans-serif") {
return Family::SansSerif;
}
let present = if let Some(&ok) = self.family_ok.get(family) {
ok
} else {
let ok = self.font_system.db().faces().any(|face| {
face.families
.iter()
.any(|(name, _)| name.eq_ignore_ascii_case(family))
});
self.family_ok.insert(family.to_string(), ok);
ok
};
if present {
Family::Name(family)
} else {
Family::SansSerif
}
}
fn shape_line(
&mut self,
text: &str,
family: &str,
size_px: f32,
max_width: f32,
weight: Weight,
) -> Buffer {
// cosmic-text panics if `metrics.font_size` is zero; callers may pass a
// scaled-to-zero size during the pill's appear overshoot at t=0.
let size_px = size_px.max(0.01);
let metrics = Metrics::new(size_px, size_px * 1.25);
let mut buffer = Buffer::new(&mut self.font_system, metrics);
buffer.set_size(&mut self.font_system, Some(max_width), Some(size_px * 2.0));
let attrs = Attrs::new().family(Family::Name(family));
let attrs = Attrs::new()
.family(self.resolve_family(family))
.weight(weight);
buffer.set_text(&mut self.font_system, text, &attrs, Shaping::Advanced);
buffer.shape_until_scroll(&mut self.font_system, false);
buffer
}
/// Exact vertical span `(top, height)` of the glyph pixels a line drawn
/// with [`Self::draw_line`] at `(0, 0)` would occupy: `top` is the
/// distance from the draw origin down to the highest glyph pixel.
/// `draw_line`'s `origin_y` anchors the *top* of the text (not the
/// baseline), so centering a line of height `h` in a box spanning
/// `[y0, y1]` needs `origin_y = y0 + (h - height) / 2 - top`.
///
/// Measured exactly by rendering the line once into a tiny offscreen
/// pixmap and scanning it, then cached — lock-screen text changes rarely
/// (clock per minute, date per day, static hints once), so the one-off
/// cost is negligible and the result is correct for any font.
pub fn measure_box(&mut self, text: &str, family: &str, size_px: f32) -> (f32, f32) {
self.measure_box_weighted(text, family, size_px, Weight::NORMAL)
}
/// Like [`Self::measure_box`] with an explicit font weight (the clock
/// uses [`Weight::BOLD`] / 700).
pub fn measure_box_weighted(
&mut self,
text: &str,
family: &str,
size_px: f32,
weight: Weight,
) -> (f32, f32) {
let key = (
text.to_string(),
family.to_string(),
(size_px * 100.0) as u32,
weight.0,
);
if let Some(b) = self.boxes.get(&key) {
return *b;
}
let w = self
.measure_line_weighted(text, family, size_px, weight)
.ceil()
.max(1.0) as u32;
let h = (size_px * 1.5).ceil().max(1.0) as u32;
let mut probe = match Pixmap::new(w, h) {
Some(p) => p,
None => return (0.0, size_px),
};
self.draw_line_weighted(
&mut probe,
text,
family,
size_px,
tiny_skia::Color::WHITE,
0.0,
0.0,
weight,
);
let (mut top, mut bottom) = (h as f32, 0.0f32);
for y in 0..h {
for x in 0..w {
if probe.pixel(x, y).is_some_and(|p| p.alpha() > 0) {
top = top.min(y as f32);
bottom = bottom.max(y as f32);
}
}
}
let boxed = if bottom >= top {
(top, bottom - top + 1.0)
} else {
(0.0, size_px)
};
self.boxes.insert(key, boxed);
boxed
}
/// Width in pixels `text` would occupy if drawn via [`Self::draw_line`]
/// with the same `family`/`size_px` — use to center text before drawing.
pub fn measure_line(&mut self, text: &str, family: &str, size_px: f32) -> f32 {
let buffer = self.shape_line(text, family, size_px, f32::INFINITY);
self.measure_line_weighted(text, family, size_px, Weight::NORMAL)
}
pub fn measure_line_weighted(
&mut self,
text: &str,
family: &str,
size_px: f32,
weight: Weight,
) -> f32 {
let buffer = self.shape_line(text, family, size_px, f32::INFINITY, weight);
buffer
.layout_runs()
.map(|run| run.line_w)
@ -71,6 +199,8 @@ impl TextRenderer {
/// Shapes `text` as a single line in `family` at `size_px` and blits it
/// into `pixmap` with its top-left baseline anchor at `(origin_x,
/// origin_y)`. Pixels outside `pixmap`'s bounds are silently clipped.
/// Origins stay float: subpixel X goes into cosmic-text's CacheKey bins
/// so appear/unlock motion doesn't stair-step against the pill path.
#[allow(clippy::too_many_arguments)]
pub fn draw_line(
&mut self,
@ -82,53 +212,120 @@ impl TextRenderer {
origin_x: f32,
origin_y: f32,
) {
let buffer = self.shape_line(text, family, size_px, pixmap.width() as f32);
self.draw_line_weighted(
pixmap, text, family, size_px, color, origin_x, origin_y, Weight::NORMAL,
);
}
#[allow(clippy::too_many_arguments)]
pub fn draw_line_weighted(
&mut self,
pixmap: &mut Pixmap,
text: &str,
family: &str,
size_px: f32,
color: tiny_skia::Color,
origin_x: f32,
origin_y: f32,
weight: Weight,
) {
// Infinite width so this agrees with [`Self::measure_line`] (a finite
// width would wrap, and centering from the unwrapped measure then
// goes negative). Overflow is clipped at blit time.
let buffer = self.shape_line(text, family, size_px, f32::INFINITY, weight);
let c8 = color.to_color_u8();
let text_color = cosmic_text::Color::rgba(c8.red(), c8.green(), c8.blue(), c8.alpha());
// cosmic-text's glyph-Mask rendering drops the base color's alpha
// entirely — its swash `with_pixels` uses the glyph coverage as the
// output alpha (see the "TODO: blend base alpha?" in its source), so
// a translucent text color would render fully opaque. Fold the
// requested alpha back in at blend time below; RGB stays straight.
let base_alpha = c8.alpha();
let text_color = cosmic_text::Color::rgba(c8.red(), c8.green(), c8.blue(), base_alpha);
let (width, height) = (pixmap.width() as i32, pixmap.height() as i32);
let ox = origin_x as i32;
let oy = origin_y as i32;
buffer.draw(
for run in buffer.layout_runs() {
for glyph in run.glyphs.iter() {
// Subpixel origin: X lands in CacheKey's subpixel bins; Y is
// hinted (cosmic-text truncates the Y offset) and then the
// run's line_y is rounded at blit so we don't trunc origin
// independently of glyph placement.
let physical = glyph.physical((origin_x, origin_y), 1.0);
let glyph_color = glyph.color_opt.unwrap_or(text_color);
self.swash_cache.with_pixels(
&mut self.font_system,
&mut self.swash_cache,
text_color,
|x, y, _w, _h, glyph_color| {
let (px, py) = (ox + x, oy + y);
physical.cache_key,
glyph_color,
|x, y, color| {
let px = physical.x + x;
let py = run.line_y.round() as i32 + physical.y + y;
if px < 0 || py < 0 || px >= width || py >= height {
return;
}
let (r, g, b, a) = glyph_color.as_rgba_tuple();
let (r, g, b, a) = color.as_rgba_tuple();
if a == 0 {
return;
}
blend_over_opaque(pixmap, px as u32, py as u32, r, g, b, a);
let a = (a as u32 * base_alpha as u32 / 255) as u8;
if a == 0 {
return;
}
blend_over(pixmap, px as u32, py as u32, r, g, b, a);
},
);
}
}
}
}
/// Alpha-blends a straight-alpha `(r, g, b, a)` source pixel over an
/// **opaque** destination pixel (always true here — the lock screen
/// background is painted fully opaque before any text or UI chrome).
/// Because the destination alpha is always 255, the blended result is also
/// opaque, so the `PremultipliedColorU8` invariant (`rgb <= a`) always holds.
fn blend_over_opaque(pixmap: &mut Pixmap, x: u32, y: u32, r: u8, g: u8, b: u8, a: u8) {
/// Alpha-blends a straight-alpha `(r, g, b, a)` source pixel over a
/// destination of *any* alpha. Two paths use this:
///
/// - **Full compose**: the background is painted fully opaque before any
/// text, so the destination alpha is always 255 and the result is opaque
/// (the exact formula below, kept byte-identical to the historic one).
/// - **GPU chrome** (`compose_chrome`): text is drawn into a *transparent*
/// pixmap that is later composited over the GPU background, so glyph
/// edges must keep real alpha — a forced-255 blend here would make every
/// glyph opaque and, composited over the background, visibly wrong.
///
/// Premultiplied source-over: `out = src_pm + dst_pm * (1 - src_a)`, which
/// preserves the `PremultipliedColorU8` invariant (`rgb <= a`).
fn blend_over(pixmap: &mut Pixmap, x: u32, y: u32, r: u8, g: u8, b: u8, a: u8) {
let idx = (y * pixmap.width() + x) as usize;
let pixels = pixmap.pixels_mut();
let Some(dst) = pixels.get(idx).copied() else {
return;
};
let a32 = a as u32;
let mix = |s: u8, d: u8| -> u8 { ((s as u32 * a32 + d as u32 * (255 - a32)) / 255) as u8 };
let blended = PremultipliedColorU8::from_rgba(
let sa = a as u32;
if dst.alpha() == 255 {
// Opaque destination: the classic exact blend. RGB mixes toward the
// source, alpha stays 255 — identical to the pre-split behavior so
// the single-pass software path doesn't move a single pixel.
let mix = |s: u8, d: u8| -> u8 { ((s as u32 * sa + d as u32 * (255 - sa)) / 255) as u8 };
if let Some(blended) = PremultipliedColorU8::from_rgba(
mix(r, dst.red()),
mix(g, dst.green()),
mix(b, dst.blue()),
255,
);
if let Some(blended) = blended {
) {
pixels[idx] = blended;
}
return;
}
// General (possibly transparent) destination: premultiplied source-over.
// out_a = sa + da*(255-sa)/255; out_rgb = src_rgb*sa/255 + dst_rgb*(1-sa).
let da = dst.alpha() as u32;
let out_a = (sa + da * (255 - sa) / 255) as u8;
let out_c = |c: u8, dc: u8| -> u8 {
(c as u32 * sa / 255 + dc as u32 * (255 - sa) / 255) as u8
};
if let Some(blended) = PremultipliedColorU8::from_rgba(
out_c(r, dst.red()),
out_c(g, dst.green()),
out_c(b, dst.blue()),
out_a,
) {
pixels[idx] = blended;
}
}
@ -168,4 +365,95 @@ mod tests {
// exact glyph coverage depends on whatever fonts are installed on the CI host.
assert!(pixmap.pixels().iter().all(|p| p.alpha() == 255));
}
#[test]
fn draw_line_respects_color_alpha() {
// Regression: cosmic-text's glyph-Mask path drops the base color's
// alpha (coverage becomes the only alpha), so translucent text used to
// render fully opaque — which broke every text fade on the lock screen
// (clock/date/hint/status never faded during appear/unlock).
let mut renderer = TextRenderer::new();
let mut full = Pixmap::new(200, 40).unwrap();
full.fill(tiny_skia::Color::BLACK);
renderer.draw_line(
&mut full,
"12:34",
"sans-serif",
24.0,
tiny_skia::Color::WHITE,
0.0,
0.0,
);
let full_max = full.pixels().iter().map(|p| p.red()).max().unwrap();
assert!(full_max > 200, "full-alpha text should render bright, got {full_max}");
let faint = tiny_skia::Color::from_rgba(1.0, 1.0, 1.0, 0.1).unwrap();
let mut low = Pixmap::new(200, 40).unwrap();
low.fill(tiny_skia::Color::BLACK);
renderer.draw_line(&mut low, "12:34", "sans-serif", 24.0, faint, 0.0, 0.0);
let low_max = low.pixels().iter().map(|p| p.red()).max().unwrap();
assert!(
low_max < 100,
"10%-alpha text must not render near-white, got {low_max}"
);
}
#[test]
fn missing_font_family_falls_back_without_panic() {
let mut pixmap = Pixmap::new(64, 16).unwrap();
pixmap.fill(tiny_skia::Color::BLACK);
let mut renderer = TextRenderer::new();
renderer.draw_line(
&mut pixmap,
"12:34",
"DefinitelyNotARealFontFamily_xyzzy",
12.0,
tiny_skia::Color::WHITE,
2.0,
2.0,
);
assert!(pixmap.pixels().iter().any(|p| p.alpha() > 0));
}
#[test]
fn draw_line_onto_transparent_keeps_real_alpha() {
// Regression: the GPU path (compose_chrome) draws text into a
// transparent pixmap that is later composited over the GPU background.
// The old blend forced output alpha to 255, so every glyph became
// opaque and, once composited, rendered visibly wrong (dark, covering
// the background instead of blending). Glyph cores must carry real
// alpha here so the final source-over composite is correct.
let mut renderer = TextRenderer::new();
let mut t = Pixmap::new(200, 40).unwrap(); // starts transparent
renderer.draw_line(
&mut t,
"12:34",
"sans-serif",
24.0,
tiny_skia::Color::WHITE,
0.0,
0.0,
);
// Full-coverage glyph cores are legitimately opaque, but the AA
// edges must carry real intermediate alphas — the old forced-255
// blend made *every* drawn pixel (edges included) fully opaque.
let has_edge = t
.pixels()
.iter()
.any(|p| p.alpha() > 0 && p.alpha() < 255);
assert!(
has_edge,
"glyph AA edges must keep intermediate alphas onto a transparent pixmap"
);
// And a 50%-alpha draw must not produce fully-opaque pixels.
let mut t2 = Pixmap::new(200, 40).unwrap();
let half = tiny_skia::Color::from_rgba(1.0, 1.0, 1.0, 0.5).unwrap();
renderer.draw_line(&mut t2, "12:34", "sans-serif", 24.0, half, 0.0, 0.0);
assert!(
t2.pixels().iter().all(|p| p.alpha() <= 128 + 3),
"50%-alpha text onto transparent must stay ~half alpha"
);
}
}

View file

@ -1,4 +1,4 @@
pub use bread_theme::{ink_on, load_palette, Palette};
pub use bread_theme::{ink_on, load_palette, load_palette_for, Palette};
/// Parse a `#rrggbb` hex colour. Falls back to opaque black on malformed input
/// (palette slots are always produced by [`bread_theme`], which guarantees

View file

@ -1,5 +1,7 @@
# Copy to ~/.config/breadlock/breadlock.toml — every field is optional and
# defaults to the value shown here if omitted or the file doesn't exist.
# A malformed file also falls back to defaults (the locker/greeter warn
# rather than treating it as missing).
[background]
# "color" (bread-theme palette background) or "image" (a PNG, cover-fit)
@ -8,15 +10,41 @@ path = ""
# v2 feature — accepted but currently just logs a warning and shows the
# background unblurred (needs a wlr-screencopy capture, not implemented yet).
blur = false
# Slow Ken Burns pan on image backgrounds (gentle drift + zoom). Opt-in.
# Cheap on the GPU wallpaper path; the software fallback still redraws
# the background continuously at a low frame rate while locked.
ken_burns = false
[clock]
# strftime format
format = "%H:%M"
# strftime format for the date line under the clock; empty string hides it
# (e.g. %A · %b %d → "Friday · Aug 21")
date_format = "%A · %b %d"
[font]
family = "Varela Round"
[input]
# How long the "wrong password" state (red pill) shows before input
# re-enables, in milliseconds.
# How long the red "wrong password" UI shows, in milliseconds. Typing is
# still accepted during this window (it clears the failed state).
fail_timeout_ms = 800
# Hold Tab to reveal the typed password as plain characters (instead of
# dots) while held. Tab can never be part of a password, so it's always
# safe as a reveal gesture. Default off.
reveal_hold = false
[animation]
# Subtle glow pulse on the password pill every few seconds while idle.
breathe = true
# Deepen the dim veil after this many seconds of no keystrokes (0 = off).
# A gentle extra darkening for OLED/burn-in or late-night comfort.
idle_dim_after_secs = 0
[status]
# Now-playing (MPRIS) and battery (upower) shown as a small line under the
# clock. Each flag controls both display and whether that D-Bus source is
# polled (background thread, every few seconds). Both default on; they
# degrade silently (no line) when the service or bus is unavailable.
now_playing = true
battery = true

View file

@ -17,15 +17,26 @@ path = "src/main.rs"
name = "breadlock-auth-check"
path = "src/bin/breadlock-auth-check.rs"
# Dev-only harness: renders the lock-screen motion system (render.rs) to a
# folder of PNGs with no Wayland involved, for eyeballing animations without
# locking a session. Not installed by the package.
[[bin]]
name = "breadlock-preview"
path = "src/bin/breadlock-preview.rs"
[dependencies]
breadlock-ui = { path = "../breadlock-ui", features = ["paint"] }
bread-utils = { workspace = true, features = ["bread-client"] }
smithay-client-toolkit = "0.20"
wayland-client = "0.31"
wayland-client = { version = "0.31", features = ["system"] }
tiny-skia = "0.12"
khronos-egl = { version = "6", features = ["dynamic"] }
glow = "0.16"
chrono = "0.4"
zbus = "4"
pam-client2 = { version = "0.5", default-features = false }
zeroize = { version = "1", features = ["std"] }
libc = "0.2"
serde.workspace = true
serde_json.workspace = true
toml.workspace = true

View file

@ -8,24 +8,33 @@
pub mod pam;
pub use pam::AuthError;
pub use pam::{username_from_process, AuthError};
use smithay_client_toolkit::reexports::calloop::channel::{self, Sender};
use smithay_client_toolkit::reexports::calloop::LoopHandle;
use std::time::Duration;
pub type AuthResult = Result<(), AuthError>;
/// Posted back to the event loop: the attempt's generation so a timed-out
/// or Escape-cancelled check cannot apply a late result.
pub type AuthOutcome = (u64, AuthResult);
/// libpam has no cancel; if it hangs we surface Authenticate after this
/// and ignore whatever it eventually returns (generation mismatch).
const PAM_TIMEOUT: Duration = Duration::from_secs(30);
/// Registers the receiving half of the auth-result channel on the event
/// loop and returns the `Sender` to hand to [`spawn_check`] on each attempt.
pub fn register<Data: 'static>(
loop_handle: &LoopHandle<'static, Data>,
mut on_result: impl FnMut(&mut Data, AuthResult) + 'static,
) -> Sender<AuthResult> {
mut on_result: impl FnMut(&mut Data, u64, AuthResult) + 'static,
) -> Sender<AuthOutcome> {
let (tx, channel) = channel::channel();
loop_handle
.insert_source(channel, move |event, _, data| {
if let channel::Event::Msg(result) = event {
on_result(data, result);
if let channel::Event::Msg((generation, result)) = event {
on_result(data, generation, result);
}
})
.expect("failed to register auth-result channel on event loop");
@ -38,13 +47,31 @@ pub fn register<Data: 'static>(
/// conversation consumes it — it is never logged. It's a `Zeroizing<String>`
/// so the buffer is wiped the moment it goes out of scope at the end of this
/// closure, rather than just deallocated with the bytes intact.
///
/// `generation` is echoed back with the result so the event loop can
/// drop timed-out or cancelled attempts. libpam itself is not aborted.
pub fn spawn_check(
username: String,
password: zeroize::Zeroizing<String>,
result_tx: Sender<AuthResult>,
generation: u64,
result_tx: Sender<AuthOutcome>,
) {
std::thread::spawn(move || {
let (done_tx, done_rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let result = pam::check(&username, &password);
let _ = result_tx.send(result);
let _ = done_tx.send(result);
});
let result = match done_rx.recv_timeout(PAM_TIMEOUT) {
Ok(result) => result,
Err(_) => {
tracing::warn!(
timeout_s = PAM_TIMEOUT.as_secs(),
"PAM check timed out; treating as authentication failure"
);
Err(AuthError::Authenticate)
}
};
let _ = result_tx.send((generation, result));
});
}

View file

@ -4,6 +4,7 @@
use pam_client2::conv_mock::Conversation;
use pam_client2::{Context, Flag};
use std::ffi::CStr;
use zeroize::Zeroize;
/// The PAM service name — matches `/etc/pam.d/breadlock`
@ -46,3 +47,136 @@ pub fn check(username: &str, password: &str) -> Result<(), AuthError> {
result
}
/// Copy a NUL-terminated `passwd.pw_name` into an owned `String`.
fn cstr_to_username(ptr: *const libc::c_char) -> Option<String> {
if ptr.is_null() {
return None;
}
// SAFETY: `ptr` is a non-null C string from getpwuid_r (into our buffer)
// or a test fixture.
let cstr = unsafe { CStr::from_ptr(ptr) };
let name = cstr.to_str().ok()?;
if name.is_empty() {
None
} else {
Some(name.to_owned())
}
}
/// Passwd lookup of `uid` via `getpwuid_r`. Grows the scratch buffer on
/// `ERANGE`. Returns `None` if the user is unknown or the name is not UTF-8.
pub fn username_from_uid(uid: libc::uid_t) -> Option<String> {
let mut pwd = std::mem::MaybeUninit::<libc::passwd>::uninit();
let mut buflen = unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) };
if buflen <= 0 {
buflen = 1024;
}
let mut buf = vec![0u8; buflen as usize];
let mut result: *mut libc::passwd = std::ptr::null_mut();
loop {
let rc = unsafe {
libc::getpwuid_r(
uid,
pwd.as_mut_ptr(),
buf.as_mut_ptr() as *mut libc::c_char,
buf.len(),
&mut result,
)
};
if rc == libc::ERANGE {
let next = buf.len().saturating_mul(2).max(buf.len() + 1024);
if next == buf.len() {
return None;
}
buf.resize(next, 0);
continue;
}
if rc != 0 || result.is_null() {
return None;
}
break;
}
// SAFETY: getpwuid_r wrote a `passwd` and `result` is non-null; `pw_name`
// points into `buf`, which we copy out before `buf` drops.
let pwd = unsafe { pwd.assume_init() };
cstr_to_username(pwd.pw_name)
}
/// Prefer the first non-empty of passwd name, `$USER`, `$LOGNAME`.
pub(crate) fn pick_username(
passwd: Option<&str>,
user: Option<&str>,
logname: Option<&str>,
) -> Option<String> {
for candidate in [passwd, user, logname] {
if let Some(s) = candidate.filter(|s| !s.is_empty()) {
return Some(s.to_owned());
}
}
None
}
/// Username for PAM: `getuid` + `getpwuid_r`, then `$USER` / `$LOGNAME`.
/// Logs a warning when the passwd lookup fails. `None` if nothing resolved.
pub fn username_from_process() -> Option<String> {
let uid = unsafe { libc::getuid() };
let from_passwd = username_from_uid(uid);
if from_passwd.is_none() {
tracing::warn!(
uid,
"passwd lookup for process uid failed; falling back to $USER / $LOGNAME"
);
}
pick_username(
from_passwd.as_deref(),
std::env::var("USER").ok().as_deref(),
std::env::var("LOGNAME").ok().as_deref(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::CString;
#[test]
fn cstr_to_username_copies_nul_terminated_name() {
let raw = CString::new("breadway").unwrap();
assert_eq!(
cstr_to_username(raw.as_ptr()),
Some("breadway".to_string())
);
}
#[test]
fn cstr_to_username_rejects_empty_and_null() {
let empty = CString::new("").unwrap();
assert_eq!(cstr_to_username(empty.as_ptr()), None);
assert_eq!(cstr_to_username(std::ptr::null()), None);
}
#[test]
fn pick_username_prefers_passwd_then_user_then_logname() {
assert_eq!(
pick_username(Some("from-pw"), Some("from-user"), Some("from-log")),
Some("from-pw".into())
);
assert_eq!(
pick_username(None, Some("from-user"), Some("from-log")),
Some("from-user".into())
);
assert_eq!(
pick_username(None, None, Some("from-log")),
Some("from-log".into())
);
assert_eq!(pick_username(Some(""), Some(""), Some("")), None);
assert_eq!(pick_username(None, None, None), None);
}
#[test]
fn username_from_uid_of_self_is_some_or_none_without_panic() {
let uid = unsafe { libc::getuid() };
let _ = username_from_uid(uid);
}
}

View file

@ -1,14 +1,214 @@
//! Lock-screen background: a solid palette color, or a static image scaled
//! to cover the surface. Live blur-of-desktop (hyprlock-style) is a v2
//! follow-up (see README) — `blur = true` is accepted but only logs a
//! warning in v1.
//! warning in v1. `ken_burns = true` adds a slow, continuous pan+zoom to
//! image backgrounds (opt-in: it keeps the background redrawing at a low
//! frame rate while locked).
//!
//! The renderer is fully software (tiny-skia), so every frame redraws the
//! whole surface. Rescaling the *source* wallpaper on every frame is
//! prohibitively expensive for large images (a 4K source at output size took
//! ~50 ms/frame — choppy at any cadence), so the source is pre-scaled once
//! per output size into a cache and each frame is a translate-only blit.
use breadlock_ui::config::{Background as BackgroundConfig, BackgroundMode};
use std::cell::RefCell;
use std::f32::consts::TAU;
use tiny_skia::{Pixmap, PixmapPaint, Transform};
/// One full Ken Burns pan+zoom cycle, in seconds. Deliberately slow so the
/// motion reads as a gentle drift rather than a slideshow.
const KENBURNS_PERIOD_S: f32 = 90.0;
/// Extra zoom beyond plain cover-fit — gives the pan room to travel without
/// ever exposing the image edges.
const KENBURNS_ZOOM: f32 = 1.06;
pub enum Background {
Color(tiny_skia::Color),
Image(Pixmap),
Image(ImageBg),
}
/// A wallpaper with a lazily-built, output-sized copy. The first `paint` for
/// a given output size does one downscale; every frame after that blits the
/// cached copy with at most a translation (the Ken Burns pan).
/// Cap on cached scaled copies — enough for a typical multi-monitor setup
/// without unbounded growth if the compositor sends many sizes.
const SCALED_CACHE_SLOTS: usize = 4;
pub struct ImageBg {
/// Original wallpaper. Kept so a different output size (hotplug) simply
/// rebuilds the cache rather than needing the source reloaded.
source: Pixmap,
ken_burns: bool,
/// Last scaled copies **per target size**. A single slot thrashed every
/// frame under `redraw_all` with two monitors of different sizes.
cache: RefCell<Vec<ScaledBg>>,
}
struct ScaledBg {
/// `source` pre-scaled to cover-fit (× Ken Burns zoom when enabled) and
/// sized to the output — same size or larger, so drawing it needs no
/// per-frame scaling.
pixmap: Pixmap,
/// How many pixels the scaled image overhangs each axis — the pan room.
pan_x: f32,
pan_y: f32,
target_w: u32,
target_h: u32,
}
/// Copies `src` into `target` shifted by `(dx, dy)` (target pixels). `src` is
/// at least as large as `target` in both axes (guaranteed by the cover-fit
/// cache build), and `dx, dy` are pan offsets in `[-pan, 0]`, so the visible
/// region is `src[-dx..-dx+tw, -dy..-dy+th]`.
///
/// With `bilinear` the fractional part of the offset is sub-pixel filtered,
/// so a slow pan glides instead of stepping one whole pixel at a time (which
/// reads as judder); when the offset is (near-)integer, or `bilinear` is off
/// (the 60 fps animation frames, where the pan moves < 0.2 px anyway), the
/// whole thing collapses to row memcpys. The bilinear path is an integer
/// fixed-point (16.16) loop with the edge clamping hoisted out of the hot
/// columns/rows — far cheaper than
/// [`tiny_skia::Pixmap::draw_pixmap`], which rasterizes every pixel through
/// its general pattern pipeline.
fn blit_translate(target: &mut Pixmap, src: &Pixmap, dx: f32, dy: f32, bilinear: bool) {
let tw = target.width() as usize;
let th = target.height() as usize;
let sw = src.width() as usize;
let sh = src.height() as usize;
let sx = (-dx).clamp(0.0, sw.saturating_sub(tw) as f32);
let sy = (-dy).clamp(0.0, sh.saturating_sub(th) as f32);
let fx = (sx.fract() * 65536.0) as u32 & 0xFFFF;
let fy = (sy.fract() * 65536.0) as u32 & 0xFFFF;
let ix = sx as usize;
let iy = sy as usize;
let sdata = src.data();
let dst = target.data_mut();
if !bilinear || (fx == 0 && fy == 0) {
for row in 0..th {
let src_row = (iy + row) * sw + ix;
let dst_row = row * tw;
let (s, d) = (
&sdata[src_row * 4..(src_row + tw) * 4],
&mut dst[dst_row * 4..(dst_row + tw) * 4],
);
d.copy_from_slice(s);
}
return;
}
let wx = fx;
let wx_inv = 65536 - wx;
let wy = fy;
let wy_inv = 65536 - wy;
let swm1 = sw - 1;
let shm1 = sh - 1;
// Per-channel bilinear in packed u32 (one load per pixel instead of four,
// one store instead of four — the loop is latency-bound). Each byte's
// products stay well under 2^32, so lanes never interfere.
#[inline(always)]
#[allow(clippy::too_many_arguments)]
unsafe fn lerp4(
sdata: &[u8],
i00: usize,
i10: usize,
i01: usize,
i11: usize,
di: usize,
wx: u32,
wx_inv: u32,
wy: u32,
wy_inv: u32,
dst: &mut [u8],
) {
let a = u32::from_ne_bytes([
*sdata.get_unchecked(i00),
*sdata.get_unchecked(i00 + 1),
*sdata.get_unchecked(i00 + 2),
*sdata.get_unchecked(i00 + 3),
]);
let b = u32::from_ne_bytes([
*sdata.get_unchecked(i01),
*sdata.get_unchecked(i01 + 1),
*sdata.get_unchecked(i01 + 2),
*sdata.get_unchecked(i01 + 3),
]);
let d = u32::from_ne_bytes([
*sdata.get_unchecked(i10),
*sdata.get_unchecked(i10 + 1),
*sdata.get_unchecked(i10 + 2),
*sdata.get_unchecked(i10 + 3),
]);
let e = u32::from_ne_bytes([
*sdata.get_unchecked(i11),
*sdata.get_unchecked(i11 + 1),
*sdata.get_unchecked(i11 + 2),
*sdata.get_unchecked(i11 + 3),
]);
let mut out = 0u32;
for c in 0..4 {
let shift = c * 8;
let av = (a >> shift) & 0xFF;
let bv = (b >> shift) & 0xFF;
let dv = (d >> shift) & 0xFF;
let ev = (e >> shift) & 0xFF;
let top = (av * wx_inv + bv * wx) >> 16;
let bot = (dv * wx_inv + ev * wx) >> 16;
out |= ((top * wy_inv + bot * wy) >> 16) << shift;
}
dst[di..di + 4].copy_from_slice(&out.to_ne_bytes());
}
// Interior rows/columns: `ix + tw <= sw` and `iy + th <= sh` (both clamped
// above), so `x0 + 1`/`y0 + 1` stay in bounds except on the last
// column/row, which are handled after the hot loop. All indices are
// verified in-bounds above the `unsafe` calls.
for row in 0..th - 1 {
let r0 = (iy + row) * sw;
let r1 = r0 + sw;
let drow = row * tw;
for col in 0..tw - 1 {
let i00 = (r0 + ix + col) * 4;
let i10 = (r1 + ix + col) * 4;
let di = (drow + col) * 4;
// SAFETY: i01/i11 are the next column (col + 1 < tw, in bounds);
// di + 4 < target size; rows in bounds per above.
unsafe { lerp4(sdata, i00, i10, i00 + 4, i10 + 4, di, wx, wx_inv, wy, wy_inv, dst) };
}
// Last column of this row: clamp x1.
let i00 = (r0 + ix + tw - 1) * 4;
let i10 = (r1 + ix + tw - 1) * 4;
let di = (drow + tw - 1) * 4;
let x1 = (ix + tw - 1 + 1).min(swm1);
let j0 = (r0 + x1) * 4;
let j1 = (r1 + x1) * 4;
// SAFETY: j0/j1 clamped within source, di within target.
unsafe { lerp4(sdata, i00, i10, j0, j1, di, wx, wx_inv, wy, wy_inv, dst) };
}
// Last row: clamp y1.
let r0 = (iy + th - 1) * sw;
let r1 = (iy + th - 1 + 1).min(shm1) * sw;
let drow = (th - 1) * tw;
for col in 0..tw - 1 {
let i00 = (r0 + ix + col) * 4;
let i10 = (r1 + ix + col) * 4;
let di = (drow + col) * 4;
// SAFETY: in bounds as in the interior loop.
unsafe { lerp4(sdata, i00, i10, i00 + 4, i10 + 4, di, wx, wx_inv, wy, wy_inv, dst) };
}
// Last column of the last row (both clamps).
let i00 = (r0 + ix + tw - 1) * 4;
let i10 = (r1 + ix + tw - 1) * 4;
let di = (drow + tw - 1) * 4;
let x1 = (ix + tw - 1 + 1).min(swm1);
let j0 = (r0 + x1) * 4;
let j1 = (r1 + x1) * 4;
// SAFETY: all clamped in bounds.
unsafe { lerp4(sdata, i00, i10, j0, j1, di, wx, wx_inv, wy, wy_inv, dst) };
}
impl Background {
@ -31,7 +231,11 @@ impl Background {
return fallback();
}
match Pixmap::load_png(&cfg.path) {
Ok(pixmap) => Background::Image(pixmap),
Ok(pixmap) => Background::Image(ImageBg {
source: pixmap,
ken_burns: cfg.ken_burns,
cache: RefCell::new(Vec::new()),
}),
Err(err) => {
tracing::warn!(path = %cfg.path, %err, "failed to load background image (PNG only in v1), falling back to palette color");
fallback()
@ -41,28 +245,252 @@ impl Background {
}
}
/// True when this background needs continuous redraws (Ken Burns pan).
pub fn ken_burns(&self) -> bool {
matches!(self, Background::Image(bg) if bg.ken_burns)
}
/// Paints this background into `target`, cover-fit (scaled uniformly to
/// fill the surface, cropping any overflow — never letterboxed).
pub fn paint(&self, target: &mut Pixmap) {
/// fill the surface, cropping any overflow — never letterboxed). `t_secs`
/// is the monotonic clock: with Ken Burns enabled the image slowly pans
/// and zooms along a smooth Lissajous-ish drift, so consecutive frames
/// differ slightly but never jump.
///
/// The expensive downscale happens at most once per output size (see
/// [`ImageBg::cache`]); steady-state frames are a 1:1 blit plus a small
/// translation, so the software renderer can hold its frame budget even
/// with a multi-megapixel wallpaper.
///
/// `smooth` asks for sub-pixel bilinear panning. The locker passes `true`
/// on its slow idle frames (where the ~1 px/frame drift is visible) and
/// `false` on 60 fps animation frames (where the pan moves < 0.2 px and
/// the ~20 ms/frame bilinear would blow the frame budget).
pub fn paint(&self, target: &mut Pixmap, t_secs: f32, smooth: bool) {
match self {
Background::Color(c) => target.fill(*c),
Background::Image(source) => {
Background::Image(bg) => {
let (tw, th) = (target.width() as f32, target.height() as f32);
let (sw, sh) = (source.width() as f32, source.height() as f32);
let (sw, sh) = (bg.source.width() as f32, bg.source.height() as f32);
if sw <= 0.0 || sh <= 0.0 {
return;
}
let scale = (tw / sw).max(th / sh);
target.fill(tiny_skia::Color::BLACK);
target.draw_pixmap(
let mut cache = bg.cache.borrow_mut();
let tw_px = target.width();
let th_px = target.height();
let hit = cache
.iter()
.position(|c| c.target_w == tw_px && c.target_h == th_px);
if let Some(i) = hit {
// LRU: most-recently used at the end.
if i + 1 != cache.len() {
let entry = cache.remove(i);
cache.push(entry);
}
} else {
let cover = (tw / sw).max(th / sh);
let scale = cover * if bg.ken_burns { KENBURNS_ZOOM } else { 1.0 };
let scaled_w = (sw * scale).round().max(1.0) as u32;
let scaled_h = (sh * scale).round().max(1.0) as u32;
let Some(mut pixmap) = Pixmap::new(scaled_w, scaled_h) else {
tracing::error!(
"failed to allocate {scaled_w}x{scaled_h} scaled wallpaper — falling back to a palette-color background"
);
drop(cache);
target.fill(breadlock_ui::theme::tiny_skia_color(
&breadlock_ui::theme::Palette::default().background,
));
return;
};
pixmap.fill(tiny_skia::Color::BLACK);
// The one real downscale in the pipeline: bilinear so the
// cached layer is smooth (per-frame draws are pure copies
// and don't re-filter).
let paint = PixmapPaint {
quality: tiny_skia::FilterQuality::Bilinear,
..Default::default()
};
pixmap.draw_pixmap(
0,
0,
source.as_ref(),
&PixmapPaint::default(),
bg.source.as_ref(),
&paint,
Transform::from_scale(scale, scale),
None,
);
if cache.len() >= SCALED_CACHE_SLOTS {
cache.remove(0);
}
cache.push(ScaledBg {
pixmap,
pan_x: scaled_w as f32 - tw,
pan_y: scaled_h as f32 - th,
target_w: tw_px,
target_h: th_px,
});
}
let scaled = cache.last().expect("cache populated above");
target.fill(tiny_skia::Color::BLACK);
let (tx, ty) = if bg.ken_burns {
let phase = t_secs * TAU / KENBURNS_PERIOD_S;
// Sin/cos offset by a quarter cycle: the pan traces a slow
// ellipse, starting from a corner.
(
-scaled.pan_x * (0.5 + 0.5 * phase.sin()),
-scaled.pan_y * (0.5 + 0.5 * phase.cos()),
)
} else {
(0.0, 0.0)
};
// The cached pixmap is already output-sized, so this per-frame
// draw is a 1:1 copy with at most a translation. `draw_pixmap`
// runs the full raster pipeline per pixel (~20 ms for a
// full-screen layer), which is the dominant software-render
// cost — so do the blit directly instead: rows are memcpy'd
// (nearest sampling on an already-correct-size image is
// pixel-identical, and the pan offsets quantize the same way
// tiny-skia's nearest filter does).
blit_translate(target, &scaled.pixmap, tx, ty, smooth);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A 4x4 pixmap whose pixel at (x, y) is `(x * 63, y * 63, 0, 255)` —
/// every pixel is distinct, so a shifted copy is easy to assert.
fn source_grid() -> Pixmap {
let mut p = Pixmap::new(4, 4).unwrap();
for y in 0..4 {
for x in 0..4 {
p.pixels_mut()[y * 4 + x] = tiny_skia::PremultipliedColorU8::from_rgba(
(x * 63) as u8,
(y * 63) as u8,
0,
255,
)
.unwrap();
}
}
p
}
#[test]
fn blit_translate_copies_shifted_region() {
let src = source_grid();
let mut dst = Pixmap::new(2, 2).unwrap();
// Shift the 4x4 source by (-1, -1): the visible region is src[1..3, 1..3].
blit_translate(&mut dst, &src, -1.0, -1.0, false);
let px = dst.pixels();
assert_eq!(px[0].red(), 63, "(0,0) should be src(1,1) red");
assert_eq!(px[0].green(), 63, "(0,0) should be src(1,1) green");
assert_eq!(px[1].red(), 126, "(1,0) should be src(2,1) red");
assert_eq!(px[1].green(), 63);
assert_eq!(px[2].red(), 63, "(0,1) should be src(1,2) red");
assert_eq!(px[2].green(), 126);
assert_eq!(px[3].red(), 126, "(1,1) should be src(2,2)");
assert_eq!(px[3].green(), 126);
}
#[test]
fn blit_translate_clamps_within_source() {
// An offset larger than the overhang must clamp, not read out of
// bounds or leave uninitialized rows.
let src = source_grid();
let mut dst = Pixmap::new(2, 2).unwrap();
blit_translate(&mut dst, &src, -99.0, -99.0, false);
// Clamped to the bottom-right 2x2 of the source.
let px = dst.pixels();
assert_eq!(px[0].red(), 126);
assert_eq!(px[0].green(), 126);
assert_eq!(px[3].red(), 189);
assert_eq!(px[3].green(), 189);
}
#[test]
fn ken_burns_pan_never_exposes_edges() {
// A small solid-color image panned through a full cycle must cover
// the whole target at every phase — no black borders.
let mut source = Pixmap::new(80, 40).unwrap();
source.fill(tiny_skia::Color::from_rgba8(200, 30, 30, 255));
let bg = Background::Image(ImageBg {
source,
ken_burns: true,
cache: RefCell::new(Vec::new()),
});
let mut target = Pixmap::new(60, 30).unwrap();
for i in 0..90 {
bg.paint(&mut target, i as f32, true);
assert!(
target.pixels().iter().all(|p| p.red() == 200 && p.green() == 30),
"frame {i} exposed an edge"
);
}
}
#[test]
fn bilinear_shift_matches_fractional_position() {
// A row of (0..255, 0, 0, 255): a half-pixel right shift should give
// the exact average of each adjacent pair.
let mut src = Pixmap::new(8, 1).unwrap();
for x in 0..8 {
src.pixels_mut()[x] =
tiny_skia::PremultipliedColorU8::from_rgba((x * 32) as u8, 0, 0, 255).unwrap();
}
let mut dst = Pixmap::new(6, 1).unwrap();
// Shift by (-0.5, 0): visible region starts at src 0.5 → each output
// pixel averages src[x] and src[x + 1].
blit_translate(&mut dst, &src, -0.5, 0.0, true);
let px = dst.pixels();
assert_eq!(px[0].red(), 16, "0.5px shift averages neighbors");
assert_eq!(px[1].red(), ((32 + 64) / 2) as u8);
assert_eq!(px[5].red(), ((160 + 192) / 2) as u8);
}
#[test]
fn static_image_keeps_cover_fit() {
// Without Ken Burns the image is cover-fit exactly: still no edges.
let mut source = Pixmap::new(80, 40).unwrap();
source.fill(tiny_skia::Color::from_rgba8(200, 30, 30, 255));
let bg = Background::Image(ImageBg {
source,
ken_burns: false,
cache: RefCell::new(Vec::new()),
});
let mut target = Pixmap::new(60, 30).unwrap();
bg.paint(&mut target, 0.0, true);
assert!(target.pixels().iter().all(|p| p.red() == 200 && p.green() == 30));
}
#[test]
fn scaled_cache_keeps_a_slot_per_target_size() {
// Two output sizes (two monitors) must not thrash a single slot.
let mut source = Pixmap::new(80, 40).unwrap();
source.fill(tiny_skia::Color::from_rgba8(200, 30, 30, 255));
let image = ImageBg {
source,
ken_burns: false,
cache: RefCell::new(Vec::new()),
};
let bg = Background::Image(image);
let mut a = Pixmap::new(60, 30).unwrap();
let mut b = Pixmap::new(40, 20).unwrap();
bg.paint(&mut a, 0.0, false);
bg.paint(&mut b, 0.0, false);
bg.paint(&mut a, 0.0, false);
let Background::Image(image) = &bg else {
panic!("expected image background");
};
let cache = image.cache.borrow();
assert_eq!(
cache.len(),
2,
"two target sizes should occupy two slots, got {} slots",
cache.len()
);
assert!(cache.iter().any(|c| c.target_w == 60 && c.target_h == 30));
assert!(cache.iter().any(|c| c.target_w == 40 && c.target_h == 20));
}
}

View file

@ -8,17 +8,21 @@
//! `cargo run --bin breadlock-auth-check`.
use std::io::Write;
use std::sync::atomic::{AtomicBool, Ordering};
use zeroize::{Zeroize, Zeroizing};
#[path = "../auth/pam.rs"]
mod pam;
fn main() {
let username = std::env::var("USER").unwrap_or_else(|_| {
let username = pam::username_from_process().unwrap_or_else(|| {
eprint!("Username: ");
std::io::stdout().flush().ok();
let mut buf = String::new();
std::io::stdin().read_line(&mut buf).ok();
buf.trim().to_string()
let name = buf.trim().to_string();
buf.zeroize();
name
});
let password = rpassword_prompt();
@ -32,27 +36,84 @@ fn main() {
}
}
static mut SAVED_TERMIOS: libc::termios = unsafe { std::mem::zeroed() };
static ECHO_SAVED: AtomicBool = AtomicBool::new(false);
extern "C" fn restore_echo_on_signal(sig: libc::c_int) {
unsafe {
if ECHO_SAVED.load(Ordering::Relaxed) {
libc::tcsetattr(
libc::STDIN_FILENO,
libc::TCSANOW,
std::ptr::addr_of!(SAVED_TERMIOS),
);
}
libc::signal(sig, libc::SIG_DFL);
libc::raise(sig);
}
}
/// Disable TTY echo; restore on drop (panic, return) and on SIGINT/SIGTERM
/// so Ctrl-C cannot leave the terminal silent.
struct EchoOff {
fd: libc::c_int,
orig: libc::termios,
}
impl EchoOff {
fn new() -> Option<Self> {
let fd = libc::STDIN_FILENO;
if unsafe { libc::isatty(fd) } == 0 {
return None;
}
let mut orig = unsafe { std::mem::zeroed() };
if unsafe { libc::tcgetattr(fd, &mut orig) } != 0 {
return None;
}
unsafe {
SAVED_TERMIOS = orig;
ECHO_SAVED.store(true, Ordering::Relaxed);
libc::signal(
libc::SIGINT,
restore_echo_on_signal as *const () as libc::sighandler_t,
);
libc::signal(
libc::SIGTERM,
restore_echo_on_signal as *const () as libc::sighandler_t,
);
}
let mut raw = orig;
raw.c_lflag &= !libc::ECHO;
if unsafe { libc::tcsetattr(fd, libc::TCSAFLUSH, &raw) } != 0 {
return None;
}
Some(Self { fd, orig })
}
}
impl Drop for EchoOff {
fn drop(&mut self) {
unsafe {
libc::tcsetattr(self.fd, libc::TCSAFLUSH, &self.orig);
ECHO_SAVED.store(false, Ordering::Relaxed);
}
eprintln!();
}
}
/// Minimal no-echo password prompt so this harness doesn't need the `rpassword`
/// crate — good enough for a dev tool, never shipped.
fn rpassword_prompt() -> String {
fn rpassword_prompt() -> Zeroizing<String> {
use std::io::BufRead;
eprint!("Password: ");
std::io::stderr().flush().ok();
// Best-effort: disable echo via `stty` if a TTY is attached, restore after.
let stty_available = std::process::Command::new("stty")
.arg("-echo")
.status()
.map(|s| s.success())
.unwrap_or(false);
let _echo = EchoOff::new();
let mut line = String::new();
std::io::stdin().lock().read_line(&mut line).ok();
if stty_available {
let _ = std::process::Command::new("stty").arg("echo").status();
eprintln!();
}
line.trim_end_matches(['\n', '\r']).to_string()
let trimmed = line.trim_end_matches(['\n', '\r']);
let password = Zeroizing::new(trimmed.to_string());
line.zeroize();
password
}

View file

@ -0,0 +1,311 @@
//! Dev-only harness: renders the breadlock lock-screen motion system to a
//! folder of PNGs so the new animations can be eyeballed without locking a
//! session (or even touching Wayland). Every scene below pins concrete
//! progress values into `render::FrameInputs` — the same struct the real
//! locker feeds from live timestamps — so what you see here is exactly what
//! `state.rs` computes at runtime.
//!
//! Not installed by the package; run from a build tree with
//! `cargo run --bin breadlock-preview [out-dir]` (default `preview/`).
//! Scenes are written as `NN-<name>.png` in alphabetical-file order, so a
//! file manager or `for f in preview/*.png; do ...` steps through them as a
//! flipbook roughly in timeline order.
use breadlock_ui::painter::TextRenderer;
use breadlock_ui::theme;
use render::{compose, FrameInputs};
// Reuse the real renderer + background code via the same `#[path]` include
// trick as `breadlock-auth-check` (dev bins are separate crates and can't see
// `main.rs`'s modules otherwise). `render.rs` pulls `crate::background::Background`,
// which this crate root provides below. Only `compose`/`FrameInputs` are used
// here; the compositor-side helpers (blit_to_shm, the timing consts) stay
// included so this harness exercises the *real* renderer, so dead-code is
// expected and silenced.
#[allow(dead_code)]
#[path = "../background.rs"]
mod background;
#[allow(dead_code)]
#[path = "../render.rs"]
mod render;
const W: u32 = 960;
const H: u32 = 540;
const FONT: &str = "Varela Round";
struct Scene {
name: &'static str,
clock: &'static str,
date: &'static str,
clock_old: Option<(&'static str, f32)>,
password_len: usize,
/// Actual password bytes. Empty except for the reveal scene: production
/// `submit()` zeros the secret (and `password_len` follows `password.len()`),
/// so checking frames show an empty pill under "Checking…".
password: &'static str,
failed: bool,
failed_t: f32,
dot_pop_t: f32,
keystroke_age: Option<f32>,
/// Idle caret blink phase driver (`t_secs` in FrameInputs). Only matters
/// for scenes with no keystroke age: phase = (t × 1.8) % 1.0, caret is
/// lit below 0.5.
t_secs: f32,
status: Option<&'static str>,
/// Now-playing / battery line under the clock (empty hides it).
info: &'static str,
appear_t: f32,
unlock_t: f32,
breathe_t: f32,
status_t: f32,
caps_lock: bool,
layout_index: u32,
reveal: bool,
idle_dim: f32,
}
impl Default for Scene {
fn default() -> Self {
Self {
name: "",
clock: "12:34",
date: "Friday · Aug 21",
clock_old: None,
password_len: 0,
password: "",
failed: false,
failed_t: 0.0,
dot_pop_t: 1.0,
keystroke_age: None,
t_secs: 0.2,
status: None,
info: "",
appear_t: 1.0,
unlock_t: 0.0,
breathe_t: 0.0,
status_t: 1.0,
caps_lock: false,
layout_index: 0,
reveal: false,
idle_dim: 0.0,
}
}
}
/// `--time [WxH] [frames] [wallpaper.png]` — renders the real compose() path
/// (image background + Ken Burns, full chrome) in a loop and prints per-frame
/// timings, so the software renderer's cost can be measured without Wayland.
fn bench(args: &[String]) {
let parse = |s: &str, d: &str| -> String { args.iter().find(|a| a.starts_with(s)).map(|a| a[s.len()..].to_string()).unwrap_or_else(|| d.to_string()) };
let size: (u32, u32) = {
let v: Vec<u32> = parse("--size=", "1920x1200").split('x').filter_map(|s| s.parse().ok()).collect();
(v[0], v[1])
};
let frames: u32 = parse("--frames=", "120").parse().unwrap_or(120);
let path = parse("--wallpaper=", "/home/breadway/.config/breadlock/wallpaper.png");
let palette = theme::load_palette();
let bg_cfg = breadlock_ui::config::Background {
mode: breadlock_ui::config::BackgroundMode::Image,
path,
blur: false,
ken_burns: true,
};
let background = background::Background::load(&bg_cfg, &palette);
let mut text = TextRenderer::new();
// Warm up once: the first frame builds the scaled-wallpaper cache and
// shapes the glyphs. Steady-state frames are what the timer loop sees.
let warm = FrameInputs {
width: size.0,
height: size.1,
background: &background,
palette: &palette,
font_family: FONT,
clock_text: "12:34",
date_text: "Friday · Aug 21",
clock_old: None, password_len: 6,
password: "hunter2",
reveal: false,
caps_lock: false,
layout_index: 0,
idle_dim: 0.0,
failed: false,
failed_t: 0.0,
dot_pop_t: 1.0,
keystroke_age: None,
t_secs: 0.0,
breathe_t: 0.0,
status_t: 1.0,
status_text: None,
info_text: "",
appear_t: 1.0,
unlock_t: 0.0,
smooth_pan: true,
};
compose(&mut text, &warm).expect("warm-up compose failed");
// Isolate the background pass cost (wallpaper blit + fills) alone.
let mut bg_times = Vec::new();
{
let mut dummy = tiny_skia::Pixmap::new(size.0, size.1).expect("pixmap");
for i in 0..60 {
let t = std::time::Instant::now();
background.paint(&mut dummy, (i as f32 / 60.0) * 90.0, true);
bg_times.push(t.elapsed().as_secs_f64() * 1000.0);
}
bg_times.sort_by(|a, b| a.partial_cmp(b).unwrap());
let avg: f64 = bg_times.iter().sum::<f64>() / bg_times.len() as f64;
println!("background.paint only: avg {avg:.2} ms max {:.2} ms", bg_times[bg_times.len() - 1]);
}
let mut times = Vec::with_capacity(frames as usize);
let start = std::time::Instant::now();
for i in 0..frames {
let t = std::time::Instant::now();
let inputs = FrameInputs {
width: size.0,
height: size.1,
background: &background,
palette: &palette,
font_family: FONT,
clock_text: "12:34",
date_text: "Friday · Aug 21",
clock_old: None,
password_len: 6,
password: "hunter2",
reveal: false,
caps_lock: false,
layout_index: 0,
idle_dim: 0.0,
failed: false,
failed_t: 0.0,
dot_pop_t: 1.0,
keystroke_age: None,
// Walk t_secs through a Ken Burns cycle so every frame differs.
t_secs: (i as f32 / frames as f32) * 90.0,
breathe_t: (i % 10) as f32 / 10.0,
status_t: 1.0,
status_text: None,
info_text: "",
appear_t: 1.0,
unlock_t: 0.0,
smooth_pan: true,
};
if compose(&mut text, &inputs).is_none() {
eprintln!("compose returned None at frame {i}");
std::process::exit(1);
}
times.push(t.elapsed().as_secs_f64() * 1000.0);
}
let total = start.elapsed().as_secs_f64() * 1000.0;
times.sort_by(|a, b| a.partial_cmp(b).unwrap());
let avg: f64 = times.iter().sum::<f64>() / times.len() as f64;
let p95 = times[(times.len() as f64 * 0.95) as usize];
println!(
"{frames} frames @ {}x{}: avg {avg:.2} ms p95 {p95:.2} ms max {:.2} ms total {total:.0} ms (first frame excluded from avg? no)",
size.0, size.1, times[times.len() - 1]
);
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.iter().any(|a| a == "--time") {
bench(&args);
return;
}
let out_dir = args
.first()
.cloned()
.unwrap_or_else(|| "preview".to_string());
std::fs::create_dir_all(&out_dir).expect("failed to create preview output dir");
let palette = theme::load_palette();
let background = background::Background::load(
&breadlock_ui::config::Background::default(),
&palette,
);
let scenes = [
// ---- Staggered entrance: clock leads, pill pops in last (overshoot).
Scene { name: "01-appear-start", appear_t: 0.0, ..Scene::default() },
Scene { name: "02-appear-clock", password_len: 4, appear_t: 0.25, ..Scene::default() },
Scene { name: "03-appear-pill", password_len: 4, appear_t: 0.55, ..Scene::default() },
// ---- Rest pose: empty pill showing the "Enter password" hint.
Scene { name: "04-rest-pose", t_secs: 0.5, ..Scene::default() },
// ---- Idle breath: glow peak on the pill (accent ring + deeper shadow).
Scene { name: "05-breathe-peak", breathe_t: 1.0, ..Scene::default() },
// ---- Typing: newest dot mid-pop, caret solid.
Scene { name: "06-typing-pop", password_len: 6, dot_pop_t: 0.4, keystroke_age: Some(0.2), ..Scene::default() },
// ---- Idle blink: two dots, caret lit (phase 0.36 → visible half-cycle).
Scene { name: "07-idle-blink", password_len: 2, ..Scene::default() },
// ---- Checking: status mid slide-in. Live submit() zeros the secret
// so password_len is 0 — don't fake a filled pill here.
Scene { name: "08-checking", status: Some("Checking…"), status_t: 0.5, password_len: 0, password: "", ..Scene::default() },
// ---- Wrong password: mid-shake, red pill, red status (settled).
Scene { name: "09-failed-shake", password_len: 6, failed: true, failed_t: 0.35, status: Some("Wrong password"), ..Scene::default() },
// ---- Success: green flash ring, dots cascading accent → white.
Scene { name: "10-success-flash", password_len: 6, unlock_t: 0.12, ..Scene::default() },
// ---- Unlock fade-out: chrome faded, parallax drift (clock furthest).
Scene { name: "11-unlock-fade", password_len: 6, unlock_t: 0.8, ..Scene::default() },
// ---- Minute rollover: old clock fading out above, new fading in below.
Scene { name: "12-clock-crossfade", clock: "12:35", clock_old: Some(("12:34", 0.5)), password_len: 4, ..Scene::default() },
// ---- Caps Lock on: chip above the pill.
Scene { name: "13-caps-lock", password_len: 4, caps_lock: true, ..Scene::default() },
// ---- Non-default layout: layout chip instead of caps.
Scene { name: "14-layout-2", password_len: 4, layout_index: 1, ..Scene::default() },
// ---- Hold-to-reveal: plain password characters instead of dots.
Scene { name: "15-reveal", password_len: 7, password: "hunter2", reveal: true, ..Scene::default() },
// ---- Idle auto-dim: deepened veil (rest pose + full idle dim).
Scene { name: "16-idle-dim", idle_dim: 1.0, ..Scene::default() },
// ---- Repeat failure: attempt counter in the status line.
Scene { name: "17-failed-3x", password_len: 6, failed: true, failed_t: 0.8, status: Some("Wrong password — 3 failed attempts"), ..Scene::default() },
// ---- D-Bus status: now-playing + battery under the clock.
Scene { name: "18-status-info", info: "The War on Drugs — Red Eyes · 87% · charging", ..Scene::default() },
];
let mut text = TextRenderer::new();
let mut count = 0;
for scene in &scenes {
let inputs = FrameInputs {
width: W,
height: H,
background: &background,
palette: &palette,
font_family: FONT,
clock_text: scene.clock,
date_text: scene.date,
clock_old: scene.clock_old,
password_len: scene.password_len,
password: scene.password,
reveal: scene.reveal,
caps_lock: scene.caps_lock,
layout_index: scene.layout_index,
idle_dim: scene.idle_dim,
failed: scene.failed,
failed_t: scene.failed_t,
dot_pop_t: scene.dot_pop_t,
keystroke_age: scene.keystroke_age,
t_secs: scene.t_secs,
breathe_t: scene.breathe_t,
status_t: scene.status_t,
status_text: scene.status,
info_text: scene.info,
appear_t: scene.appear_t,
unlock_t: scene.unlock_t,
smooth_pan: false,
};
let Some(pixmap) = compose(&mut text, &inputs) else {
eprintln!("compose returned None for scene {}", scene.name);
std::process::exit(1);
};
let path = format!("{}/{}.png", out_dir, scene.name);
pixmap
.save_png(&path)
.unwrap_or_else(|err| panic!("failed to write {path}: {err}"));
count += 1;
println!("wrote {path}");
}
println!("{count} frames → {out_dir}/");
}

View file

@ -10,11 +10,13 @@
//! locked (already-locked is `bread.lock.lock.done`). `breadlock listen`
//! is the unlocked-path subscriber: it starts this same binary the way
//! hypridle's `lock_cmd = breadlock` does, and treats unlock as already
//! unlocked (`bread.lock.unlock.done`). Session-level equivalents are
//! `loginctl lock-session` / `loginctl unlock-session`. Unlock never
//! calls compositor `unlock()` — that stays on the PAM path.
//! unlocked (`bread.lock.unlock.done`). If the locker is running, unlock
//! is `bread.lock.unlock.failed` — only PAM at the lock screen may
//! unlock. Super+L / hypridle remain `loginctl lock-session`. Bus unlock
//! never calls compositor `unlock()` or `loginctl unlock-session`.
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use bread_utils::bread_client::{BreadClient, BreadEvent, Subscription};
@ -29,6 +31,26 @@ pub const APP_ID: &str = "lock";
/// locker process can coexist. The locker itself uses [`APP_ID`].
pub const LISTEN_APP: &str = "lock-listen";
/// Set for the life of `run_lock` so [`locker_is_running`] is true without
/// a second `try_acquire("lock")` from the locker process (flock is
/// per-process, so that check would miss ourselves).
static LOCKER_RUNNING: AtomicBool = AtomicBool::new(false);
/// RAII flag: [`locker_is_running`] is true until this drops.
pub struct LockerRunningGuard;
impl Drop for LockerRunningGuard {
fn drop(&mut self) {
LOCKER_RUNNING.store(false, Ordering::SeqCst);
}
}
/// Mark this process as the locker for the life of the returned guard.
pub fn enter_lock_process() -> LockerRunningGuard {
LOCKER_RUNNING.store(true, Ordering::SeqCst);
LockerRunningGuard
}
pub fn emit_locked() {
BreadClient::connect(APP_ID).emit("bread.lock.locked", serde_json::json!({}));
}
@ -59,11 +81,10 @@ pub fn emit_unlock_failed(error: &str) {
);
}
/// True when another process holds the locker singleton — i.e. breadlock
/// is already locking this session. A `try_acquire` that succeeds is
/// released immediately; this is a check, not a claim.
/// True when this process is the locker, or another process holds the
/// locker singleton — i.e. breadlock is already locking this session.
pub fn locker_is_running() -> bool {
singleton_held(APP_ID)
LOCKER_RUNNING.load(Ordering::SeqCst) || singleton_held(APP_ID)
}
fn singleton_held(app: &str) -> bool {
@ -81,6 +102,8 @@ pub fn start_locker() -> Result<(), String> {
let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("breadlock"));
let mut child = Command::new(exe)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("failed to start breadlock: {e}"))?;
thread::spawn(move || {
@ -94,36 +117,23 @@ pub fn start_locker() -> Result<(), String> {
/// `ext-session-lock-v1` has been accepted — wait on `bread.lock.locked`
/// for the compositor confirmation.
pub fn honor_lock_command() {
honor_lock_command_with(start_locker);
honor_lock_command_with(locker_is_running(), start_locker);
}
/// Session-level unlock (`loginctl unlock-session` on the caller's
/// session). Does not send compositor `unlock` and does not skip PAM —
/// that stays on the typed-password path. `done` means the command was
/// acted on (or the session was already unlocked), not that
/// `ext-session-lock-v1` has been released — wait on
/// `bread.lock.unlocked` for the compositor confirmation.
fn unlock_session() -> Result<(), String> {
let status = Command::new("loginctl")
.arg("unlock-session")
.stdin(Stdio::null())
.status()
.map_err(|e| format!("failed to run loginctl unlock-session: {e}"))?;
if status.success() {
Ok(())
} else {
Err(format!("loginctl unlock-session exited with {status}"))
}
}
/// Payload on `bread.lock.unlock.failed` while the locker is running.
/// Bus clients cannot unlock; only PAM at the lock screen can.
const UNLOCK_REFUSED_WHILE_LOCKED: &str =
"bus unlock cannot bypass PAM; authenticate at the lock screen";
/// Honor `bread.command.lock.unlock`: already unlocked is success;
/// otherwise ask logind to unlock this session.
/// Honor `bread.command.lock.unlock`. Fail-secure: never compositor
/// `unlock()`, never `loginctl unlock-session`. Already unlocked is
/// `.done`; a running locker is `.failed`.
pub fn honor_unlock_command() {
honor_unlock_command_with(locker_is_running(), unlock_session);
honor_unlock_command_with(locker_is_running(), emit_unlock_done, emit_unlock_failed);
}
fn honor_lock_command_with(start: impl FnOnce() -> Result<(), String>) {
if locker_is_running() {
fn honor_lock_command_with(locked: bool, start: impl FnOnce() -> Result<(), String>) {
if locked {
tracing::info!("bread.command.lock.lock: already locked");
emit_lock_done();
return;
@ -140,32 +150,35 @@ fn honor_lock_command_with(start: impl FnOnce() -> Result<(), String>) {
}
}
fn honor_unlock_command_with(locked: bool, unlock: impl FnOnce() -> Result<(), String>) {
fn honor_unlock_command_with(
locked: bool,
emit_done: impl FnOnce(),
emit_failed: impl FnOnce(&str),
) {
if !locked {
tracing::info!("bread.command.lock.unlock: already unlocked");
emit_unlock_done();
emit_done();
return;
}
match unlock() {
Ok(()) => {
tracing::info!("bread.command.lock.unlock: loginctl unlock-session");
emit_unlock_done();
}
Err(error) => {
tracing::error!(%error, "bread.command.lock.unlock: failed");
emit_unlock_failed(&error);
}
}
tracing::error!(
error = UNLOCK_REFUSED_WHILE_LOCKED,
"bread.command.lock.unlock: refused while locked"
);
emit_failed(UNLOCK_REFUSED_WHILE_LOCKED);
}
/// Reacts to `bread.command.lock.*`. Unknown verbs are ignored, not stubbed.
pub fn handle_command(event: &BreadEvent) {
handle_command_with(event, honor_lock_command, honor_unlock_command);
}
fn handle_command_with(event: &BreadEvent, on_lock: impl FnOnce(), on_unlock: impl FnOnce()) {
let Some(verb) = event.event.strip_prefix("bread.command.lock.") else {
return;
};
match verb {
"lock" => honor_lock_command(),
"unlock" => honor_unlock_command(),
"lock" => on_lock(),
"unlock" => on_unlock(),
other => tracing::info!(verb = other, "ignoring unknown bread.command.lock verb"),
}
}
@ -181,6 +194,7 @@ pub fn subscribe_commands() -> Subscription {
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
fn event(name: &str) -> BreadEvent {
BreadEvent {
@ -192,9 +206,48 @@ mod tests {
#[test]
fn handle_command_ignores_unrecognized_verb() {
handle_command(&event("bread.command.lock.pin"));
handle_command(&event("bread.command.clip.clear"));
handle_command(&event("bread.lock.locked"));
let lock = Cell::new(false);
let unlock = Cell::new(false);
handle_command_with(
&event("bread.command.lock.pin"),
|| lock.set(true),
|| unlock.set(true),
);
handle_command_with(
&event("bread.command.clip.clear"),
|| lock.set(true),
|| unlock.set(true),
);
handle_command_with(
&event("bread.lock.locked"),
|| lock.set(true),
|| unlock.set(true),
);
assert!(!lock.get());
assert!(!unlock.get());
}
#[test]
fn handle_command_dispatches_only_lock_and_unlock() {
let lock = Cell::new(0u32);
let unlock = Cell::new(0u32);
handle_command_with(
&event("bread.command.lock.lock"),
|| lock.set(lock.get() + 1),
|| unlock.set(unlock.get() + 1),
);
handle_command_with(
&event("bread.command.lock.unlock"),
|| lock.set(lock.get() + 1),
|| unlock.set(unlock.get() + 1),
);
handle_command_with(
&event("bread.command.lock.pin"),
|| lock.set(lock.get() + 1),
|| unlock.set(unlock.get() + 1),
);
assert_eq!(lock.get(), 1);
assert_eq!(unlock.get(), 1);
}
#[test]
@ -216,32 +269,76 @@ mod tests {
}
#[test]
fn honor_lock_command_with_failed_start_does_not_panic() {
honor_lock_command_with(|| Err("boom".into()));
}
#[test]
fn honor_lock_command_with_successful_start_does_not_panic() {
honor_lock_command_with(|| Ok(()));
}
#[test]
fn honor_unlock_command_already_unlocked_does_not_call_loginctl() {
let called = std::cell::Cell::new(false);
honor_unlock_command_with(false, || {
called.set(true);
Err("should not run".into())
fn honor_lock_command_with_failed_start_runs_start() {
let started = Cell::new(false);
honor_lock_command_with(false, || {
started.set(true);
Err("boom".into())
});
assert!(!called.get());
assert!(started.get());
}
#[test]
fn honor_unlock_command_with_failed_loginctl_does_not_panic() {
honor_unlock_command_with(true, || Err("boom".into()));
fn honor_lock_command_with_successful_start_runs_start() {
let started = Cell::new(false);
honor_lock_command_with(false, || {
started.set(true);
Ok(())
});
assert!(started.get());
}
#[test]
fn honor_unlock_command_with_successful_loginctl_does_not_panic() {
honor_unlock_command_with(true, || Ok(()));
fn honor_lock_command_already_locked_does_not_start() {
let started = Cell::new(false);
honor_lock_command_with(true, || {
started.set(true);
Ok(())
});
assert!(!started.get());
}
#[test]
fn honor_unlock_command_already_unlocked_emits_done() {
let done = Cell::new(false);
let failed = Cell::new(false);
honor_unlock_command_with(false, || done.set(true), |_| failed.set(true));
assert!(done.get());
assert!(!failed.get());
}
#[test]
fn honor_unlock_command_while_locked_emits_failed_not_done() {
let done = Cell::new(false);
let failed = Cell::new(false);
honor_unlock_command_with(
true,
|| done.set(true),
|e| {
assert_eq!(e, UNLOCK_REFUSED_WHILE_LOCKED);
failed.set(true);
},
);
assert!(!done.get());
assert!(failed.get());
}
#[test]
fn honor_unlock_command_while_locked_error_mentions_pam() {
assert!(
UNLOCK_REFUSED_WHILE_LOCKED.contains("PAM"),
"bus unlock refusal must say it cannot bypass PAM, got {UNLOCK_REFUSED_WHILE_LOCKED:?}"
);
}
#[test]
fn enter_lock_process_makes_locker_is_running_true_without_singleton() {
let app = format!("breadlock-test-running-flag-{}", std::process::id());
assert!(!singleton_held(&app));
{
let _g = enter_lock_process();
assert!(LOCKER_RUNNING.load(Ordering::SeqCst));
}
assert!(!LOCKER_RUNNING.load(Ordering::SeqCst));
}
}

View file

@ -8,19 +8,73 @@ pub struct Config {
#[serde(flatten)]
pub appearance: Appearance,
pub input: Input,
pub animation: Animation,
pub status: Status,
}
/// System-status line under the clock (D-Bus). Both default on; they are
/// polled on a background thread and degrade silently when D-Bus or the
/// relevant service is unavailable.
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct Status {
/// Show the currently-playing MPRIS track under the clock.
pub now_playing: bool,
/// Show the upower battery percentage under the clock.
pub battery: bool,
}
impl Default for Status {
fn default() -> Self {
Self {
now_playing: true,
battery: true,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct Input {
/// How long the "wrong password" shake shows before input re-enables.
/// How long the red "wrong password" UI stays up. Input is not blocked
/// during this window — typing or Escape clears it immediately.
pub fail_timeout_ms: u64,
/// Hold `Tab` to reveal the typed password as plain characters instead
/// of dots. Off by default: plaintext would sit in compositor buffers
/// while held. Tab can never be part of a password (it produces no
/// utf8), so holding it is always safe to use as a reveal gesture.
pub reveal_hold: bool,
}
impl Default for Input {
fn default() -> Self {
Self {
fail_timeout_ms: 800,
reveal_hold: false,
}
}
}
/// Idle animation toggles. Everything here runs on a low-duty-cycle timer so
/// the software-rendered lock screen doesn't burn CPU while idle.
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct Animation {
/// Subtle glow pulse on the password pill every few seconds — proves the
/// screen is live, not frozen. Runs only during a short active window of
/// each cycle (see `BREATHE_*` in render.rs).
pub breathe: bool,
/// Deepen the dim veil after this many seconds of no keystrokes (0 =
/// off). A gentle extra darkening for OLED/burn-in and late-night
/// comfort; ramps in over a few seconds once the idle threshold hits.
pub idle_dim_after_secs: u64,
}
impl Default for Animation {
fn default() -> Self {
Self {
breathe: true,
idle_dim_after_secs: 0,
}
}
}
@ -46,6 +100,31 @@ mod tests {
assert_eq!(Config::default().input.fail_timeout_ms, 800);
}
#[test]
fn default_reveal_hold_is_off() {
assert!(!Config::default().input.reveal_hold);
}
#[test]
fn default_animation_breathe_is_on() {
assert!(Config::default().animation.breathe);
}
#[test]
fn status_defaults_on() {
let cfg = Config::default();
assert!(cfg.status.now_playing);
assert!(cfg.status.battery);
}
#[test]
fn status_can_be_turned_off() {
let toml = "[status]\nnow_playing = false\nbattery = false\n";
let cfg: Config = toml::from_str(toml).unwrap();
assert!(!cfg.status.now_playing);
assert!(!cfg.status.battery);
}
#[test]
fn flattened_appearance_parses_alongside_input() {
let toml = "[clock]\nformat = \"%H:%M:%S\"\n[input]\nfail_timeout_ms = 1200\n";

1025
breadlock/src/gpu.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -2,12 +2,13 @@ use smithay_client_toolkit::seat::keyboard::{
KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers,
};
use smithay_client_toolkit::seat::{Capability, SeatHandler, SeatState};
use std::time::Instant;
use wayland_client::protocol::{wl_keyboard, wl_seat, wl_surface};
use wayland_client::{Connection, QueueHandle};
use zeroize::Zeroize;
use crate::auth;
use crate::state::{AppState, AuthState};
use crate::state::{AppState, AuthState, PASSWORD_CAP};
impl SeatHandler for AppState {
fn seat_state(&mut self) -> &mut SeatState {
@ -24,48 +25,40 @@ impl SeatHandler for AppState {
capability: Capability,
) {
if capability == Capability::Keyboard && self.keyboard.is_none() {
// Plain `get_keyboard` never populates SCTK's internal repeat
// timer, so `KeyboardHandler::repeat_key` below only ever fires
// for compositors that implement server-side key repeat
// (wl_keyboard >= v10's "repeated" pseudo key-state) themselves —
// Hyprland does not reliably do this. `get_keyboard_with_repeat`
// registers SCTK's own client-side repeat timer driven by the
// compositor's `repeat_info` (delay/rate); if a compositor *does*
// do server-side repeat it advertises `rate = 0`, which this
// timer already treats as disabled, so the two mechanisms can't
// double-fire.
let repeat_qh = qh.clone();
let loop_handle = self.loop_handle.clone();
match self.seat_state.get_keyboard_with_repeat(
qh,
&seat,
None,
loop_handle,
Box::new(move |state: &mut AppState, _keyboard, event| {
state.handle_key(&repeat_qh, event);
}),
) {
Ok(keyboard) => self.keyboard = Some(keyboard),
Err(err) => tracing::error!(%err, "failed to bind keyboard"),
}
self.try_bind_keyboard(qh, &seat);
}
}
fn remove_capability(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_seat: wl_seat::WlSeat,
qh: &QueueHandle<Self>,
seat: wl_seat::WlSeat,
capability: Capability,
) {
if capability == Capability::Keyboard {
if capability != Capability::Keyboard {
return;
}
// Only release if THIS seat owns the bound keyboard.
if self.keyboard_seat.as_ref() != Some(&seat) {
return;
}
if let Some(keyboard) = self.keyboard.take() {
keyboard.release();
}
}
self.keyboard_seat = None;
self.bind_keyboard_from_available_seats(qh);
}
fn remove_seat(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _seat: wl_seat::WlSeat) {
fn remove_seat(&mut self, _conn: &Connection, qh: &QueueHandle<Self>, seat: wl_seat::WlSeat) {
if self.keyboard_seat.as_ref() != Some(&seat) {
return;
}
if let Some(keyboard) = self.keyboard.take() {
keyboard.release();
}
self.keyboard_seat = None;
self.bind_keyboard_from_available_seats(qh);
}
}
@ -85,11 +78,16 @@ impl KeyboardHandler for AppState {
fn leave(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
qh: &QueueHandle<Self>,
_keyboard: &wl_keyboard::WlKeyboard,
_surface: &wl_surface::WlSurface,
_serial: u32,
) {
// Tab-held then focus leave would otherwise leave plaintext on screen.
if self.reveal_held {
self.reveal_held = false;
self.redraw_all(qh);
}
}
fn press_key(
@ -117,31 +115,124 @@ impl KeyboardHandler for AppState {
fn release_key(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
qh: &QueueHandle<Self>,
_keyboard: &wl_keyboard::WlKeyboard,
_serial: u32,
_event: KeyEvent,
event: KeyEvent,
) {
// Letting go of the reveal key (Tab) drops the plain-text view back
// to dots. Any other release doesn't change state.
if event.keysym == Keysym::Tab && self.reveal_held {
self.reveal_held = false;
self.redraw_all(qh);
}
}
fn update_modifiers(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
qh: &QueueHandle<Self>,
_keyboard: &wl_keyboard::WlKeyboard,
_serial: u32,
_modifiers: Modifiers,
modifiers: Modifiers,
_raw_modifiers: RawModifiers,
_layout: u32,
layout: u32,
) {
let changed = self.caps_lock != modifiers.caps_lock || self.layout_index != layout;
self.caps_lock = modifiers.caps_lock;
self.layout_index = layout;
// A modifier update is still "activity" — it follows a key press, so
// don't let the idle auto-dim start counting while typing.
self.last_activity = Instant::now();
if changed {
self.redraw_all(qh);
}
}
}
impl AppState {
fn try_bind_keyboard(&mut self, qh: &QueueHandle<Self>, seat: &wl_seat::WlSeat) {
if self.keyboard.is_some() {
return;
}
// Plain `get_keyboard` never populates SCTK's internal repeat
// timer, so `KeyboardHandler::repeat_key` below only ever fires
// for compositors that implement server-side key repeat
// (wl_keyboard >= v10's "repeated" pseudo key-state) themselves —
// Hyprland does not reliably do this. `get_keyboard_with_repeat`
// registers SCTK's own client-side repeat timer driven by the
// compositor's `repeat_info` (delay/rate); if a compositor *does*
// do server-side repeat it advertises `rate = 0`, which this
// timer already treats as disabled, so the two mechanisms can't
// double-fire.
let repeat_qh = qh.clone();
let loop_handle = self.loop_handle.clone();
match self.seat_state.get_keyboard_with_repeat(
qh,
seat,
None,
loop_handle,
Box::new(move |state: &mut AppState, _keyboard, event| {
state.handle_key(&repeat_qh, event);
}),
) {
Ok(keyboard) => {
self.keyboard = Some(keyboard);
self.keyboard_seat = Some(seat.clone());
}
Err(err) => tracing::error!(%err, "failed to bind keyboard"),
}
}
fn bind_keyboard_from_available_seats(&mut self, qh: &QueueHandle<Self>) {
if self.keyboard.is_some() {
return;
}
let seats: Vec<wl_seat::WlSeat> = self.seat_state.seats().collect();
for seat in seats {
if self.keyboard.is_some() {
return;
}
if self
.seat_state
.info(&seat)
.is_some_and(|info| info.has_keyboard)
{
self.try_bind_keyboard(qh, &seat);
}
}
}
fn handle_key(&mut self, qh: &QueueHandle<Self>, event: KeyEvent) {
// Ignore all input while a PAM check is in flight so a fast second
// Enter can't race the first attempt.
// Unlock fade: auth already succeeded; surfaces stay up until it ends.
if self.unlocking.is_some() {
return;
}
// Escape during Checking cancels the wait (generation bump so a
// late PAM result cannot unlock). libpam itself is not aborted.
if self.auth_state == AuthState::Checking {
if event.keysym == Keysym::Escape {
self.auth_generation = self.auth_generation.wrapping_add(1);
self.auth_state = AuthState::Idle;
self.checking_started = None;
self.password_display_len = 0;
self.last_activity = Instant::now();
self.redraw_all(qh);
}
return;
}
// Any key counts as activity — it resets the idle auto-dim ramp even
// when it doesn't change the password (e.g. pressing Enter on an
// empty field).
self.last_activity = Instant::now();
// Hold-to-reveal (Tab): show the plain characters while held. Tab
// itself produces no utf8, so it can't corrupt the password.
if event.keysym == Keysym::Tab && self.config.input.reveal_hold {
self.reveal_held = true;
self.redraw_all(qh);
return;
}
@ -168,6 +259,7 @@ impl AppState {
}
Keysym::Escape => {
self.password.zeroize();
self.password_display_len = 0;
self.clear_failed_state();
}
_ => {
@ -175,20 +267,39 @@ impl AppState {
// Return/BackSpace/Escape are handled above by keysym;
// this guards against a compositor also sending utf8 for
// those (defensive — filters any stray control chars).
let mut grew = false;
for ch in text.chars().filter(|c| !c.is_control()) {
self.password.push(ch);
if try_push_password(&mut self.password, ch) {
grew = true;
} else {
break;
}
}
if grew {
// Only keystrokes that *grew* the password re-prime the
// newest-dot pop-in and the caret's solid phase (see the
// `last_keystroke` field doc in state.rs).
self.last_keystroke = Some(Instant::now());
self.clear_failed_state();
}
}
}
}
self.redraw_all(qh);
}
fn clear_failed_state(&mut self) {
if matches!(self.auth_state, AuthState::Failed | AuthState::ConfigError) {
if matches!(
self.auth_state,
AuthState::Failed | AuthState::AccountInvalid | AuthState::ConfigError
) {
self.auth_state = AuthState::Idle;
// Drop the red-pill tint and shake offsets; `failed_at` is also
// cleared so `schedule_clear_failed`'s timer is a no-op unless
// its generation still matches a later fail.
self.failed_at = None;
self.password_display_len = 0;
}
}
@ -196,7 +307,14 @@ impl AppState {
if self.password.is_empty() {
return;
}
if self.username.is_empty() {
self.enter_fail(AuthState::ConfigError);
return;
}
self.password_display_len = password_char_count(&self.password);
self.auth_state = AuthState::Checking;
self.checking_started = Some(Instant::now());
self.auth_generation = self.auth_generation.wrapping_add(1);
// Hand ownership of the buffer to the auth thread; re-reserve
// capacity up front so the next password typed doesn't reallocate
// (see the `password` field doc in state.rs). The taken buffer is
@ -204,8 +322,61 @@ impl AppState {
// check (`auth::spawn_check`/`pam::check`).
let password = std::mem::replace(
&mut self.password,
zeroize::Zeroizing::new(String::with_capacity(128)),
zeroize::Zeroizing::new(String::with_capacity(PASSWORD_CAP)),
);
auth::spawn_check(
self.username.clone(),
password,
self.auth_generation,
self.auth_tx.clone(),
);
auth::spawn_check(self.username.clone(), password, self.auth_tx.clone());
}
}
/// Push `ch` only if it fits in the already-reserved capacity (no realloc,
/// so an old unzeroized heap buffer is never leaked).
pub(crate) fn try_push_password(password: &mut String, ch: char) -> bool {
let extra = ch.len_utf8();
if password.len().saturating_add(extra) > password.capacity() {
return false;
}
password.push(ch);
true
}
/// Character count for the password pill — never `String::len()` (UTF-8).
pub(crate) fn password_char_count(password: &str) -> usize {
password.chars().count()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn password_cap_ignores_push_that_would_realloc() {
let mut s = String::with_capacity(8);
assert!(try_push_password(&mut s, 'a'));
while try_push_password(&mut s, 'x') {}
let cap = s.capacity();
let len = s.len();
assert!(!try_push_password(&mut s, 'y'));
assert_eq!(s.len(), len);
assert_eq!(s.capacity(), cap);
}
#[test]
fn password_char_count_is_not_byte_len() {
let mut s = String::with_capacity(16);
assert!(try_push_password(&mut s, 'é'));
assert_eq!(s.len(), 2);
assert_eq!(password_char_count(&s), 1);
}
#[test]
fn reserved_capacity_is_256() {
assert_eq!(PASSWORD_CAP, 256);
let s = String::with_capacity(PASSWORD_CAP);
assert!(s.capacity() >= PASSWORD_CAP);
}
}

View file

@ -1 +1 @@
mod keyboard;
pub(crate) mod keyboard;

View file

@ -16,14 +16,27 @@ impl SessionLockHandler for AppState {
/// from under us (e.g. protocol error). Either way there's no lock left
/// to protect, so the only sane move is to exit — staying resident
/// unlocked would be worse than not running at all.
///
/// If `locked` already arrived, dropping the object sends `destroy()`
/// which is a protocol error; send `unlock_and_destroy` first.
fn finished(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_session_lock: SessionLock,
) {
tracing::warn!("compositor ended the session lock; exiting");
self.session_lock = None;
// PAM unlock already took the stored lock; don't unlock/emit again.
let Some(lock) = self.session_lock.take() else {
self.exit = true;
return;
};
if lock.is_locked() {
tracing::warn!("compositor ended an active session lock; unlocking then exiting");
lock.unlock();
crate::bread_events::emit_unlocked();
} else {
tracing::warn!("compositor ended the session lock before it was acquired; exiting");
}
self.exit = true;
}
@ -36,14 +49,29 @@ impl SessionLockHandler for AppState {
_serial: u32,
) {
let (width, height) = configure.new_size;
if let Some(s) = self
let (buf_w, buf_h) = if let Some(s) = self
.surfaces
.iter_mut()
.find(|s| s.surface.wl_surface() == surface.wl_surface())
{
s.width = width;
s.height = height;
}
self.redraw_surface(qh, &surface, width, height);
let scale = s.scale.max(1);
surface.wl_surface().set_buffer_scale(scale);
let buf_w = width.saturating_mul(scale as u32);
let buf_h = height.saturating_mul(scale as u32);
// Lazily wrap the surface in EGL on its first (sized) configure;
// resize the EGL window on subsequent ones. Size is buffer pixels.
if let Some(renderer) = &self.gpu {
match &mut s.gpu {
None => s.gpu = renderer.create_surface(surface.wl_surface(), buf_w, buf_h),
Some(gs) => gs.resize(buf_w, buf_h),
}
}
(buf_w, buf_h)
} else {
(width, height)
};
self.redraw_surface(qh, &surface, buf_w, buf_h);
}
}

View file

@ -9,10 +9,30 @@ impl CompositorHandler for AppState {
fn scale_factor_changed(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_surface: &wl_surface::WlSurface,
_new_factor: i32,
qh: &QueueHandle<Self>,
surface: &wl_surface::WlSurface,
new_factor: i32,
) {
// Protocol: buffer scale must be > 0. Treat 0 (or negative) as 1.
let scale = new_factor.max(1);
let (lock_surface, width, height) = {
let Some(s) = self
.surfaces
.iter_mut()
.find(|s| s.surface.wl_surface() == surface)
else {
return;
};
s.scale = scale;
surface.set_buffer_scale(scale);
let width = s.width.saturating_mul(scale as u32);
let height = s.height.saturating_mul(scale as u32);
if let Some(gs) = s.gpu.as_mut() {
gs.resize(width, height);
}
(s.surface.clone(), width, height)
};
self.redraw_surface(qh, &lock_surface, width, height);
}
fn transform_changed(
@ -66,6 +86,12 @@ impl OutputHandler for AppState {
qh: &QueueHandle<Self>,
output: wl_output::WlOutput,
) {
// SCTK also fires `new_output` for outputs already bound at
// registry-init; `main` already created a lock surface for those.
// One lock surface per output is a protocol requirement.
if self.surfaces.iter().any(|s| s.output == output) {
return;
}
let Some(session_lock) = self.session_lock.clone() else {
return;
};
@ -76,6 +102,10 @@ impl OutputHandler for AppState {
output,
width: 0,
height: 0,
scale: 1,
gpu: None,
shm_pool: None,
shm_buffer: None,
});
}

View file

@ -2,10 +2,12 @@ mod auth;
mod background;
mod bread_events;
mod config;
mod gpu;
mod input;
mod lock;
mod render;
mod state;
mod status;
use smithay_client_toolkit::compositor::CompositorState;
use smithay_client_toolkit::output::OutputState;
@ -53,7 +55,8 @@ fn print_usage() {
(no args) lock this session hypridle lock_cmd / Super+L via loginctl lock-session\n\
listen subscribe to bread.command.lock.lock / unlock so both work while unlocked\n\
\n\
Session-level: loginctl lock-session / unlock-session.\n\
Session-level lock: loginctl lock-session (hypridle then runs breadlock).\n\
Bus unlock does not replace PAM type the password at the lock screen.\n\
See EVENTS.md for the bus contract."
);
}
@ -78,8 +81,9 @@ fn main() {
/// Long-running subscriber so `bread.command.lock.lock` / `.unlock` work
/// while the session is unlocked. The locker process also subscribes;
/// this path is what actually starts breadlock (the same no-args
/// invocation hypridle uses) and what runs `loginctl unlock-session`
/// when a locker is up. One listen process per session.
/// invocation hypridle uses). Unlock while a locker is running is
/// refused (`.failed`); only PAM may unlock. One listen process per
/// session.
fn run_listen() {
let _guard = match try_acquire(bread_events::LISTEN_APP) {
Ok(Acquire::Acquired(g)) => g,
@ -127,24 +131,37 @@ fn run_lock() {
None
}
};
let _running = bread_events::enter_lock_process();
// Honor bread.command.lock.lock / unlock while this locker is up
// (already-locked is bread.lock.lock.done; unlock is loginctl, not
// compositor unlock()). Unlocked-path commands need `breadlock listen`.
// (already-locked is bread.lock.lock.done; unlock is .failed —
// never compositor unlock() or loginctl). Unlocked-path commands
// need `breadlock listen`.
let _commands = bread_events::subscribe_commands();
let username = std::env::var("USER")
.or_else(|_| std::env::var("LOGNAME"))
.unwrap_or_else(|_| {
tracing::error!("neither $USER nor $LOGNAME is set — refusing to start without a username to authenticate");
std::process::exit(1);
let username = auth::username_from_process().unwrap_or_else(|| {
tracing::error!(
"could not resolve a username (passwd lookup and $USER/$LOGNAME all failed) — \
taking the session lock anyway and refusing PAM"
);
String::new()
});
let username_missing = username.is_empty();
let config = config::load();
let palette = breadlock_ui::theme::load_palette();
let background = Background::load(&config.appearance.background, &palette);
let conn = Connection::connect_to_env().expect("failed to connect to the Wayland display — breadlock must run inside an active Wayland session");
// GPU background rendering (EGL/GLES2). Any failure is non-fatal: the
// software renderer takes over. `run_lock` is only ever entered in Lock
// mode (the listen subscriber never renders), so no mode check here.
let gpu = gpu::GpuRenderer::new(&conn, &config.appearance.background, &palette);
if gpu.is_some() {
tracing::info!("GPU background rendering enabled (EGL/GLES2)");
} else {
tracing::warn!("GPU background rendering unavailable — using the software renderer");
}
let (globals, event_queue) =
registry_queue_init::<AppState>(&conn).expect("failed to initialize Wayland registry");
let qh: QueueHandle<AppState> = event_queue.handle();
@ -153,15 +170,22 @@ fn run_lock() {
let loop_handle = event_loop.handle();
let auth_result_qh = qh.clone();
let auth_tx = auth::register(&loop_handle, move |state: &mut AppState, result| {
let auth_tx = auth::register(&loop_handle, move |state: &mut AppState, generation, result| {
if generation != state.auth_generation {
return;
}
match result {
Ok(()) => {
tracing::info!("authenticated, unlocking");
if let Some(lock) = state.session_lock.take() {
lock.unlock();
bread_events::emit_unlocked();
// Keep the lock surfaces up and fade the overlay out.
// Compositor unlock() runs only after UNLOCK_MS — dying
// mid-fade is fail-secure (session stays locked).
tracing::info!("authenticated, fading out");
state.failed_attempts = 0;
state.auth_state = AuthState::Idle;
state.checking_started = None;
if state.unlocking.is_none() {
state.unlocking = Some(std::time::Instant::now());
}
state.exit = true;
}
Err(err) => {
match err {
@ -175,11 +199,16 @@ fn run_lock() {
%err,
"PAM context initialization failed — check /etc/pam.d/breadlock exists and is valid; authentication cannot succeed until this is fixed"
);
state.auth_state = AuthState::ConfigError;
state.enter_fail(AuthState::ConfigError);
}
auth::AuthError::Authenticate | auth::AuthError::AccountInvalid => {
auth::AuthError::Authenticate => {
tracing::warn!(%err, "authentication failed");
state.auth_state = AuthState::Failed;
state.failed_attempts = state.failed_attempts.saturating_add(1);
state.enter_fail(AuthState::Failed);
}
auth::AuthError::AccountInvalid => {
tracing::warn!(%err, "account locked or expired");
state.enter_fail(AuthState::AccountInvalid);
}
}
state.schedule_clear_failed(auth_result_qh.clone());
@ -188,6 +217,17 @@ fn run_lock() {
state.redraw_all(&auth_result_qh);
});
// D-Bus status (now-playing / battery): the poller posts snapshots here
// and each one triggers a redraw so the line under the clock stays live.
let status_qh = qh.clone();
let status_tx = status::register(&loop_handle, move |state: &mut AppState, info| {
if state.status_info != info {
state.status_info = info;
state.redraw_all(&status_qh);
}
});
status::spawn_poller(status_tx, config.status.now_playing, config.status.battery);
let compositor_state =
CompositorState::bind(&globals, &qh).expect("compositor global not advertised");
let output_state = OutputState::new(&globals, &qh);
@ -206,17 +246,44 @@ fn run_lock() {
session_lock: None,
surfaces: Vec::new(),
keyboard: None,
keyboard_seat: None,
config,
palette,
background,
gpu,
text_renderer: breadlock_ui::painter::TextRenderer::new(),
username,
// Pre-reserve capacity so ordinary typing doesn't reallocate — a
// reallocation leaves the old (unzeroized) backing buffer, with the
// password bytes still in it, on the heap.
password: zeroize::Zeroizing::new(String::with_capacity(128)),
password: zeroize::Zeroizing::new(String::with_capacity(state::PASSWORD_CAP)),
password_display_len: 0,
auth_state: AuthState::Idle,
auth_tx,
auth_generation: 0,
failed_generation: 0,
checking_started: None,
started: std::time::Instant::now(),
appear_started: None,
unlocking: None,
last_keystroke: None,
failed_at: None,
last_clock_text: String::new(),
clock_from: None,
status_anim_started: None,
last_auth_state: AuthState::Idle,
breathe_started: None,
breathe_next_at: Some(
std::time::Instant::now()
+ std::time::Duration::from_millis(render::BREATHE_INITIAL_DELAY_MS),
),
anim_timer_armed: false,
caps_lock: false,
layout_index: 0,
reveal_held: false,
last_activity: std::time::Instant::now(),
failed_attempts: 0,
status_info: status::StatusInfo::default(),
exit: false,
};
@ -236,10 +303,18 @@ fn run_lock() {
output,
width: 0,
height: 0,
scale: 1,
gpu: None,
shm_pool: None,
shm_buffer: None,
});
}
app_state.session_lock = Some(session_lock);
if username_missing {
app_state.enter_fail(AuthState::ConfigError);
}
WaylandSource::new(conn, event_queue)
.insert(loop_handle.clone())
.expect("failed to register the Wayland source on the event loop");
@ -272,7 +347,13 @@ fn run_lock() {
let mut consecutive_errors = 0u32;
while !app_state.exit {
match event_loop.dispatch(Duration::from_millis(250), &mut app_state) {
Ok(()) => consecutive_errors = 0,
Ok(()) => {
consecutive_errors = 0;
// Backup if the 16ms anim timer failed to register: the
// 250ms dispatch timeout (or the 1s clock tick) still
// completes a finished unlock fade.
app_state.complete_unlock_if_ready();
}
Err(err) => {
consecutive_errors += 1;
tracing::error!(

File diff suppressed because it is too large Load diff

View file

@ -7,16 +7,21 @@ use smithay_client_toolkit::registry::{ProvidesRegistryState, RegistryState};
use smithay_client_toolkit::registry_handlers;
use smithay_client_toolkit::seat::SeatState;
use smithay_client_toolkit::session_lock::{SessionLock, SessionLockState, SessionLockSurface};
use smithay_client_toolkit::shm::slot::{Buffer, SlotPool};
use smithay_client_toolkit::shm::{Shm, ShmHandler};
use std::time::Duration;
use wayland_client::protocol::{wl_keyboard, wl_output, wl_shm};
use std::time::{Duration, Instant};
use wayland_client::protocol::{wl_keyboard, wl_output, wl_seat, wl_shm};
use wayland_client::{Connection, QueueHandle};
use crate::auth::AuthResult;
use crate::auth::AuthOutcome;
use crate::background::Background;
use crate::config::Config;
use crate::render;
/// Reserved password buffer size. Typing past this is ignored so `String`
/// never reallocates (an old unzeroized heap buffer would leak).
pub(crate) const PASSWORD_CAP: usize = 256;
/// Per-output lock surface plus the size the compositor last `configure`d it
/// to (0x0 until the first configure arrives). `output` is kept so
/// `output_destroyed` can find and drop the surface belonging to an unplugged
@ -26,23 +31,36 @@ pub struct LockSurface {
pub output: wl_output::WlOutput,
pub width: u32,
pub height: u32,
/// `wl_surface` buffer scale. 1 until `scale_factor_changed`. Always >= 1.
pub scale: i32,
/// EGL-backed renderer for this surface (created on first `configure`);
/// `None` when the GPU path is unavailable, in which case the software
/// wl_shm path is used.
pub gpu: Option<crate::gpu::GpuSurface>,
/// Reused shm pool + current buffer (software path). Not recreated every
/// frame; SlotPool waits for compositor release before reuse.
pub shm_pool: Option<SlotPool>,
pub shm_buffer: Option<Buffer>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthState {
Idle,
/// A PAM check is running on its own thread; input is ignored until it
/// resolves so a second Enter can't race the first attempt.
/// A PAM check is running on its own thread; input other than Escape is
/// ignored until it resolves so a second Enter can't race the first
/// attempt. Escape cancels the wait (the in-flight libpam call is not
/// aborted; its result is ignored).
Checking,
/// The password (or account state) was rejected by PAM — an ordinary
/// wrong-password/locked-account outcome the user can retry.
/// The password was rejected by PAM — an ordinary wrong-password
/// outcome the user can retry. Input is not blocked.
Failed,
/// PAM `acct_mgmt` rejected the account (locked, expired, etc.).
AccountInvalid,
/// PAM itself failed to initialize (e.g. `/etc/pam.d/breadlock` is
/// missing or unreadable) — this is a config/deployment problem, not
/// something the user's password can fix. Rendered with a distinct
/// message so a broken install doesn't look like an endless string of
/// typos with no way to discover the real cause. See `main.rs`'s
/// auth-result callback, which is the only place this is set.
/// missing or unreadable), or the process username could not be
/// resolved — a config/deployment problem, not something the user's
/// password can fix. Rendered with a distinct message so a broken
/// install doesn't look like an endless string of typos.
ConfigError,
}
@ -58,10 +76,16 @@ pub struct AppState {
pub session_lock: Option<SessionLock>,
pub surfaces: Vec<LockSurface>,
pub keyboard: Option<wl_keyboard::WlKeyboard>,
/// Seat that owns [`Self::keyboard`]. `remove_capability` only releases
/// the keyboard if that seat lost Keyboard.
pub keyboard_seat: Option<wl_seat::WlSeat>,
pub config: Config,
pub palette: breadlock_ui::theme::Palette,
pub background: Background,
/// GPU background renderer (EGL/GLES2). `None` falls back to the
/// fully-software path.
pub gpu: Option<crate::gpu::GpuRenderer>,
pub text_renderer: breadlock_ui::painter::TextRenderer,
pub username: String,
@ -72,8 +96,77 @@ pub struct AppState {
/// explicit zeroing — see `input/keyboard.rs` — since `Zeroizing` only
/// hooks `Drop`, not in-place mutation.
pub password: zeroize::Zeroizing<String>,
/// Character count shown in the pill after submit (secret already
/// moved to the auth thread). Used until Idle or the user types again.
pub password_display_len: usize,
pub auth_state: AuthState,
pub auth_tx: Sender<AuthResult>,
pub auth_tx: Sender<AuthOutcome>,
/// Bumped on each submit / Escape-cancel. Late PAM results whose
/// generation does not match are ignored.
pub auth_generation: u64,
/// Bumped each time Failed / AccountInvalid / ConfigError is set.
/// The fail-clear timer captures this and only clears if it still matches.
pub failed_generation: u64,
/// When the current PAM check entered Checking — drives `checking_dots`.
pub checking_started: Option<Instant>,
/// Monotonic clock reference — drives the idle caret blink cadence.
pub started: Instant,
/// First-frame timestamp for the lock-appear animation. `None` until
/// the first non-degenerate redraw so the fade starts when the surface
/// is actually visible, not when the process starts.
pub appear_started: Option<Instant>,
/// Set on PAM success. While `Some`, lock surfaces stay up and the
/// overlay fades out; compositor `unlock()` happens only after the
/// fade completes. Dying mid-fade leaves the session locked (fail-secure).
pub unlocking: Option<Instant>,
/// Timestamp of the most recent keystroke that grew the password — drives
/// the newest-dot pop-in and the caret's solid-then-blink behavior.
pub last_keystroke: Option<Instant>,
/// When the failed state was entered — drives the wrong-password shake.
/// Cleared (with the failed state) by typing or `fail_timeout_ms`.
pub failed_at: Option<Instant>,
/// Clock text drawn last frame; a change starts a minute-rollover
/// crossfade instead of a hard text swap.
pub last_clock_text: String,
/// Outgoing clock string + when its crossfade started. Kept until the
/// fade completes so later frames still pass the previous string.
pub clock_from: Option<(String, Instant)>,
/// When the current status line appeared ("Checking…" / "Wrong password") —
/// drives its slide-in. Reset whenever `auth_state` changes (see
/// `last_auth_state`).
pub status_anim_started: Option<Instant>,
/// The `auth_state` from the last frame — a change resets the status
/// slide-in so a freshly appearing status rises in instead of popping.
pub last_auth_state: AuthState,
/// When the current idle-breath window started (glow pulse). `None`
/// between breaths.
pub breathe_started: Option<Instant>,
/// When the next idle-breath window is due — the 1s clock tick arms the
/// animation timer once it's due, so idle CPU stays near zero.
pub breathe_next_at: Option<Instant>,
/// True while a ~16ms animation timer is registered on the event loop.
pub anim_timer_armed: bool,
/// Caps Lock is on (from the last keyboard modifier update) — drives the
/// small "Caps Lock" chip so the user isn't mystified by uppercase-only
/// input. Stale until the first modifier update arrives.
pub caps_lock: bool,
/// Active keyboard layout index (0-based) — shown next to the caps chip
/// when a non-default layout is selected.
pub layout_index: u32,
/// True while the user holds the reveal key (Tab) — dots render as the
/// plain characters while held.
pub reveal_held: bool,
/// Last keystroke/activity timestamp — drives the idle auto-dim ramp
/// (`animation.idle_dim_after_secs`). Any key press resets it.
pub last_activity: Instant,
/// Consecutive failed password attempts this session — drives the
/// "N failed attempts" status line. Reset on a successful auth.
pub failed_attempts: u32,
/// Latest D-Bus snapshot (now-playing / battery) from the status poller.
/// Empty fields render nothing; replaced wholesale on each poll.
pub status_info: crate::status::StatusInfo,
pub exit: bool,
}
@ -93,62 +186,318 @@ impl AppState {
return;
}
if self.appear_started.is_none() {
self.appear_started = Some(Instant::now());
}
let now = Instant::now();
let clock_text = chrono::Local::now()
.format(&self.config.appearance.clock.format)
.to_string();
let status_text = match self.auth_state {
AuthState::Checking => Some("Checking…".to_string()),
AuthState::Failed => Some("Wrong password".to_string()),
AuthState::ConfigError => {
Some("PAM config error — check logs (breadlock service not set up correctly)".to_string())
let date_text = chrono::Local::now()
.format(&self.config.appearance.clock.date_format)
.to_string();
// A status line appearing (or changing) resets its slide-in.
if self.auth_state != self.last_auth_state {
self.status_anim_started = Some(now);
self.last_auth_state = self.auth_state;
}
// Idle breath: one sine hump over the active window. When the window
// ends, schedule the next one a full period out (the 1s clock tick
// re-arms the animation timer once it's due).
let breathe_t = if let Some(started) = self.breathe_started {
let p = render::unit_progress(started, render::BREATHE_ACTIVE_MS);
if p >= 1.0 {
self.breathe_started = None;
self.breathe_next_at =
Some(started + Duration::from_millis(render::BREATHE_PERIOD_MS));
0.0
} else {
render::breathe_envelope(p)
}
} else {
0.0
};
// While a PAM check runs, the status dots tick to signal progress.
let status_text = match self.auth_state {
AuthState::Checking => {
let started = self.checking_started.unwrap_or(now);
Some(format!("Checking{}", checking_dots(started)))
}
AuthState::Failed => {
// Repeat failures get a counter so the user can tell the
// locker apart from a stuck/corrupt one ("Wrong password"
// alone reads identically every time).
let n = self.failed_attempts.max(1);
Some(if n > 1 {
format!("Wrong password — {n} failed attempts")
} else {
"Wrong password".to_string()
})
}
AuthState::AccountInvalid => Some("Account locked or expired".to_string()),
AuthState::ConfigError => Some(
"PAM config error — check logs (breadlock service not set up correctly)"
.to_string(),
),
AuthState::Idle => None,
};
// D-Bus status line under the clock: now-playing and/or battery,
// joined with a dot separator. Fades in with the appear animation
// (render.rs keys `info_text` off `appear_t`, so no per-frame state
// is needed here).
let mut info_parts: Vec<&str> = Vec::new();
if self.config.status.now_playing && !self.status_info.now_playing.is_empty() {
info_parts.push(&self.status_info.now_playing);
}
if self.config.status.battery && !self.status_info.battery.is_empty() {
info_parts.push(&self.status_info.battery);
}
let info_text = info_parts.join(" · ");
// Idle auto-dim: ramp 0..1 over IDLE_DIM_RAMP_MS once the configured
// idle threshold elapses with no keystrokes. 0 when disabled.
let idle_dim = if self.config.animation.idle_dim_after_secs > 0 {
let idle_s = self.last_activity.elapsed().as_secs_f64()
- self.config.animation.idle_dim_after_secs as f64;
if idle_s <= 0.0 {
0.0
} else {
(idle_s / (render::IDLE_DIM_RAMP_MS as f64 / 1000.0)).min(1.0) as f32
}
} else {
0.0
};
let status_t = self
.status_anim_started
.map(|t| render::unit_progress(t, render::STATUS_SLIDE_MS))
.unwrap_or(1.0);
// Minute rollover: keep the previous clock text in `clock_from`
// until the crossfade completes. Do not overwrite the outgoing string.
if let Some((_, started)) = self.clock_from {
if render::unit_progress(started, render::CLOCK_CROSSFADE_MS) >= 1.0 {
self.clock_from = None;
}
}
if self.clock_from.is_none()
&& !self.last_clock_text.is_empty()
&& clock_text != self.last_clock_text
{
self.clock_from = Some((self.last_clock_text.clone(), now));
}
self.last_clock_text = clock_text.clone();
let clock_old = self.clock_from.as_ref().map(|(from, started)| {
(
from.as_str(),
render::unit_progress(*started, render::CLOCK_CROSSFADE_MS),
)
});
let appear_t = self
.appear_started
.map(|t| render::unit_progress(t, render::APPEAR_MS))
.unwrap_or(0.0);
let unlock_t = self
.unlocking
.map(|t| render::unit_progress(t, render::UNLOCK_MS))
.unwrap_or(0.0);
let failed_t = self
.failed_at
.map(|t| render::unit_progress(t, render::SHAKE_MS))
.unwrap_or(0.0);
let dot_pop_t = self
.last_keystroke
.map(|t| render::unit_progress(t, render::DOT_POP_MS))
.unwrap_or(1.0);
let password_len = if self.password.is_empty() {
self.password_display_len
} else {
self.password.chars().count()
};
let output_palette = self.palette_for_surface(surface);
let inputs = render::FrameInputs {
width,
height,
background: &self.background,
palette: &self.palette,
palette: &output_palette,
font_family: &self.config.appearance.font.family,
clock_text: &clock_text,
password_len: self.password.len(),
failed: matches!(self.auth_state, AuthState::Failed | AuthState::ConfigError),
date_text: &date_text,
clock_old,
password_len,
password: &self.password,
reveal: self.reveal_held,
caps_lock: self.caps_lock,
layout_index: self.layout_index,
idle_dim,
failed: matches!(
self.auth_state,
AuthState::Failed | AuthState::AccountInvalid | AuthState::ConfigError
),
failed_t,
dot_pop_t,
keystroke_age: self.last_keystroke.map(|t| t.elapsed().as_secs_f32()),
t_secs: self.started.elapsed().as_secs_f32(),
breathe_t,
status_t,
status_text: status_text.as_deref(),
info_text: &info_text,
appear_t,
unlock_t,
smooth_pan: !self.fast_anim_in_progress(),
};
// GPU path: the EGL surface renders the wallpaper (pan/veil in the
// shader) and the software-composed chrome on top. Disjoint-field
// borrows of `self` make `gpu` + `surfaces` + `text_renderer`
// simultaneously mutable.
let wants_gpu = self.gpu.is_some()
&& self
.surfaces
.iter()
.any(|s| s.surface.wl_surface() == surface.wl_surface() && s.gpu.is_some());
if wants_gpu {
let Some(renderer) = self.gpu.as_mut() else {
return;
};
let Some(lock_surface) = self
.surfaces
.iter_mut()
.find(|s| s.surface.wl_surface() == surface.wl_surface())
else {
return;
};
let Some(gpu_surface) = lock_surface.gpu.as_mut() else {
return;
};
if renderer.render_frame(gpu_surface, &inputs, &mut self.text_renderer) {
self.arm_anim_if_needed(qh);
return;
}
tracing::warn!("GPU frame failed — dropping EGL window and falling back to software");
}
// An EGL window on this wl_surface makes a later shm attach illegal;
// Drop of GpuSurface destroys the native window first.
if wants_gpu {
if let Some(s) = self
.surfaces
.iter_mut()
.find(|s| s.surface.wl_surface() == surface.wl_surface())
{
s.gpu = None;
}
}
let Some(pixmap) = render::compose(&mut self.text_renderer, &inputs) else {
return;
};
let stride = width as usize * 4;
let pool =
smithay_client_toolkit::shm::raw::RawPool::new(stride * height as usize, &self.shm);
let mut pool = match pool {
Ok(pool) => pool,
self.present_shm(surface, width, height, &pixmap);
self.arm_anim_if_needed(qh);
}
fn present_shm(
&mut self,
surface: &SessionLockSurface,
width: u32,
height: u32,
pixmap: &tiny_skia::Pixmap,
) {
let Some(px) = (width as usize).checked_mul(height as usize) else {
return;
};
let Some(len) = px.checked_mul(4) else {
return;
};
if len == 0 {
return;
}
if width > i32::MAX as u32 || height > i32::MAX as u32 {
return;
}
let stride = match (width as usize).checked_mul(4) {
Some(s) if s <= i32::MAX as usize => s as i32,
_ => return,
};
let idx = self
.surfaces
.iter()
.position(|s| s.surface.wl_surface() == surface.wl_surface());
let Some(idx) = idx else {
return;
};
if self.surfaces[idx].shm_pool.is_none() {
match SlotPool::new(len, &self.shm) {
Ok(pool) => self.surfaces[idx].shm_pool = Some(pool),
Err(err) => {
tracing::error!(%err, "failed to allocate shm pool for lock surface redraw");
return;
}
};
render::blit_to_shm(&pixmap, pool.mmap());
}
}
let buffer = pool.create_buffer(
0,
let lock = &mut self.surfaces[idx];
if let Some(buf) = &lock.shm_buffer {
if buf.height() != height as i32 || buf.stride() != stride {
lock.shm_buffer = None;
}
}
let mut reused = false;
if let Some(pool) = lock.shm_pool.as_mut() {
if let Some(buf) = lock.shm_buffer.as_ref() {
if let Some(canvas) = pool.canvas(buf) {
render::blit_to_shm(pixmap, canvas);
reused = true;
}
}
}
if !reused {
let Some(pool) = lock.shm_pool.as_mut() else {
return;
};
let (new_buf, canvas) = match pool.create_buffer(
width as i32,
height as i32,
stride as i32,
stride,
wl_shm::Format::Argb8888,
(),
qh,
);
) {
Ok(pair) => pair,
Err(err) => {
tracing::error!(%err, "failed to create shm buffer for lock surface redraw");
return;
}
};
render::blit_to_shm(pixmap, canvas);
lock.shm_buffer = Some(new_buf);
}
surface.wl_surface().attach(Some(&buffer), 0, 0);
let Some(buf) = lock.shm_buffer.as_ref() else {
return;
};
if buf.attach_to(surface.wl_surface()).is_err() {
return;
}
surface
.wl_surface()
.damage_buffer(0, 0, width as i32, height as i32);
surface.wl_surface().commit();
buffer.destroy();
}
fn palette_for_surface(&self, surface: &SessionLockSurface) -> breadlock_ui::theme::Palette {
self.surfaces
.iter()
.find(|s| s.surface.wl_surface() == surface.wl_surface())
.and_then(|s| self.output_state.info(&s.output))
.and_then(|info| info.name)
.map(|name| breadlock_ui::theme::load_palette_for(&name))
.unwrap_or_else(|| self.palette.clone())
}
/// Redraws every currently-configured surface — used for the clock tick
@ -157,28 +506,242 @@ impl AppState {
let surfaces: Vec<(SessionLockSurface, u32, u32)> = self
.surfaces
.iter()
.map(|s| (s.surface.clone(), s.width, s.height))
.map(|s| {
let scale = s.scale.max(1) as u32;
(
s.surface.clone(),
s.width.saturating_mul(scale),
s.height.saturating_mul(scale),
)
})
.collect();
for (surface, width, height) in surfaces {
self.redraw_surface(qh, &surface, width, height);
}
self.complete_unlock_if_ready();
}
/// After a failed attempt, clears the "wrong password" state (and
/// re-enables the red pill) once `input.fail_timeout_ms` has elapsed —
/// unless the user already cleared it themselves by typing again.
fn appear_in_progress(&self) -> bool {
self.appear_started
.map(|t| t.elapsed() < Duration::from_millis(render::APPEAR_MS))
.unwrap_or(true)
}
fn unlock_in_progress(&self) -> bool {
self.unlocking
.map(|t| t.elapsed() < Duration::from_millis(render::UNLOCK_MS))
.unwrap_or(false)
}
fn failed_shake_in_progress(&self) -> bool {
self.failed_at
.map(|t| t.elapsed() < Duration::from_millis(render::SHAKE_MS))
.unwrap_or(false)
}
fn dot_pop_in_progress(&self) -> bool {
self.last_keystroke
.map(|t| t.elapsed() < Duration::from_millis(render::DOT_POP_MS))
.unwrap_or(false)
}
fn clock_fade_in_progress(&self) -> bool {
self.clock_from
.as_ref()
.map(|(_, t)| t.elapsed() < Duration::from_millis(render::CLOCK_CROSSFADE_MS))
.unwrap_or(false)
}
fn status_slide_in_progress(&self) -> bool {
self.status_anim_started
.map(|t| t.elapsed() < Duration::from_millis(render::STATUS_SLIDE_MS))
.unwrap_or(false)
}
fn breathe_in_progress(&self) -> bool {
self.breathe_started.is_some()
}
/// An idle breath is due when the cycle timer says so (and no breath is
/// already running). The 1s clock tick calls `redraw_all`, which arms the
/// animation timer through here — so the screen stays asleep between
/// breaths.
fn breathe_due(&self) -> bool {
if !self.config.animation.breathe || self.breathe_started.is_some() {
return false;
}
self.breathe_next_at
.map(|t| Instant::now() >= t)
.unwrap_or(false)
}
fn idle_dim_in_progress(&self) -> bool {
if self.config.animation.idle_dim_after_secs == 0 {
return false;
}
let idle_s = self.last_activity.elapsed().as_secs_f64();
let threshold = self.config.animation.idle_dim_after_secs as f64;
let ramp_s = render::IDLE_DIM_RAMP_MS as f64 / 1000.0;
idle_s > threshold && idle_s < threshold + ramp_s
}
fn caret_blink_in_progress(&self) -> bool {
if self.unlocking.is_some() {
return false;
}
let len = if self.password.is_empty() {
self.password_display_len
} else {
self.password.chars().count()
};
len > 0
}
/// Any effect still running that needs the animation timer: the fast ones
/// (entrance, unlock flash+fade, shake, dot pop, clock rollover, status
/// slide, a live PAM check) plus the slow ones (idle breath, Ken Burns
/// pan, idle dim ramp, caret blink) which run at a reduced cadence — see
/// `tick_animation`.
fn anim_in_progress(&self) -> bool {
self.unlocking.is_some()
|| self.appear_in_progress()
|| self.failed_shake_in_progress()
|| self.dot_pop_in_progress()
|| self.clock_fade_in_progress()
|| self.status_slide_in_progress()
|| self.breathe_in_progress()
|| self.breathe_due()
|| self.auth_state == AuthState::Checking
|| self.background.ken_burns()
|| self.idle_dim_in_progress()
|| self.caret_blink_in_progress()
}
/// Keep requesting frames while any effect is running.
fn arm_anim_if_needed(&mut self, qh: &QueueHandle<Self>) {
if self.anim_timer_armed || !self.anim_in_progress() {
return;
}
// A breath that's due starts its window now, so the first ticked
// frame already shows the start of the hump.
if self.breathe_due() {
self.breathe_started = Some(Instant::now());
}
self.anim_timer_armed = true;
let qh = qh.clone();
if self
.loop_handle
.insert_source(
Timer::from_duration(Duration::from_millis(render::ANIM_FRAME_MS)),
move |_, _, state| state.tick_animation(&qh),
)
.is_err()
{
tracing::error!("failed to arm lock animation timer");
self.anim_timer_armed = false;
}
}
/// A 60 fps animation is in flight (everything except the slow idle
/// effects: idle breath, Ken Burns pan, idle dim, caret blink). Drives
/// both the timer cadence and whether background frames get sub-pixel
/// panning.
fn fast_anim_in_progress(&self) -> bool {
self.appear_in_progress()
|| self.unlock_in_progress()
|| self.failed_shake_in_progress()
|| self.dot_pop_in_progress()
|| self.clock_fade_in_progress()
|| self.status_slide_in_progress()
|| self.auth_state == AuthState::Checking
}
fn tick_animation(&mut self, qh: &QueueHandle<Self>) -> TimeoutAction {
self.redraw_all(qh);
if self.unlocking.is_some() && !self.unlock_in_progress() {
self.anim_timer_armed = false;
TimeoutAction::Drop
} else if self.anim_in_progress() {
// Slow effects (idle breath, Ken Burns, dim, caret) don't need
// 60fps — halve the redraw cost for them. Everything else stays
// at ~60Hz.
let fast = self.fast_anim_in_progress();
TimeoutAction::ToDuration(Duration::from_millis(if fast {
render::ANIM_FRAME_MS
} else {
render::SLOW_FRAME_MS
}))
} else {
self.anim_timer_armed = false;
TimeoutAction::Drop
}
}
/// After the unlock fade reaches t==1, send compositor `unlock` and
/// exit. Not called until then — dying mid-fade stays locked.
pub fn complete_unlock_if_ready(&mut self) {
let Some(started) = self.unlocking else {
return;
};
if started.elapsed() < Duration::from_millis(render::UNLOCK_MS) {
return;
}
if let Some(lock) = self.session_lock.take() {
tracing::info!("unlock fade complete");
lock.unlock();
crate::bread_events::emit_unlocked();
}
self.exit = true;
}
/// After a failed attempt, clears the red UI once `input.fail_timeout_ms`
/// has elapsed — unless a newer fail (or the user typing) has moved the
/// generation. Input is not blocked during Failed.
pub fn schedule_clear_failed(&self, qh: QueueHandle<Self>) {
let timeout = Duration::from_millis(self.config.input.fail_timeout_ms);
let gen = self.failed_generation;
let _ =
self.loop_handle
.insert_source(Timer::from_duration(timeout), move |_, _, state| {
if matches!(state.auth_state, AuthState::Failed | AuthState::ConfigError) {
if fail_timer_applies(gen, state.failed_generation, state.auth_state) {
state.auth_state = AuthState::Idle;
state.failed_at = None;
state.password_display_len = 0;
state.redraw_all(&qh);
}
TimeoutAction::Drop
});
}
/// Record a Failed / AccountInvalid / ConfigError and bump the
/// generation so an older fail-clear timer cannot wipe this one.
pub fn enter_fail(&mut self, next: AuthState) {
self.auth_state = next;
self.failed_at = Some(Instant::now());
self.checking_started = None;
self.failed_generation = self.failed_generation.wrapping_add(1);
}
}
/// The animated ellipsis for the "Checking" status while a PAM check runs:
/// cycles "", ".", "..", "…" every ~500ms (driven by time since `started`).
fn checking_dots(started: Instant) -> &'static str {
match (started.elapsed().as_secs_f32() * 2.0) as usize % 4 {
0 => "",
1 => ".",
2 => "..",
_ => "",
}
}
/// A fail-clear timer only fires if its captured generation is still current
/// and the UI is still in a fail-style state.
fn fail_timer_applies(timer_gen: u64, current_gen: u64, auth: AuthState) -> bool {
timer_gen == current_gen
&& matches!(
auth,
AuthState::Failed | AuthState::AccountInvalid | AuthState::ConfigError
)
}
impl ShmHandler for AppState {
@ -193,3 +756,30 @@ impl ProvidesRegistryState for AppState {
}
registry_handlers![OutputState, SeatState];
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn checking_dots_with_stale_instant_is_not_empty() {
let started = Instant::now() - Duration::from_millis(750);
assert_ne!(checking_dots(started), "");
}
#[test]
fn checking_dots_at_now_is_empty_or_dot() {
// Fresh Instant: elapsed ≈ 0 → "".
assert_eq!(checking_dots(Instant::now()), "");
}
#[test]
fn fail_timer_ignores_stale_generation() {
assert!(!fail_timer_applies(1, 2, AuthState::Failed));
assert!(fail_timer_applies(3, 3, AuthState::Failed));
assert!(fail_timer_applies(1, 1, AuthState::AccountInvalid));
assert!(fail_timer_applies(1, 1, AuthState::ConfigError));
assert!(!fail_timer_applies(1, 1, AuthState::Idle));
assert!(!fail_timer_applies(1, 1, AuthState::Checking));
}
}

389
breadlock/src/status.rs Normal file
View file

@ -0,0 +1,389 @@
//! D-Bus status integration — now-playing (MPRIS) and battery (upower).
//!
//! Both are polled on a single background thread (zbus's blocking API has no
//! place on the render loop) and the result is posted back through a
//! `calloop::channel`, mirroring how [`crate::auth`] bridges PAM. Session and
//! system bus connections are opened once in that thread and reused; a failed
//! call drops the connection so the next tick reconnects. Missing or broken
//! D-Bus (headless CI, a session without upower, etc.) just yields empty
//! status — this module never blocks or fails the locker.
use smithay_client_toolkit::reexports::calloop::channel::{self, Sender};
use smithay_client_toolkit::reexports::calloop::LoopHandle;
use std::collections::HashMap;
use zbus::zvariant::{Dict, OwnedValue, Value};
/// One snapshot of the system status, rendered as a small line under the
/// clock when either field is present.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct StatusInfo {
/// `"{title} — {artist}"` for the currently-playing MPRIS player (the
/// first one advertising `PlaybackStatus == "Playing"`, else the first
/// paused one). Playing players with no title fall back to artist, the
/// player name, or `"Playing"`. Empty when nothing is playing or MPRIS
/// is unreachable.
pub now_playing: String,
/// `"87% · charging"`-style summary from upower's display device.
/// Empty when there is no battery or upower is unreachable.
pub battery: String,
}
/// Registers the receiving half of the status channel on the event loop and
/// returns the `Sender` the background poller hands snapshots to.
pub fn register<Data: 'static>(
loop_handle: &LoopHandle<'static, Data>,
mut on_update: impl FnMut(&mut Data, StatusInfo) + 'static,
) -> Sender<StatusInfo> {
let (tx, channel) = channel::channel();
loop_handle
.insert_source(channel, move |event, _, data| {
if let channel::Event::Msg(info) = event {
on_update(data, info);
}
})
.expect("failed to register status channel on event loop");
tx
}
/// How often the background thread re-queries D-Bus.
const POLL_SECS: u64 = 3;
const MPRIS_FIELD_MAX: usize = 80;
const MPRIS_LINE_MAX: usize = 120;
/// UPower Device Type for a battery. DisplayDevice on a desktop is often
/// some other kind (line power) with `Percentage == 0`.
const UPOWER_TYPE_BATTERY: u32 = 2;
/// Spawns the poller thread. It runs for the life of the process (the locker
/// exits on unlock), re-querying every [`POLL_SECS`] seconds and forwarding
/// each snapshot. When both `now_playing` and `battery` are false, returns
/// immediately without touching D-Bus.
pub fn spawn_poller(tx: Sender<StatusInfo>, now_playing: bool, battery: bool) {
if !now_playing && !battery {
return;
}
std::thread::spawn(move || {
let mut session: Option<zbus::blocking::Connection> = None;
let mut system: Option<zbus::blocking::Connection> = None;
loop {
let info = poll_once(&mut session, &mut system, now_playing, battery);
if tx.send(info).is_err() {
// Event loop gone (unlocked) — nothing left to report.
return;
}
std::thread::sleep(std::time::Duration::from_secs(POLL_SECS));
}
});
}
fn poll_once(
session: &mut Option<zbus::blocking::Connection>,
system: &mut Option<zbus::blocking::Connection>,
now_playing: bool,
battery: bool,
) -> StatusInfo {
StatusInfo {
now_playing: if now_playing {
poll_now_playing(session)
} else {
String::new()
},
battery: if battery {
poll_battery(system)
} else {
String::new()
},
}
}
fn poll_now_playing(session: &mut Option<zbus::blocking::Connection>) -> String {
if session.is_none() {
*session = zbus::blocking::Connection::session().ok();
}
match session.as_ref().map(poll_now_playing_on) {
Some(Ok(line)) => line,
Some(Err(())) => {
*session = None;
String::new()
}
None => String::new(),
}
}
fn poll_now_playing_on(conn: &zbus::blocking::Connection) -> Result<String, ()> {
let names = conn
.call_method(
Some("org.freedesktop.DBus"),
"/org/freedesktop/DBus",
Some("org.freedesktop.DBus"),
"ListNames",
&(),
)
.and_then(|reply| reply.body().deserialize::<Vec<String>>())
.map_err(|_| ())?;
let mut paused: Option<String> = None;
for name in names.iter().filter(|n| n.starts_with("org.mpris.MediaPlayer2.")) {
let Some((status, title, artist)) = read_player(conn, name) else {
continue;
};
let line = format_now_playing(title.as_deref(), artist.as_deref(), name);
match status.as_str() {
"Playing" => return Ok(line),
"Paused" if paused.is_none() => paused = Some(line),
_ => {}
}
}
Ok(paused.unwrap_or_default())
}
fn read_player(
conn: &zbus::blocking::Connection,
name: &str,
) -> Option<(String, Option<String>, Option<String>)> {
let props = conn
.call_method(
Some(name),
"/org/mpris/MediaPlayer2",
Some("org.freedesktop.DBus.Properties"),
"GetAll",
&("org.mpris.MediaPlayer2.Player",),
)
.ok()?;
let dict: HashMap<String, OwnedValue> = props.body().deserialize().ok()?;
let status = dict
.get("PlaybackStatus")
.and_then(|v| v.downcast_ref::<&str>().ok())
.unwrap_or("")
.to_string();
let mut title = None;
let mut artist = None;
if let Some(metadata) = dict.get("Metadata").and_then(|v| v.downcast_ref::<Dict>().ok()) {
title = metadata
.get::<&str, &str>(&"xesam:title")
.ok()
.flatten()
.map(str::to_string);
artist = metadata
.get::<&str, Value>(&"xesam:artist")
.ok()
.flatten()
.and_then(|v| match v {
Value::Array(arr) => {
let joined = arr
.iter()
.filter_map(|e| e.downcast_ref::<&str>().ok())
.collect::<Vec<_>>()
.join(", ");
if joined.is_empty() {
None
} else {
Some(joined)
}
}
_ => None,
});
}
Some((status, title, artist))
}
/// Builds the now-playing line. Title and artist are newline-stripped and
/// capped; a Playing player with neither still yields the player name (or
/// `"Playing"`) so it is not outranked by a later titled Paused player.
fn format_now_playing(title: Option<&str>, artist: Option<&str>, player: &str) -> String {
let title = title
.map(sanitize_mpris_field)
.filter(|s| !s.is_empty());
let artist = artist
.map(sanitize_mpris_field)
.filter(|s| !s.is_empty());
let line = match (title, artist) {
(Some(t), Some(a)) => format!("{t}{a}"),
(Some(t), None) => t,
(None, Some(a)) => a,
(None, None) => mpris_player_fallback(player),
};
truncate_chars(&line, MPRIS_LINE_MAX)
}
fn sanitize_mpris_field(s: &str) -> String {
let collapsed = s.split_whitespace().collect::<Vec<_>>().join(" ");
truncate_chars(&collapsed, MPRIS_FIELD_MAX)
}
fn mpris_player_fallback(bus_name: &str) -> String {
bus_name
.strip_prefix("org.mpris.MediaPlayer2.")
.and_then(|rest| rest.split('.').next())
.filter(|s| !s.is_empty())
.unwrap_or("Playing")
.to_string()
}
fn truncate_chars(s: &str, max: usize) -> String {
match s.char_indices().nth(max) {
None => s.to_string(),
Some((idx, _)) => s[..idx].to_string(),
}
}
fn poll_battery(system: &mut Option<zbus::blocking::Connection>) -> String {
if system.is_none() {
*system = zbus::blocking::Connection::system().ok();
}
match system.as_ref().map(poll_battery_on) {
Some(Ok(line)) => line,
Some(Err(())) => {
*system = None;
String::new()
}
None => String::new(),
}
}
fn poll_battery_on(conn: &zbus::blocking::Connection) -> Result<String, ()> {
let path = conn
.call_method(
Some("org.freedesktop.UPower"),
"/org/freedesktop/UPower",
Some("org.freedesktop.UPower"),
"GetDisplayDevice",
&(),
)
.and_then(|reply| reply.body().deserialize::<zbus::zvariant::OwnedObjectPath>())
.map_err(|_| ())?;
let props = conn
.call_method(
Some("org.freedesktop.UPower"),
path.as_str(),
Some("org.freedesktop.DBus.Properties"),
"GetAll",
&("org.freedesktop.UPower.Device",),
)
.and_then(|reply| {
reply
.body()
.deserialize::<HashMap<String, OwnedValue>>()
})
.map_err(|_| ())?;
// DisplayDevice always exists; without a battery IsPresent is false
// and Percentage is often 0. Missing IsPresent is treated as absent.
let present = props
.get("IsPresent")
.and_then(|v| v.downcast_ref::<bool>().ok())
.unwrap_or(false);
if let Some(kind) = props.get("Type").and_then(|v| v.downcast_ref::<u32>().ok()) {
if kind != UPOWER_TYPE_BATTERY {
return Ok(String::new());
}
}
let Some(pct) = props
.get("Percentage")
.and_then(|v| v.downcast_ref::<f64>().ok())
else {
return Ok(String::new());
};
let state = props
.get("State")
.and_then(|v| v.downcast_ref::<u32>().ok())
.unwrap_or(0);
Ok(format_battery(present, pct, state))
}
fn format_battery(present: bool, pct: f64, state: u32) -> String {
if !present {
return String::new();
}
// UPower Device state: 1 charging, 2 discharging, 3 empty, 4 full.
let suffix = match state {
1 => " · charging",
2 => "",
4 => " · full",
_ => "",
};
format!("{pct:.0}%{suffix}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_battery_absent_is_empty() {
assert_eq!(format_battery(false, 0.0, 0), "");
assert_eq!(format_battery(false, 87.4, 1), "");
}
#[test]
fn format_battery_present_covers_common_states() {
assert_eq!(format_battery(true, 87.4, 1), "87% · charging");
assert_eq!(format_battery(true, 43.0, 2), "43%");
assert_eq!(format_battery(true, 100.0, 4), "100% · full");
assert_eq!(format_battery(true, 2.0, 3), "2%");
// Laptop at 0% still has a battery; desktops are filtered via IsPresent.
assert_eq!(format_battery(true, 0.0, 2), "0%");
}
#[test]
fn format_now_playing_joins_title_and_artist() {
assert_eq!(
format_now_playing(
Some("Paranoid Android"),
Some("Radiohead"),
"org.mpris.MediaPlayer2.spotify"
),
"Paranoid Android — Radiohead"
);
assert_eq!(
format_now_playing(Some("Untitled"), None, "org.mpris.MediaPlayer2.mpv"),
"Untitled"
);
}
#[test]
fn format_now_playing_playing_without_title_uses_fallback() {
assert_eq!(
format_now_playing(None, Some("Radiohead"), "org.mpris.MediaPlayer2.spotify"),
"Radiohead"
);
assert_eq!(
format_now_playing(None, None, "org.mpris.MediaPlayer2.spotify"),
"spotify"
);
assert_eq!(
format_now_playing(None, None, "org.mpris.MediaPlayer2.firefox.instance1"),
"firefox"
);
assert_eq!(format_now_playing(None, None, ""), "Playing");
assert_eq!(
format_now_playing(Some("\n\n"), None, "org.mpris.MediaPlayer2.mpv"),
"mpv"
);
}
#[test]
fn format_now_playing_strips_newlines_and_truncates() {
assert_eq!(
format_now_playing(Some("foo\nbar"), Some("a\r\nb"), "org.mpris.MediaPlayer2.x"),
"foo bar — a b"
);
let title = "T".repeat(100);
let titled = format_now_playing(Some(&title), None, "org.mpris.MediaPlayer2.x");
assert_eq!(titled.chars().count(), MPRIS_FIELD_MAX);
assert!(!titled.contains('\n'));
let artist = "A".repeat(100);
let combined = format_now_playing(Some(&title), Some(&artist), "org.mpris.MediaPlayer2.x");
assert_eq!(combined.chars().count(), MPRIS_LINE_MAX);
assert!(combined.starts_with('T'));
assert!(!combined.contains('\n'));
}
#[test]
fn spawn_poller_both_false_returns() {
let (tx, _rx) = channel::channel();
spawn_poller(tx, false, false);
}
}

661
design/sketch.html Normal file
View file

@ -0,0 +1,661 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>breadlock × breadgreet — style &amp; motion sketch</title>
<style>
/* ============================================================
Design tokens — mirrors bread-theme (BREAD_DESIGN_SYSTEM.md)
bg/surface/overlay/foreground are the fixed BOS dark base;
color1-6 are pywal-derived accents. Two palettes shown:
"bread" = curated bread-toned defaults (fresh install)
"tokyo" = an example pywal palette (Tokyo Night)
============================================================ */
:root, [data-palette="bread"] {
--bg: #0c0c0c;
--surface: #1a1a1a;
--surface2: #232323;
--overlay: #d8d8d8;
--fg: #e8e8e8;
--red: #b98749; /* color1 */
--green: #cd9450; /* color2 */
--yellow: #e3a85c; /* color3 */
--accent: #eab672; /* color4 */
--pink: #f6c477; /* color5 */
--teal: #eabe82; /* color6 */
--radius: 8px; /* primary */
--radius-sm: 6px; /* secondary (inputs) */
--font: "Varela Round", "Segoe UI", system-ui, -apple-system, sans-serif;
--line: #2b2b2b;
}
[data-palette="tokyo"] {
--red: #f7768e;
--green: #9ece6a;
--yellow: #e0af68;
--accent: #7aa2f7;
--pink: #bb9af7;
--teal: #7dcfff;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--fg);
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
padding: 32px clamp(16px, 4vw, 48px) 80px;
}
header.maxw, main { max-width: 1240px; margin: 0 auto; }
header h1 { font-size: 26px; margin: 0 0 6px; letter-spacing: .2px; }
header p.sub { color: var(--overlay); opacity: .72; margin: 0 0 18px; max-width: 72ch; }
header .meta {
display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 10px;
}
.palette-toggle {
display: inline-flex; border: 1px solid var(--line); border-radius: var(--radius);
overflow: hidden; margin-left: auto;
}
.palette-toggle button {
background: transparent; color: var(--overlay); border: 0; padding: 6px 14px;
font: inherit; font-size: 12px; cursor: pointer;
}
.palette-toggle button.active { background: var(--surface2); color: var(--fg); }
.swatches { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.swatch { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--overlay); opacity: .85; }
.swatch i { width: 14px; height: 14px; border-radius: 4px; border: 1px solid rgba(255,255,255,.15); display: inline-block; }
h2 { font-size: 16px; margin: 34px 0 4px; }
h2 small { color: var(--overlay); opacity: .6; font-weight: 400; margin-left: 8px; }
.hint { color: var(--overlay); opacity: .7; font-size: 12px; margin: 0 0 14px; max-width: 90ch; }
/* ---------- stages ---------- */
.stages { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
@media (max-width: 960px) { .stages { grid-template-columns: 1fr; } }
.stage {
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
padding: 14px; display: flex; flex-direction: column; gap: 12px;
}
.stage > .stage-head { display: flex; align-items: baseline; gap: 8px; }
.stage > .stage-head h3 { margin: 0; font-size: 14px; }
.stage > .stage-head span { font-size: 11px; color: var(--overlay); opacity: .6; }
.stage .states { display: flex; flex-wrap: wrap; gap: 6px; }
.stage .states button {
background: var(--surface2); color: var(--overlay); border: 1px solid var(--line);
border-radius: 999px; padding: 4px 12px; font: inherit; font-size: 11px; cursor: pointer;
}
.stage .states button.active { background: var(--accent); color: #0c0c0c; border-color: transparent; font-weight: 700; }
/* the "monitor" */
.screen {
position: relative; width: 100%; aspect-ratio: 16 / 9; border-radius: var(--radius-sm);
overflow: hidden; border: 1px solid var(--line); background: var(--bg);
}
.wallpaper, .veil { position: absolute; inset: 0; }
[data-palette="bread"] .wallpaper {
background:
radial-gradient(120% 90% at 18% 8%, #3b2f1e 0%, transparent 55%),
radial-gradient(100% 100% at 88% 88%, #2c2313 0%, transparent 60%),
linear-gradient(160deg, #17130c 0%, #0c0c0c 72%);
}
[data-palette="tokyo"] .wallpaper {
background:
radial-gradient(120% 90% at 18% 8%, #2b3152 0%, transparent 55%),
radial-gradient(100% 100% at 88% 88%, #1c2338 0%, transparent 60%),
linear-gradient(160deg, #10121c 0%, #0c0c0c 72%);
}
.veil { background: rgba(0, 0, 0, .28); } /* DIM_ALPHA = 0.28, fades with chrome */
/* ---------- breadlock overlay ---------- */
.lockoverlay {
position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center;
padding-top: 12%; transition: opacity 400ms ease, transform 400ms ease;
}
.screen[data-state="unlock"] .lockoverlay { opacity: 0; transform: translateY(-20px); }
.lclock {
font-size: clamp(34px, 7.5vw, 60px); font-weight: 700; color: #fff; text-align: center;
animation: riseIn 450ms ease-out both;
}
.ldate {
font-size: clamp(11px, 1.6vw, 14px); color: rgba(255,255,255,.82); margin-top: 6px;
animation: riseIn 450ms ease-out 80ms both;
}
.badge-new {
display: inline-block; vertical-align: middle; margin-left: 6px; padding: 1px 6px;
font-size: 9px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase;
border-radius: 4px; background: var(--accent); color: #0c0c0c;
}
.pill {
position: relative; margin-top: clamp(20px, 5vh, 44px);
width: clamp(190px, 36vw, 280px); height: clamp(34px, 6vw, 48px);
border-radius: var(--radius-sm);
background: var(--surface);
border: 1px solid rgba(255,255,255,.08);
display: flex; align-items: center; justify-content: center; gap: clamp(8px, 1.6vw, 18px);
animation: popIn 380ms cubic-bezier(.34, 1.56, .64, 1) 100ms both;
transition: background-color 150ms ease, border-color 150ms ease;
box-shadow: 0 4px 18px rgba(0,0,0,.45);
}
.dot {
width: clamp(6px, 1.1vw, 9px); height: clamp(6px, 1.1vw, 9px); border-radius: 50%;
background: var(--accent); animation: dotPop 200ms ease-out both;
}
.pill .dot:nth-child(1) { animation-delay: 150ms; }
.pill .dot:nth-child(2) { animation-delay: 200ms; }
.pill .dot:nth-child(3) { animation-delay: 250ms; }
.caret {
width: 2px; height: 1.2em; background: var(--accent); border-radius: 1px;
animation: caretBlink 1.1s steps(1) infinite; opacity: .9;
}
.lstatus { margin-top: 12px; font-size: 12px; color: var(--overlay); min-height: 1em; text-align: center; }
/* wrong password: shake + red, then fade back */
.screen[data-state="wrong"] .pill {
background: var(--red); border-color: transparent;
animation: shake 380ms cubic-bezier(.36,.07,.19,.97);
}
.screen[data-state="wrong"] .dot, .screen[data-state="wrong"] .caret { background: #fff; }
.screen[data-state="wrong"] .lstatus { color: var(--red); font-weight: 700; }
/* success: green flash, then the unlock fade is handled by data-state="unlock" */
.screen[data-state="success"] .pill { background: var(--green); border-color: transparent; animation: successFlash 300ms ease-out; }
.screen[data-state="success"] .dot, .screen[data-state="success"] .caret { background: #fff; }
.screen[data-state="success"] .lstatus { color: var(--green); font-weight: 700; }
.replay {
align-self: flex-start; background: transparent; color: var(--accent); border: 1px solid var(--line);
border-radius: var(--radius-sm); padding: 4px 12px; font: inherit; font-size: 11px; cursor: pointer;
}
.replay:hover { border-color: var(--accent); }
/* ---------- breadgreet ---------- */
.greetoverlay {
position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 18px;
}
.gclock { font-size: clamp(28px, 5vw, 44px); font-weight: 700; color: #fff; animation: riseIn 450ms ease-out both; }
.card {
width: clamp(240px, 44vw, 340px); background: var(--surface);
border: 1px solid rgba(255,255,255,.08); border-radius: var(--radius);
padding: 20px; display: flex; flex-direction: column; gap: 10px;
animation: riseIn 450ms ease-out 120ms both;
box-shadow: 0 6px 24px rgba(0,0,0,.5);
}
.gentry {
width: 100%; background: var(--surface2); color: var(--fg);
border: 1px solid var(--line); border-radius: var(--radius-sm);
padding: 10px 14px; font: inherit; font-size: 14px;
transition: border-color 200ms ease, box-shadow 200ms ease;
}
.gentry:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px rgba(234,182,114,.25); }
[data-palette="tokyo"] .gentry:focus { box-shadow: 0 0 0 2px rgba(122,162,247,.28); }
.gstatus { font-size: 12px; color: var(--overlay); opacity: .75; min-height: 1em; text-align: center; transition: opacity 200ms; }
.screen[data-state="error"] .gstatus { color: var(--red); opacity: 1; font-weight: 700; }
.spinner {
margin: 0 auto; width: 18px; height: 18px; border-radius: 50%;
border: 2px solid rgba(255,255,255,.15); border-top-color: var(--accent);
animation: spin .8s linear infinite; display: none;
}
.screen[data-state="checking"] .spinner { display: block; }
.srow {
display: flex; align-items: center; gap: 10px; width: 100%;
background: var(--surface2); border: 1px solid var(--line); border-radius: var(--radius-sm);
padding: 8px 12px; font-size: 12px; color: var(--overlay);
}
.srow .icon {
width: 20px; height: 20px; border-radius: 5px; flex: none;
background: linear-gradient(135deg, var(--accent), var(--teal));
}
.srow .label { flex: 1; text-align: left; }
.srow .chev { color: var(--overlay); opacity: .6; }
.screen[data-state="error"] .card { animation: shake 380ms cubic-bezier(.36,.07,.19,.97); }
/* ---------- keyframes ---------- */
@keyframes riseIn { from { opacity: 0; transform: translateY(18px); } to { opacity: 1; transform: none; } }
@keyframes popIn { from { opacity: 0; transform: scale(.94); } 70% { transform: scale(1.02); } to { opacity: 1; transform: scale(1); } }
@keyframes dotPop { from { transform: scale(0); } 70% { transform: scale(1.35); } to { transform: scale(1); } }
@keyframes caretBlink { 0%, 55% { opacity: .9; } 56%, 100% { opacity: 0; } }
@keyframes shake {
10%, 90% { transform: translateX(-2px); } 20%, 80% { transform: translateX(5px); }
30%, 50%, 70% { transform: translateX(-8px); } 40%, 60% { transform: translateX(8px); }
}
@keyframes successFlash { from { box-shadow: 0 0 0 0 rgba(205,148,80,.55); } to { box-shadow: 0 0 0 22px rgba(205,148,80,0); } }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes breathe { 0%, 100% { box-shadow: 0 4px 18px rgba(0,0,0,.45); } 50% { box-shadow: 0 4px 26px rgba(0,0,0,.6), 0 0 0 1px rgba(234,182,114,.12); } }
@keyframes clockFlip { 0% { opacity: 1; } 45% { opacity: 0; transform: translateY(6px); } 55% { opacity: 0; transform: translateY(-6px); } 100% { opacity: 1; transform: none; } }
@keyframes kbdPan { 0% { transform: translateX(-2.5%) scale(1.06); } 100% { transform: translateX(2.5%) scale(1.06); } }
/* ---------- motion library ---------- */
.tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }
.tile {
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
padding: 12px; display: flex; flex-direction: column; gap: 8px;
}
.tile .tname { font-size: 13px; font-weight: 700; display: flex; align-items: center; gap: 6px; }
.tile .tag { font-size: 9px; font-weight: 700; letter-spacing: .5px; text-transform: uppercase; padding: 1px 6px; border-radius: 4px; background: var(--surface2); color: var(--overlay); }
.tile .tag.S { color: var(--green); } .tile .tag.M { color: var(--yellow); } .tile .tag.L { color: var(--red); }
.tile .tnote { font-size: 11px; color: var(--overlay); opacity: .75; min-height: 3em; }
.tile .tscreen {
position: relative; width: 100%; aspect-ratio: 16 / 7; border-radius: var(--radius-sm);
overflow: hidden; background: var(--bg); border: 1px solid var(--line);
}
/* tile demos */
.tile .tscreen .tclock { position: absolute; top: 22%; left: 0; right: 0; text-align: center; color: #fff; font-weight: 700; font-size: 22px; }
.tile .tscreen .tpill {
position: absolute; top: 52%; left: 50%; transform: translateX(-50%);
width: 120px; height: 26px; border-radius: var(--radius-sm); background: var(--surface);
border: 1px solid rgba(255,255,255,.08); display: flex; align-items: center; justify-content: center; gap: 9px;
}
.tile .tscreen .tpill i { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); }
.tile .tscreen .tpill .tc { width: 2px; height: 12px; border-radius: 1px; background: var(--accent); animation: caretBlink 1.1s steps(1) infinite; }
.tile[data-tile="stagger"] .tclock, .tile[data-tile="stagger"] .tpill { animation: riseIn 450ms ease-out both; }
.tile[data-tile="stagger"] .tpill { animation-name: popIn; animation-delay: 140ms; }
.tile[data-tile="dotpop"] .tpill i:nth-child(1) { animation: dotPop 200ms ease-out 120ms both; }
.tile[data-tile="dotpop"] .tpill i:nth-child(2) { animation: dotPop 200ms ease-out 180ms both; }
.tile[data-tile="dotpop"] .tpill i:nth-child(3) { animation: dotPop 200ms ease-out 240ms both; }
.tile[data-tile="shake"] .tpill { background: var(--red); animation: shake 380ms cubic-bezier(.36,.07,.19,.97) 200ms both; }
.tile[data-tile="shake"] .tpill i { background: #fff; }
.tile[data-tile="shake"] .tstatus { position: absolute; top: 66%; width: 100%; text-align: center; font-size: 10px; color: var(--red); opacity: 0; animation: fadeIn 200ms ease 380ms both; }
.tile[data-tile="flash"] .tpill { background: var(--green); animation: successFlash 300ms ease-out 200ms both; }
.tile[data-tile="flash"] .tpill i { background: #fff; }
.tile[data-tile="crossfade"] .tclock .old, .tile[data-tile="crossfade"] .tclock .new {
position: absolute; inset: 0; transition: opacity 300ms ease, transform 300ms ease;
}
.tile[data-tile="crossfade"] .tclock .new { opacity: 0; transform: translateY(6px); }
.tile[data-tile="crossfade"].ticked .tclock .old { opacity: 0; transform: translateY(-6px); }
.tile[data-tile="crossfade"].ticked .tclock .new { opacity: 1; transform: none; }
.tile[data-tile="breathe"] .tpill { animation: breathe 3.2s ease-in-out infinite; }
.tile[data-tile="gcard"] .gcard-mini {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
width: 130px; background: var(--surface); border: 1px solid rgba(255,255,255,.08);
border-radius: var(--radius); padding: 10px; animation: riseIn 450ms ease-out both;
}
.tile[data-tile="gcard"] .gcard-mini b { display: block; height: 12px; border-radius: 4px; background: var(--surface2); }
.tile[data-tile="gcard"] .gcard-mini b + b { margin-top: 6px; height: 8px; opacity: .6; }
.tile[data-tile="focus"] .gcard-mini b:first-child { transition: border-color 200ms, box-shadow 200ms; border: 1px solid var(--line); }
.tile[data-tile="focus"].focused .gcard-mini b:first-child { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(234,182,114,.25); }
.tile[data-tile="spinner"] .spin-mini {
position: absolute; top: 50%; left: 50%; width: 20px; height: 20px; margin: -10px 0 0 -10px;
border-radius: 50%; border: 2px solid rgba(255,255,255,.15); border-top-color: var(--accent);
animation: spin .8s linear infinite;
}
.tile[data-tile="session"] .rows { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 140px; display: flex; flex-direction: column; gap: 5px; }
.tile[data-tile="session"] .rows div {
display: flex; align-items: center; gap: 7px; background: var(--surface2);
border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 5px 8px; font-size: 10px; color: var(--overlay);
}
.tile[data-tile="session"] .rows div i { width: 12px; height: 12px; border-radius: 3px; background: linear-gradient(135deg, var(--accent), var(--teal)); }
.tile[data-tile="session"] .rows div.sel { border-color: var(--accent); color: var(--fg); }
.tile[data-tile="kenburns"] .tpill { opacity: 0; }
.tile[data-tile="kenburns"] .tscreen.noanim::after { animation: none; }
.tile[data-tile="kenburns"] .tscreen::after {
content: ""; position: absolute; inset: -8%;
background: radial-gradient(120% 90% at 18% 8%, #3b2f1e 0%, transparent 55%),
radial-gradient(100% 100% at 88% 88%, #2c2313 0%, transparent 60%),
linear-gradient(160deg, #17130c 0%, #0c0c0c 72%);
animation: kbdPan 9s ease-in-out infinite alternate;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
/* ---------- implementation notes ---------- */
.notes { margin-top: 34px; display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
@media (max-width: 960px) { .notes { grid-template-columns: 1fr; } }
.notes .col { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px 18px; }
.notes h4 { margin: 0 0 10px; font-size: 13px; }
.notes ul { margin: 0; padding-left: 18px; font-size: 12px; color: var(--overlay); opacity: .9; }
.notes li { margin-bottom: 8px; }
.notes code { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11px; color: var(--accent); opacity: .9; }
footer { margin-top: 40px; color: var(--overlay); opacity: .55; font-size: 11px; max-width: 100ch; }
</style>
</head>
<body data-palette="bread">
<header class="maxw">
<div class="meta">
<h1>breadlock × breadgreet — style &amp; motion sketch</h1>
<div class="palette-toggle" id="paletteToggle">
<button data-palette="bread" class="active">Bread default</button>
<button data-palette="tokyo">Pywal (Tokyo Night)</button>
</div>
</div>
<p class="sub">
Live CSS prototype of the lock screen and greeter, grounded in the real
<code>bread-theme</code> tokens (fixed BOS dark base + pywal accents). The locker's
software renderer (tiny-skia) can reproduce every motion here; the greeter uses the
same CSS engine directly (GTK4). <b>Badges</b> mark what already ships vs what's proposed.
</p>
<div class="swatches">
<span class="swatch"><i style="background:#0c0c0c"></i>bg</span>
<span class="swatch"><i style="background:#1a1a1a"></i>surface</span>
<span class="swatch"><i id="swRed" style="background:var(--red)"></i>red</span>
<span class="swatch"><i id="swGreen" style="background:var(--green)"></i>green</span>
<span class="swatch"><i id="swAccent" style="background:var(--accent)"></i>accent</span>
</div>
</header>
<main>
<h2>Live stages <small>click a state chip to replay it</small></h2>
<p class="hint">The two apps should feel like one family: same palette, same radius/spacing tokens, same motion language (ease-out, 300450ms).</p>
<div class="stages">
<!-- ================= breadlock ================= -->
<section class="stage">
<div class="stage-head"><h3>breadlock</h3><span>tiny-skia · 16ms timer loop · <code>render.rs</code></span></div>
<div class="screen" id="lockScreen" data-state="idle">
<div class="wallpaper"></div>
<div class="veil"></div>
<div class="lockoverlay">
<div class="lclock">21:47<span class="badge-new">date</span><div class="ldate">Friday · Aug 21</div></div>
<div class="pill">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
<span class="caret" title="proposed"></span>
</div>
<div class="lstatus" id="lockStatus"></div>
</div>
</div>
<div class="states" data-screen="lockScreen">
<button data-state="idle" class="active">Idle</button>
<button data-state="typing">Typing</button>
<button data-state="wrong">Wrong pw</button>
<button data-state="success">Success</button>
<button data-state="unlock">Unlock</button>
</div>
<button class="replay" data-replay="lockScreen">Replay entrance</button>
</section>
<!-- ================= breadgreet ================= -->
<section class="stage">
<div class="stage-head"><h3>breadgreet</h3><span>GTK4 · relm4 · CSS in <code>theme.rs</code></span></div>
<div class="screen" id="greetScreen" data-state="username">
<div class="wallpaper"></div>
<div class="veil"></div>
<div class="greetoverlay">
<div class="gclock">21:47</div>
<div class="card">
<input class="gentry" id="greetEntry" type="text" placeholder="Username" />
<div class="gstatus" id="greetStatus"></div>
<div class="spinner"></div>
<div class="srow">
<span class="icon"></span>
<span class="label">bos — Hyprland <span class="badge-new">icon</span></span>
<span class="chev"></span>
</div>
</div>
</div>
</div>
<div class="states" data-screen="greetScreen">
<button data-state="username" class="active">Username</button>
<button data-state="prompt">Password prompt</button>
<button data-state="checking">Checking…</button>
<button data-state="error">Wrong pw</button>
</div>
<button class="replay" data-replay="greetScreen">Replay entrance</button>
</section>
</div>
<h2>Motion library <small>proposed animations, mapped to where each lands</small></h2>
<p class="hint">Every idea below is prototypeable in CSS first, then ported. Effort: <b style="color:var(--green)">S</b> small ·
<b style="color:var(--yellow)">M</b> medium · <b style="color:var(--red)">L</b> large (protocol/CPU work).</p>
<div class="tiles">
<div class="tile" data-tile="stagger">
<div class="tname">Entrance stagger <span class="tag S">S</span></div>
<div class="tscreen"><div class="tclock">21:47</div><div class="tpill"><i></i><i></i><i></i></div></div>
<div class="tnote">Clock → pill → status cascade instead of one uniform fade. Pill overshoots ~2% (ease-out-back). Replaces the single overlay motion in <code>render.rs</code>.</div>
<button class="replay" data-replay="tile-stagger">Replay</button>
</div>
<div class="tile" data-tile="dotpop">
<div class="tname">Dot pop + caret <span class="tag S">S</span></div>
<div class="tscreen"><div class="tpill"><i></i><i></i><i></i><span class="tc"></span></div></div>
<div class="tnote">Newest password dot scales in with overshoot; a blinking caret marks where you're typing. Today dots just appear — <code>render.rs</code> dot loop.</div>
<button class="replay" data-replay="tile-dotpop">Replay</button>
</div>
<div class="tile" data-tile="shake">
<div class="tname">Wrong-password shake <span class="tag S">S</span></div>
<div class="tscreen"><div class="tpill"><i></i><i></i><i></i></div><div class="tstatus">Wrong password</div></div>
<div class="tnote">The classic, currently reserved for v2 — failure is just a red pill today. Damped 8px shake, red fill, auto-clear after <code>fail_timeout_ms</code>.</div>
<button class="replay" data-replay="tile-shake">Replay</button>
</div>
<div class="tile" data-tile="flash">
<div class="tname">Success flash → unlock drift <span class="tag S">S</span></div>
<div class="tscreen"><div class="tpill"><i></i><i></i><i></i></div></div>
<div class="tnote">Correct password: green (<code>color2</code>) flash + glow ring, then the existing 400ms fade-and-drift-up unlock in <code>state.rs</code>.</div>
<button class="replay" data-replay="tile-flash">Replay</button>
</div>
<div class="tile" data-tile="crossfade">
<div class="tname">Clock minute crossfade <span class="tag S">S</span></div>
<div class="tscreen"><div class="tclock"><span class="old">21:47</span><span class="new">21:48</span></div></div>
<div class="tnote">300ms dip-and-swap on the minute tick instead of a hard blink. Locker: crossfade layer in <code>render.rs</code>. Greeter: GTK CSS transition.</div>
<button class="replay" data-replay="tile-crossfade">Tick</button>
</div>
<div class="tile" data-tile="breathe">
<div class="tname">Idle breathing <span class="tag S">S</span></div>
<div class="tscreen"><div class="tpill"><i></i><i></i><i></i></div></div>
<div class="tnote">Very subtle 34s sine on the pill's glow — proof the screen is live, not frozen. Cheap in <code>render.rs</code>; keep amplitude tiny (CPU is software-rendered).</div>
<button class="replay" data-replay="tile-breathe">Replay</button>
</div>
<div class="tile" data-tile="gcard">
<div class="tname">Greeter card entrance <span class="tag S">S</span></div>
<div class="tscreen"><div class="gcard-mini"><b></b><b></b></div></div>
<div class="tnote">Greeter currently has zero animation. Fade + 18px rise, staggered after the clock — GTK4 CSS <code>@keyframes</code> in <code>breadgreet/theme.rs</code>.</div>
<button class="replay" data-replay="tile-gcard">Replay</button>
</div>
<div class="tile" data-tile="focus">
<div class="tname">Entry focus ring <span class="tag S">S</span></div>
<div class="tscreen"><div class="gcard-mini"><b></b><b></b></div></div>
<div class="tnote">Accent border + soft glow on focus, 200ms transition. Standard GTK CSS <code>:focus</code> — matches the shared stylesheet's "blue on focus" input rule.</div>
<button class="replay" data-replay="tile-focus">Focus</button>
</div>
<div class="tile" data-tile="spinner">
<div class="tname">Auth spinner <span class="tag S">S</span></div>
<div class="tscreen"><div class="spin-mini"></div></div>
<div class="tnote">Real <code>gtk::Spinner</code> during <code>Stage::Working</code> instead of static "Checking…" text. One widget swap in <code>breadgreet/main.rs</code>.</div>
<button class="replay" data-replay="tile-spinner">Replay</button>
</div>
<div class="tile" data-tile="session">
<div class="tname">Session icon rows <span class="tag M">M</span></div>
<div class="tscreen"><div class="rows"><div class="sel"><i></i>bos — Hyprland</div><div><i></i>Hyprland</div></div></div>
<div class="tnote"><code>sessions.rs</code> doesn't parse <code>Icon=</code> today. Custom dropdown rows with per-session icons; falls back to a letter tile.</div>
<button class="replay" data-replay="tile-session">Replay</button>
</div>
<div class="tile" data-tile="kenburns">
<div class="tname">Wallpaper Ken Burns <span class="tag M">M</span></div>
<div class="tscreen"><div class="tpill"><i></i></div></div>
<div class="tnote">Slow pan on image wallpapers — just a drifting <code>Transform</code> in <code>background.rs::paint</code>, no new protocol. Gate behind config (CPU cost).</div>
<button class="replay" data-replay="tile-kenburns">Replay</button>
</div>
</div>
<div class="notes">
<div class="col">
<h4>breadlock — where things land</h4>
<ul>
<li><b>Motion</b>: extend the existing <code>anim_timer</code>/<code>tick_animation</code> loop in <code>state.rs</code>; add per-element progress fields (appear started per element, fail-shake start, dot-pop start).</li>
<li><b>Frame math</b>: all easing lives in <code>render.rs</code> (<code>ease_out_cubic</code>, <code>overlay_motion</code>). Add <code>ease_out_back</code> for the pill overshoot and a damped sinusoid for the shake.</li>
<li><b>Success flash</b>: reuse <code>unlocking: Option&lt;Instant&gt;</code> — flash phase 0250ms, fade 250650ms, then <code>unlock()</code>.</li>
<li><b>Bigger</b>: live blur-of-desktop needs a <code>wlr-screencopy</code> capture (already flagged v2 in README) — software downscale → blur → upscale to keep CPU sane. New <code>[animation]</code> config section (enabled / speed / per-effect toggles).</li>
</ul>
</div>
<div class="col">
<h4>breadgreet — where things land</h4>
<ul>
<li><b>Motion</b>: GTK4 CSS supports <code>@keyframes</code>/<code>animation</code> and transitions — everything goes in <code>breadgreet/theme.rs::load_css</code>, no Rust logic needed for entrance/focus/status.</li>
<li><b>Spinner</b>: swap the status label for a <code>gtk::Spinner</code> during <code>Stage::Working</code> in <code>main.rs</code>.</li>
<li><b>Session icons</b>: parse <code>Icon=</code> in <code>sessions.rs</code> and switch <code>DropDown</code> to custom rows.</li>
<li><b>Unify with the locker</b>: same clock sizing/weight, same radius + spacing tokens; card <code>backdrop-filter: blur()</code> if GTK ≥ 4.12 supports it (README already requires 4.12).</li>
</ul>
</div>
</div>
<footer>
Sketch mirrors <code>breadlock/src/render.rs</code> + <code>state.rs</code>, <code>breadgreet/src/theme.rs</code> + <code>main.rs</code>, and the tokens in
<code>bread-ecosystem/BREAD_DESIGN_SYSTEM.md</code> / <code>bread-theme/src/palette.rs</code>. Palette: fixed BOS dark base
(bg <code>#0c0c0c</code>, surface <code>#1a1a1a</code>, overlay <code>#d8d8d8</code>) with pywal-driven accents (color16).
The "bread" palette's red/green/accent are the curated bread-toned defaults, which is why "wrong password" is brownish until pywal is active.
</footer>
</main>
<script>
/* Palette toggle */
const toggle = document.getElementById("paletteToggle");
toggle.addEventListener("click", (e) => {
const btn = e.target.closest("button");
if (!btn) return;
document.body.dataset.palette = btn.dataset.palette;
toggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b === btn));
});
/* Restart a stylesheet-driven CSS animation: drop the animation via an
inline override, force a reflow, then remove the override so the rule
applies again from its first frame. */
function replayAnim(el) {
el.style.animation = "none";
void el.offsetWidth;
el.style.animation = "";
}
/* ---- breadlock stage ---- */
const lockScreen = document.getElementById("lockScreen");
const lockStatus = document.getElementById("lockStatus");
function setLockState(state) {
// The attribute change is what starts each CSS animation; bounce through
// idle so repeat clicks on the same chip replay it.
if (state === "wrong" || state === "success" || state === "unlock") {
lockScreen.dataset.state = "idle";
void lockScreen.offsetWidth;
}
lockScreen.dataset.state = state;
document.querySelectorAll('[data-screen="lockScreen"] button').forEach((b) =>
b.classList.toggle("active", b.dataset.state === state));
switch (state) {
case "typing":
lockScreen.querySelectorAll(".dot").forEach(replayAnim);
lockStatus.textContent = "";
break;
case "wrong":
lockStatus.textContent = "Wrong password";
setTimeout(() => { if (lockScreen.dataset.state === "wrong") setLockState("idle"); }, 1200);
break;
case "success":
lockStatus.textContent = "✓ Unlocked";
setTimeout(() => setLockState("unlock"), 600);
break;
case "unlock":
setTimeout(() => { setLockState("idle"); replayLockEntrance(); }, 900);
break;
default:
lockStatus.textContent = "";
}
}
function replayLockEntrance() {
replayAnim(lockScreen.querySelector(".lclock"));
replayAnim(lockScreen.querySelector(".ldate"));
replayAnim(lockScreen.querySelector(".pill"));
}
document.querySelectorAll('[data-screen="lockScreen"] button').forEach((b) =>
b.addEventListener("click", () => setLockState(b.dataset.state)));
document.querySelector('[data-replay="lockScreen"]').addEventListener("click", replayLockEntrance);
/* ---- breadgreet stage ---- */
const greetScreen = document.getElementById("greetScreen");
const greetEntry = document.getElementById("greetEntry");
const greetStatus = document.getElementById("greetStatus");
const greetStates = {
username: { placeholder: "Username", status: "" },
prompt: { placeholder: "Password", status: "Password for breadway" },
checking: { placeholder: "Password", status: "Checking…" },
error: { placeholder: "Password", status: "Wrong password" },
};
function setGreetState(state) {
if (state === "error") {
greetScreen.dataset.state = "username";
void greetScreen.offsetWidth;
}
greetScreen.dataset.state = state;
document.querySelectorAll('[data-screen="greetScreen"] button').forEach((b) =>
b.classList.toggle("active", b.dataset.state === state));
const s = greetStates[state];
greetEntry.placeholder = s.placeholder;
greetStatus.textContent = s.status;
if (state === "error") {
setTimeout(() => { if (greetScreen.dataset.state === "error") setGreetState("prompt"); }, 1200);
} else if (state === "checking") {
setTimeout(() => { if (greetScreen.dataset.state === "checking") setGreetState("username"); }, 1600);
}
}
document.querySelectorAll('[data-screen="greetScreen"] button').forEach((b) =>
b.addEventListener("click", () => setGreetState(b.dataset.state)));
document.querySelector('[data-replay="greetScreen"]').addEventListener("click", () => {
replayAnim(greetScreen.querySelector(".gclock"));
replayAnim(greetScreen.querySelector(".card"));
});
/* ---- motion library ---- */
const tileActions = {
"tile-stagger": (t) => { replayAnim(t.querySelector(".tclock")); replayAnim(t.querySelector(".tpill")); },
"tile-dotpop": (t) => t.querySelectorAll(".tpill i").forEach(replayAnim),
"tile-shake": (t) => { replayAnim(t.querySelector(".tpill")); replayAnim(t.querySelector(".tstatus")); },
"tile-flash": (t) => replayAnim(t.querySelector(".tpill")),
"tile-crossfade": (t) => t.classList.toggle("ticked"),
"tile-breathe": (t) => replayAnim(t.querySelector(".tpill")),
"tile-gcard": (t) => replayAnim(t.querySelector(".gcard-mini")),
"tile-focus": (t) => t.classList.toggle("focused"),
"tile-spinner": () => {},
"tile-session": (t) => replayAnim(t.querySelector(".rows")),
"tile-kenburns": (t) => {
const sc = t.querySelector(".tscreen");
sc.classList.add("noanim");
void sc.offsetWidth;
sc.classList.remove("noanim");
},
};
document.querySelectorAll(".tile").forEach((tile) => {
const btn = tile.querySelector(".replay");
if (!btn) return;
btn.addEventListener("click", () => tileActions["tile-" + tile.dataset.tile]?.(tile));
});
/* Boot the lock stage with the entrance visible. */
replayLockEntrance();
</script>
</body>
</html>

View file

@ -1,7 +1,7 @@
# Maintainer: Breadway <plasticbread849@gmail.com>
pkgname=breadlock
pkgver=0.1.0
pkgver=0.2.0
pkgrel=1
pkgdesc="Session locker and greetd greeter for Hyprland / Wayland"
arch=('x86_64')
@ -15,15 +15,18 @@ depends=('pam' 'wayland' 'libxkbcommon' 'gtk4')
optdepends=(
'cage: minimal Wayland compositor to host breadgreet under greetd'
'hyprland: the session breadlock protects and breadgreet launches'
'upower: battery line on the lock screen'
)
makedepends=('rust' 'cargo')
backup=('etc/pam.d/breadlock')
source=("${pkgname}-${pkgver}.tar.gz")
sha256sums=('SKIP')
build() {
cd "${srcdir}/${pkgname}-${pkgver}"
# --bin (not -p breadlock) deliberately excludes the breadlock-auth-check
# dev harness, which shares the breadlock package but isn't installed.
# and breadlock-preview dev harnesses, which share the breadlock package
# but aren't installed.
cargo build --release --locked --bin breadlock --bin breadgreet
}