Compare commits

..

22 commits
v0.2.0 ... 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
Breadway
d49df0b5fa Honor bread.command.lock.unlock via loginctl unlock-session
All checks were successful
Build and publish package / package (push) Successful in 3m15s
Subscribe the same locker and breadlock listen handlers. Already
unlocked is bread.lock.unlock.done; otherwise run loginctl
unlock-session and emit done / failed. Do not call compositor
unlock() — PAM stays the compositor path (fail-secure). Document
in EVENTS.md and README; drop the "Not implemented: unlock"
paragraph. pin/blur stay unimplemented.
2026-08-16 00:24:53 +08:00
Breadway
495fe1aaad Add breadgreet session picker for installed Wayland sessions
List .desktop sessions from the existing scan, pre-select bos when
present (else the first entry), and pass the chosen Exec= argv to
greetd StartSession.
2026-08-15 23:55:32 +08:00
Breadway
80caedc7fd Honor bread.command.lock.lock; pin theme to v0.7.2
Pin bread-theme/bread-utils to bread-ecosystem tag v0.7.2.
Subscribe from the locker and from `breadlock listen` so the command
works while unlocked; start breadlock the same way hypridle does.
Emit bread.lock.lock.done / .failed. Document loginctl lock-session
as the Super+L equivalent.

Optional GPG detach-sign of the .pkg.tar.zst when GPG_PRIVATE_KEY is
set; ISO [breadway] stays SigLevel = Never until a signed db exists.
2026-08-15 22:51:35 +08:00
Breadway
76544398d3 gitignore: exclude graphify-out local cache 2026-08-15 22:32:34 +08:00
Breadway
2a541ca5e9 Wire breadlock to the bread event bus (app id lock)
Emit bread.lock.locked when ext-session-lock-v1 is accepted and
bread.lock.unlocked after PAM success. Fail-silent if breadd is down.
No command verbs (no pin/blur stubs). breadgreet is not on the bus.

Note that Forgejo Arch registry packages are unsigned (SigLevel=Never
on BOS) without changing the package.yml registry.
2026-08-15 22:15:17 +08:00
Breadway
0937c3b05c Track AGENTS.md; point CONTRIBUTING at it 2026-08-15 22:03:30 +08:00
Breadway
478d8285c3 Pin bread-theme to ecosystem v0.7.1; document BOS wiring and pacman-only exception
Point workspace bread-theme at git.breadway.dev tag v0.7.1 (gtk feature stays
on breadgreet). README no longer claims BOS ships tuigreet or that Super+L
is unimplemented. CONTRIBUTING records the single-trunk, pacman-only model;
there is still no bakery.toml.
2026-08-15 21:38:01 +08:00
a91cde5621 Merge pull request 'random commit message, read it yourself' (#1) from dev into main
Reviewed-on: #1
2026-08-03 10:53:25 +08:00
Breadway
48294db18c random commit message, read it yourself 2026-07-22 11:50:06 +08:00
Breadway
1363c95a0c ci: remove GitHub push-mirror workflow 2026-07-21 19:17:54 +08:00
41 changed files with 8507 additions and 654 deletions

View file

@ -1,21 +0,0 @@
name: Mirror to GitHub
on:
push:
branches: ['**']
tags: ['**']
jobs:
mirror:
runs-on: [self-hosted, hestia]
steps:
- name: Mirror to GitHub
run: |
set -euo pipefail
git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git
cd repo.git
# Mirror only branches and tags (not refs/pull/*, which GitHub rejects);
# --prune deletes GitHub refs that no longer exist on Forgejo.
git push --prune \
"https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadlock.git" \
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'

View file

@ -7,6 +7,10 @@ on:
jobs:
package:
runs-on: [self-hosted, hestia]
# Forgejo's Arch package registry does not GPG-sign a pacman db.
# BOS ISO [breadway] stays SigLevel = Never until a signed db exists.
# Do not flip that here — flipping without a signed db breaks pacman.
# Keep publishing the unsigned .pkg.tar.zst to the registry below.
container:
image: archlinux:latest
steps:
@ -30,11 +34,52 @@ 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/
echo "${VERSION}" > /tmp/breadlock-pkg/VERSION
curl -fsS -X PUT \
-H "Authorization: token ${PUBLISH_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${PKG}" \
"https://git.breadway.dev/api/packages/Breadway/arch/os"
# Optional detach-sign. secrets.GPG_PRIVATE_KEY is the same BOS
# release-signing key (releases@breadway.dev). If the secret is
# missing, skip — the registry PUT above already published the
# unsigned package. A lone .sig is not a signed repo: ISO
# [breadway] stays SigLevel = Never until a signed db exists.
# The .sig is uploaded as a generic-package artifact next to that
# PUT, not injected into the Arch repo (which would not make
# pacman verify anything without a signed db).
- name: Detach-sign package (optional)
env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
if [ -z "${GPG_PRIVATE_KEY:-}" ]; then
echo "GPG_PRIVATE_KEY unset; skipping detach-sign."
echo "ISO [breadway] stays SigLevel = Never until a signed db exists."
exit 0
fi
PKG=$(find /tmp/breadlock-pkg -name '*.pkg.tar.zst' | head -1)
if [ -z "$PKG" ]; then
echo "no package in /tmp/breadlock-pkg; cannot sign" >&2
exit 1
fi
VERSION=$(cat /tmp/breadlock-pkg/VERSION)
pacman -S --noconfirm --needed gnupg
export GNUPGHOME=/tmp/gnupg-breadlock
mkdir -m 700 -p "$GNUPGHOME"
echo "$GPG_PRIVATE_KEY" | gpg --batch --import
gpg --batch --yes --detach-sign -o "${PKG}.sig" "$PKG"
echo "Signed $(basename "$PKG") -> $(basename "$PKG").sig"
# Generic package: workflow artifact alongside the Arch PUT.
# Does not change [breadway] / pacman SigLevel.
curl -fsS -X PUT \
-H "Authorization: token ${PUBLISH_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${PKG}.sig" \
"https://git.breadway.dev/api/packages/Breadway/generic/breadlock/${VERSION}/$(basename "$PKG").sig"

9
.gitignore vendored
View file

@ -19,3 +19,12 @@ Thumbs.db
# Claude Code session data
.claude/
# Local hygiene notes (not for commit)
CLAUDE.md
# graphify knowledge-graph output (local tool cache, not for commit)
graphify-out/
# breadlock-preview PNG output (dev-only animation harness)
preview/

26
AGENTS.md Normal file
View file

@ -0,0 +1,26 @@
# AGENTS.md — Repo hygiene
Scope: this file covers *repo hygiene* — branching, remotes, CI, cleanup. It is not project documentation.
## Branch model
- Single-trunk: `main` only. No `dev` or `beta` branch. Land small changes directly, or use short-lived `feature/x`/`fix/x` branches for anything non-trivial and merge back to `main`.
- This replaced an earlier three-branch (`dev`/`beta`/`main`) model after `main` silently rotted across the ecosystem. Don't recreate those branches.
## Channel
- **Pacman-only, permanently.** There is no `bakery.toml` on purpose: breadlock installs a root-owned `/etc/pam.d/breadlock` PAM service (and `breadgreet` is a greetd greeter). Bakery has no privileged-install path. Do not add `bakery.toml`.
- Releases are `v*` tags. `.forgejo/workflows/package.yml` builds the `[breadway]` pacman package. There are no bakery tracks (`dev`/`beta`/`stable` indexes) for this repo.
## Remotes
- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative. Push here.
- `github` — GitHub mirror (push-mirror; do not push to it by hand).
## CI
- `.forgejo/workflows/package.yml` triggers only on `push: tags: ['v*']` — regular pushes to `main` run nothing. Tag a release to trigger packaging.
- No build/lint/test CI runs on ordinary commits or PRs — test locally before merging.
## Cleanup
- Delete feature/fix branches (local + remote) once merged. Check with `git branch --merged main`.
## Don't
- Don't add `bakery.toml`.
- Don't embed credentials in remote URLs — SSH or a credential helper only.

22
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,22 @@
# Contributing
`breadlock` / `breadgreet` — session locker and greetd greeter for Hyprland.
Single-trunk, same as the rest of the ecosystem: one long-lived branch
(`main`), short-lived `feature/<name>` / `fix/<name>` branches, merge back.
No `dev` or `beta` branch.
This repo is a deliberate **pacman-only** exception. There is no
`bakery.toml` — breadlock needs a root-owned `/etc/pam.d/breadlock` PAM
service, which bakery cannot install. Don't add one. Releases are `v*`
tags that fire `.forgejo/workflows/package.yml` into the `[breadway]`
pacman repo. There are no bakery tracks.
See `AGENTS.md` for remotes and CI details.
## Local development
```sh
cargo build --release --bin breadlock --bin breadgreet
cargo test --workspace
```

984
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,8 @@ members = ["breadlock-ui", "breadlock", "breadgreet"]
resolver = "2"
[workspace.dependencies]
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10" }
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"
toml = "0.8"

97
EVENTS.md Normal file
View file

@ -0,0 +1,97 @@
# breadlock — bread event integration
breadlock is a standalone session locker: it works exactly the same with
or without `breadd` running. When breadd *is* present, `breadlock`
publishes events into the shared bread automation fabric. See the parent
`bread` repo's `Documentation.md` — specifically its "Namespaces" and
"Integrating a bread\* app" sections — for the general convention this
follows.
App id: **`lock`**. Transport: `bread-utils`'s `bread_client` module
(feature `bread-client`) — `breadlock` links it directly. Each `emit` is
its own short-lived connection (`BreadClient::emit` is fire-and-forget).
Commands are received on a `BreadClient::subscribe` background thread
(reconnect/backoff) from two places:
- the locker process itself, while the session is locked
- `breadlock listen`, a tiny long-running subscriber so lock/unlock
work while unlocked
`breadgreet` is not wired to the bus. It runs under greetd (typically as
the dedicated greeter user, before a user session exists), so breadd is
usually not there to receive anything, and login is a different lifecycle
from session lock/unlock.
## Events published (`bread.lock.*`)
| 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, **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 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) 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`.
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
`bread.command.lock.lock` / `bread.command.lock.unlock` are a silent
no-op if nobody is subscribed (bread's usual "no listener, no-op"
rule). Two subscribers exist:
1. **`breadlock listen`** — run this for the unlocked path (Hyprland
`exec-once = breadlock listen`, a bread module, or equivalent).
Without it, a command sent while the session is unlocked has no
process to receive it. Unlock while already unlocked is an
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` is `.failed` (cannot bypass PAM). Never compositor
`unlock()`, never `loginctl unlock-session`.
### Session-level equivalent
Super+L on BOS is `loginctl lock-session`. hypridle picks that up and
runs `lock_cmd = breadlock`. That path does **not** go through the
bread command bus. It is the session-level equivalent of
`bread.command.lock.lock` + `breadlock listen`: same locker binary,
same `ext-session-lock-v1` request. Prefer `loginctl lock-session`
from a keybind; prefer the bus command from a Lua workflow.
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`
`background.blur` in `breadlock.toml` remains a documented locker
no-op (accepted, warned, surface drawn unblurred). That is appearance
config, not a bus command — do not invent `bread.command.lock.blur`
for it.
## Fail-safe behavior
- If breadd isn't installed or isn't running, `emit` is a silent no-op
(`BreadClient::emit` never blocks or errors the caller) and the
command subscription simply never receives anything — breadlock's
actual lock/unlock path is entirely unaffected either way.
- If breadd restarts, the command subscription reconnects automatically
(`BreadClient::subscribe`'s background thread has its own backoff loop);
no restart of the locker or of `breadlock listen` is needed.

View file

@ -1,14 +1,30 @@
# breadlock
Session locker and graphical [greetd](https://git.sr.ht/~kennylevinsen/greetd) greeter for [Hyprland](https://hyprland.org/) on Wayland — the bread-ecosystem replacement for `hyprlock` and the TUI greeter (`tuigreet`) BOS currently ships.
Session locker and graphical [greetd](https://git.sr.ht/~kennylevinsen/greetd) greeter for [Hyprland](https://hyprland.org/) on Wayland — the bread-ecosystem replacement for `hyprlock` and `tuigreet`. BOS already ships both binaries: `breadgreet` under `cage` via greetd, and `breadlock` via hypridle (`SUPER+L` is `loginctl lock-session`).
Two binaries, one workspace:
Two binaries, one workspace:
- **`breadlock`** — locks the *already running* Hyprland session via `ext-session-lock-v1`. Drop-in for `hyprlock`.
- **`breadgreet`** — a graphical greeter that speaks `greetd`'s own IPC protocol (the same architecture as `gtkgreet`/`regreet`). `greetd` keeps owning PAM auth, VT switching, and session launching; `breadgreet` only draws the login UI and relays the conversation. This is a deliberate choice over reimplementing a display manager from scratch — `greetd` is already installed and battle-tested.
Both use [`bread-theme`](https://git.breadway.dev/Breadway/bread-ecosystem) for palette loading, matching the rest of the bread* ecosystem (breadbar, breadbox, bos-settings).
## bread event integration
`breadlock` works the same with or without `breadd`. When `breadd` is
running, it publishes `bread.lock.locked` / `bread.lock.unlocked` and
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 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
```
@ -16,27 +32,29 @@ 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). Live blur-of-desktop (hyprlock-style) is a **v2 follow-up** — it needs a `wlr-screencopy` capture (`libwayshot` is the right crate when this gets picked up); `background.blur = true` is accepted today but just logs a warning.
- **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
- **Protocol**: [`greetd_ipc`](https://crates.io/crates/greetd_ipc) (greetd's own crate) over the Unix socket at `$GREETD_SOCK`: `CreateSession` → answer each `AuthMessage` via `PostAuthMessageResponse``StartSession` hands the resolved session command to `greetd`, which execs it and owns the VT switch away.
- **UI**: GTK4 + [relm4](https://relm4.org/), matching breadbar's stack — **without** `gtk4-layer-shell`. `greetd` hosts the greeter under a single-client kiosk compositor (`cage -s`), which already fullscreens its one client, so layer-shell's multi-surface/anchor semantics don't apply. Confirmed against ReGreet's real dependency list, which has no layer-shell dependency either.
- **Sessions**: scans `/usr/share/wayland-sessions` and `/usr/share/xsessions` for `.desktop` entries and auto-selects the configured default (or the only one found). BOS ships one session today, so there's no picker UI in v1 — a natural v2 addition if that changes.
- **Sessions**: scans `/usr/share/wayland-sessions` and `/usr/share/xsessions` for `.desktop` entries and shows a keyboard-accessible picker. The configured default (compiled-in: `bos`) is pre-selected when that stem exists; otherwise the first discovered session. `StartSession` is the chosen entry's `Exec=` argv.
## Config
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
@ -44,39 +62,46 @@ 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
`packaging/arch/PKGBUILD` builds and installs both binaries plus `/etc/pam.d/breadlock`, published to the `[breadway]` pacman repo by `.forgejo/workflows/package.yml`. breadlock is pacman-only — it is not in bread-ecosystem's registry and has no `bakery.toml`; a PAM/greeter component gets installed through the package manager, not the bakery curl-script channel.
`packaging/arch/PKGBUILD` builds and installs both binaries plus `/etc/pam.d/breadlock`, published to the `[breadway]` pacman repo by `.forgejo/workflows/package.yml`. breadlock is a deliberate **pacman-only** exception — there is no `bakery.toml` on purpose. A PAM service and greetd greeter need a root-owned install (`/etc/pam.d/breadlock`), which bakery has no privileged path for.
**Not included, by design**: this repo does not touch `/etc/greetd/config.toml`, install a lock keybind, or wire up `hypridle`. Once packaged, wiring BOS to actually use these binaries means:
BOS already wires the packaged binaries (this repo still does not ship those system files):
```toml
# /etc/greetd/config.toml — replace the current tuigreet line
# /etc/greetd/config.toml — BOS default
[default_session]
command = "cage -s -- breadgreet"
```
```
# hyprland.conf
bind = SUPER, L, exec, breadlock
# hypridle lock_cmd (BOS). SUPER+L is loginctl lock-session, which hypridle picks up.
lock_cmd = breadlock
```
That's a separate, later BOS task — deliberately kept out of this change so the existing `tuigreet` login path stays untouched and available as a fallback while these binaries are tested.
`breadlock listen` is the unlocked-path subscriber for
`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 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)
1. **PAM logic in isolation first**: `cargo run --bin breadlock-auth-check` exercises the exact PAM flow `breadlock` uses, against a typed password, with **no Wayland surface at all**. A bad `/etc/pam.d/breadlock` just prints an error here — it can never lock a session.
2. **Locker rendering/lock lifecycle nested, never against the live session**: run `breadlock` inside a nested Hyprland instance or under `cage -- breadlock`. `ext-session-lock-v1` only ever affects the compositor instance the client is connected to (scoped to `$WAYLAND_DISPLAY`), so a nested lock can never lock the real outer session. Verify the full type-password → PAM check → unlock cycle there, including the wrong-password path, before ever binding a real keybind.
3. **If testing against a live session**: keep a second TTY or SSH session open the whole time. Killing the `breadlock` process is **not** a safe unlock path — per the protocol, an abnormally-terminated lock client is expected to leave the compositor still locked. The real recovery path is "kill it, then use the second session to restart Hyprland or switch VT."
4. **breadgreet**: `cargo test -p breadgreet` runs the `greetd_ipc` framing/state-machine tests against a mock Unix-socket server — no real `greetd` or PAM involved. Manual testing against a real `greetd` should happen on a disposable VT, leaving the existing `tuigreet` config on VT1 untouched as a fallback.
4. **breadgreet**: `cargo test -p breadgreet` runs the `greetd_ipc` framing/state-machine tests against a mock Unix-socket server — no real `greetd` or PAM involved. Manual testing against a real `greetd` should happen on a disposable VT, not by replacing the live BOS `cage -s -- breadgreet` session on VT1.
## License

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"
@ -20,7 +25,8 @@ family = "Varela Round"
# Directories scanned for .desktop session entries, in order.
wayland_dirs = ["/usr/share/wayland-sessions"]
xsessions_dirs = ["/usr/share/xsessions"]
# .desktop file stem (without extension) to auto-select. Falls back to the
# first entry found if this isn't present. v1 has no session picker UI —
# BOS only ships one session (Hyprland) today.
default = "hyprland"
# .desktop file stem (without extension) pre-selected in the picker.
# Falls back to the first entry found if this isn't present. BOS ships
# bos.desktop (Exec=bos-session); leaving this as "bos" is what the ISO
# config expects so Hyprland's own hyprland.desktop is not picked first.
default = "bos"

View file

@ -15,7 +15,8 @@ pub struct Config {
pub struct Sessions {
pub wayland_dirs: Vec<String>,
pub xsessions_dirs: Vec<String>,
/// `.desktop` file stem (without extension) to auto-select.
/// `.desktop` file stem (without extension) to pre-select in the picker.
/// Falls back to the first discovered session if this stem is missing.
pub default: String,
}
@ -24,7 +25,7 @@ impl Default for Sessions {
Self {
wayland_dirs: vec!["/usr/share/wayland-sessions".to_string()],
xsessions_dirs: vec!["/usr/share/xsessions".to_string()],
default: "hyprland".to_string(),
default: "bos".to_string(),
}
}
}
@ -41,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)]
@ -58,6 +62,6 @@ mod tests {
let s = Sessions::default();
assert_eq!(s.wayland_dirs, vec!["/usr/share/wayland-sessions"]);
assert_eq!(s.xsessions_dirs, vec!["/usr/share/xsessions"]);
assert_eq!(s.default, "hyprland");
assert_eq!(s.default, "bos");
}
}

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)]
@ -37,17 +35,27 @@ enum AppInput {
Outcome(Outcome),
Error(String),
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,
username: String,
session: Option<sessions::Session>,
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]
@ -61,11 +69,18 @@ impl SimpleComponent for App {
add_css_class: "breadgreet",
set_title: Some("breadgreet"),
#[name = "root_box"]
gtk4::Box {
set_orientation: gtk4::Orientation::Vertical,
set_halign: gtk4::Align::Center,
set_valign: gtk4::Align::Center,
#[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,
set_halign: gtk4::Align::Center,
set_valign: gtk4::Align::Center,
}
}
}
}
@ -78,15 +93,41 @@ impl SimpleComponent for App {
root.fullscreen();
let config = config::load();
let session = sessions::discover(
let sessions = sessions::list(
&config.sessions.wayland_dirs,
&config.sessions.xsessions_dirs,
);
// Same default rule as `discover()`: configured stem (compiled-in
// `bos`), else the first listed session.
let selected = sessions::discover(
&config.sessions.wayland_dirs,
&config.sessions.xsessions_dirs,
&config.sessions.default,
);
)
.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"));
@ -99,41 +140,124 @@ impl SimpleComponent for App {
let status_lbl = gtk4::Label::new(None);
status_lbl.add_css_class("login-status");
let session_lbl = gtk4::Label::new(session.as_ref().map(|s| s.name.as_str()));
session_lbl.add_css_class("login-session");
if session.is_none() {
session_lbl.set_label("No session found — cannot log in");
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");
session_lbl.upcast()
} else {
let names: Vec<&str> = sessions.iter().map(|s| s.name.as_str()).collect();
let dropdown = gtk4::DropDown::from_strings(&names);
dropdown.add_css_class("login-session");
dropdown.set_hexpand(true);
dropdown.set_focusable(true);
dropdown.set_tooltip_text(Some("Session"));
dropdown.update_property(&[gtk4::accessible::Property::Label("Session")]);
dropdown.set_selected(selected as u32);
{
let sender = sender.clone();
dropdown.connect_selected_notify(move |dd| {
sender.input(AppInput::SessionSelected(dd.selected()));
});
}
dropdown.upcast()
};
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 8);
card.add_css_class("login-card");
card.append(&entry);
card.append(&status_lbl);
card.append(&session_lbl);
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,
username: String::new(),
session,
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 }
}
@ -142,38 +266,45 @@ 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;
if idx < self.sessions.len() {
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;
}
@ -181,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.entry.set_sensitive(true);
self.entry.grab_focus();
self.stage = Stage::Prompt;
self.pam_status_held = true;
self.dispatch(greetd::Command::Respond(None));
}
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;
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;
}
fn start_session(&mut self) {
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…");
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 start_session(&mut self) {
let Some(session) = &self.session else {
self.status_lbl.set_label("No session available to start");
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");
return;
};
self.status_lbl.set_label("Starting session…");
let _ = self.cmd_tx.send(GreetdCommand::StartSession {
cmd: session.exec.clone(),
env: Vec::new(),
});
}
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();
}
}
}
/// 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`].
fn spawn_greetd_actor(
mut cmd_rx: mpsc::UnboundedReceiver<GreetdCommand>,
sender: ComponentSender<App>,
/// 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,
) {
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;
}
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);
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),
}
}
};
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();
});
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()));
}
}
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` and translates the
/// UI's [`greetd::Command`]s into greetd IPC round-trips, forwarding each
/// outcome back as an [`AppInput`].
fn spawn_greetd_actor(
cmd_rx: mpsc::UnboundedReceiver<greetd::Command>,
sender: ComponentSender<App>,
) {
let input = sender.input_sender().clone();
relm4::spawn(async move {
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,
};
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;
}
}
});
}
@ -302,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

@ -1,15 +1,77 @@
//! Session discovery: scans the standard greetd-greeter session directories
//! for `.desktop` entries. BOS effectively ships one session (Hyprland via
//! `bos-session`), so v1 has no picker UI — it just auto-selects the
//! configured default (or the only entry found) and resolves its `Exec=`
//! line to hand to `greetd`'s `StartSession`.
//! for `.desktop` entries, lists them for the picker, and resolves the
//! chosen entry's `Exec=` line for `greetd`'s `StartSession`.
//!
//! Default selection matches by `.desktop` file stem (`bos` compiled-in,
//! overridable via `[sessions].default`). If that stem is missing, the
//! first entry from `wayland_dirs` then `xsessions_dirs` is used.
use breadlock_ui::desktop_entry::{scan_dir, DesktopEntry};
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
/// `[sessions].default`.
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();
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,
});
}
}
}
/// Index of the configured default stem, or `0` if it is absent. Callers
/// with an empty list should not use this as a subscript.
pub fn default_index(sessions: &[Session], default: &str) -> usize {
sessions.iter().position(|s| s.stem == default).unwrap_or(0)
}
/// Scans `wayland_dirs` then `xsessions_dirs` (in that order) and returns
@ -21,34 +83,72 @@ pub fn discover(
xsessions_dirs: &[String],
default: &str,
) -> Option<Session> {
let mut all: Vec<(String, DesktopEntry)> = Vec::new();
for dir in wayland_dirs.iter().chain(xsessions_dirs) {
all.extend(scan_dir(Path::new(dir)));
}
let chosen = all
.iter()
.find(|(stem, _)| stem == default)
.or_else(|| all.first())?;
Some(Session {
name: chosen.1.name.clone(),
exec: split_exec(&chosen.1.exec),
})
let all = list(wayland_dirs, xsessions_dirs);
let idx = default_index(&all, default);
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::*;
@ -59,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(
@ -69,26 +183,92 @@ mod tests {
.is_none());
}
fn write_fixture(dir: &std::path::Path, stem: &str, name: &str, exec: &str) {
std::fs::write(
dir.join(format!("{stem}.desktop")),
format!("[Desktop Entry]\nName={name}\nExec={exec}\n"),
)
.unwrap();
}
#[test]
fn discover_prefers_configured_default_over_first_entry() {
let dir = std::env::temp_dir().join("breadgreet-test-sessions-discover");
let dir = std::env::temp_dir().join(format!(
"breadgreet-test-sessions-discover-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("aaa.desktop"),
"[Desktop Entry]\nName=A\nExec=a-cmd\n",
)
.unwrap();
std::fs::write(
dir.join("hyprland.desktop"),
"[Desktop Entry]\nName=Hyprland\nExec=Hyprland\n",
)
.unwrap();
write_fixture(&dir, "aaa", "A", "a-cmd");
write_fixture(&dir, "hyprland", "Hyprland", "Hyprland");
let dir_str = dir.to_str().unwrap().to_string();
let session = discover(&[dir_str], &[], "hyprland").unwrap();
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();
}
#[test]
fn list_returns_all_sessions_wayland_then_x() {
let pid = std::process::id();
let wayland = std::env::temp_dir().join(format!("breadgreet-test-sessions-list-w-{pid}"));
let x11 = std::env::temp_dir().join(format!("breadgreet-test-sessions-list-x-{pid}"));
std::fs::create_dir_all(&wayland).unwrap();
std::fs::create_dir_all(&x11).unwrap();
write_fixture(&wayland, "bos", "BOS", "/usr/local/bin/bos-session");
write_fixture(&wayland, "hyprland", "Hyprland", "Hyprland");
write_fixture(&x11, "openbox", "Openbox", "openbox-session");
let listed = list(
&[wayland.to_str().unwrap().to_string()],
&[x11.to_str().unwrap().to_string()],
);
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();
}
#[test]
fn default_index_prefers_bos_then_first() {
let sessions = vec![
Session {
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);
assert_eq!(default_index(&sessions, "missing"), 0);
assert_eq!(default_index(&[], "bos"), 0);
}
}

View file

@ -6,30 +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.6; margin-top: 12px; }}",
.login-session {{ font-size: 12px; opacity: 0.85; margin-top: 12px; }}\
dropdown.login-session {{ min-height: 32px; }}\
.login-veil {{ background-image: linear-gradient(to bottom, rgba(0,0,0,0.34) 0%, rgba(0,0,0,0.16) 100%); }}",
bg = p.background,
surface = p.color0,
red = p.color1,
on_bg = ink_on(&p.background),
on_surface = ink_on(&p.color0),
font = font,
)
}
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(
&mut self.font_system,
&mut self.swash_cache,
text_color,
|x, y, _w, _h, glyph_color| {
let (px, py) = (ox + x, oy + y);
if px < 0 || py < 0 || px >= width || py >= height {
return;
}
let (r, g, b, a) = glyph_color.as_rgba_tuple();
if a == 0 {
return;
}
blend_over_opaque(pixmap, px as u32, py as u32, r, g, b, a);
},
);
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,
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) = color.as_rgba_tuple();
if a == 0 {
return;
}
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(
mix(r, dst.red()),
mix(g, dst.green()),
mix(b, dst.blue()),
255,
);
if let Some(blended) = blended {
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,
) {
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,28 @@ 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
tracing.workspace = true
tracing-subscriber.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 result = pam::check(&username, &password);
let _ = result_tx.send(result);
let (done_tx, done_rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let result = pam::check(&username, &password);
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);
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,
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);
target.draw_pixmap(
0,
0,
source.as_ref(),
&PixmapPaint::default(),
Transform::from_scale(scale, scale),
None,
);
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

@ -0,0 +1,344 @@
//! `bread.lock.*` event integration — optional, non-blocking. See
//! `EVENTS.md` at the repo root for the full contract. breadlock works
//! identically with or without breadd running; every `emit` here is
//! fire-and-forget (`BreadClient::emit` never blocks or errors this
//! process) so a missing or restarting breadd never affects locking
//! itself.
//!
//! `bread.command.lock.lock` and `bread.command.lock.unlock` are the
//! verbs this process honors. The locker subscribes while the session is
//! 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`). 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};
use bread_utils::singleton::{try_acquire, Acquire};
/// This app's id in bread's sibling-app namespace registry
/// (`bread_shared::apps::KNOWN_APPS`) — events publish as `bread.lock.*`,
/// commands arrive on `bread.command.lock.*`.
pub const APP_ID: &str = "lock";
/// Distinct singleton for `breadlock listen` so a listen process and a
/// 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!({}));
}
pub fn emit_unlocked() {
BreadClient::connect(APP_ID).emit("bread.lock.unlocked", serde_json::json!({}));
}
pub fn emit_lock_done() {
BreadClient::connect(APP_ID).emit("bread.lock.lock.done", serde_json::json!({}));
}
pub fn emit_lock_failed(error: &str) {
BreadClient::connect(APP_ID).emit(
"bread.lock.lock.failed",
serde_json::json!({ "error": error }),
);
}
pub fn emit_unlock_done() {
BreadClient::connect(APP_ID).emit("bread.lock.unlock.done", serde_json::json!({}));
}
pub fn emit_unlock_failed(error: &str) {
BreadClient::connect(APP_ID).emit(
"bread.lock.unlock.failed",
serde_json::json!({ "error": error }),
);
}
/// 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 {
LOCKER_RUNNING.load(Ordering::SeqCst) || singleton_held(APP_ID)
}
fn singleton_held(app: &str) -> bool {
match try_acquire(app) {
Ok(Acquire::HeldByOther(_)) => true,
Ok(Acquire::Acquired(_guard)) => false,
Err(_) => false,
}
}
/// Start a locker the same way hypridle's `lock_cmd = breadlock` does:
/// this binary, no args. The child is reaped on a background thread so
/// a later unlock cannot leave a zombie under `breadlock listen`.
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 || {
let _ = child.wait();
});
Ok(())
}
/// Honor `bread.command.lock.lock`: already locked is success; otherwise
/// start the locker. `done` means the command was acted on, not that
/// `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(locker_is_running(), start_locker);
}
/// 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`. 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(), emit_unlock_done, emit_unlock_failed);
}
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;
}
match start() {
Ok(()) => {
tracing::info!("bread.command.lock.lock: started breadlock");
emit_lock_done();
}
Err(error) => {
tracing::error!(%error, "bread.command.lock.lock: failed to start breadlock");
emit_lock_failed(&error);
}
}
}
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_done();
return;
}
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" => on_lock(),
"unlock" => on_unlock(),
other => tracing::info!(verb = other, "ignoring unknown bread.command.lock verb"),
}
}
/// Subscribe to commands addressed to this app. Keep the handle alive
/// for as long as this process should honor them.
pub fn subscribe_commands() -> Subscription {
BreadClient::connect(APP_ID).subscribe("bread.command.lock.**", |event| {
handle_command(&event);
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
fn event(name: &str) -> BreadEvent {
BreadEvent {
event: name.to_string(),
timestamp: 0,
data: serde_json::json!({}),
}
}
#[test]
fn handle_command_ignores_unrecognized_verb() {
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]
fn singleton_held_is_false_when_nothing_holds_the_name() {
let app = format!("breadlock-test-held-false-{}", std::process::id());
assert!(!singleton_held(&app));
}
#[test]
fn singleton_held_is_true_while_this_process_holds_the_name() {
let app = format!("breadlock-test-held-true-{}", std::process::id());
let guard = match try_acquire(&app).unwrap() {
Acquire::Acquired(g) => g,
Acquire::HeldByOther(_) => panic!("expected to be the first instance"),
};
assert!(singleton_held(&app));
drop(guard);
assert!(!singleton_held(&app));
}
#[test]
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!(started.get());
}
#[test]
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_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 let Some(keyboard) = self.keyboard.take() {
keyboard.release();
}
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,10 +267,21 @@ 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.clear_failed_state();
}
}
}
@ -187,8 +290,16 @@ impl AppState {
}
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

@ -9,20 +9,34 @@ impl SessionLockHandler for AppState {
fn locked(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, session_lock: SessionLock) {
tracing::info!("session locked");
self.session_lock = Some(session_lock);
crate::bread_events::emit_locked();
}
/// The compositor denied the lock request, or ended an active lock out
/// 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;
}
@ -35,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

@ -1,10 +1,13 @@
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;
@ -20,25 +23,145 @@ use wayland_client::globals::registry_queue_init;
use wayland_client::{protocol::wl_buffer, Connection, QueueHandle};
use background::Background;
use bread_utils::singleton::{try_acquire, Acquire};
use state::{AppState, AuthState, LockSurface};
#[derive(Debug, PartialEq, Eq)]
enum Mode {
Lock,
Listen,
Help,
}
fn parse_mode<I, S>(args: I) -> Result<Mode, String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut args = args.into_iter();
match args.next().as_ref().map(|s| s.as_ref()) {
None => Ok(Mode::Lock),
Some("listen") if args.next().is_none() => Ok(Mode::Listen),
Some("-h" | "--help" | "help") => Ok(Mode::Help),
Some("listen") => Err("listen takes no arguments".into()),
Some(other) => Err(format!("unknown argument '{other}'")),
}
}
fn print_usage() {
eprintln!(
"Usage: breadlock [listen]\n\
\n\
(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 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."
);
}
fn main() {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
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");
match parse_mode(std::env::args().skip(1)) {
Ok(Mode::Lock) => run_lock(),
Ok(Mode::Listen) => run_listen(),
Ok(Mode::Help) => print_usage(),
Err(err) => {
eprintln!("breadlock: {err}");
print_usage();
std::process::exit(2);
}
}
}
/// 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). 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,
Ok(Acquire::HeldByOther(pid)) => {
tracing::info!(?pid, "breadlock listen already running");
return;
}
Err(err) => {
tracing::error!(%err, "failed to acquire listen singleton");
std::process::exit(1);
});
}
};
// Common when started early in the session (exec-once). The locker we
// spawn needs WAYLAND_DISPLAY; we stay up either way so a later command
// still has a subscriber.
for _ in 0..20 {
if std::env::var_os("WAYLAND_DISPLAY").is_some() {
break;
}
std::thread::sleep(Duration::from_millis(500));
}
if std::env::var_os("WAYLAND_DISPLAY").is_none() {
tracing::warn!("WAYLAND_DISPLAY not set; spawned breadlock will fail until it is");
}
let _commands = bread_events::subscribe_commands();
tracing::info!("listening for bread.command.lock.lock / unlock");
loop {
std::thread::sleep(Duration::from_secs(3600));
}
}
fn run_lock() {
let _locker_guard = match try_acquire(bread_events::APP_ID) {
Ok(Acquire::Acquired(g)) => Some(g),
Ok(Acquire::HeldByOther(pid)) => {
tracing::info!(?pid, "session already locked by another breadlock; exiting");
return;
}
Err(err) => {
// Refusing to lock because flock failed would be worse than
// running without the singleton — hypridle still needs a locker.
tracing::warn!(%err, "could not acquire lock singleton; continuing");
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 .failed —
// never compositor unlock() or loginctl). Unlocked-path commands
// need `breadlock listen`.
let _commands = bread_events::subscribe_commands();
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();
@ -47,14 +170,22 @@ fn main() {
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();
// 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 {
@ -68,11 +199,16 @@ fn main() {
%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());
@ -81,6 +217,17 @@ fn main() {
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);
@ -99,17 +246,44 @@ fn main() {
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,
};
@ -129,10 +303,18 @@ fn main() {
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");
@ -165,7 +347,13 @@ fn main() {
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!(
@ -202,3 +390,32 @@ smithay_client_toolkit::delegate_seat!(AppState);
smithay_client_toolkit::delegate_keyboard!(AppState);
smithay_client_toolkit::delegate_registry!(AppState);
wayland_client::delegate_noop!(AppState: ignore wl_buffer::WlBuffer);
#[cfg(test)]
mod tests {
use super::{parse_mode, Mode};
#[test]
fn parse_mode_no_args_is_lock() {
let args: [&str; 0] = [];
assert_eq!(parse_mode(args), Ok(Mode::Lock));
}
#[test]
fn parse_mode_listen() {
assert_eq!(parse_mode(["listen"]), Ok(Mode::Listen));
}
#[test]
fn parse_mode_help() {
assert_eq!(parse_mode(["--help"]), Ok(Mode::Help));
assert_eq!(parse_mode(["-h"]), Ok(Mode::Help));
assert_eq!(parse_mode(["help"]), Ok(Mode::Help));
}
#[test]
fn parse_mode_rejects_unknown_and_extra_listen_args() {
assert!(parse_mode(["unlock"]).is_err());
assert!(parse_mode(["listen", "--foreground"]).is_err());
}
}

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,
Err(err) => {
tracing::error!(%err, "failed to allocate shm pool for lock surface redraw");
return;
}
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,
};
render::blit_to_shm(&pixmap, pool.mmap());
let buffer = pool.create_buffer(
0,
width as i32,
height as i32,
stride as i32,
wl_shm::Format::Argb8888,
(),
qh,
);
let idx = self
.surfaces
.iter()
.position(|s| s.surface.wl_surface() == surface.wl_surface());
let Some(idx) = idx else {
return;
};
surface.wl_surface().attach(Some(&buffer), 0, 0);
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;
}
}
}
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,
wl_shm::Format::Argb8888,
) {
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);
}
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
}