From 1363c95a0c74a4b1c32378465370f9317e117423 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 21 Jul 2026 19:17:54 +0800 Subject: [PATCH 01/20] ci: remove GitHub push-mirror workflow --- .forgejo/workflows/mirror.yml | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 .forgejo/workflows/mirror.yml diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml deleted file mode 100644 index eac2504..0000000 --- a/.forgejo/workflows/mirror.yml +++ /dev/null @@ -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/*' From 48294db18c8782ad7d1631b4a42cb7e9c5149f37 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 11:50:06 +0800 Subject: [PATCH 02/20] random commit message, read it yourself --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index f3bd9af..d6fe8f9 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ Thumbs.db # Claude Code session data .claude/ + +# Local hygiene notes (not for commit) +CLAUDE.md From 478d8285c36fe383b7cc084e0c3486b31a505863 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 21:38:01 +0800 Subject: [PATCH 03/20] 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. --- CONTRIBUTING.md | 22 ++++++++++++++++++++++ Cargo.lock | 4 ++-- Cargo.toml | 2 +- README.md | 18 ++++++++---------- 4 files changed, 33 insertions(+), 13 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c60c189 --- /dev/null +++ b/CONTRIBUTING.md @@ -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/` / `fix/` 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 `CLAUDE.md` (local hygiene notes) for remotes and CI details. + +## Local development + +```sh +cargo build --release --bin breadlock --bin breadgreet +cargo test --workspace +``` diff --git a/Cargo.lock b/Cargo.lock index 4e04b04..7b3404e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,8 +66,8 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.2.3" -source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.10#17d1bb85801b9a8c195b64c02d288cd662c9c780" +version = "0.3.1" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" dependencies = [ "dirs", "gtk4", diff --git a/Cargo.toml b/Cargo.toml index 0c077cc..eb392dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ 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.1" } serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" diff --git a/README.md b/README.md index 3b49969..67449ee 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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: @@ -24,7 +24,7 @@ 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. +- **Background**: a solid palette color or a static PNG (cover-fit). `background.blur` is **not implemented** — the key is accepted and logs a warning; the surface is drawn unblurred. Live blur-of-desktop (hyprlock-style) would need a `wlr-screencopy` capture. - **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 @@ -54,29 +54,27 @@ sudo pacman -S gtk4 wayland libxkbcommon pam rust cargo ## 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. - ## 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 From 0937c3b05cfa326888ca6030ef3cbeebfc69f3dc Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:03:30 +0800 Subject: [PATCH 04/20] Track AGENTS.md; point CONTRIBUTING at it --- .gitignore | 1 - AGENTS.md | 26 ++++++++++++++++++++++++++ CONTRIBUTING.md | 2 +- 3 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 AGENTS.md diff --git a/.gitignore b/.gitignore index d6fe8f9..37a962f 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,3 @@ Thumbs.db .claude/ # Local hygiene notes (not for commit) -CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e1c15f4 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c60c189..c8b1d9c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ 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 `CLAUDE.md` (local hygiene notes) for remotes and CI details. +See `AGENTS.md` for remotes and CI details. ## Local development From 2a541ca5e9b7c89ff4301f64ffbe75f26a381989 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:15:17 +0800 Subject: [PATCH 05/20] 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. --- .forgejo/workflows/package.yml | 4 ++++ Cargo.lock | 24 +++++++++++++++++++ Cargo.toml | 1 + EVENTS.md | 44 ++++++++++++++++++++++++++++++++++ README.md | 7 ++++++ breadlock/Cargo.toml | 2 ++ breadlock/src/bread_events.rs | 20 ++++++++++++++++ breadlock/src/lock/session.rs | 1 + breadlock/src/main.rs | 2 ++ 9 files changed, 105 insertions(+) create mode 100644 EVENTS.md create mode 100644 breadlock/src/bread_events.rs diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 7ee14ba..9cfa436 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -7,6 +7,10 @@ on: jobs: package: runs-on: [self-hosted, hestia] + # Forgejo's Arch package registry does not GPG-sign packages for pacman. + # BOS therefore uses SigLevel=Never on [Breadway.os.git.breadway.dev] + # until a signed repo exists. Keep publishing here — do not flip this + # job to a different registry just to get signatures. container: image: archlinux:latest steps: diff --git a/Cargo.lock b/Cargo.lock index 7b3404e..5f96f51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,6 +64,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bread-shared" +version = "0.7.0" +source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b" +dependencies = [ + "dirs", + "serde", + "serde_json", + "toml 0.8.23", +] + [[package]] name = "bread-theme" version = "0.3.1" @@ -75,6 +86,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "bread-utils" +version = "0.3.1" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" +dependencies = [ + "bread-shared", + "dirs", + "serde", + "serde_json", +] + [[package]] name = "breadgreet" version = "0.2.0" @@ -97,10 +119,12 @@ dependencies = [ name = "breadlock" version = "0.2.0" dependencies = [ + "bread-utils", "breadlock-ui", "chrono", "pam-client2", "serde", + "serde_json", "smithay-client-toolkit", "thiserror 2.0.18", "tiny-skia", diff --git a/Cargo.toml b/Cargo.toml index eb392dc..0cfefb2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ resolver = "2" [workspace.dependencies] bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1" } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1" } serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" diff --git a/EVENTS.md b/EVENTS.md new file mode 100644 index 0000000..208e0f7 --- /dev/null +++ b/EVENTS.md @@ -0,0 +1,44 @@ +# 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); +there is no long-running subscription half because breadlock has no +command verbs (see below). + +`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. Not emitted on a compositor-ended lock (`finished`), a dispatch-error exit (fail-secure: the session stays locked), or a failed/typo password. | + +## Commands honored (`bread.command.lock.*`) + +None. breadlock is started by hypridle / `loginctl lock-session` (or +directly) and unlocks only via PAM on this process. There is no +`lock`/`unlock`/`pin`/`blur` verb, and none is stubbed as a no-op. + +`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) — breadlock's + actual lock/unlock path is entirely unaffected either way. +- There is no command subscription, so a breadd restart while the lock + screen is up changes nothing on this side. diff --git a/README.md b/README.md index 67449ee..a59182f 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,13 @@ Two binaries, one workspace: 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`. See +[EVENTS.md](EVENTS.md) for the bus contract. `breadgreet` is not on the +bus. There is no `bakery.toml` (PAM / pacman exception). + ## Architecture ``` diff --git a/breadlock/Cargo.toml b/breadlock/Cargo.toml index 003840a..5e2dc70 100644 --- a/breadlock/Cargo.toml +++ b/breadlock/Cargo.toml @@ -19,6 +19,7 @@ path = "src/bin/breadlock-auth-check.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" tiny-skia = "0.12" @@ -26,6 +27,7 @@ chrono = "0.4" pam-client2 = { version = "0.5", default-features = false } zeroize = { version = "1", features = ["std"] } serde.workspace = true +serde_json.workspace = true toml.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/breadlock/src/bread_events.rs b/breadlock/src/bread_events.rs new file mode 100644 index 0000000..7cc1492 --- /dev/null +++ b/breadlock/src/bread_events.rs @@ -0,0 +1,20 @@ +//! `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 call here is +//! fire-and-forget (`BreadClient::emit` never blocks or errors this +//! process) so a missing or restarting breadd never affects locking +//! itself. + +use bread_utils::bread_client::BreadClient; + +/// This app's id in bread's sibling-app namespace registry +/// (`bread_shared::apps::KNOWN_APPS`) — events publish as `bread.lock.*`. +pub const APP_ID: &str = "lock"; + +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!({})); +} diff --git a/breadlock/src/lock/session.rs b/breadlock/src/lock/session.rs index a6adf14..a3c0ba4 100644 --- a/breadlock/src/lock/session.rs +++ b/breadlock/src/lock/session.rs @@ -9,6 +9,7 @@ impl SessionLockHandler for AppState { fn locked(&mut self, _conn: &Connection, _qh: &QueueHandle, 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 diff --git a/breadlock/src/main.rs b/breadlock/src/main.rs index 484b06a..0e9a65f 100644 --- a/breadlock/src/main.rs +++ b/breadlock/src/main.rs @@ -1,5 +1,6 @@ mod auth; mod background; +mod bread_events; mod config; mod input; mod lock; @@ -53,6 +54,7 @@ fn main() { tracing::info!("authenticated, unlocking"); if let Some(lock) = state.session_lock.take() { lock.unlock(); + bread_events::emit_unlocked(); } state.exit = true; } From 76544398d3a917767e670cb0e38af3f612ee1f6c Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:32:34 +0800 Subject: [PATCH 06/20] gitignore: exclude graphify-out local cache --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 37a962f..ec10cb0 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ Thumbs.db .claude/ # Local hygiene notes (not for commit) +CLAUDE.md + +# graphify knowledge-graph output (local tool cache, not for commit) +graphify-out/ From 80caedc7fd40b6c34a0502478206be50c8d6249d Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:51:35 +0800 Subject: [PATCH 07/20] 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. --- .forgejo/workflows/package.yml | 50 ++++++++++- Cargo.lock | 8 +- Cargo.toml | 4 +- EVENTS.md | 62 ++++++++++--- README.md | 16 +++- breadlock/src/bread_events.rs | 157 ++++++++++++++++++++++++++++++++- breadlock/src/main.rs | 132 +++++++++++++++++++++++++++ 7 files changed, 401 insertions(+), 28 deletions(-) diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 9cfa436..0705ebb 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -7,10 +7,10 @@ on: jobs: package: runs-on: [self-hosted, hestia] - # Forgejo's Arch package registry does not GPG-sign packages for pacman. - # BOS therefore uses SigLevel=Never on [Breadway.os.git.breadway.dev] - # until a signed repo exists. Keep publishing here — do not flip this - # job to a different registry just to get signatures. + # 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: @@ -37,8 +37,50 @@ jobs: # --nocheck: packaging builds the artifact; tests belong in a CI job. su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck" 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" diff --git a/Cargo.lock b/Cargo.lock index 5f96f51..536cd82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -77,8 +77,8 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" +version = "0.7.2" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" dependencies = [ "dirs", "gtk4", @@ -88,8 +88,8 @@ dependencies = [ [[package]] name = "bread-utils" -version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" +version = "0.7.2" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" dependencies = [ "bread-shared", "dirs", diff --git a/Cargo.toml b/Cargo.toml index 0cfefb2..7402de6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,8 +3,8 @@ members = ["breadlock-ui", "breadlock", "breadgreet"] resolver = "2" [workspace.dependencies] -bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1" } -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1" } +bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" } +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" diff --git a/EVENTS.md b/EVENTS.md index 208e0f7..9b5801c 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -9,9 +9,13 @@ 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); -there is no long-running subscription half because breadlock has no -command verbs (see below). +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 the command + works 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 @@ -24,21 +28,55 @@ from session lock/unlock. |-------|------|------| | `bread.lock.locked` | `{}` | The compositor accepted the `ext-session-lock-v1` request (`SessionLockHandler::locked`). Not emitted merely because breadlock started or asked to lock. | | `bread.lock.unlocked` | `{}` | PAM authenticated successfully and breadlock sent `unlock` to the compositor. Not emitted on a compositor-ended lock (`finished`), a dispatch-error exit (fail-secure: the session stays locked), or a failed/typo password. | +| `bread.lock.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": "" }` | `bread.command.lock.lock` was received but the locker could not be started (e.g. this binary is missing from disk). | ## Commands honored (`bread.command.lock.*`) -None. breadlock is started by hypridle / `loginctl lock-session` (or -directly) and unlocks only via PAM on this process. There is no -`lock`/`unlock`/`pin`/`blur` verb, and none is stubbed as a no-op. +| 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`. | -`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. +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`. + +### Who is listening + +`bread.command.lock.lock` is 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. +2. **The locker process** — always subscribes once the lock screen is + up, so a command received during an active lock is an idempotent + `done`. + +### 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. + +### Not implemented: `unlock` / `pin` / `blur` + +Unlock is PAM on this process only — there is no `bread.command.lock.unlock` +and none is stubbed. `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) — breadlock's + (`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. -- There is no command subscription, so a breadd restart while the lock - screen is up changes nothing on this side. +- 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. diff --git a/README.md b/README.md index a59182f..230f94d 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,14 @@ Both use [`bread-theme`](https://git.breadway.dev/Breadway/bread-ecosystem) for ## bread event integration `breadlock` works the same with or without `breadd`. When `breadd` is -running, it publishes `bread.lock.locked` / `bread.lock.unlocked`. See -[EVENTS.md](EVENTS.md) for the bus contract. `breadgreet` is not on the -bus. There is no `bakery.toml` (PAM / pacman exception). +running, it publishes `bread.lock.locked` / `bread.lock.unlocked` and +honors `bread.command.lock.lock` (emits `bread.lock.lock.done` / +`.failed`). Run `breadlock listen` so the command works while unlocked; +the locker also subscribes while the session is locked. Super+L remains +`loginctl lock-session` (hypridle then runs `breadlock`) — that is the +session-level equivalent, not a bus command. See [EVENTS.md](EVENTS.md). +`breadgreet` is not on the bus. There is no `bakery.toml` (PAM / pacman +exception). ## Architecture @@ -76,6 +81,11 @@ command = "cage -s -- breadgreet" lock_cmd = breadlock ``` +`breadlock listen` is the unlocked-path subscriber for +`bread.command.lock.lock`. 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. + ## 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. diff --git a/breadlock/src/bread_events.rs b/breadlock/src/bread_events.rs index 7cc1492..b8a8fd7 100644 --- a/breadlock/src/bread_events.rs +++ b/breadlock/src/bread_events.rs @@ -1,16 +1,32 @@ //! `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 call here is +//! 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` is the one verb 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. Session-level equivalent of Super+L is +//! `loginctl lock-session`. -use bread_utils::bread_client::BreadClient; +use std::process::{Command, Stdio}; +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.*`. +/// (`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"; + pub fn emit_locked() { BreadClient::connect(APP_ID).emit("bread.lock.locked", serde_json::json!({})); } @@ -18,3 +34,138 @@ pub fn emit_locked() { 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 }), + ); +} + +/// True when another process holds the locker singleton — i.e. breadlock +/// is already locking this session. A `try_acquire` that succeeds is +/// released immediately; this is a check, not a claim. +pub fn locker_is_running() -> bool { + 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()) + .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(start_locker); +} + +fn honor_lock_command_with(start: impl FnOnce() -> Result<(), String>) { + if locker_is_running() { + 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); + } + } +} + +/// Reacts to `bread.command.lock.*`. Unknown verbs are ignored, not stubbed. +pub fn handle_command(event: &BreadEvent) { + let Some(verb) = event.event.strip_prefix("bread.command.lock.") else { + return; + }; + match verb { + "lock" => honor_lock_command(), + 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::*; + + fn event(name: &str) -> BreadEvent { + BreadEvent { + event: name.to_string(), + timestamp: 0, + data: serde_json::json!({}), + } + } + + #[test] + fn handle_command_ignores_unrecognized_verb() { + handle_command(&event("bread.command.lock.unlock")); + handle_command(&event("bread.command.lock.pin")); + handle_command(&event("bread.command.clip.clear")); + handle_command(&event("bread.lock.locked")); + } + + #[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_does_not_panic() { + honor_lock_command_with(|| Err("boom".into())); + } + + #[test] + fn honor_lock_command_with_successful_start_does_not_panic() { + honor_lock_command_with(|| Ok(())); + } +} diff --git a/breadlock/src/main.rs b/breadlock/src/main.rs index 0e9a65f..7a2c661 100644 --- a/breadlock/src/main.rs +++ b/breadlock/src/main.rs @@ -21,13 +21,116 @@ 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(args: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + 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 so the command works while unlocked\n\ + \n\ + Session-level equivalent of Super+L: loginctl lock-session (hypridle then runs breadlock).\n\ + See EVENTS.md for the bus contract." + ); +} + fn main() { tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); + 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` works while the +/// session is unlocked. The locker process also subscribes; this path is +/// what actually starts breadlock (the same no-args invocation hypridle +/// uses). 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"); + 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 + } + }; + + // Honor bread.command.lock.lock while this locker is up (already-locked + // is bread.lock.lock.done). Unlocked commands need `breadlock listen`. + let _commands = bread_events::subscribe_commands(); + let username = std::env::var("USER") .or_else(|_| std::env::var("LOGNAME")) .unwrap_or_else(|_| { @@ -204,3 +307,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()); + } +} From 495fe1aaadc4bbbac2ff90ac6006525788fbbdee Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 23:55:32 +0800 Subject: [PATCH 08/20] 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. --- README.md | 2 +- breadgreet.example.toml | 9 +-- breadgreet/src/config.rs | 7 ++- breadgreet/src/main.rs | 57 +++++++++++++---- breadgreet/src/sessions.rs | 123 ++++++++++++++++++++++++++++--------- breadgreet/src/theme.rs | 3 +- 6 files changed, 151 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 230f94d..663f279 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ breadlock/ - **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 diff --git a/breadgreet.example.toml b/breadgreet.example.toml index c5f2093..ae0af95 100644 --- a/breadgreet.example.toml +++ b/breadgreet.example.toml @@ -20,7 +20,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" diff --git a/breadgreet/src/config.rs b/breadgreet/src/config.rs index a560945..bd9aa32 100644 --- a/breadgreet/src/config.rs +++ b/breadgreet/src/config.rs @@ -15,7 +15,8 @@ pub struct Config { pub struct Sessions { pub wayland_dirs: Vec, pub xsessions_dirs: Vec, - /// `.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(), } } } @@ -58,6 +59,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"); } } diff --git a/breadgreet/src/main.rs b/breadgreet/src/main.rs index 8666e1d..231c26f 100644 --- a/breadgreet/src/main.rs +++ b/breadgreet/src/main.rs @@ -37,6 +37,8 @@ enum AppInput { Outcome(Outcome), Error(String), SessionStarted, + /// Picker changed; `u32::MAX` (`INVALID_LIST_POSITION`) is ignored. + SessionSelected(u32), } struct App { @@ -45,7 +47,8 @@ struct App { entry: gtk4::Entry, stage: Stage, username: String, - session: Option, + sessions: Vec, + selected: usize, clock_format: String, cmd_tx: mpsc::UnboundedSender, } @@ -78,11 +81,19 @@ 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); let clock_lbl = gtk4::Label::new(None); clock_lbl.add_css_class("login-clock"); @@ -99,17 +110,33 @@ 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"); - } + 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!(); widgets.root_box.append(&clock_lbl); @@ -127,13 +154,15 @@ impl SimpleComponent for App { entry, stage: Stage::Username, username: String::new(), - session, + sessions, + selected, clock_format: config.appearance.clock.format.clone(), cmd_tx, }; model .clock_lbl .set_label(¤t_time(&model.clock_format)); + model.entry.grab_focus(); ComponentParts { model, widgets } } @@ -161,6 +190,12 @@ impl SimpleComponent for App { // nothing left for the greeter to do. self.status_lbl.set_label("Starting session…"); } + AppInput::SessionSelected(idx) => { + let idx = idx as usize; + if idx < self.sessions.len() { + self.selected = idx; + } + } } } } @@ -225,7 +260,7 @@ impl App { } fn start_session(&mut self) { - let Some(session) = &self.session else { + let Some(session) = self.sessions.get(self.selected) else { self.status_lbl.set_label("No session available to start"); self.status_lbl.add_css_class("error"); return; diff --git a/breadgreet/src/sessions.rs b/breadgreet/src/sessions.rs index d7d9dcc..3fe4d72 100644 --- a/breadgreet/src/sessions.rs +++ b/breadgreet/src/sessions.rs @@ -1,17 +1,45 @@ //! 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, 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, } +/// 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 { + let mut all = Vec::new(); + for dir in wayland_dirs.iter().chain(xsessions_dirs) { + for (stem, entry) in scan_dir(Path::new(dir)) { + all.push(Session { + stem, + name: entry.name, + exec: split_exec(&entry.exec), + }); + } + } + all +} + +/// 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 /// the entry matching `default` (by `.desktop` file stem), falling back to /// the first entry found in either directory. `None` if nothing is @@ -21,20 +49,9 @@ pub fn discover( xsessions_dirs: &[String], default: &str, ) -> Option { - 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 @@ -69,26 +86,72 @@ 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"]); 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"]); + + 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()], + }, + Session { + stem: "bos".into(), + name: "BOS".into(), + exec: vec!["bos-session".into()], + }, + ]; + assert_eq!(default_index(&sessions, "bos"), 1); + assert_eq!(default_index(&sessions, "missing"), 0); + assert_eq!(default_index(&[], "bos"), 0); + } } diff --git a/breadgreet/src/theme.rs b/breadgreet/src/theme.rs index 30a8aa2..155e333 100644 --- a/breadgreet/src/theme.rs +++ b/breadgreet/src/theme.rs @@ -16,7 +16,8 @@ fn load_css() -> String { .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; }}", bg = p.background, surface = p.color0, red = p.color1, From d49df0b5fab138ebd3e91808621494cb3a771030 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:24:53 +0800 Subject: [PATCH 09/20] Honor bread.command.lock.unlock via loginctl unlock-session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- EVENTS.md | 40 +++++++++++----- README.md | 24 ++++++---- breadlock/src/bread_events.rs | 90 ++++++++++++++++++++++++++++++++--- breadlock/src/main.rs | 20 ++++---- 4 files changed, 135 insertions(+), 39 deletions(-) diff --git a/EVENTS.md b/EVENTS.md index 9b5801c..51f2d63 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -14,8 +14,8 @@ 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 the command - works while unlocked +- `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 @@ -30,29 +30,38 @@ from session lock/unlock. | `bread.lock.unlocked` | `{}` | PAM authenticated successfully and breadlock sent `unlock` to the compositor. Not emitted on a compositor-ended lock (`finished`), a dispatch-error exit (fail-secure: the session stays locked), or a failed/typo password. | | `bread.lock.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": "" }` | `bread.command.lock.lock` was received but the locker could not be started (e.g. this binary is missing from disk). | +| `bread.lock.unlock.done` | `{}` | `bread.command.lock.unlock` was honored: no locker was running (already unlocked), or `loginctl unlock-session` was invoked for this session. This is the command confirmation, not compositor proof — wait on `bread.lock.unlocked` if you need PAM + `ext-session-lock-v1` unlock. | +| `bread.lock.unlock.failed` | `{ "error": "" }` | `bread.command.lock.unlock` was received but `loginctl unlock-session` could not be run (binary missing, non-zero exit). | ## Commands honored (`bread.command.lock.*`) | Verb | Effect | |------|--------| | `lock` | If a locker is already running, emit `bread.lock.lock.done` and do nothing else. Otherwise start `breadlock` the same way hypridle does (`lock_cmd = breadlock`: this binary, no args) and emit `done` or `failed`. | +| `unlock` | If no locker is running, emit `bread.lock.unlock.done` (already unlocked). Otherwise run `loginctl unlock-session` on the caller's session and emit `done` or `failed`. This is session-level (logind), not a passwordless PAM bypass: breadlock does not call compositor `unlock()` for this verb. | A Lua workflow that wants the session locked should `bread.wait` / `bread.wait_any` on `bread.lock.lock.done` (or `.failed`) with a timeout. To know the compositor actually locked, wait on `bread.lock.locked`. +The same pattern applies to unlock: wait on `bread.lock.unlock.done` / +`.failed` for the command, and on `bread.lock.unlocked` for PAM + +compositor unlock. ### Who is listening -`bread.command.lock.lock` is a silent no-op if nobody is subscribed -(bread's usual "no listener, no-op" rule). Two subscribers exist: +`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. + 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 a command received during an active lock is an idempotent - `done`. + up, so `lock` during an active lock is an idempotent `done`, and + `unlock` runs `loginctl unlock-session` rather than compositor + `unlock()`. ### Session-level equivalent @@ -63,13 +72,18 @@ bread command bus. It is the session-level equivalent of same `ext-session-lock-v1` request. Prefer `loginctl lock-session` from a keybind; prefer the bus command from a Lua workflow. -### Not implemented: `unlock` / `pin` / `blur` +`loginctl unlock-session` is the matching session-level unlock. The +bus verb invokes that same command. Compositor unlock after a typed +password is still PAM on this process (`bread.lock.unlocked`); a +dispatch-error or crash path still does **not** call compositor +`unlock()` (fail-secure). -Unlock is PAM on this process only — there is no `bread.command.lock.unlock` -and none is stubbed. `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. +### 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 diff --git a/README.md b/README.md index 663f279..cc6ec11 100644 --- a/README.md +++ b/README.md @@ -13,13 +13,15 @@ Both use [`bread-theme`](https://git.breadway.dev/Breadway/bread-ecosystem) for `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` (emits `bread.lock.lock.done` / -`.failed`). Run `breadlock listen` so the command works while unlocked; -the locker also subscribes while the session is locked. Super+L remains -`loginctl lock-session` (hypridle then runs `breadlock`) — that is the -session-level equivalent, not a bus command. See [EVENTS.md](EVENTS.md). -`breadgreet` is not on the bus. There is no `bakery.toml` (PAM / pacman -exception). +honors `bread.command.lock.lock` / `bread.command.lock.unlock` (emits +`bread.lock.lock.done` / `.failed` and `bread.lock.unlock.done` / +`.failed`). Run `breadlock listen` so both commands work while +unlocked; the locker also subscribes while the session is locked. +Unlock is `loginctl unlock-session` at the session level — not a +passwordless compositor `unlock()`. Super+L remains +`loginctl lock-session` (hypridle then runs `breadlock`). See +[EVENTS.md](EVENTS.md). `breadgreet` is not on the bus. There is no +`bakery.toml` (PAM / pacman exception). ## Architecture @@ -82,9 +84,11 @@ lock_cmd = breadlock ``` `breadlock listen` is the unlocked-path subscriber for -`bread.command.lock.lock`. 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. +`bread.command.lock.lock` and `bread.command.lock.unlock`. It is not +started by hypridle; add it to session startup +(`exec-once = breadlock listen`) if a Lua workflow should be able to +lock or unlock the session while it is unlocked. Session-level unlock +is `loginctl unlock-session` (the bus verb runs that). ## Verification (why this is safe to test without a lockout risk) diff --git a/breadlock/src/bread_events.rs b/breadlock/src/bread_events.rs index b8a8fd7..5ece110 100644 --- a/breadlock/src/bread_events.rs +++ b/breadlock/src/bread_events.rs @@ -5,12 +5,14 @@ //! process) so a missing or restarting breadd never affects locking //! itself. //! -//! `bread.command.lock.lock` is the one verb 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. Session-level equivalent of Super+L is -//! `loginctl lock-session`. +//! `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`). Session-level equivalents are +//! `loginctl lock-session` / `loginctl unlock-session`. Unlock never +//! calls compositor `unlock()` — that stays on the PAM path. use std::process::{Command, Stdio}; use std::thread; @@ -46,6 +48,17 @@ pub fn emit_lock_failed(error: &str) { ); } +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 another process holds the locker singleton — i.e. breadlock /// is already locking this session. A `try_acquire` that succeeds is /// released immediately; this is a check, not a claim. @@ -84,6 +97,31 @@ pub fn honor_lock_command() { honor_lock_command_with(start_locker); } +/// Session-level unlock (`loginctl unlock-session` on the caller's +/// session). Does not send compositor `unlock` and does not skip PAM — +/// that stays on the typed-password path. `done` means the command was +/// acted on (or the session was already unlocked), not that +/// `ext-session-lock-v1` has been released — wait on +/// `bread.lock.unlocked` for the compositor confirmation. +fn unlock_session() -> Result<(), String> { + let status = Command::new("loginctl") + .arg("unlock-session") + .stdin(Stdio::null()) + .status() + .map_err(|e| format!("failed to run loginctl unlock-session: {e}"))?; + if status.success() { + Ok(()) + } else { + Err(format!("loginctl unlock-session exited with {status}")) + } +} + +/// Honor `bread.command.lock.unlock`: already unlocked is success; +/// otherwise ask logind to unlock this session. +pub fn honor_unlock_command() { + honor_unlock_command_with(locker_is_running(), unlock_session); +} + fn honor_lock_command_with(start: impl FnOnce() -> Result<(), String>) { if locker_is_running() { tracing::info!("bread.command.lock.lock: already locked"); @@ -102,6 +140,24 @@ fn honor_lock_command_with(start: impl FnOnce() -> Result<(), String>) { } } +fn honor_unlock_command_with(locked: bool, unlock: impl FnOnce() -> Result<(), String>) { + if !locked { + tracing::info!("bread.command.lock.unlock: already unlocked"); + emit_unlock_done(); + return; + } + match unlock() { + Ok(()) => { + tracing::info!("bread.command.lock.unlock: loginctl unlock-session"); + emit_unlock_done(); + } + Err(error) => { + tracing::error!(%error, "bread.command.lock.unlock: failed"); + emit_unlock_failed(&error); + } + } +} + /// Reacts to `bread.command.lock.*`. Unknown verbs are ignored, not stubbed. pub fn handle_command(event: &BreadEvent) { let Some(verb) = event.event.strip_prefix("bread.command.lock.") else { @@ -109,6 +165,7 @@ pub fn handle_command(event: &BreadEvent) { }; match verb { "lock" => honor_lock_command(), + "unlock" => honor_unlock_command(), other => tracing::info!(verb = other, "ignoring unknown bread.command.lock verb"), } } @@ -135,7 +192,6 @@ mod tests { #[test] fn handle_command_ignores_unrecognized_verb() { - handle_command(&event("bread.command.lock.unlock")); handle_command(&event("bread.command.lock.pin")); handle_command(&event("bread.command.clip.clear")); handle_command(&event("bread.lock.locked")); @@ -168,4 +224,24 @@ mod tests { fn honor_lock_command_with_successful_start_does_not_panic() { honor_lock_command_with(|| Ok(())); } + + #[test] + fn honor_unlock_command_already_unlocked_does_not_call_loginctl() { + let called = std::cell::Cell::new(false); + honor_unlock_command_with(false, || { + called.set(true); + Err("should not run".into()) + }); + assert!(!called.get()); + } + + #[test] + fn honor_unlock_command_with_failed_loginctl_does_not_panic() { + honor_unlock_command_with(true, || Err("boom".into())); + } + + #[test] + fn honor_unlock_command_with_successful_loginctl_does_not_panic() { + honor_unlock_command_with(true, || Ok(())); + } } diff --git a/breadlock/src/main.rs b/breadlock/src/main.rs index 7a2c661..18cd008 100644 --- a/breadlock/src/main.rs +++ b/breadlock/src/main.rs @@ -51,9 +51,9 @@ fn print_usage() { "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 so the command works while unlocked\n\ + listen subscribe to bread.command.lock.lock / unlock so both work while unlocked\n\ \n\ - Session-level equivalent of Super+L: loginctl lock-session (hypridle then runs breadlock).\n\ + Session-level: loginctl lock-session / unlock-session.\n\ See EVENTS.md for the bus contract." ); } @@ -75,10 +75,11 @@ fn main() { } } -/// Long-running subscriber so `bread.command.lock.lock` works while the -/// session is unlocked. The locker process also subscribes; this path is -/// what actually starts breadlock (the same no-args invocation hypridle -/// uses). One listen process per session. +/// Long-running subscriber so `bread.command.lock.lock` / `.unlock` work +/// while the session is unlocked. The locker process also subscribes; +/// this path is what actually starts breadlock (the same no-args +/// invocation hypridle uses) and what runs `loginctl unlock-session` +/// when a locker is up. One listen process per session. fn run_listen() { let _guard = match try_acquire(bread_events::LISTEN_APP) { Ok(Acquire::Acquired(g)) => g, @@ -106,7 +107,7 @@ fn run_listen() { } let _commands = bread_events::subscribe_commands(); - tracing::info!("listening for bread.command.lock.lock"); + tracing::info!("listening for bread.command.lock.lock / unlock"); loop { std::thread::sleep(Duration::from_secs(3600)); } @@ -127,8 +128,9 @@ fn run_lock() { } }; - // Honor bread.command.lock.lock while this locker is up (already-locked - // is bread.lock.lock.done). Unlocked commands need `breadlock listen`. + // Honor bread.command.lock.lock / unlock while this locker is up + // (already-locked is bread.lock.lock.done; unlock is loginctl, not + // compositor unlock()). Unlocked-path commands need `breadlock listen`. let _commands = bread_events::subscribe_commands(); let username = std::env::var("USER") From 46ecea18ac1d58cefce36c5908edc6b103dfe161 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 14:44:07 +0800 Subject: [PATCH 10/20] can't be bothered writing a commit message --- Cargo.lock | 259 ++++++++++++++++---------------- Cargo.toml | 2 +- breadgreet/src/main.rs | 1 + breadlock-ui/src/theme.rs | 2 +- breadlock/src/input/keyboard.rs | 5 +- breadlock/src/main.rs | 22 ++- breadlock/src/render.rs | 109 +++++++++++++- breadlock/src/state.rs | 117 ++++++++++++++- 8 files changed, 362 insertions(+), 155 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 536cd82..8cf2d85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,18 +10,18 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -40,13 +40,13 @@ checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -77,8 +77,8 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.7.2" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" +version = "0.7.4" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#fcba3760387e2523edb71350f8efea3bc851b21e" dependencies = [ "dirs", "gtk4", @@ -108,7 +108,7 @@ dependencies = [ "gtk4", "relm4", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "toml 0.8.23", "tracing", @@ -126,7 +126,7 @@ dependencies = [ "serde", "serde_json", "smithay-client-toolkit", - "thiserror 2.0.18", + "thiserror 2.0.20", "tiny-skia", "toml 0.8.23", "tracing", @@ -155,22 +155,22 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" -version = "1.25.1" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" dependencies = [ "bytemuck_derive", ] [[package]] name = "bytemuck_derive" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -229,9 +229,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.3.0" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ "find-msvc-tools", "shlex", @@ -370,11 +370,11 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", ] [[package]] @@ -398,9 +398,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "flate2" @@ -426,9 +426,9 @@ dependencies = [ [[package]] name = "font-types" -version = "0.12.1" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad67eced03f5504d9cbd3a879b5958b5c54d4e5fd794361c6eb21b05fb703411" +checksum = "75382bc7392ef10aad10935f92fc3db36d2d4dad0e5d96d8d65e04f89a07ec39" dependencies = [ "bytemuck", ] @@ -467,9 +467,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -482,9 +482,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -492,15 +492,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -509,38 +509,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -623,15 +623,14 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.4" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi", - "wasip2", "wasm-bindgen", ] @@ -695,7 +694,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -814,7 +813,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -896,9 +895,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -913,9 +912,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libm" @@ -925,9 +924,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" dependencies = [ "libc", ] @@ -1089,9 +1088,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "png" @@ -1131,42 +1130,42 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quick-xml" -version = "0.39.4" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" -version = "5.3.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rangemap" -version = "1.7.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" [[package]] name = "read-fonts" @@ -1192,9 +1191,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1238,7 +1237,7 @@ checksum = "36c9dbf50a60c82375e66b61d522c936b187a11b25c0a42e91c516326ad24a4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1318,9 +1317,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -1328,29 +1327,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -1455,7 +1454,7 @@ dependencies = [ "memmap2", "pkg-config", "rustix", - "thiserror 2.0.18", + "thiserror 2.0.20", "wayland-backend", "wayland-client", "wayland-csd-frame", @@ -1522,6 +1521,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sys-locale" version = "0.3.2" @@ -1540,7 +1550,7 @@ dependencies = [ "cfg-expr", "heck", "pkg-config", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "version-compare", ] @@ -1561,11 +1571,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.20", ] [[package]] @@ -1576,18 +1586,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -1642,9 +1652,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.53.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -1658,13 +1668,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -1681,9 +1691,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.3+spec-1.1.0" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap", "serde_core", @@ -1740,9 +1750,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow 1.0.4", ] @@ -1779,7 +1789,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1905,20 +1915,11 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -1929,9 +1930,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1939,31 +1940,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] [[package]] name = "wayland-backend" -version = "0.3.15" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", @@ -1974,9 +1975,9 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.14" +version = "0.31.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ "bitflags", "rustix", @@ -2059,9 +2060,9 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.10" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", "quick-xml", @@ -2098,7 +2099,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2109,7 +2110,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2229,17 +2230,11 @@ dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - [[package]] name = "xcursor" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" [[package]] name = "xkbcommon" diff --git a/Cargo.toml b/Cargo.toml index 7402de6..ca9ac0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["breadlock-ui", "breadlock", "breadgreet"] resolver = "2" [workspace.dependencies] -bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" } +bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4" } bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/breadgreet/src/main.rs b/breadgreet/src/main.rs index 231c26f..1633d43 100644 --- a/breadgreet/src/main.rs +++ b/breadgreet/src/main.rs @@ -146,6 +146,7 @@ impl SimpleComponent for App { spawn_greetd_actor(cmd_rx, sender.clone()); theme::apply(); + bread_theme::gtk::bind_window_auto(&root); spawn_clock_ticker(sender.clone()); let model = App { diff --git a/breadlock-ui/src/theme.rs b/breadlock-ui/src/theme.rs index 364c3d0..282ef41 100644 --- a/breadlock-ui/src/theme.rs +++ b/breadlock-ui/src/theme.rs @@ -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 diff --git a/breadlock/src/input/keyboard.rs b/breadlock/src/input/keyboard.rs index 0add42b..c90d229 100644 --- a/breadlock/src/input/keyboard.rs +++ b/breadlock/src/input/keyboard.rs @@ -140,8 +140,9 @@ impl KeyboardHandler for AppState { impl AppState { fn handle_key(&mut self, qh: &QueueHandle, event: KeyEvent) { // Ignore all input while a PAM check is in flight so a fast second - // Enter can't race the first attempt. - if self.auth_state == AuthState::Checking { + // Enter can't race the first attempt, and while the unlock fade is + // playing (auth already succeeded; surfaces stay up until it ends). + if self.auth_state == AuthState::Checking || self.unlocking.is_some() { return; } diff --git a/breadlock/src/main.rs b/breadlock/src/main.rs index 18cd008..dde44f7 100644 --- a/breadlock/src/main.rs +++ b/breadlock/src/main.rs @@ -156,12 +156,13 @@ fn run_lock() { let auth_tx = auth::register(&loop_handle, move |state: &mut AppState, result| { match result { Ok(()) => { - tracing::info!("authenticated, unlocking"); - if let Some(lock) = state.session_lock.take() { - lock.unlock(); - bread_events::emit_unlocked(); + // Keep the lock surfaces up and fade the overlay out. + // Compositor unlock() runs only after UNLOCK_MS — dying + // mid-fade is fail-secure (session stays locked). + tracing::info!("authenticated, fading out"); + if state.unlocking.is_none() { + state.unlocking = Some(std::time::Instant::now()); } - state.exit = true; } Err(err) => { match err { @@ -217,6 +218,9 @@ fn run_lock() { password: zeroize::Zeroizing::new(String::with_capacity(128)), auth_state: AuthState::Idle, auth_tx, + appear_started: None, + unlocking: None, + anim_timer_armed: false, exit: false, }; @@ -272,7 +276,13 @@ fn run_lock() { let mut consecutive_errors = 0u32; while !app_state.exit { match event_loop.dispatch(Duration::from_millis(250), &mut app_state) { - Ok(()) => consecutive_errors = 0, + Ok(()) => { + consecutive_errors = 0; + // Backup if the 16ms anim timer failed to register: the + // 250ms dispatch timeout (or the 1s clock tick) still + // completes a finished unlock fade. + app_state.complete_unlock_if_ready(); + } Err(err) => { consecutive_errors += 1; tracing::error!( diff --git a/breadlock/src/render.rs b/breadlock/src/render.rs index 37796dc..acc7bc9 100644 --- a/breadlock/src/render.rs +++ b/breadlock/src/render.rs @@ -9,8 +9,20 @@ use crate::background::Background; use breadlock_ui::painter::{rounded_rect, tokens, TextRenderer}; use breadlock_ui::theme::tiny_skia_color; +use std::time::Instant; use tiny_skia::{Color, Paint, Pixmap}; +/// Lock-appear duration: overlay fades in and eases up from below rest. +pub const APPEAR_MS: u64 = 450; +/// Unlock-fade duration: overlay fades out with a slight upward drift. +pub const UNLOCK_MS: u64 = 400; +/// Redraw cadence while an animation is in flight (~60 Hz). +pub const ANIM_FRAME_MS: u64 = 16; + +const APPEAR_SLIDE_PX: f32 = 28.0; +const UNLOCK_DRIFT_PX: f32 = 20.0; +const DIM_ALPHA: f32 = 0.28; + pub struct FrameInputs<'a> { pub width: u32, pub height: u32, @@ -23,6 +35,40 @@ pub struct FrameInputs<'a> { /// shake in v1 — just a color/status-text indicator. pub failed: bool, pub status_text: Option<&'a str>, + /// Raw 0..1 lock-appear progress (pre-ease). 1 is rest pose. + pub appear_t: f32, + /// Raw 0..1 unlock-fade progress (pre-ease). 0 when not unlocking. + pub unlock_t: f32, +} + +/// Ease-out cubic. `t` is clamped to 0..1. +pub fn ease_out_cubic(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + let inv = 1.0 - t; + 1.0 - inv * inv * inv +} + +/// Linear 0..1 progress since `started` over `duration_ms`. +pub fn unit_progress(started: Instant, duration_ms: u64) -> f32 { + let dur = duration_ms as f32 / 1000.0; + if dur <= 0.0 { + return 1.0; + } + (started.elapsed().as_secs_f32() / dur).clamp(0.0, 1.0) +} + +/// Overlay alpha and y-offset (positive is down) from raw 0..1 progress. +pub fn overlay_motion(appear_t: f32, unlock_t: f32) -> (f32, f32) { + let appear = ease_out_cubic(appear_t); + let unlock = ease_out_cubic(unlock_t); + let alpha = (appear * (1.0 - unlock)).clamp(0.0, 1.0); + let y = APPEAR_SLIDE_PX * (1.0 - appear) - UNLOCK_DRIFT_PX * unlock; + (alpha, y) +} + +fn faded(mut color: Color, alpha: f32) -> Color { + color.apply_opacity(alpha); + color } /// Composes one frame. Returns `None` only if `width`/`height` are degenerate @@ -31,11 +77,28 @@ pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option let mut pixmap = Pixmap::new(inputs.width, inputs.height)?; inputs.background.paint(&mut pixmap); + let (alpha, y_off) = overlay_motion(inputs.appear_t, inputs.unlock_t); + if alpha <= 0.0 { + return Some(pixmap); + } + let (w, h) = (inputs.width as f32, inputs.height as f32); - let surface_color = tiny_skia_color(&inputs.palette.color0); - let accent_color = tiny_skia_color(&inputs.palette.color4); - let on_surface = tiny_skia_color(breadlock_ui::theme::ink_on(&inputs.palette.color0)); - let red_color = tiny_skia_color(&inputs.palette.color1); + let surface_color = faded(tiny_skia_color(&inputs.palette.color0), alpha); + let accent_color = faded(tiny_skia_color(&inputs.palette.color4), alpha); + let on_surface = faded( + tiny_skia_color(breadlock_ui::theme::ink_on(&inputs.palette.color0)), + alpha, + ); + let red_color = faded(tiny_skia_color(&inputs.palette.color1), alpha); + + // Translucent veil over the (static) wallpaper — fades with the chrome. + if let Some(rect) = tiny_skia::Rect::from_xywh(0.0, 0.0, w, h) { + let mut paint = Paint::default(); + paint.set_color( + Color::from_rgba(0.0, 0.0, 0.0, DIM_ALPHA * alpha).unwrap_or(Color::TRANSPARENT), + ); + pixmap.fill_rect(rect, &paint, tiny_skia::Transform::identity(), None); + } // Clock, large, centered in the upper third. let clock_size = 64.0; @@ -45,16 +108,16 @@ pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option inputs.clock_text, inputs.font_family, clock_size, - Color::WHITE, + faded(Color::WHITE, alpha), (w - clock_w) / 2.0, - h * 0.28, + h * 0.28 + y_off, ); // Password pill, centered; turns red while showing a failed attempt. let pill_w = 280.0_f32.min(w - tokens::SPACE_XL as f32 * 2.0); let pill_h = 48.0; let pill_x = (w - pill_w) / 2.0; - let pill_y = h * 0.5; + let pill_y = h * 0.5 + y_off; if let Some(path) = rounded_rect( pill_x, @@ -97,7 +160,7 @@ pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option { let mut paint = Paint::default(); paint.set_color(if inputs.failed { - Color::WHITE + faded(Color::WHITE, alpha) } else { accent_color }); @@ -172,8 +235,38 @@ mod tests { password_len: 0, failed: false, status_text: None, + appear_t: 1.0, + unlock_t: 0.0, }; let pixmap = compose(&mut text, &inputs).unwrap(); assert_eq!((pixmap.width(), pixmap.height()), (400, 300)); } + + #[test] + fn ease_out_cubic_bounds_and_shape() { + assert_eq!(ease_out_cubic(0.0), 0.0); + assert_eq!(ease_out_cubic(1.0), 1.0); + assert_eq!(ease_out_cubic(-1.0), 0.0); + assert_eq!(ease_out_cubic(2.0), 1.0); + // Ease-out sits above the linear diagonal in the middle of the curve. + assert!(ease_out_cubic(0.5) > 0.5); + } + + #[test] + fn overlay_motion_appear_starts_below_and_fades_in() { + let (a0, y0) = overlay_motion(0.0, 0.0); + assert_eq!(a0, 0.0); + assert!(y0 > 0.0, "clock/pill should start below rest, got y={y0}"); + + let (a1, y1) = overlay_motion(1.0, 0.0); + assert_eq!(a1, 1.0); + assert_eq!(y1, 0.0); + } + + #[test] + fn overlay_motion_unlock_fades_out_and_drifts_up() { + let (a, y) = overlay_motion(1.0, 1.0); + assert_eq!(a, 0.0); + assert!(y < 0.0, "unlock should drift up from rest, got y={y}"); + } } diff --git a/breadlock/src/state.rs b/breadlock/src/state.rs index 8726cc9..4fd5dd6 100644 --- a/breadlock/src/state.rs +++ b/breadlock/src/state.rs @@ -8,7 +8,7 @@ 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::{Shm, ShmHandler}; -use std::time::Duration; +use std::time::{Duration, Instant}; use wayland_client::protocol::{wl_keyboard, wl_output, wl_shm}; use wayland_client::{Connection, QueueHandle}; @@ -75,6 +75,17 @@ pub struct AppState { pub auth_state: AuthState, pub auth_tx: Sender, + /// 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, + /// 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, + /// True while a ~16ms animation timer is registered on the event loop. + pub anim_timer_armed: bool, + pub exit: bool, } @@ -93,28 +104,45 @@ impl AppState { return; } + if self.appear_started.is_none() { + self.appear_started = Some(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()) - } + AuthState::ConfigError => Some( + "PAM config error — check logs (breadlock service not set up correctly)" + .to_string(), + ), AuthState::Idle => None, }; + 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 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), status_text: status_text.as_deref(), + appear_t, + unlock_t, }; let Some(pixmap) = render::compose(&mut self.text_renderer, &inputs) else { @@ -149,6 +177,18 @@ impl AppState { .damage_buffer(0, 0, width as i32, height as i32); surface.wl_surface().commit(); buffer.destroy(); + + self.arm_anim_if_needed(qh); + } + + 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 @@ -162,6 +202,73 @@ impl AppState { for (surface, width, height) in surfaces { self.redraw_surface(qh, &surface, width, height); } + self.complete_unlock_if_ready(); + } + + 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 anim_in_progress(&self) -> bool { + self.unlocking.is_some() || self.appear_in_progress() + } + + /// Keep requesting frames while appear or unlock-fade is running. + fn arm_anim_if_needed(&mut self, qh: &QueueHandle) { + if self.anim_timer_armed || !self.anim_in_progress() { + return; + } + 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; + } + } + + fn tick_animation(&mut self, qh: &QueueHandle) -> 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() { + TimeoutAction::ToDuration(Duration::from_millis(render::ANIM_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 "wrong password" state (and From 647a211a93c2699f6d746d88296c5b2212eda653 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 14:48:34 +0800 Subject: [PATCH 11/20] can't be bothered writing a commit message --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cc6ec11..bfcefb8 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 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. From f0b66cd79179d5f289a14b3bc3e6ca789f8c0ea5 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 21 Aug 2026 12:28:41 +0800 Subject: [PATCH 12/20] 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. --- .gitignore | 3 + breadgreet.example.toml | 4 + breadlock-ui/src/config.rs | 11 + breadlock-ui/src/painter.rs | 94 +++- breadlock.example.toml | 10 + breadlock/Cargo.toml | 7 + breadlock/src/background.rs | 54 +- breadlock/src/bin/breadlock-preview.rs | 153 ++++++ breadlock/src/config.rs | 23 + breadlock/src/input/keyboard.rs | 8 + breadlock/src/main.rs | 14 + breadlock/src/render.rs | 635 +++++++++++++++++++++--- breadlock/src/state.rs | 178 ++++++- design/sketch.html | 661 +++++++++++++++++++++++++ 14 files changed, 1771 insertions(+), 84 deletions(-) create mode 100644 breadlock/src/bin/breadlock-preview.rs create mode 100644 design/sketch.html diff --git a/.gitignore b/.gitignore index ec10cb0..2baf113 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ CLAUDE.md # graphify knowledge-graph output (local tool cache, not for commit) graphify-out/ + +# breadlock-preview PNG output (dev-only animation harness) +preview/ diff --git a/breadgreet.example.toml b/breadgreet.example.toml index ae0af95..150d830 100644 --- a/breadgreet.example.toml +++ b/breadgreet.example.toml @@ -9,9 +9,13 @@ mode = "color" path = "" blur = false +# (Ken Burns is a breadlock-only background effect; not used by the greeter.) +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" diff --git a/breadlock-ui/src/config.rs b/breadlock-ui/src/config.rs index 2747769..179a5f7 100644 --- a/breadlock-ui/src/config.rs +++ b/breadlock-ui/src/config.rs @@ -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(), } } } @@ -89,7 +98,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"); } diff --git a/breadlock-ui/src/painter.rs b/breadlock-ui/src/painter.rs index d383058..b84f471 100644 --- a/breadlock-ui/src/painter.rs +++ b/breadlock-ui/src/painter.rs @@ -6,6 +6,7 @@ pub use bread_theme::tokens; 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 +33,10 @@ pub fn rounded_rect(x: f32, y: f32, w: f32, h: f32, radius: f32) -> Option pub struct TextRenderer { font_system: FontSystem, swash_cache: SwashCache, + /// Exact glyph-pixel span `(top, height)` per unique `(text, family, + /// size)` — see [`Self::measure_box`]. Keyed by size in centipixels so + /// fractional sizes don't thrash the cache. + boxes: HashMap<(String, String, u32), (f32, f32)>, } impl Default for TextRenderer { @@ -45,6 +50,7 @@ impl TextRenderer { Self { font_system: FontSystem::new(), swash_cache: SwashCache::new(), + boxes: HashMap::new(), } } @@ -58,6 +64,47 @@ impl TextRenderer { 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) { + let key = (text.to_string(), family.to_string(), (size_px * 100.0) as u32); + if let Some(b) = self.boxes.get(&key) { + return *b; + } + let w = self.measure_line(text, family, size_px).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(&mut probe, text, family, size_px, tiny_skia::Color::WHITE, 0.0, 0.0); + 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 { @@ -85,7 +132,13 @@ impl TextRenderer { let buffer = self.shape_line(text, family, size_px, pixmap.width() as f32); 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; @@ -103,6 +156,12 @@ impl TextRenderer { if a == 0 { return; } + // `a` is the glyph coverage; combine it with the requested + // color alpha for the true source alpha. + let a = (a as u32 * base_alpha as u32 / 255) as u8; + if a == 0 { + return; + } blend_over_opaque(pixmap, px as u32, py as u32, r, g, b, a); }, ); @@ -168,4 +227,37 @@ 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}" + ); + } } diff --git a/breadlock.example.toml b/breadlock.example.toml index 223c563..e9e53e6 100644 --- a/breadlock.example.toml +++ b/breadlock.example.toml @@ -8,10 +8,16 @@ 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: the +# background redraws 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" @@ -20,3 +26,7 @@ family = "Varela Round" # How long the "wrong password" state (red pill) shows before input # re-enables, in milliseconds. fail_timeout_ms = 800 + +[animation] +# Subtle glow pulse on the password pill every few seconds while idle. +breathe = true diff --git a/breadlock/Cargo.toml b/breadlock/Cargo.toml index 5e2dc70..98355b3 100644 --- a/breadlock/Cargo.toml +++ b/breadlock/Cargo.toml @@ -17,6 +17,13 @@ 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"] } diff --git a/breadlock/src/background.rs b/breadlock/src/background.rs index fa65b49..41cabcb 100644 --- a/breadlock/src/background.rs +++ b/breadlock/src/background.rs @@ -1,14 +1,26 @@ //! 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). use breadlock_ui::config::{Background as BackgroundConfig, BackgroundMode}; +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), + /// `(source, ken_burns)` — the flag decides whether `paint` pans over + /// time or draws statically. + Image(Pixmap, bool), } impl Background { @@ -31,7 +43,7 @@ impl Background { return fallback(); } match Pixmap::load_png(&cfg.path) { - Ok(pixmap) => Background::Image(pixmap), + Ok(pixmap) => Background::Image(pixmap, cfg.ken_burns), Err(err) => { tracing::warn!(path = %cfg.path, %err, "failed to load background image (PNG only in v1), falling back to palette color"); fallback() @@ -41,25 +53,51 @@ impl Background { } } + /// True when this background needs continuous redraws (Ken Burns pan). + pub fn ken_burns(&self) -> bool { + matches!(self, Background::Image(_, true)) + } + /// 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. + pub fn paint(&self, target: &mut Pixmap, t_secs: f32) { match self { Background::Color(c) => target.fill(*c), - Background::Image(source) => { + Background::Image(source, ken_burns) => { let (tw, th) = (target.width() as f32, target.height() as f32); let (sw, sh) = (source.width() as f32, source.height() as f32); if sw <= 0.0 || sh <= 0.0 { return; } - let scale = (tw / sw).max(th / sh); + let cover = (tw / sw).max(th / sh); + let (scale, tx, ty) = if *ken_burns { + let scale = cover * KENBURNS_ZOOM; + // Pan range: how far the scaled image overhangs each axis. + let pan_x = (sw * scale - tw).max(0.0); + let pan_y = (sh * scale - th).max(0.0); + 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. + ( + scale, + -pan_x * (0.5 + 0.5 * phase.sin()), + -pan_y * (0.5 + 0.5 * phase.cos()), + ) + } else { + (cover, 0.0, 0.0) + }; target.fill(tiny_skia::Color::BLACK); target.draw_pixmap( 0, 0, source.as_ref(), &PixmapPaint::default(), - Transform::from_scale(scale, scale), + // scale first (image coords → scaled), then translate into + // the pan position. + Transform::from_translate(tx, ty).pre_concat(Transform::from_scale(scale, scale)), None, ); } diff --git a/breadlock/src/bin/breadlock-preview.rs b/breadlock/src/bin/breadlock-preview.rs new file mode 100644 index 0000000..d8ae223 --- /dev/null +++ b/breadlock/src/bin/breadlock-preview.rs @@ -0,0 +1,153 @@ +//! 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-.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, + failed: bool, + failed_t: f32, + dot_pop_t: f32, + keystroke_age: Option, + /// 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>, + appear_t: f32, + unlock_t: f32, + breathe_t: f32, + status_t: f32, +} + +impl Default for Scene { + fn default() -> Self { + Self { + name: "", + clock: "12:34", + date: "Friday · Aug 21", + clock_old: None, + password_len: 0, + failed: false, + failed_t: 0.0, + dot_pop_t: 1.0, + keystroke_age: None, + t_secs: 0.2, + status: None, + appear_t: 1.0, + unlock_t: 0.0, + breathe_t: 0.0, + status_t: 1.0, + } + } +} + +fn main() { + let out_dir = std::env::args() + .nth(1) + .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 with the animated ellipsis. + Scene { name: "08-checking", status: Some("Checking…"), status_t: 0.5, ..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() }, + ]; + + 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, + 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, + appear_t: scene.appear_t, + unlock_t: scene.unlock_t, + }; + 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}/"); +} diff --git a/breadlock/src/config.rs b/breadlock/src/config.rs index 49579e7..4cf6252 100644 --- a/breadlock/src/config.rs +++ b/breadlock/src/config.rs @@ -8,6 +8,7 @@ pub struct Config { #[serde(flatten)] pub appearance: Appearance, pub input: Input, + pub animation: Animation, } #[derive(Debug, Clone, Deserialize)] @@ -25,6 +26,23 @@ impl Default for Input { } } +/// 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, +} + +impl Default for Animation { + fn default() -> Self { + Self { breathe: true } + } +} + pub fn load() -> Config { breadlock_ui::config::load_or_default(&config_path()) } @@ -46,6 +64,11 @@ mod tests { assert_eq!(Config::default().input.fail_timeout_ms, 800); } + #[test] + fn default_animation_breathe_is_on() { + assert!(Config::default().animation.breathe); + } + #[test] fn flattened_appearance_parses_alongside_input() { let toml = "[clock]\nformat = \"%H:%M:%S\"\n[input]\nfail_timeout_ms = 1200\n"; diff --git a/breadlock/src/input/keyboard.rs b/breadlock/src/input/keyboard.rs index c90d229..fb3c10f 100644 --- a/breadlock/src/input/keyboard.rs +++ b/breadlock/src/input/keyboard.rs @@ -2,6 +2,7 @@ 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; @@ -179,6 +180,10 @@ impl AppState { for ch in text.chars().filter(|c| !c.is_control()) { self.password.push(ch); } + // 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(); } } @@ -190,6 +195,9 @@ impl AppState { fn clear_failed_state(&mut self) { if matches!(self.auth_state, AuthState::Failed | 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. + self.failed_at = None; } } diff --git a/breadlock/src/main.rs b/breadlock/src/main.rs index dde44f7..9d9d4e4 100644 --- a/breadlock/src/main.rs +++ b/breadlock/src/main.rs @@ -177,10 +177,12 @@ fn run_lock() { "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.failed_at = Some(std::time::Instant::now()); } auth::AuthError::Authenticate | auth::AuthError::AccountInvalid => { tracing::warn!(%err, "authentication failed"); state.auth_state = AuthState::Failed; + state.failed_at = Some(std::time::Instant::now()); } } state.schedule_clear_failed(auth_result_qh.clone()); @@ -218,8 +220,20 @@ fn run_lock() { password: zeroize::Zeroizing::new(String::with_capacity(128)), auth_state: AuthState::Idle, auth_tx, + started: std::time::Instant::now(), appear_started: None, unlocking: None, + last_keystroke: None, + failed_at: None, + last_clock_text: String::new(), + clock_anim_started: 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, exit: false, }; diff --git a/breadlock/src/render.rs b/breadlock/src/render.rs index acc7bc9..fe35bcc 100644 --- a/breadlock/src/render.rs +++ b/breadlock/src/render.rs @@ -1,6 +1,11 @@ -//! Frame composition: paints one full lock-screen frame (background, -//! password pill, clock, status line) into a `tiny_skia::Pixmap`, then -//! copies it into a Wayland `wl_shm` buffer. +//! Frame composition: paints one full lock-screen frame (background, clock, +//! date, password pill, dots/caret, status line) into a `tiny_skia::Pixmap`, +//! then copies it into a Wayland `wl_shm` buffer. +//! +//! Motion: every effect is driven by raw 0..1 progress inputs (see +//! [`FrameInputs`]) that `state.rs` computes from timestamps; this module only +//! turns progress into pixels, so the whole timeline is unit-testable off a +//! compositor (see `breadlock-preview`, which renders frames to PNG). //! //! tiny-skia's in-memory pixel format is byte-order RGBA; `wl_shm`'s //! `Argb8888` format is host-endian `0xAARRGGBB`, i.e. byte-order BGRA on @@ -9,19 +14,88 @@ use crate::background::Background; use breadlock_ui::painter::{rounded_rect, tokens, TextRenderer}; use breadlock_ui::theme::tiny_skia_color; +use std::f32::consts::PI; use std::time::Instant; -use tiny_skia::{Color, Paint, Pixmap}; +use tiny_skia::{Color, Paint, Pixmap, Rect, Transform}; -/// Lock-appear duration: overlay fades in and eases up from below rest. +/// Lock-appear duration: elements ease in on a small stagger (see the +/// `*_DELAY_MS` consts) instead of one uniform fade. pub const APPEAR_MS: u64 = 450; -/// Unlock-fade duration: overlay fades out with a slight upward drift. -pub const UNLOCK_MS: u64 = 400; -/// Redraw cadence while an animation is in flight (~60 Hz). +/// Total unlock duration: [`FLASH_MS`] of green success flash, then a +/// fade-out with a slight upward drift. +pub const UNLOCK_MS: u64 = 650; +/// Green success-flash phase at the start of the unlock. +pub const FLASH_MS: u64 = 250; +/// Wrong-password shake duration (the red pill stays up until +/// `input.fail_timeout_ms`, which outlives the shake). +pub const SHAKE_MS: u64 = 380; +/// Newest password-dot pop-in duration. +pub const DOT_POP_MS: u64 = 200; +/// Minute-rollover crossfade duration. +pub const CLOCK_CROSSFADE_MS: u64 = 300; +/// Redraw cadence while any fast animation is in flight (~60 Hz). pub const ANIM_FRAME_MS: u64 = 16; +/// Cadence while only slow effects are running (idle breath, Ken Burns pan). +pub const SLOW_FRAME_MS: u64 = 62; +/// Status-line slide-in duration ("Checking…" / "Wrong password" rise in). +pub const STATUS_SLIDE_MS: u64 = 200; +/// Idle breathing (config `animation.breathe`): a subtle glow pulse on the +/// pill every few seconds. Only the active window redraws (low-duty-cycle +/// timer in state.rs), so idle CPU stays near zero. +pub const BREATHE_PERIOD_MS: u64 = 4000; +pub const BREATHE_ACTIVE_MS: u64 = 1200; +/// How long after the lock appears before the first breath. +pub const BREATHE_INITIAL_DELAY_MS: u64 = 1500; const APPEAR_SLIDE_PX: f32 = 28.0; const UNLOCK_DRIFT_PX: f32 = 20.0; -const DIM_ALPHA: f32 = 0.28; +/// Parallax: during the unlock fade each element drifts up at a slightly +/// different speed (clock furthest, status least) for a sense of depth. +const DRIFT_CLOCK: f32 = 1.25; +const DRIFT_DATE: f32 = 1.25; +const DRIFT_PILL: f32 = 1.0; +const DRIFT_STATUS: f32 = 0.8; +/// Peak glow multiplier added to the pill shadow during an idle breath, and +/// the accent-ring alpha at the breath peak (sketch's `breathe` keyframes). +const BREATHE_GLOW: f32 = 0.6; +const BREATHE_RING_ALPHA: f32 = 0.12; +/// How far the status line rises during its slide-in. +const STATUS_SLIDE_PX: f32 = 8.0; +/// Dim veil over the wallpaper: a vertical gradient, darker at the top so +/// the clock (in the upper third) sits on the deepest tone. +const DIM_ALPHA_TOP: f32 = 0.34; +const DIM_ALPHA_BOTTOM: f32 = 0.16; +/// Pill hairline-border alpha (sketch: `1px solid rgba(255,255,255,.08)`). +const PILL_BORDER_ALPHA: f32 = 0.10; +/// Fake drop-shadow layers under the pill (tiny-skia has no blur filter): +/// `(grow_px, black_alpha)` — a few concentric copies at fading alpha read +/// as a soft shadow when drawn under the fill. +const PILL_SHADOW: [(f32, f32); 3] = [(2.5, 0.20), (5.0, 0.11), (7.5, 0.05)]; +/// Clock/date scale with the surface, clamped to the sketch's CSS ranges +/// (`clamp(34px, 7.5vw, 60px)` and `clamp(11px, 1.6vw, 14px)`). +const CLOCK_SIZE_MIN: f32 = 34.0; +const CLOCK_SIZE_MAX: f32 = 60.0; +const DATE_SIZE_MIN: f32 = 11.0; +const DATE_SIZE_MAX: f32 = 14.0; +/// Fraction of the shake window over which the pill tints to red (smooth +/// transition instead of an instant color swap). +const SHAKE_RED_FRAC: f32 = 150.0 / SHAKE_MS as f32; +/// Fraction of the unlock window that is the green flash. +const FLASH_FRAC: f32 = FLASH_MS as f32 / UNLOCK_MS as f32; +/// Entrance stagger: each element's appear starts this many ms into the +/// `APPEAR_MS` window, so the clock leads and the status trails. +const CLOCK_DELAY_MS: u64 = 0; +const DATE_DELAY_MS: u64 = 80; +const PILL_DELAY_MS: u64 = 100; +const STATUS_DELAY_MS: u64 = 160; +/// Dot/caret geometry. Dot diameter matches the sketch's 6–9px range. +const DOT_R: f32 = 4.5; +const DOT_GAP: f32 = 18.0; +const CARET_W: f32 = 2.0; +/// Seconds between caret blinks while idle; solid for the first `CARET_HOLD_S` +/// after a keystroke (terminal-style). +const CARET_BLINK_HZ: f32 = 1.8; +const CARET_HOLD_S: f32 = 0.5; pub struct FrameInputs<'a> { pub width: u32, @@ -30,10 +104,27 @@ pub struct FrameInputs<'a> { pub palette: &'a breadlock_ui::theme::Palette, pub font_family: &'a str, pub clock_text: &'a str, + /// Date line under the clock. Empty string hides it. + pub date_text: &'a str, + /// Minute-rollover crossfade: `(previous clock text, raw 0..1 progress)`. + pub clock_old: Option<(&'a str, f32)>, pub password_len: usize, - /// True while showing a failed-attempt state (red pill). No animated - /// shake in v1 — just a color/status-text indicator. + /// True while showing a failed attempt (red pill + shake + red status). pub failed: bool, + /// Raw 0..1 progress of the wrong-password shake. 0 when not failed. + pub failed_t: f32, + /// Raw 0..1 progress of the newest dot's pop-in. 1 when no pop is live. + pub dot_pop_t: f32, + /// Seconds since the most recent keystroke (caret solid/blink behavior). + pub keystroke_age: Option, + /// Monotonic seconds since app start (idle caret blink cadence, Ken Burns + /// pan phase). + pub t_secs: f32, + /// Idle-breathing envelope: 0 when no breath is active, ramping 0..1..0 + /// (one sine hump) over the active window. Scales the pill's glow. + pub breathe_t: f32, + /// Status-line slide-in progress (0..1, 1 settled). + pub status_t: f32, pub status_text: Option<&'a str>, /// Raw 0..1 lock-appear progress (pre-ease). 1 is rest pose. pub appear_t: f32, @@ -48,6 +139,23 @@ pub fn ease_out_cubic(t: f32) -> f32 { 1.0 - inv * inv * inv } +/// Ease-out-back: overshoots past 1 (~5%) then settles — used for the pill's +/// entrance scale so it pops instead of sliding. +pub fn ease_out_back(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + let c1 = 1.70158; + let c3 = c1 + 1.0; + 1.0 + c3 * (t - 1.0).powi(3) + c1 * (t - 1.0).powi(2) +} + +/// Horizontal shake offset in px for raw progress `t` in 0..1 — a damped +/// sinusoid that starts and ends at rest. +pub fn damped_shake_x(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + let env = (1.0 - t) * (1.0 - t); + env * 9.0 * (PI * 5.5 * t).sin() +} + /// Linear 0..1 progress since `started` over `duration_ms`. pub fn unit_progress(started: Instant, duration_ms: u64) -> f32 { let dur = duration_ms as f32 / 1000.0; @@ -57,7 +165,25 @@ pub fn unit_progress(started: Instant, duration_ms: u64) -> f32 { (started.elapsed().as_secs_f32() / dur).clamp(0.0, 1.0) } -/// Overlay alpha and y-offset (positive is down) from raw 0..1 progress. +/// Idle-breath envelope: a single sine hump over the active window (0 at the +/// start and end, 1 at the peak). +pub fn breathe_envelope(t: f32) -> f32 { + (PI * t.clamp(0.0, 1.0)).sin() +} + +/// Appear progress for a single staggered element: raw overall progress +/// `appear_t` is spread over the `APPEAR_MS` window; the element only starts +/// moving `delay_ms` in. +fn staggered_t(appear_t: f32, delay_ms: u64) -> f32 { + if APPEAR_MS <= delay_ms { + return appear_t; + } + let window = (APPEAR_MS - delay_ms) as f32; + ((appear_t * APPEAR_MS as f32 - delay_ms as f32) / window).clamp(0.0, 1.0) +} + +/// Overlay alpha and y-offset (positive is down) from raw 0..1 progress — +/// used for the full-screen dim veil, which fades with the whole chrome. pub fn overlay_motion(appear_t: f32, unlock_t: f32) -> (f32, f32) { let appear = ease_out_cubic(appear_t); let unlock = ease_out_cubic(unlock_t); @@ -71,129 +197,397 @@ fn faded(mut color: Color, alpha: f32) -> Color { color } +fn lerp_color(a: Color, b: Color, t: f32) -> Color { + let t = t.clamp(0.0, 1.0); + let mix = |x: f32, y: f32| x + (y - x) * t; + Color::from_rgba( + mix(a.red(), b.red()), + mix(a.green(), b.green()), + mix(a.blue(), b.blue()), + mix(a.alpha(), b.alpha()), + ) + .unwrap_or(a) +} + /// Composes one frame. Returns `None` only if `width`/`height` are degenerate /// (a `0x0` `configure`, which some compositors send transiently). pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option { let mut pixmap = Pixmap::new(inputs.width, inputs.height)?; - inputs.background.paint(&mut pixmap); + inputs.background.paint(&mut pixmap, inputs.t_secs); - let (alpha, y_off) = overlay_motion(inputs.appear_t, inputs.unlock_t); - if alpha <= 0.0 { + // Overall chrome fade: appear eased in, unlock eased out. The unlock + // `fade` multiplies every element below. + let unlock = ease_out_cubic(inputs.unlock_t); + let fade = 1.0 - unlock; + let (veil_alpha, _) = overlay_motion(inputs.appear_t, inputs.unlock_t); + if veil_alpha <= 0.0 { return Some(pixmap); } let (w, h) = (inputs.width as f32, inputs.height as f32); - let surface_color = faded(tiny_skia_color(&inputs.palette.color0), alpha); - let accent_color = faded(tiny_skia_color(&inputs.palette.color4), alpha); + let surface_color = faded(tiny_skia_color(&inputs.palette.color0), veil_alpha); + let accent_color = faded(tiny_skia_color(&inputs.palette.color4), veil_alpha); + let green_color = faded(tiny_skia_color(&inputs.palette.color2), veil_alpha); let on_surface = faded( tiny_skia_color(breadlock_ui::theme::ink_on(&inputs.palette.color0)), - alpha, + veil_alpha, ); - let red_color = faded(tiny_skia_color(&inputs.palette.color1), alpha); + let red_color = faded(tiny_skia_color(&inputs.palette.color1), veil_alpha); - // Translucent veil over the (static) wallpaper — fades with the chrome. - if let Some(rect) = tiny_skia::Rect::from_xywh(0.0, 0.0, w, h) { + // Translucent veil over the (static) wallpaper — a vertical gradient + // (deeper at the top) that fades in with the chrome. + if let Some(shader) = tiny_skia::LinearGradient::new( + tiny_skia::Point::from_xy(0.0, 0.0), + tiny_skia::Point::from_xy(0.0, h), + vec![ + tiny_skia::GradientStop::new( + 0.0, + Color::from_rgba(0.0, 0.0, 0.0, DIM_ALPHA_TOP * veil_alpha) + .unwrap_or(Color::TRANSPARENT), + ), + tiny_skia::GradientStop::new( + 1.0, + Color::from_rgba(0.0, 0.0, 0.0, DIM_ALPHA_BOTTOM * veil_alpha) + .unwrap_or(Color::TRANSPARENT), + ), + ], + tiny_skia::SpreadMode::Pad, + Transform::identity(), + ) { let mut paint = Paint::default(); - paint.set_color( - Color::from_rgba(0.0, 0.0, 0.0, DIM_ALPHA * alpha).unwrap_or(Color::TRANSPARENT), - ); - pixmap.fill_rect(rect, &paint, tiny_skia::Transform::identity(), None); + paint.shader = shader; + if let Some(rect) = Rect::from_xywh(0.0, 0.0, w, h) { + pixmap.fill_rect(rect, &paint, Transform::identity(), None); + } } - // Clock, large, centered in the upper third. - let clock_size = 64.0; - let clock_w = text.measure_line(inputs.clock_text, inputs.font_family, clock_size); - text.draw_line( - &mut pixmap, - inputs.clock_text, - inputs.font_family, - clock_size, - faded(Color::WHITE, alpha), - (w - clock_w) / 2.0, - h * 0.28 + y_off, - ); + // Per-element staggered entrance. + let clock_e = ease_out_cubic(staggered_t(inputs.appear_t, CLOCK_DELAY_MS)); + let date_e = ease_out_cubic(staggered_t(inputs.appear_t, DATE_DELAY_MS)); + let pill_t = staggered_t(inputs.appear_t, PILL_DELAY_MS); + let pill_e = ease_out_cubic(pill_t); + let pill_scale = ease_out_back(pill_t); + let status_e = ease_out_cubic(staggered_t(inputs.appear_t, STATUS_DELAY_MS)); + // Per-element vertical motion: the appear part is uniform, the unlock + // drift is scaled per element for parallax. + let elem_y = |e: f32, drift: f32| APPEAR_SLIDE_PX * (1.0 - e) - UNLOCK_DRIFT_PX * unlock * drift; - // Password pill, centered; turns red while showing a failed attempt. + // ---- Clock, large, centered in the upper third (size scales with the + // surface). A minute rollover crossfades old text out (drifting up) while + // the new fades in from below. + let clock_size = (w * 0.075).clamp(CLOCK_SIZE_MIN, CLOCK_SIZE_MAX); + // Rest-pose anchors: every element drifts from its own rest position, so + // the clock+date and pill+status clusters move as units. (Anchoring the + // date/status to the already-drifted clock/pill *and* adding their own + // `elem_y` would drift them twice, sliding them up into their anchors + // during the unlock.) + let clock_y_rest = h * 0.28; + let clock_y = clock_y_rest + elem_y(clock_e, DRIFT_CLOCK); + let clock_alpha = clock_e * fade; + match inputs.clock_old { + Some((old, t)) => { + let t = t.clamp(0.0, 1.0); + let old_w = text.measure_line(old, inputs.font_family, clock_size); + text.draw_line( + &mut pixmap, + old, + inputs.font_family, + clock_size, + faded(Color::WHITE, clock_alpha * (1.0 - t)), + (w - old_w) / 2.0, + clock_y - 6.0 * t, + ); + let new_w = text.measure_line(inputs.clock_text, inputs.font_family, clock_size); + text.draw_line( + &mut pixmap, + inputs.clock_text, + inputs.font_family, + clock_size, + faded(Color::WHITE, clock_alpha * t), + (w - new_w) / 2.0, + clock_y + 6.0 * (1.0 - t), + ); + } + None => { + let clock_w = text.measure_line(inputs.clock_text, inputs.font_family, clock_size); + text.draw_line( + &mut pixmap, + inputs.clock_text, + inputs.font_family, + clock_size, + faded(Color::WHITE, clock_alpha), + (w - clock_w) / 2.0, + clock_y, + ); + } + } + + // ---- Date line under the clock (hidden when date_text is empty). The + // clock's `origin_y` anchors its *top*, so the date is placed below the + // clock's actual glyph box (exact per font, cached) with a small gap. + if !inputs.date_text.is_empty() { + let date_size = (w * 0.016).clamp(DATE_SIZE_MIN, DATE_SIZE_MAX); + let (clock_top, clock_height) = + text.measure_box(inputs.clock_text, inputs.font_family, clock_size); + let date_y = clock_y_rest + elem_y(date_e, DRIFT_DATE) + clock_top + clock_height + + tokens::SPACE_SM as f32; + let date_w = text.measure_line(inputs.date_text, inputs.font_family, date_size); + text.draw_line( + &mut pixmap, + inputs.date_text, + inputs.font_family, + date_size, + faded(Color::WHITE, date_e * fade * 0.82), + (w - date_w) / 2.0, + date_y, + ); + } + + // ---- Password pill, centered. Red while failed (tinting in smoothly over + // the first part of the shake), green during the success flash. let pill_w = 280.0_f32.min(w - tokens::SPACE_XL as f32 * 2.0); let pill_h = 48.0; let pill_x = (w - pill_w) / 2.0; - let pill_y = h * 0.5 + y_off; + let pill_y_rest = h * 0.5; + let pill_y = pill_y_rest + elem_y(pill_e, DRIFT_PILL); + let pill_alpha = pill_e * fade; + // Idle breath: glow multiplier on the shadow/border (1 at rest, up to + // 1 + BREATHE_GLOW at the breath peak). + let breathe = 1.0 + BREATHE_GLOW * inputs.breathe_t; + + let base_pill = if inputs.failed { + lerp_color(surface_color, red_color, (inputs.failed_t / SHAKE_RED_FRAC).clamp(0.0, 1.0)) + } else { + surface_color + }; + let pill_color = if inputs.unlock_t > 0.0 { + green_color + } else { + base_pill + }; + // The pill scales about its center (ease-out-back overshoot) instead of + // rising like the text; while unlocking it stays at rest scale. + let scale = if inputs.unlock_t > 0.0 { 1.0 } else { pill_scale }; + let shake_x = if inputs.failed { damped_shake_x(inputs.failed_t) } else { 0.0 }; + let cx = pill_x + pill_w / 2.0; + let cy = pill_y + pill_h / 2.0; + let pill_xf = Transform::from_row( + scale, + 0.0, + 0.0, + scale, + cx * (1.0 - scale) + shake_x, + cy * (1.0 - scale), + ); + + if let Some(path) = + rounded_rect(pill_x, pill_y, pill_w, pill_h, tokens::RADIUS_SECONDARY as f32) + { + // Soft drop shadow first (under the fill): concentric expanded copies + // offset downward at fading alpha. The idle breath scales the glow. + for (grow, alpha) in PILL_SHADOW { + if let Some(shadow_path) = rounded_rect( + pill_x - grow, + pill_y - grow + 3.0, + pill_w + grow * 2.0, + pill_h + grow * 2.0, + tokens::RADIUS_SECONDARY as f32 + grow, + ) { + let mut paint = Paint::default(); + paint.set_color(faded(Color::BLACK, alpha * pill_alpha * breathe)); + paint.anti_alias = true; + pixmap.fill_path( + &shadow_path, + &paint, + tiny_skia::FillRule::Winding, + pill_xf, + None, + ); + } + } - if let Some(path) = rounded_rect( - pill_x, - pill_y, - pill_w, - pill_h, - tokens::RADIUS_SECONDARY as f32, - ) { let mut paint = Paint::default(); - paint.set_color(if inputs.failed { - red_color - } else { - surface_color - }); + paint.set_color(faded(pill_color, pill_alpha)); paint.anti_alias = true; pixmap.fill_path( &path, &paint, tiny_skia::FillRule::Winding, - tiny_skia::Transform::identity(), + pill_xf, None, ); + + // Hairline border for depth — dropped on the wrong/success states + // (the sketch sets `border-color: transparent` there). + if !inputs.failed && inputs.unlock_t == 0.0 { + let mut stroke = tiny_skia::Stroke::default(); + stroke.width = 1.0; + let mut paint = Paint::default(); + paint.set_color(faded( + Color::WHITE, + PILL_BORDER_ALPHA * pill_alpha * (1.0 + 0.4 * inputs.breathe_t), + )); + pixmap.stroke_path(&path, &paint, &stroke, pill_xf, None); + } + + // Idle breath: a faint accent ring blooms around the pill at the + // breath peak (matches the sketch's `breathe` keyframes). + if inputs.breathe_t > 0.0 && !inputs.failed && inputs.unlock_t == 0.0 { + let mut stroke = tiny_skia::Stroke::default(); + stroke.width = 1.5; + let mut paint = Paint::default(); + paint.set_color(faded(accent_color, BREATHE_RING_ALPHA * inputs.breathe_t * pill_alpha)); + pixmap.stroke_path(&path, &paint, &stroke, pill_xf, None); + } + + // Success flash: expanding accent ring around the pill for the first + // `FLASH_MS` of the unlock. + if inputs.unlock_t > 0.0 && inputs.unlock_t < FLASH_FRAC { + let flash_t = inputs.unlock_t / FLASH_FRAC; + let mut stroke = tiny_skia::Stroke::default(); + stroke.width = 2.0 + 16.0 * flash_t; + let mut paint = Paint::default(); + paint.set_color(faded(green_color, 0.55 * (1.0 - flash_t) * pill_alpha)); + pixmap.stroke_path(&path, &paint, &stroke, pill_xf, None); + } } - // Password dots — one filled circle per typed character, capped so a - // very long password can't overflow the pill. - let dot_r = 5.0; - let dot_gap = 18.0; - let max_dots = ((pill_w - tokens::SPACE_LG as f32 * 2.0) / dot_gap) + // ---- Password dots — one filled circle per typed character, capped so a + // very long password can't overflow the pill. The newest dot pops in with + // an overshoot; the rest sit at rest size. + let max_dots = ((pill_w - tokens::SPACE_LG as f32 * 2.0) / DOT_GAP) .floor() .max(1.0) as usize; let shown_dots = inputs.password_len.min(max_dots); + let dot_y = pill_y + pill_h / 2.0; if shown_dots > 0 { - let dots_w = (shown_dots as f32 - 1.0).max(0.0) * dot_gap; - let start_x = pill_x + (pill_w - dots_w) / 2.0; - let dot_y = pill_y + pill_h / 2.0; + let start_x = start_x_for(shown_dots, pill_x, pill_w); for i in 0..shown_dots { + let newest = i == shown_dots - 1; + let r = if newest && inputs.dot_pop_t < 1.0 { + (DOT_R * ease_out_back(inputs.dot_pop_t)).max(0.4) + } else { + DOT_R + }; + // Success: dots flip accent → white in a quick left-to-right + // cascade over the green flash (each dot finishes (i+1)/n through + // the flash), instead of all flipping at once. + let dot_color = if inputs.failed { + Color::WHITE + } else if inputs.unlock_t > 0.0 { + let flash_t = (inputs.unlock_t / FLASH_FRAC).clamp(0.0, 1.0); + let cascade = (flash_t * shown_dots as f32 - i as f32).clamp(0.0, 1.0); + lerp_color(accent_color, Color::WHITE, cascade) + } else { + accent_color + }; if let Some(path) = - tiny_skia::PathBuilder::from_circle(start_x + i as f32 * dot_gap, dot_y, dot_r) + tiny_skia::PathBuilder::from_circle(start_x + i as f32 * DOT_GAP, dot_y, r) { let mut paint = Paint::default(); - paint.set_color(if inputs.failed { - faded(Color::WHITE, alpha) - } else { - accent_color - }); + paint.set_color(faded(dot_color, pill_alpha)); paint.anti_alias = true; pixmap.fill_path( &path, &paint, tiny_skia::FillRule::Winding, - tiny_skia::Transform::identity(), + Transform::identity(), None, ); } } } - // Status line (e.g. "wrong password" / "checking…") below the pill. + // ---- Empty pill: a centered "enter password" hint instead of dots. The + // caret only appears with the first typed character, so the pill reads as + // an input field rather than an empty dark bar. Centered on the exact + // glyph box (origin anchors the text top, not the baseline). + if shown_dots == 0 && !inputs.failed { + let hint = "Enter password"; + let hint_size = tokens::FONT_SIZE_BASE as f32; + let hint_w = text.measure_line(hint, inputs.font_family, hint_size); + let (hint_top, hint_height) = text.measure_box(hint, inputs.font_family, hint_size); + let hint_y = pill_y + (pill_h - hint_height) / 2.0 - hint_top; + text.draw_line( + &mut pixmap, + hint, + inputs.font_family, + hint_size, + faded(on_surface, pill_alpha * 0.5), + (w - hint_w) / 2.0, + hint_y, + ); + } else if shown_dots > 0 { + // ---- Caret after the last dot: solid for half a second after a + // keystroke, then blinking at ~1.8 Hz. + let caret_x = start_x_for(shown_dots, pill_x, pill_w) + + (shown_dots - 1) as f32 * DOT_GAP + + DOT_R + + 6.0; + let blink = match inputs.keystroke_age { + Some(age) if age < CARET_HOLD_S => 1.0, + Some(age) => ((age - CARET_HOLD_S) * CARET_BLINK_HZ) % 1.0, + None => (inputs.t_secs * CARET_BLINK_HZ) % 1.0, + }; + if blink < 0.5 { + let caret_color = if inputs.failed || inputs.unlock_t > 0.0 { + Color::WHITE + } else { + accent_color + }; + let caret_h = pill_h * 0.5; + if let Some(path) = rounded_rect( + caret_x, + dot_y - caret_h / 2.0, + CARET_W, + caret_h, + 1.0, + ) { + let mut paint = Paint::default(); + paint.set_color(faded(caret_color, pill_alpha)); + paint.anti_alias = true; + pixmap.fill_path( + &path, + &paint, + tiny_skia::FillRule::Winding, + Transform::identity(), + None, + ); + } + } + } + + // ---- Status line below the pill (e.g. "wrong password" / "checking…"). + // Slides up 8px with a fade when it appears (state.rs resets `status_t` + // on every auth-state change). if let Some(status) = inputs.status_text { let status_size = tokens::FONT_SIZE_SECONDARY as f32; let status_w = text.measure_line(status, inputs.font_family, status_size); + let status_anim = ease_out_cubic(inputs.status_t); + let status_alpha = status_e * fade * status_anim; + let color = if inputs.failed { red_color } else { on_surface }; text.draw_line( &mut pixmap, status, inputs.font_family, status_size, - on_surface, + faded(color, status_alpha), (w - status_w) / 2.0, - pill_y + pill_h + tokens::SPACE_MD as f32, + pill_y_rest + pill_h + tokens::SPACE_MD as f32 + elem_y(status_e, DRIFT_STATUS) + + STATUS_SLIDE_PX * (1.0 - status_anim), ); } Some(pixmap) } +/// Recomputes the left edge of the dot row (shared by the dot loop and the +/// caret placement — kept out of `compose` to avoid a long-lived binding). +fn start_x_for(shown_dots: usize, pill_x: f32, pill_w: f32) -> f32 { + let dots_w = (shown_dots as f32 - 1.0).max(0.0) * DOT_GAP; + pill_x + (pill_w - dots_w) / 2.0 +} + /// Copies a composed frame into a `wl_shm` `Argb8888` buffer, swizzling /// tiny-skia's RGBA byte order to the host-endian `0xAARRGGBB` `wl_shm` /// expects (BGRA bytes on little-endian, which is every target this ships @@ -211,6 +605,41 @@ pub fn blit_to_shm(pixmap: &Pixmap, shm_bytes: &mut [u8]) { mod tests { use super::*; + fn inputs<'a>( + bg: &'a Background, + palette: &'a breadlock_ui::theme::Palette, + text: &'a str, + date: &'a str, + password_len: usize, + failed: bool, + failed_t: f32, + dot_pop_t: f32, + appear_t: f32, + unlock_t: f32, + ) -> FrameInputs<'a> { + FrameInputs { + width: 400, + height: 300, + background: bg, + palette, + font_family: "sans-serif", + clock_text: text, + date_text: date, + clock_old: None, + password_len, + failed, + failed_t, + dot_pop_t, + keystroke_age: None, + t_secs: 0.0, + breathe_t: 0.0, + status_t: 1.0, + status_text: None, + appear_t, + unlock_t, + } + } + #[test] fn blit_swizzles_rgba_to_bgra() { let mut pixmap = Pixmap::new(1, 1).unwrap(); @@ -232,8 +661,16 @@ mod tests { palette: &palette, font_family: "sans-serif", clock_text: "12:34", + date_text: "Friday · Aug 21", + clock_old: None, password_len: 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, appear_t: 1.0, unlock_t: 0.0, @@ -242,6 +679,22 @@ mod tests { assert_eq!((pixmap.width(), pixmap.height()), (400, 300)); } + #[test] + fn compose_renders_failed_and_unlock_states() { + let bg = Background::Color(Color::BLACK); + let palette = breadlock_ui::theme::Palette::default(); + let mut text = TextRenderer::new(); + // Wrong-password shake mid-flight. + let failed = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, true, 0.3, 0.4, 1.0, 0.0); + assert!(compose(&mut text, &failed).is_some()); + // Success flash phase of the unlock. + let success = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 0.12); + assert!(compose(&mut text, &success).is_some()); + // Fully faded unlock returns just the background. + let done = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 1.0); + assert!(compose(&mut text, &done).is_some()); + } + #[test] fn ease_out_cubic_bounds_and_shape() { assert_eq!(ease_out_cubic(0.0), 0.0); @@ -252,6 +705,48 @@ mod tests { assert!(ease_out_cubic(0.5) > 0.5); } + #[test] + fn ease_out_back_overshoots_past_one() { + assert_eq!(ease_out_back(0.0), 0.0); + assert_eq!(ease_out_back(1.0), 1.0); + assert!( + (0..=20).any(|i| ease_out_back(i as f32 / 20.0) > 1.0), + "ease-out-back must overshoot past 1 somewhere in (0, 1)" + ); + } + + #[test] + fn damped_shake_starts_and_ends_at_rest_and_stays_bounded() { + assert_eq!(damped_shake_x(0.0), 0.0); + assert_eq!(damped_shake_x(1.0), 0.0); + for i in 0..=40 { + let x = damped_shake_x(i as f32 / 40.0); + assert!( + x.abs() < 9.5, + "shake amplitude must stay bounded, got {x} at t={}", + i as f32 / 40.0 + ); + } + } + + #[test] + fn staggered_t_spreads_elements_across_the_window() { + // Clock (delay 0) starts immediately; pill (delay 100ms of 450ms) + // only begins after ~22% of the window. + assert_eq!(staggered_t(0.0, CLOCK_DELAY_MS), 0.0); + assert_eq!(staggered_t(0.0, PILL_DELAY_MS), 0.0); + assert!(staggered_t(0.1, CLOCK_DELAY_MS) > 0.0); + assert_eq!(staggered_t(0.1, PILL_DELAY_MS), 0.0); + assert_eq!(staggered_t(1.0, PILL_DELAY_MS), 1.0); + // Monotonic: later raw progress never regresses an element. + let mut prev = 0.0f32; + for i in 0..=20 { + let t = staggered_t(i as f32 / 20.0, STATUS_DELAY_MS); + assert!(t >= prev, "staggered progress regressed: {t} < {prev}"); + prev = t; + } + } + #[test] fn overlay_motion_appear_starts_below_and_fades_in() { let (a0, y0) = overlay_motion(0.0, 0.0); diff --git a/breadlock/src/state.rs b/breadlock/src/state.rs index 4fd5dd6..858bc7e 100644 --- a/breadlock/src/state.rs +++ b/breadlock/src/state.rs @@ -75,6 +75,8 @@ pub struct AppState { pub auth_state: AuthState, pub auth_tx: Sender, + /// 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. @@ -83,6 +85,30 @@ pub struct AppState { /// overlay fades out; compositor `unlock()` happens only after the /// fade completes. Dying mid-fade leaves the session locked (fail-secure). pub unlocking: Option, + /// 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, + /// 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, + /// Clock text drawn last frame; a change starts a minute-rollover + /// crossfade instead of a hard text swap. + pub last_clock_text: String, + /// When the current clock crossfade started. + pub clock_anim_started: Option, + /// 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, + /// 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, + /// 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, /// True while a ~16ms animation timer is registered on the event loop. pub anim_timer_armed: bool, @@ -108,11 +134,37 @@ impl AppState { 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 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 => Some("Checking…".to_string()), + AuthState::Checking => Some(format!("Checking{}", checking_dots(now))), AuthState::Failed => Some("Wrong password".to_string()), AuthState::ConfigError => Some( "PAM config error — check logs (breadlock service not set up correctly)" @@ -120,6 +172,22 @@ impl AppState { ), AuthState::Idle => None, }; + 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 for a short crossfade. + let clock_old = if !self.last_clock_text.is_empty() && clock_text != self.last_clock_text { + self.clock_anim_started = Some(now); + Some((self.last_clock_text.clone(), 0.0)) + } else { + self.clock_anim_started + .map(|t| render::unit_progress(t, render::CLOCK_CROSSFADE_MS)) + .filter(|t| *t < 1.0) + .map(|t| (self.last_clock_text.clone(), t)) + }; + self.last_clock_text = clock_text.clone(); let appear_t = self .appear_started @@ -129,6 +197,14 @@ impl AppState { .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 output_palette = self.palette_for_surface(surface); let inputs = render::FrameInputs { @@ -138,8 +214,16 @@ impl AppState { palette: &output_palette, font_family: &self.config.appearance.font.family, clock_text: &clock_text, + date_text: &date_text, + clock_old: clock_old.as_ref().map(|(s, t)| (s.as_str(), *t)), password_len: self.password.len(), failed: matches!(self.auth_state, AuthState::Failed | 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(), appear_t, unlock_t, @@ -217,15 +301,74 @@ impl AppState { .unwrap_or(false) } - fn anim_in_progress(&self) -> bool { - self.unlocking.is_some() || self.appear_in_progress() + fn failed_shake_in_progress(&self) -> bool { + self.failed_at + .map(|t| t.elapsed() < Duration::from_millis(render::SHAKE_MS)) + .unwrap_or(false) } - /// Keep requesting frames while appear or unlock-fade is running. + 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_anim_started + .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) + } + + /// 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) 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() + } + + /// Keep requesting frames while any effect is running. fn arm_anim_if_needed(&mut self, qh: &QueueHandle) { 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 @@ -247,7 +390,20 @@ impl AppState { self.anim_timer_armed = false; TimeoutAction::Drop } else if self.anim_in_progress() { - TimeoutAction::ToDuration(Duration::from_millis(render::ANIM_FRAME_MS)) + // Slow effects (idle breath, Ken Burns) don't need 60fps — halve + // the redraw cost for them. Everything else stays at ~60Hz. + let fast = 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; + TimeoutAction::ToDuration(Duration::from_millis(if fast { + render::ANIM_FRAME_MS + } else { + render::SLOW_FRAME_MS + })) } else { self.anim_timer_armed = false; TimeoutAction::Drop @@ -281,6 +437,7 @@ impl AppState { .insert_source(Timer::from_duration(timeout), move |_, _, state| { if matches!(state.auth_state, AuthState::Failed | AuthState::ConfigError) { state.auth_state = AuthState::Idle; + state.failed_at = None; state.redraw_all(&qh); } TimeoutAction::Drop @@ -288,6 +445,17 @@ impl AppState { } } +/// The animated ellipsis for the "Checking" status while a PAM check runs: +/// cycles "", ".", "..", "…" every ~500ms (driven by the monotonic clock). +fn checking_dots(now: Instant) -> &'static str { + match (now.elapsed().as_secs_f32() * 2.0) as usize % 4 { + 0 => "", + 1 => ".", + 2 => "..", + _ => "…", + } +} + impl ShmHandler for AppState { fn shm_state(&mut self) -> &mut Shm { &mut self.shm diff --git a/design/sketch.html b/design/sketch.html new file mode 100644 index 0000000..f96b1f9 --- /dev/null +++ b/design/sketch.html @@ -0,0 +1,661 @@ + + + + + +breadlock × breadgreet — style & motion sketch + + + + +
+
+

breadlock × breadgreet — style & motion sketch

+
+ + +
+
+

+ Live CSS prototype of the lock screen and greeter, grounded in the real + bread-theme 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). Badges mark what already ships vs what's proposed. +

+
+ bg + surface + red + green + accent +
+
+ +
+

Live stages click a state chip to replay it

+

The two apps should feel like one family: same palette, same radius/spacing tokens, same motion language (ease-out, 300–450ms).

+ +
+ +
+

breadlock

tiny-skia · 16ms timer loop · render.rs
+
+
+
+
+
21:47date
Friday · Aug 21
+
+ + +
+
+
+
+
+ + + + + +
+ +
+ + +
+

breadgreet

GTK4 · relm4 · CSS in theme.rs
+
+
+
+
+
21:47
+
+ +
+
+
+ + bos — Hyprland icon + +
+
+
+
+
+ + + + +
+ +
+
+ +

Motion library proposed animations, mapped to where each lands

+

Every idea below is prototypeable in CSS first, then ported. Effort: S small · + M medium · L large (protocol/CPU work).

+ +
+
+
Entrance stagger S
+
21:47
+
Clock → pill → status cascade instead of one uniform fade. Pill overshoots ~2% (ease-out-back). Replaces the single overlay motion in render.rs.
+ +
+ +
+
Dot pop + caret S
+
+
Newest password dot scales in with overshoot; a blinking caret marks where you're typing. Today dots just appear — render.rs dot loop.
+ +
+ +
+
Wrong-password shake S
+
Wrong password
+
The classic, currently reserved for v2 — failure is just a red pill today. Damped 8px shake, red fill, auto-clear after fail_timeout_ms.
+ +
+ +
+
Success flash → unlock drift S
+
+
Correct password: green (color2) flash + glow ring, then the existing 400ms fade-and-drift-up unlock in state.rs.
+ +
+ +
+
Clock minute crossfade S
+
21:4721:48
+
300ms dip-and-swap on the minute tick instead of a hard blink. Locker: crossfade layer in render.rs. Greeter: GTK CSS transition.
+ +
+ +
+
Idle breathing S
+
+
Very subtle 3–4s sine on the pill's glow — proof the screen is live, not frozen. Cheap in render.rs; keep amplitude tiny (CPU is software-rendered).
+ +
+ +
+
Greeter card entrance S
+
+
Greeter currently has zero animation. Fade + 18px rise, staggered after the clock — GTK4 CSS @keyframes in breadgreet/theme.rs.
+ +
+ +
+
Entry focus ring S
+
+
Accent border + soft glow on focus, 200ms transition. Standard GTK CSS :focus — matches the shared stylesheet's "blue on focus" input rule.
+ +
+ +
+
Auth spinner S
+
+
Real gtk::Spinner during Stage::Working instead of static "Checking…" text. One widget swap in breadgreet/main.rs.
+ +
+ +
+
Session icon rows M
+
bos — Hyprland
Hyprland
+
sessions.rs doesn't parse Icon= today. Custom dropdown rows with per-session icons; falls back to a letter tile.
+ +
+ +
+
Wallpaper Ken Burns M
+
+
Slow pan on image wallpapers — just a drifting Transform in background.rs::paint, no new protocol. Gate behind config (CPU cost).
+ +
+
+ +
+
+

breadlock — where things land

+
    +
  • Motion: extend the existing anim_timer/tick_animation loop in state.rs; add per-element progress fields (appear started per element, fail-shake start, dot-pop start).
  • +
  • Frame math: all easing lives in render.rs (ease_out_cubic, overlay_motion). Add ease_out_back for the pill overshoot and a damped sinusoid for the shake.
  • +
  • Success flash: reuse unlocking: Option<Instant> — flash phase 0–250ms, fade 250–650ms, then unlock().
  • +
  • Bigger: live blur-of-desktop needs a wlr-screencopy capture (already flagged v2 in README) — software downscale → blur → upscale to keep CPU sane. New [animation] config section (enabled / speed / per-effect toggles).
  • +
+
+
+

breadgreet — where things land

+
    +
  • Motion: GTK4 CSS supports @keyframes/animation and transitions — everything goes in breadgreet/theme.rs::load_css, no Rust logic needed for entrance/focus/status.
  • +
  • Spinner: swap the status label for a gtk::Spinner during Stage::Working in main.rs.
  • +
  • Session icons: parse Icon= in sessions.rs and switch DropDown to custom rows.
  • +
  • Unify with the locker: same clock sizing/weight, same radius + spacing tokens; card backdrop-filter: blur() if GTK ≥ 4.12 supports it (README already requires 4.12).
  • +
+
+
+ +
+ Sketch mirrors breadlock/src/render.rs + state.rs, breadgreet/src/theme.rs + main.rs, and the tokens in + bread-ecosystem/BREAD_DESIGN_SYSTEM.md / bread-theme/src/palette.rs. Palette: fixed BOS dark base + (bg #0c0c0c, surface #1a1a1a, overlay #d8d8d8) with pywal-driven accents (color1–6). + The "bread" palette's red/green/accent are the curated bread-toned defaults, which is why "wrong password" is brownish until pywal is active. +
+
+ + + + From 3d2e35e0034d485b5575677ce6cf61d8e9871239 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 21 Aug 2026 15:02:09 +0800 Subject: [PATCH 13/20] GPU background rendering (EGL/GLES2) with software chrome, plus expanded tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Cargo.lock | 62 ++ breadlock-ui/src/painter.rs | 101 +++- breadlock/Cargo.toml | 4 +- breadlock/src/background.rs | 400 ++++++++++++- breadlock/src/bin/breadlock-preview.rs | 116 +++- breadlock/src/gpu.rs | 796 +++++++++++++++++++++++++ breadlock/src/lock/session.rs | 8 + breadlock/src/lock/surface.rs | 1 + breadlock/src/main.rs | 12 + breadlock/src/render.rs | 312 ++++++++-- breadlock/src/state.rs | 51 +- 11 files changed, 1774 insertions(+), 89 deletions(-) create mode 100644 breadlock/src/gpu.rs diff --git a/Cargo.lock b/Cargo.lock index 8cf2d85..41855fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -122,6 +122,8 @@ dependencies = [ "bread-utils", "breadlock-ui", "chrono", + "glow", + "khronos-egl", "pam-client2", "serde", "serde_json", @@ -346,6 +348,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -707,6 +718,18 @@ dependencies = [ "system-deps", ] +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "gobject-sys" version = "0.22.6" @@ -904,6 +927,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -916,6 +949,16 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" @@ -1297,6 +1340,12 @@ dependencies = [ "unicode-script", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -1969,6 +2018,7 @@ dependencies = [ "cc", "downcast-rs", "rustix", + "scoped-tls", "smallvec", "wayland-sys", ] @@ -2075,9 +2125,21 @@ version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" dependencies = [ + "dlib", + "log", "pkg-config", ] +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "windows-core" version = "0.62.2" diff --git a/breadlock-ui/src/painter.rs b/breadlock-ui/src/painter.rs index b84f471..5cc0adc 100644 --- a/breadlock-ui/src/painter.rs +++ b/breadlock-ui/src/painter.rs @@ -162,32 +162,60 @@ impl TextRenderer { if a == 0 { return; } - blend_over_opaque(pixmap, px as u32, py as u32, r, g, b, a); + 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; } } @@ -260,4 +288,45 @@ mod tests { "10%-alpha text must not render near-white, got {low_max}" ); } + + #[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" + ); + } } diff --git a/breadlock/Cargo.toml b/breadlock/Cargo.toml index 98355b3..e9e91b4 100644 --- a/breadlock/Cargo.toml +++ b/breadlock/Cargo.toml @@ -28,8 +28,10 @@ path = "src/bin/breadlock-preview.rs" 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" pam-client2 = { version = "0.5", default-features = false } zeroize = { version = "1", features = ["std"] } diff --git a/breadlock/src/background.rs b/breadlock/src/background.rs index 41cabcb..6af5d92 100644 --- a/breadlock/src/background.rs +++ b/breadlock/src/background.rs @@ -4,8 +4,15 @@ //! 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}; @@ -18,9 +25,183 @@ const KENBURNS_ZOOM: f32 = 1.06; pub enum Background { Color(tiny_skia::Color), - /// `(source, ken_burns)` — the flag decides whether `paint` pans over - /// time or draws statically. - Image(Pixmap, bool), + 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). +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, + cache: RefCell>, +} + +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 - tw).max(0) as f32); + let sy = (-dy).clamp(0.0, (sh - th).max(0) 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)] + 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) as u32; + let bv = ((b >> shift) & 0xFF) as u32; + let dv = ((d >> shift) & 0xFF) as u32; + let ev = ((e >> shift) & 0xFF) as u32; + 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 { @@ -43,7 +224,11 @@ impl Background { return fallback(); } match Pixmap::load_png(&cfg.path) { - Ok(pixmap) => Background::Image(pixmap, cfg.ken_burns), + Ok(pixmap) => Background::Image(ImageBg { + source: pixmap, + ken_burns: cfg.ken_burns, + cache: RefCell::new(None), + }), Err(err) => { tracing::warn!(path = %cfg.path, %err, "failed to load background image (PNG only in v1), falling back to palette color"); fallback() @@ -55,7 +240,7 @@ impl Background { /// True when this background needs continuous redraws (Ken Burns pan). pub fn ken_burns(&self) -> bool { - matches!(self, Background::Image(_, true)) + matches!(self, Background::Image(bg) if bg.ken_burns) } /// Paints this background into `target`, cover-fit (scaled uniformly to @@ -63,44 +248,201 @@ impl Background { /// 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. - pub fn paint(&self, target: &mut Pixmap, t_secs: f32) { + /// + /// 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, ken_burns) => { + 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 cover = (tw / sw).max(th / sh); - let (scale, tx, ty) = if *ken_burns { - let scale = cover * KENBURNS_ZOOM; - // Pan range: how far the scaled image overhangs each axis. - let pan_x = (sw * scale - tw).max(0.0); - let pan_y = (sh * scale - th).max(0.0); + let mut cache = bg.cache.borrow_mut(); + let stale = cache + .as_ref() + .map(|c| c.target_w != target.width() || c.target_h != target.height()) + .unwrap_or(true); + if stale { + 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" + ); + *cache = None; + 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 mut paint = PixmapPaint::default(); + paint.quality = tiny_skia::FilterQuality::Bilinear; + pixmap.draw_pixmap( + 0, + 0, + bg.source.as_ref(), + &paint, + Transform::from_scale(scale, scale), + None, + ); + *cache = Some(ScaledBg { + pixmap, + pan_x: scaled_w as f32 - tw, + pan_y: scaled_h as f32 - th, + target_w: target.width(), + target_h: target.height(), + }); + } + let scaled = cache.as_ref().expect("cache populated above"); + target.fill(tiny_skia::Color::BLACK); + let (tx, ty) = if bg.ken_burns { let phase = t_secs * TAU / KENBURNS_PERIOD_S; // Sin/cos offset by a quarter cycle: the pan traces a slow // ellipse, starting from a corner. ( - scale, - -pan_x * (0.5 + 0.5 * phase.sin()), - -pan_y * (0.5 + 0.5 * phase.cos()), + -scaled.pan_x * (0.5 + 0.5 * phase.sin()), + -scaled.pan_y * (0.5 + 0.5 * phase.cos()), ) } else { - (cover, 0.0, 0.0) + (0.0, 0.0) }; - target.fill(tiny_skia::Color::BLACK); - target.draw_pixmap( - 0, - 0, - source.as_ref(), - &PixmapPaint::default(), - // scale first (image coords → scaled), then translate into - // the pan position. - Transform::from_translate(tx, ty).pre_concat(Transform::from_scale(scale, scale)), - None, - ); + // 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(None), + }); + 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(), ((0 + 32) / 2) as u8, "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(None), + }); + 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)); + } +} diff --git a/breadlock/src/bin/breadlock-preview.rs b/breadlock/src/bin/breadlock-preview.rs index d8ae223..3fb14fd 100644 --- a/breadlock/src/bin/breadlock-preview.rs +++ b/breadlock/src/bin/breadlock-preview.rs @@ -77,9 +77,120 @@ impl Default for Scene { } } +/// `--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 = 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, + 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, + 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::() / 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, + 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, + 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::() / 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 out_dir = std::env::args() - .nth(1) + let args: Vec = 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"); @@ -137,6 +248,7 @@ fn main() { status_text: scene.status, 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); diff --git a/breadlock/src/gpu.rs b/breadlock/src/gpu.rs new file mode 100644 index 0000000..2b32d1c --- /dev/null +++ b/breadlock/src/gpu.rs @@ -0,0 +1,796 @@ +//! GPU background rendering via EGL/GLES2, with the chrome composited in +//! software (tiny-skia) on top — the hybrid that makes the Ken Burns pan +//! smooth without a GPU-hungry full renderer. +//! +//! The lock surface's `wl_surface` is wrapped in a `wl_egl_window`; each +//! frame the wallpaper is drawn as a full-screen textured quad whose shader +//! applies the pan transform (GPU bilinear filtering makes sub-pixel motion +//! free — the ~19 ms/frame software bilinear is gone) and the vertical dim +//! veil. The chrome (clock/date/pill/status) is still composed by +//! `render::compose_chrome` into a transparent pixmap and blitted to a +//! texture each frame (only the bounding rect of what was drawn). +//! +//! If EGL initialization fails for any reason (headless, no GPU, compositor +//! without EGL), [`GpuRenderer::new`] returns `None` and the locker falls +//! back to the fully-software path unchanged. + +use crate::render::{self, FrameInputs}; +use breadlock_ui::config::{Background as BackgroundConfig, BackgroundMode}; +use breadlock_ui::painter::TextRenderer; +use breadlock_ui::theme::Palette; +use glow::HasContext; +use khronos_egl as egl; +use std::os::raw::c_void; +use tiny_skia::Pixmap; +use wayland_client::protocol::wl_surface::WlSurface; +use wayland_client::{Connection, Proxy}; + +// Same pan geometry as `background.rs` — kept in sync by comment. +const KENBURNS_PERIOD_S: f32 = 90.0; +const KENBURNS_ZOOM: f32 = 1.06; + +const EGL_ATTRIBS: [egl::Int; 11] = [ + egl::SURFACE_TYPE, + (egl::WINDOW_BIT | egl::PBUFFER_BIT) as egl::Int, + egl::RED_SIZE, + 8, + egl::GREEN_SIZE, + 8, + egl::BLUE_SIZE, + 8, + egl::ALPHA_SIZE, + 8, + egl::NONE, +]; + +const VERTEX_SRC: &str = "\ +attribute vec2 a_pos; // pixels, (0,0) top-left +uniform vec2 u_screen; +uniform vec2 u_uv_scale; +uniform vec2 u_uv_offset; +varying vec2 v_uv; +void main() { + v_uv = a_pos * u_uv_scale + u_uv_offset; + vec2 clip = vec2(a_pos.x / u_screen.x * 2.0 - 1.0, 1.0 - a_pos.y / u_screen.y * 2.0); + gl_Position = vec4(clip, 0.0, 1.0); +}"; + +// Background: sample the wallpaper (or a 1x1 white texture for solid color), +// apply the vertical dim veil. v_uv has v = 0 at the top of the image. +const BG_FRAG_SRC: &str = "\ +precision mediump float; +varying vec2 v_uv; +uniform sampler2D u_tex; +uniform vec4 u_color; +uniform float u_dim_top; +uniform float u_dim_bottom; +uniform float u_veil_alpha; +uniform float u_screen_h; +void main() { + vec4 c = texture2D(u_tex, v_uv) * u_color; + float row = 1.0 - gl_FragCoord.y / u_screen_h; // 1 at top + float dim = mix(u_dim_top, u_dim_bottom, row) * u_veil_alpha; + gl_FragColor = vec4(c.rgb * (1.0 - dim), 1.0); +}"; + +// Chrome: premultiplied alpha texture, blended with GL_ONE / ONE_MINUS_SRC_ALPHA. +const CHROME_FRAG_SRC: &str = "\ +precision mediump float; +varying vec2 v_uv; +uniform sampler2D u_tex; +void main() { + gl_FragColor = texture2D(u_tex, v_uv); +}"; + +// The wl_egl_window C API (libwayland-egl). The window wraps a wl_surface +// so EGL can allocate its buffers against the lock surface. +#[repr(C)] +struct wl_surface { + _private: [u8; 0], +} +#[repr(C)] +pub struct wl_egl_window { + _private: [u8; 0], +} + +#[link(name = "wayland-egl")] +extern "C" { + fn wl_egl_window_create(surface: *mut wl_surface, width: i32, height: i32) -> *mut wl_egl_window; + fn wl_egl_window_resize(window: *mut wl_egl_window, width: i32, height: i32, dx: i32, dy: i32); +} + +// EGL objects are intentionally not destroyed on the way out: the process +// exits immediately after unlock, and dropping the pbuffer/context while it +// might still be current would be UB — leaving them for the OS is cleaner. +const _: () = (); + +/// One EGL-backed lock surface. Created lazily on the first `configure` (the +/// size is unknown before that) and resized on subsequent ones. The process +/// exits right after unlock, so EGL objects are deliberately not destroyed +/// individually. +pub struct GpuSurface { + egl_window: *mut wl_egl_window, + egl_surface: egl::Surface, + width: u32, + height: u32, +} + +impl GpuSurface { + pub(crate) fn resize(&mut self, width: u32, height: u32) { + if (width, height) == (self.width, self.height) { + return; + } + // SAFETY: `egl_window` is the pointer `create_surface` stored. + unsafe { wl_egl_window_resize(self.egl_window, width as i32, height as i32, 0, 0) }; + self.width = width; + self.height = height; + } +} + +struct Wallpaper { + tex: glow::Texture, + size: (u32, u32), + ken_burns: bool, +} + +pub struct GpuRenderer { + egl: egl::DynamicInstance, + display: egl::Display, + config: egl::Config, + context: egl::Context, + /// 1x1 pbuffer used to make the context current during setup (before any + /// real lock surface exists). Kept alive for the renderer's lifetime — + /// the read is deliberate: dropping it while the context might still be + /// current on it is undefined behavior. + #[allow(dead_code)] + setup_surface: egl::Surface, + gl: glow::Context, + bg_program: glow::Program, + chrome_program: glow::Program, + quad_vao: glow::VertexArray, + quad_vbo: glow::Buffer, + wallpaper: Option, + /// 1x1 white texture for solid-color backgrounds (shader multiplies by + /// the palette color). + white_tex: glow::Texture, + bg_color: [f32; 4], + chrome_tex: glow::Texture, + chrome_tex_size: (u32, u32), + /// Reused scratch for the chrome compose. + chrome_pixmap: Option, + u_screen: [Option; 2], + u_uv_scale: [Option; 2], + u_uv_offset: [Option; 2], + u_tex: [Option; 2], + u_color: Option, + u_dim_top: Option, + u_dim_bottom: Option, + u_veil_alpha: Option, + u_screen_h: Option, +} + +impl GpuRenderer { + /// Initializes EGL/GLES2 against the session's Wayland display and loads + /// the wallpaper into a texture. Returns `None` (after logging) on any + /// failure — the caller keeps the software path. + pub fn new(conn: &Connection, bg_cfg: &BackgroundConfig, palette: &Palette) -> Option { + // SAFETY: khronos-egl's dynamic instance loads libEGL.so.1; the + // returned handles are only used while the library stays loaded. + let egl = unsafe { egl::DynamicInstance::::load_required() }.ok()?; + // SAFETY: the display pointer comes from our live wayland connection. + let display = unsafe { egl.get_display(conn.display().id().as_ptr() as *mut c_void) }?; + egl.initialize(display).ok()?; + let mut configs = Vec::with_capacity(1); + egl.choose_config(display, &EGL_ATTRIBS, &mut configs).ok()?; + let config = *configs.first()?; + let context = egl + .create_context(display, config, None, &[egl::CONTEXT_CLIENT_VERSION, 2, egl::NONE]) + .ok()?; + // A 1x1 pbuffer is enough to make the context current for setup + // before any real lock surface exists (pbuffers size via + // EGL_WIDTH/EGL_HEIGHT). + let setup_surface = egl + .create_pbuffer_surface(display, config, &[egl::WIDTH, 1, egl::HEIGHT, 1, egl::NONE]) + .ok()?; + if egl + .make_current(display, Some(setup_surface), Some(setup_surface), Some(context)) + .is_err() + { + return None; + } + + let gl = unsafe { + glow::Context::from_loader_function_cstr(|name| { + egl.get_proc_address(name.to_str().unwrap_or("")) + .map(|p| p as *const c_void) + .unwrap_or(std::ptr::null()) + }) + }; + + let bg_program = compile_program(&gl, VERTEX_SRC, BG_FRAG_SRC)?; + let chrome_program = compile_program(&gl, VERTEX_SRC, CHROME_FRAG_SRC)?; + + // Fullscreen quad: two triangles covering [0, w] x [0, h] (pixel + // space). A single unit quad scaled by `u_screen` in the shader + // would need a uniform; instead the vertices are normalized and the + // vertex shader multiplies by u_screen... but a_pos is in pixels — + // so upload actual pixel positions per surface size? No: keep the + // quad in unit space and let the shader's u_screen scale it. The + // shader expects a_pos in pixels, so upload a 1x1 unit quad scaled + // at bind time via glVertexAttrib? Simpler: use normalized coords. + let quad_vao = unsafe { gl.create_vertex_array() }.ok()?; + let quad_vbo = unsafe { gl.create_buffer() }.ok()?; + unsafe { + gl.bind_vertex_array(Some(quad_vao)); + gl.bind_buffer(glow::ARRAY_BUFFER, Some(quad_vbo)); + // Unit quad [0,1]^2; the vertex shader multiplies by u_screen. + let verts: [f32; 12] = [0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0]; + gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, f32s_as_bytes(&verts), glow::STATIC_DRAW); + gl.enable_vertex_attrib_array(0); + gl.vertex_attrib_pointer_f32(0, 2, glow::FLOAT, false, 8, 0); + } + + // Wallpaper texture (original resolution; the GPU downscales + mipmaps). + let wallpaper = match &bg_cfg.mode { + BackgroundMode::Color => None, + BackgroundMode::Image if bg_cfg.path.is_empty() => { + tracing::warn!("background.mode = \"image\" but background.path is empty, using solid color"); + None + } + BackgroundMode::Image => match Pixmap::load_png(&bg_cfg.path) { + Ok(pix) => { + let (w, h) = (pix.width(), pix.height()); + let tex = unsafe { gl.create_texture() }.ok()?; + unsafe { + gl.bind_texture(glow::TEXTURE_2D, Some(tex)); + gl.tex_image_2d( + glow::TEXTURE_2D, + 0, + glow::RGBA as i32, + w as i32, + h as i32, + 0, + glow::RGBA, + glow::UNSIGNED_BYTE, + glow::PixelUnpackData::Slice(Some(pix.data())), + ); + gl.generate_mipmap(glow::TEXTURE_2D); + gl.tex_parameter_i32( + glow::TEXTURE_2D, + glow::TEXTURE_MIN_FILTER, + glow::LINEAR_MIPMAP_LINEAR as i32, + ); + gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::LINEAR as i32); + gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32); + gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32); + } + Some(Wallpaper { + tex, + size: (w, h), + ken_burns: bg_cfg.ken_burns, + }) + } + Err(err) => { + tracing::warn!(path = %bg_cfg.path, %err, "GPU: failed to load background image, using solid color"); + None + } + }, + }; + + // 1x1 white texture for the solid-color shader path. + let white_tex = unsafe { gl.create_texture() }.ok()?; + unsafe { + gl.bind_texture(glow::TEXTURE_2D, Some(white_tex)); + gl.tex_image_2d( + glow::TEXTURE_2D, + 0, + glow::RGBA as i32, + 1, + 1, + 0, + glow::RGBA, + glow::UNSIGNED_BYTE, + glow::PixelUnpackData::Slice(Some(&[255, 255, 255, 255])), + ); + gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32); + gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32); + } + + // Full-size chrome texture (sub-image uploaded per frame). + let chrome_tex = unsafe { gl.create_texture() }.ok()?; + unsafe { + gl.bind_texture(glow::TEXTURE_2D, Some(chrome_tex)); + gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32); + gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32); + gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32); + gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32); + } + + let bg = breadlock_ui::theme::tiny_skia_color(&palette.background); + let bg_color = [bg.red(), bg.green(), bg.blue(), 1.0]; + + // Resolve all uniform locations up front, then drop the closure so + // `gl` can move into the renderer. + let (u_screen, u_uv_scale, u_uv_offset, u_tex, u_color, u_dim_top, u_dim_bottom, u_veil_alpha, u_screen_h) = { + let loc = |p: glow::Program, n: &str| unsafe { gl.get_uniform_location(p, n) }; + ( + [loc(bg_program, "u_screen"), loc(chrome_program, "u_screen")], + [loc(bg_program, "u_uv_scale"), loc(chrome_program, "u_uv_scale")], + [loc(bg_program, "u_uv_offset"), loc(chrome_program, "u_uv_offset")], + [loc(bg_program, "u_tex"), loc(chrome_program, "u_tex")], + loc(bg_program, "u_color"), + loc(bg_program, "u_dim_top"), + loc(bg_program, "u_dim_bottom"), + loc(bg_program, "u_veil_alpha"), + loc(bg_program, "u_screen_h"), + ) + }; + + Some(Self { + egl, + display, + config, + context, + setup_surface, + gl, + bg_program, + chrome_program, + quad_vao, + quad_vbo, + wallpaper, + white_tex, + bg_color, + chrome_tex, + chrome_tex_size: (0, 0), + chrome_pixmap: None, + u_screen, + u_uv_scale, + u_uv_offset, + u_tex, + u_color, + u_dim_top, + u_dim_bottom, + u_veil_alpha, + u_screen_h, + }) + } + + /// Wraps a lock surface's `wl_surface` in an EGL window + surface. + /// Called once per surface from its first `configure`. + pub fn create_surface(&self, surface: &WlSurface, width: u32, height: u32) -> Option { + // SAFETY: the surface proxy is live (this is called from its + // `configure` handler); the returned window is owned by us. + let egl_window = unsafe { + wl_egl_window_create( + surface.id().as_ptr() as *mut wl_surface, + width as i32, + height as i32, + ) + }; + if egl_window.is_null() { + tracing::error!("wl_egl_window_create failed"); + return None; + } + // SAFETY: `egl_window` is a valid wl_egl_window native window. + let egl_surface = unsafe { + self.egl + .create_window_surface(self.display, self.config, egl_window as *mut c_void, None) + } + .ok()?; + Some(GpuSurface { + egl_window, + egl_surface, + width, + height, + }) + } + + /// Renders one frame for `surface`: wallpaper quad (pan + veil in the + /// shader), then the software-composed chrome blitted over it. + pub fn render_frame( + &mut self, + surface: &mut GpuSurface, + inputs: &FrameInputs, + text: &mut TextRenderer, + ) { + let (w, h) = (surface.width, surface.height); + if w == 0 || h == 0 { + return; + } + if self + .egl + .make_current(self.display, Some(surface.egl_surface), Some(surface.egl_surface), Some(self.context)) + .is_err() + { + return; + } + let gl = &self.gl; + unsafe { gl.viewport(0, 0, w as i32, h as i32) }; + self.draw_background(w, h, inputs); + self.draw_chrome(w, h, inputs, text); + let _ = self.egl.swap_buffers(self.display, surface.egl_surface); + } + + fn draw_background(&mut self, w: u32, h: u32, inputs: &FrameInputs) { + let gl = &self.gl; + let (veil_alpha, _) = render::overlay_motion(inputs.appear_t, inputs.unlock_t); + unsafe { + gl.use_program(Some(self.bg_program)); + gl.bind_vertex_array(Some(self.quad_vao)); + // Unit quad -> pixels: the vertex shader uses a_pos in pixels, so + // upload the quad scaled... a_pos IS in pixels only if we pass + // pixel positions; with a unit quad, scale here instead. + // The vertex shader treats a_pos as pixels and divides by + // u_screen — for a unit quad we pass a_pos * screen, so set the + // buffer? Simpler: keep unit quad and multiply u_screen into the + // uv math in the shader. To avoid shader churn: upload a full + // pixel-space quad per surface size. + let wf = w as f32; + let hf = h as f32; + let verts: [f32; 12] = [ + 0.0, 0.0, wf, 0.0, 0.0, hf, // + wf, 0.0, wf, hf, 0.0, hf, + ]; + gl.bind_buffer(glow::ARRAY_BUFFER, Some(self.quad_vbo)); + gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, f32s_as_bytes(&verts), glow::DYNAMIC_DRAW); + + if let Some(loc) = self.u_screen[0].as_ref() { + gl.uniform_2_f32(Some(loc), wf, hf); + } + if let Some(loc) = self.u_uv_scale[0].as_ref() { + match &self.wallpaper { + Some(wp) => { + let (_, _, scaled_w, scaled_h) = + pan_region(wp.size, (w, h), wp.ken_burns, inputs.t_secs); + gl.uniform_2_f32(Some(loc), 1.0 / scaled_w, 1.0 / scaled_h); + } + None => gl.uniform_2_f32(Some(loc), 0.0, 0.0), + } + } + if let Some(loc) = self.u_uv_offset[0].as_ref() { + match &self.wallpaper { + Some(wp) => { + let (sx0, sy0, scaled_w, scaled_h) = + pan_region(wp.size, (w, h), wp.ken_burns, inputs.t_secs); + gl.uniform_2_f32(Some(loc), sx0 / scaled_w, sy0 / scaled_h); + } + None => gl.uniform_2_f32(Some(loc), 0.0, 0.0), + } + } + if let Some(loc) = self.u_color.as_ref() { + match &self.wallpaper { + Some(_) => gl.uniform_4_f32(Some(loc), 1.0, 1.0, 1.0, 1.0), + None => gl.uniform_4_f32( + Some(loc), + self.bg_color[0], + self.bg_color[1], + self.bg_color[2], + 1.0, + ), + } + } + if let Some(loc) = self.u_dim_top.as_ref() { + gl.uniform_1_f32(Some(loc), render::DIM_ALPHA_TOP); + } + if let Some(loc) = self.u_dim_bottom.as_ref() { + gl.uniform_1_f32(Some(loc), render::DIM_ALPHA_BOTTOM); + } + if let Some(loc) = self.u_veil_alpha.as_ref() { + gl.uniform_1_f32(Some(loc), veil_alpha); + } + if let Some(loc) = self.u_screen_h.as_ref() { + gl.uniform_1_f32(Some(loc), h as f32); + } + gl.active_texture(glow::TEXTURE0); + match &self.wallpaper { + Some(wp) => gl.bind_texture(glow::TEXTURE_2D, Some(wp.tex)), + None => gl.bind_texture(glow::TEXTURE_2D, Some(self.white_tex)), + } + if let Some(loc) = self.u_tex[0].as_ref() { + gl.uniform_1_i32(Some(loc), 0); + } + gl.disable(glow::BLEND); + gl.draw_arrays(glow::TRIANGLES, 0, 6); + } + } + + fn draw_chrome(&mut self, w: u32, h: u32, inputs: &FrameInputs, text: &mut TextRenderer) { + let dirty = self + .chrome_pixmap + .as_ref() + .map(|p| (p.width(), p.height()) != (w, h)) + .unwrap_or(true); + if dirty { + self.chrome_pixmap = Pixmap::new(w, h); + } + let Some(pixmap) = self.chrome_pixmap.as_mut() else { + return; + }; + let rect = render::compose_chrome(pixmap, text, inputs); + let x0 = rect.x0.max(0.0).floor() as i32; + let y0 = rect.y0.max(0.0).floor() as i32; + let x1 = (rect.x1.min(w as f32)).ceil() as i32; + let y1 = (rect.y1.min(h as f32)).ceil() as i32; + if x1 <= x0 || y1 <= y0 { + return; + } + if self.chrome_tex_size != (w, h) { + let gl = &self.gl; + unsafe { + gl.bind_texture(glow::TEXTURE_2D, Some(self.chrome_tex)); + gl.tex_image_2d( + glow::TEXTURE_2D, + 0, + glow::RGBA as i32, + w as i32, + h as i32, + 0, + glow::RGBA, + glow::UNSIGNED_BYTE, + glow::PixelUnpackData::Slice(None), + ); + } + self.chrome_tex_size = (w, h); + } + + let data = pixmap.data(); + let stride = w as usize * 4; + let offset = y0 as usize * stride + x0 as usize * 4; + let rw = (x1 - x0) as i32; + let rh = (y1 - y0) as i32; + let gl = &self.gl; + unsafe { + gl.bind_texture(glow::TEXTURE_2D, Some(self.chrome_tex)); + gl.tex_sub_image_2d( + glow::TEXTURE_2D, + 0, + x0, + y0, + rw, + rh, + glow::RGBA, + glow::UNSIGNED_BYTE, + glow::PixelUnpackData::Slice(Some(&data[offset..])), + ); + gl.use_program(Some(self.chrome_program)); + gl.bind_vertex_array(Some(self.quad_vao)); + gl.bind_buffer(glow::ARRAY_BUFFER, Some(self.quad_vbo)); + let wf = w as f32; + let hf = h as f32; + let verts: [f32; 12] = [ + 0.0, 0.0, wf, 0.0, 0.0, hf, // + wf, 0.0, wf, hf, 0.0, hf, + ]; + gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, f32s_as_bytes(&verts), glow::DYNAMIC_DRAW); + if let Some(loc) = self.u_screen[1].as_ref() { + gl.uniform_2_f32(Some(loc), wf, hf); + } + if let Some(loc) = self.u_uv_scale[1].as_ref() { + gl.uniform_2_f32(Some(loc), 1.0 / wf, 1.0 / hf); + } + if let Some(loc) = self.u_uv_offset[1].as_ref() { + gl.uniform_2_f32(Some(loc), 0.0, 0.0); + } + if let Some(loc) = self.u_tex[1].as_ref() { + gl.uniform_1_i32(Some(loc), 0); + } + gl.active_texture(glow::TEXTURE0); + gl.bind_texture(glow::TEXTURE_2D, Some(self.chrome_tex)); + gl.enable(glow::BLEND); + gl.blend_func(glow::ONE, glow::ONE_MINUS_SRC_ALPHA); + gl.draw_arrays(glow::TRIANGLES, 0, 6); + gl.disable(glow::BLEND); + } + } +} + +/// Visible source region of the wallpaper for the current pan phase — the +/// same cover-fit + Ken Burns math as `background.rs`. +fn pan_region(wp: (u32, u32), target: (u32, u32), ken_burns: bool, t_secs: f32) -> (f32, f32, f32, f32) { + let (sw, sh) = (wp.0 as f32, wp.1 as f32); + let (tw, th) = (target.0 as f32, target.1 as f32); + let cover = (tw / sw).max(th / sh); + let scale = cover * if ken_burns { KENBURNS_ZOOM } else { 1.0 }; + let scaled_w = sw * scale; + let scaled_h = sh * scale; + let pan_x = (scaled_w - tw).max(0.0); + let pan_y = (scaled_h - th).max(0.0); + let (tx, ty) = if ken_burns { + let phase = t_secs * std::f32::consts::TAU / KENBURNS_PERIOD_S; + ( + -pan_x * (0.5 + 0.5 * phase.sin()), + -pan_y * (0.5 + 0.5 * phase.cos()), + ) + } else { + (0.0, 0.0) + }; + (-tx, -ty, scaled_w, scaled_h) +} + +fn compile_program(gl: &glow::Context, vs_src: &str, fs_src: &str) -> Option { + unsafe { + let program = gl.create_program().ok()?; + let vs_sh = gl.create_shader(glow::VERTEX_SHADER).ok()?; + gl.shader_source(vs_sh, vs_src); + gl.compile_shader(vs_sh); + if !gl.get_shader_compile_status(vs_sh) { + let log = gl.get_shader_info_log(vs_sh); + tracing::error!(%log, "GPU: vertex shader compile failed"); + return None; + } + let fs_sh = gl.create_shader(glow::FRAGMENT_SHADER).ok()?; + gl.shader_source(fs_sh, fs_src); + gl.compile_shader(fs_sh); + if !gl.get_shader_compile_status(fs_sh) { + let log = gl.get_shader_info_log(fs_sh); + tracing::error!(%log, "GPU: fragment shader compile failed"); + return None; + } + gl.attach_shader(program, vs_sh); + gl.attach_shader(program, fs_sh); + gl.link_program(program); + if !gl.get_program_link_status(program) { + let log = gl.get_program_info_log(program); + tracing::error!(%log, "GPU: program link failed"); + return None; + } + gl.delete_shader(vs_sh); + gl.delete_shader(fs_sh); + Some(program) + } +} + +fn f32s_as_bytes(v: &[f32; 12]) -> &[u8] { + // SAFETY: f32 is POD; the byte length is exact. + unsafe { std::slice::from_raw_parts(v.as_ptr() as *const u8, std::mem::size_of_val(v)) } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::f32::consts::TAU; + + /// The software path's pan math (background.rs `Background::Image::paint`), + /// re-implemented here so the GPU `pan_region` can be checked against it. + /// Software rounds the scaled dims to pixels; GPU keeps floats, so + /// compare with a 1px tolerance. + fn software_pan(wp: (u32, u32), target: (u32, u32), ken_burns: bool, t_secs: f32) -> (f32, f32) { + let (sw, sh) = (wp.0 as f32, wp.1 as f32); + let (tw, th) = (target.0 as f32, target.1 as f32); + let cover = (tw / sw).max(th / sh); + let scale = cover * if ken_burns { KENBURNS_ZOOM } else { 1.0 }; + let scaled_w = (sw * scale).round().max(1.0); + let scaled_h = (sh * scale).round().max(1.0); + let pan_x = scaled_w - tw; + let pan_y = scaled_h - th; + let (tx, ty) = if ken_burns { + let phase = t_secs * TAU / KENBURNS_PERIOD_S; + ( + -pan_x * (0.5 + 0.5 * phase.sin()), + -pan_y * (0.5 + 0.5 * phase.cos()), + ) + } else { + (0.0, 0.0) + }; + (-tx, -ty) + } + + #[test] + fn pan_region_static_matches_software_centered() { + let wp = (3840, 2160); + let target = (1920, 1200); + let (sx, sy, sw, sh) = pan_region(wp, target, false, 123.4); + assert_eq!(sx, 0.0, "no ken burns: no horizontal pan"); + assert_eq!(sy, 0.0, "no ken burns: no vertical pan"); + // Cover fit: the scaled region covers the target in both axes. + assert!(sw >= 1920.0 && sh >= 1200.0); + // And it's the tightest cover: at least one axis exactly matches. + assert!( + (sw - 1920.0).abs() < 0.01 || (sh - 1200.0).abs() < 0.01, + "cover must be tight, got {sw}x{sh}" + ); + } + + #[test] + fn pan_region_ken_burns_tracks_software_path() { + let wp = (3840, 2160); + let target = (1920, 1200); + for i in 0..=40 { + let t = i as f32 / 40.0 * KENBURNS_PERIOD_S; + let (sx, sy, _, _) = pan_region(wp, target, true, t); + let (ex, ey) = software_pan(wp, target, true, t); + assert!( + (sx - ex).abs() < 1.0, + "x pan diverged from software at t={t}: gpu {sx} vs sw {ex}" + ); + assert!( + (sy - ey).abs() < 1.0, + "y pan diverged from software at t={t}: gpu {sy} vs sw {ey}" + ); + } + } + + #[test] + fn pan_region_never_exposes_edges() { + let wp = (3840, 2160); + let target = (1920, 1200); + for i in 0..=200 { + let t = i as f32 / 200.0 * KENBURNS_PERIOD_S; + let (sx, sy, sw, sh) = pan_region(wp, target, true, t); + assert!(sx >= -0.001, "negative x offset at t={t}"); + assert!(sy >= -0.001, "negative y offset at t={t}"); + assert!( + sx + 1920.0 <= sw + 0.001, + "right edge exposed at t={t}: sx {sx} + 1920 > sw {sw}" + ); + assert!( + sy + 1200.0 <= sh + 0.001, + "bottom edge exposed at t={t}: sy {sy} + 1200 > sh {sh}" + ); + } + } + + #[test] + fn pan_region_starts_at_corner_and_returns() { + // t=0: sin=0, cos=1 → the region sits at the top, horizontally centered. + let wp = (3840, 2160); + let target = (1920, 1200); + let (sx0, sy0, sw, _) = pan_region(wp, target, true, 0.0); + let pan_x = sw - 1920.0; + let cover = (1920.0f32 / 3840.0).max(1200.0f32 / 2160.0); + let pan_y = (2160.0 * (cover * KENBURNS_ZOOM)).round() - 1200.0; + assert!((sx0 - pan_x * 0.5).abs() < 0.5, "at t=0 x should be half-panned, got {sx0}"); + assert!((sy0 - pan_y).abs() < 0.5, "at t=0 y should be fully panned (top), got {sy0}"); + // Half a period later it has returned to the same spot. + let (sx1, sy1, _, _) = pan_region(wp, target, true, KENBURNS_PERIOD_S); + assert!((sx1 - sx0).abs() < 0.01 && (sy1 - sy0).abs() < 0.01); + } + + /// Extracts every `uniform ;` declaration from a GLSL source. + fn declared_uniforms(src: &str) -> Vec { + let mut out = Vec::new(); + for line in src.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("uniform ") { + if let Some((_, name)) = rest.rsplit_once(' ') { + out.push(name.trim_end_matches(';').to_string()); + } + } + } + out + } + + #[test] + fn shaders_declare_every_uniform_the_renderer_sets() { + // If a uniform is renamed in the GLSL but not at the call site (or + // vice versa) it silently becomes -1 and the frame renders wrong; + // this test pins the two together. + let declared = [ + declared_uniforms(VERTEX_SRC), + declared_uniforms(BG_FRAG_SRC), + declared_uniforms(CHROME_FRAG_SRC), + ] + .concat(); + for name in [ + "u_screen", "u_uv_scale", "u_uv_offset", "u_tex", "u_color", + "u_dim_top", "u_dim_bottom", "u_veil_alpha", "u_screen_h", + ] { + assert!( + declared.iter().any(|d| d == name), + "uniform {name} missing from shader sources" + ); + } + } + + #[test] + fn egl_attribs_are_none_terminated_pairs() { + assert_eq!(EGL_ATTRIBS.len() % 2, 1, "attribs must be key/value pairs + NONE"); + assert_eq!(*EGL_ATTRIBS.last().unwrap(), egl::NONE, "attrib list must be NONE-terminated"); + } + + #[test] + fn f32s_as_bytes_has_exact_length() { + let v: [f32; 12] = [0.0; 12]; + assert_eq!(f32s_as_bytes(&v).len(), 12 * 4); + } +} diff --git a/breadlock/src/lock/session.rs b/breadlock/src/lock/session.rs index a3c0ba4..3641a49 100644 --- a/breadlock/src/lock/session.rs +++ b/breadlock/src/lock/session.rs @@ -43,6 +43,14 @@ impl SessionLockHandler for AppState { { s.width = width; s.height = height; + // Lazily wrap the surface in EGL on its first (sized) configure; + // resize the EGL window on subsequent ones. + if let Some(renderer) = &self.gpu { + match &mut s.gpu { + None => s.gpu = renderer.create_surface(surface.wl_surface(), width, height), + Some(gs) => gs.resize(width, height), + } + } } self.redraw_surface(qh, &surface, width, height); } diff --git a/breadlock/src/lock/surface.rs b/breadlock/src/lock/surface.rs index bb6d520..b1d20ac 100644 --- a/breadlock/src/lock/surface.rs +++ b/breadlock/src/lock/surface.rs @@ -76,6 +76,7 @@ impl OutputHandler for AppState { output, width: 0, height: 0, + gpu: None, }); } diff --git a/breadlock/src/main.rs b/breadlock/src/main.rs index 9d9d4e4..4e43ea6 100644 --- a/breadlock/src/main.rs +++ b/breadlock/src/main.rs @@ -2,6 +2,7 @@ mod auth; mod background; mod bread_events; mod config; +mod gpu; mod input; mod lock; mod render; @@ -145,6 +146,15 @@ fn run_lock() { 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::(&conn).expect("failed to initialize Wayland registry"); let qh: QueueHandle = event_queue.handle(); @@ -212,6 +222,7 @@ fn run_lock() { config, palette, background, + gpu, text_renderer: breadlock_ui::painter::TextRenderer::new(), username, // Pre-reserve capacity so ordinary typing doesn't reallocate — a @@ -254,6 +265,7 @@ fn run_lock() { output, width: 0, height: 0, + gpu: None, }); } app_state.session_lock = Some(session_lock); diff --git a/breadlock/src/render.rs b/breadlock/src/render.rs index fe35bcc..273814e 100644 --- a/breadlock/src/render.rs +++ b/breadlock/src/render.rs @@ -16,7 +16,7 @@ use breadlock_ui::painter::{rounded_rect, tokens, TextRenderer}; use breadlock_ui::theme::tiny_skia_color; use std::f32::consts::PI; use std::time::Instant; -use tiny_skia::{Color, Paint, Pixmap, Rect, Transform}; +use tiny_skia::{Color, Paint, Pixmap, Transform}; /// Lock-appear duration: elements ease in on a small stagger (see the /// `*_DELAY_MS` consts) instead of one uniform fade. @@ -62,9 +62,34 @@ const BREATHE_RING_ALPHA: f32 = 0.12; /// How far the status line rises during its slide-in. const STATUS_SLIDE_PX: f32 = 8.0; /// Dim veil over the wallpaper: a vertical gradient, darker at the top so -/// the clock (in the upper third) sits on the deepest tone. -const DIM_ALPHA_TOP: f32 = 0.34; -const DIM_ALPHA_BOTTOM: f32 = 0.16; +/// the clock (in the upper third) sits on the deepest tone. `pub(crate)` for +/// the GPU background shader, which applies the same gradient. +pub(crate) const DIM_ALPHA_TOP: f32 = 0.34; +pub(crate) const DIM_ALPHA_BOTTOM: f32 = 0.16; + +/// Darkens a full-screen pixmap with the vertical dim veil, in place: +/// premultiplied pixels scale by `1 - lerp(DIM_ALPHA_TOP, DIM_ALPHA_BOTTOM, +/// y/h) * veil_alpha` (equivalent to blending a black gradient over it). A +/// single pass over the surface — the software renderer's largest recurring +/// cost was the full-screen gradient fill/blit, so this keeps it cheap. +fn dim_rows(pixmap: &mut Pixmap, veil_alpha: f32) { + let w = pixmap.width() as usize; + let h = pixmap.height() as usize; + let data = pixmap.data_mut(); + for y in 0..h { + let a = (DIM_ALPHA_TOP + (DIM_ALPHA_BOTTOM - DIM_ALPHA_TOP) * (y as f32 / h as f32)) + * veil_alpha; + let k = 1.0 - a; + let row = y * w * 4; + for px in 0..w { + let i = row + px * 4; + data[i] = (data[i] as f32 * k) as u8; + data[i + 1] = (data[i + 1] as f32 * k) as u8; + data[i + 2] = (data[i + 2] as f32 * k) as u8; + data[i + 3] = (data[i + 3] as f32 * k) as u8; + } + } +} /// Pill hairline-border alpha (sketch: `1px solid rgba(255,255,255,.08)`). const PILL_BORDER_ALPHA: f32 = 0.10; /// Fake drop-shadow layers under the pill (tiny-skia has no blur filter): @@ -130,6 +155,11 @@ pub struct FrameInputs<'a> { pub appear_t: f32, /// Raw 0..1 unlock-fade progress (pre-ease). 0 when not unlocking. pub unlock_t: f32, + /// Sub-pixel bilinear panning for the background. True on slow idle frames + /// (the Ken Burns drift is ~1 px/frame there and integer steps read as + /// judder); false on 60 fps animation frames, where the pan moves < 0.2 px + /// per frame and the bilinear pass would blow the 16 ms budget. + pub smooth_pan: bool, } /// Ease-out cubic. `t` is clamped to 0..1. @@ -209,11 +239,63 @@ fn lerp_color(a: Color, b: Color, t: f32) -> Color { .unwrap_or(a) } +/// Bounding rect of the lock-screen chrome (clock, date, pill, status) in +/// surface pixels — the GPU path uses it to know which region of the chrome +/// texture was drawn (and therefore needs uploading each frame). +#[derive(Debug, Clone, Copy, Default)] +pub struct ChromeRect { + pub x0: f32, + pub y0: f32, + pub x1: f32, + pub y1: f32, +} + +impl ChromeRect { + fn expand(&mut self, x0: f32, y0: f32, x1: f32, y1: f32) { + self.x0 = self.x0.min(x0); + self.y0 = self.y0.min(y0); + self.x1 = self.x1.max(x1); + self.y1 = self.y1.max(y1); + } +} + /// Composes one frame. Returns `None` only if `width`/`height` are degenerate /// (a `0x0` `configure`, which some compositors send transiently). pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option { let mut pixmap = Pixmap::new(inputs.width, inputs.height)?; - inputs.background.paint(&mut pixmap, inputs.t_secs); + compose_impl(&mut pixmap, text, inputs, None); + Some(pixmap) +} + +/// Composes only the chrome (clock/date/pill/status) into a transparent +/// `pixmap`, returning the bounding rect of everything drawn. The background +/// and veil are the GPU's job in the accelerated path; colors are still +/// pre-faded by the veil alpha so the software and GPU paths match. +pub fn compose_chrome( + pixmap: &mut Pixmap, + text: &mut TextRenderer, + inputs: &FrameInputs, +) -> ChromeRect { + pixmap.fill(Color::TRANSPARENT); + let mut rect = ChromeRect::default(); + compose_impl(pixmap, text, inputs, Some(&mut rect)); + rect +} + +/// Shared body of [`compose`] / [`compose_chrome`]. With `rects`, the +/// background/veil are skipped (chrome-only) and each drawn element's box is +/// recorded. +fn compose_impl( + mut pixmap: &mut Pixmap, + text: &mut TextRenderer, + inputs: &FrameInputs, + mut rects: Option<&mut ChromeRect>, +) { + if rects.is_none() { + inputs + .background + .paint(pixmap, inputs.t_secs, inputs.smooth_pan); + } // Overall chrome fade: appear eased in, unlock eased out. The unlock // `fade` multiplies every element below. @@ -221,7 +303,7 @@ pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option let fade = 1.0 - unlock; let (veil_alpha, _) = overlay_motion(inputs.appear_t, inputs.unlock_t); if veil_alpha <= 0.0 { - return Some(pixmap); + return; } let (w, h) = (inputs.width as f32, inputs.height as f32); @@ -235,30 +317,13 @@ pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option let red_color = faded(tiny_skia_color(&inputs.palette.color1), veil_alpha); // Translucent veil over the (static) wallpaper — a vertical gradient - // (deeper at the top) that fades in with the chrome. - if let Some(shader) = tiny_skia::LinearGradient::new( - tiny_skia::Point::from_xy(0.0, 0.0), - tiny_skia::Point::from_xy(0.0, h), - vec![ - tiny_skia::GradientStop::new( - 0.0, - Color::from_rgba(0.0, 0.0, 0.0, DIM_ALPHA_TOP * veil_alpha) - .unwrap_or(Color::TRANSPARENT), - ), - tiny_skia::GradientStop::new( - 1.0, - Color::from_rgba(0.0, 0.0, 0.0, DIM_ALPHA_BOTTOM * veil_alpha) - .unwrap_or(Color::TRANSPARENT), - ), - ], - tiny_skia::SpreadMode::Pad, - Transform::identity(), - ) { - let mut paint = Paint::default(); - paint.shader = shader; - if let Some(rect) = Rect::from_xywh(0.0, 0.0, w, h) { - pixmap.fill_rect(rect, &paint, Transform::identity(), None); - } + // (deeper at the top) that fades with the whole chrome. Applied in place + // as a per-pixel multiply (premultiplied pixels scale by `1 - a` for a + // black overlay), which is far cheaper than a full-surface gradient + // fill/blit every frame. Skipped in the chrome-only path (the GPU shader + // applies the same veil to the background). + if rects.is_none() && veil_alpha > 0.0 { + dim_rows(pixmap, veil_alpha); } // Per-element staggered entrance. @@ -284,6 +349,15 @@ pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option let clock_y_rest = h * 0.28; let clock_y = clock_y_rest + elem_y(clock_e, DRIFT_CLOCK); let clock_alpha = clock_e * fade; + if let Some(r) = rects.as_deref_mut() { + let old_w = inputs + .clock_old + .map(|(t, _)| text.measure_line(t, inputs.font_family, clock_size)) + .unwrap_or(0.0); + let new_w = text.measure_line(inputs.clock_text, inputs.font_family, clock_size); + let cw = old_w.max(new_w); + r.expand((w - cw) / 2.0, clock_y, (w + cw) / 2.0, clock_y + clock_size); + } match inputs.clock_old { Some((old, t)) => { let t = t.clamp(0.0, 1.0); @@ -332,6 +406,9 @@ pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option let date_y = clock_y_rest + elem_y(date_e, DRIFT_DATE) + clock_top + clock_height + tokens::SPACE_SM as f32; let date_w = text.measure_line(inputs.date_text, inputs.font_family, date_size); + if let Some(r) = rects.as_deref_mut() { + r.expand((w - date_w) / 2.0, date_y, (w + date_w) / 2.0, date_y + date_size); + } text.draw_line( &mut pixmap, inputs.date_text, @@ -379,6 +456,17 @@ pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option cx * (1.0 - scale) + shake_x, cy * (1.0 - scale), ); + // Chrome rect: pad for the shadow layers, breath/success rings, the + // shake offset and the scale overshoot. + if let Some(r) = rects.as_deref_mut() { + const PILL_PAD: f32 = 26.0; + r.expand( + pill_x - PILL_PAD, + pill_y - PILL_PAD, + pill_x + pill_w + PILL_PAD, + pill_y + pill_h + PILL_PAD, + ); + } if let Some(path) = rounded_rect(pill_x, pill_y, pill_w, pill_h, tokens::RADIUS_SECONDARY as f32) @@ -566,6 +654,11 @@ pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option let status_anim = ease_out_cubic(inputs.status_t); let status_alpha = status_e * fade * status_anim; let color = if inputs.failed { red_color } else { on_surface }; + let status_y = pill_y_rest + pill_h + tokens::SPACE_MD as f32 + elem_y(status_e, DRIFT_STATUS) + + STATUS_SLIDE_PX * (1.0 - status_anim); + if let Some(r) = rects.as_deref_mut() { + r.expand((w - status_w) / 2.0, status_y, (w + status_w) / 2.0, status_y + status_size); + } text.draw_line( &mut pixmap, status, @@ -573,12 +666,9 @@ pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option status_size, faded(color, status_alpha), (w - status_w) / 2.0, - pill_y_rest + pill_h + tokens::SPACE_MD as f32 + elem_y(status_e, DRIFT_STATUS) - + STATUS_SLIDE_PX * (1.0 - status_anim), + status_y, ); } - - Some(pixmap) } /// Recomputes the left edge of the dot row (shared by the dot loop and the @@ -605,6 +695,33 @@ pub fn blit_to_shm(pixmap: &Pixmap, shm_bytes: &mut [u8]) { mod tests { use super::*; + #[test] + fn dim_rows_darkens_top_more_than_bottom() { + // 2 wide × 4 tall: top row is y/h = 0, bottom row is y/h = 0.75. + let mut p = Pixmap::new(2, 4).unwrap(); + p.fill(Color::WHITE); + dim_rows(&mut p, 1.0); + let px = p.pixels(); + let top = px[0]; + let bottom = px[2 * 3]; + // DIM_ALPHA_TOP (0.34) > DIM_ALPHA_BOTTOM (0.16): top row darker. + assert!(top.red() < bottom.red(), "top {} should be darker than bottom {}", top.red(), bottom.red()); + // White at top dim 0.34 → 255 * (1 - 0.34) = 168. + assert_eq!(top.red(), 168); + // Bottom row is y/h = 0.75 → dim = 0.34 + (0.16 - 0.34) * 0.75 = 0.205. + let expected = (255.0 * (1.0 - 0.205)) as u8; + assert_eq!(bottom.red(), expected); + } + + #[test] + fn dim_rows_noop_at_zero_alpha() { + let mut p = Pixmap::new(2, 2).unwrap(); + p.fill(Color::from_rgba8(100, 150, 200, 255)); + let before = p.pixels().to_vec(); + dim_rows(&mut p, 0.0); + assert_eq!(p.pixels(), before.as_slice()); + } + fn inputs<'a>( bg: &'a Background, palette: &'a breadlock_ui::theme::Palette, @@ -637,6 +754,7 @@ mod tests { status_text: None, appear_t, unlock_t, + smooth_pan: false, } } @@ -674,6 +792,7 @@ mod tests { status_text: None, appear_t: 1.0, unlock_t: 0.0, + smooth_pan: false, }; let pixmap = compose(&mut text, &inputs).unwrap(); assert_eq!((pixmap.width(), pixmap.height()), (400, 300)); @@ -764,4 +883,129 @@ mod tests { assert_eq!(a, 0.0); assert!(y < 0.0, "unlock should drift up from rest, got y={y}"); } + + #[test] + fn compose_chrome_rect_contains_clock_and_pill() { + let bg = Background::Color(Color::BLACK); + let palette = breadlock_ui::theme::Palette::default(); + let mut text = TextRenderer::new(); + let mut pixmap = Pixmap::new(400, 300).unwrap(); + let inputs = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 0.0); + let rect = compose_chrome(&mut pixmap, &mut text, &inputs); + assert!( + rect.x1 > rect.x0 && rect.y1 > rect.y0, + "chrome rect must be non-empty, got {rect:?}" + ); + // Clock sits at h*0.28 with glyph height ~ clock_size (400*0.075=30). + assert!(rect.y0 < 300.0 * 0.28 + 40.0, "rect must cover the clock band"); + // Pill sits at h*0.5; with the 26px pad the rect must reach it. + assert!(rect.y1 > 300.0 * 0.5 + 24.0, "rect must cover the pill band"); + // Both are horizontally centered. + assert!(rect.x0 < 200.0 && rect.x1 > 200.0, "rect must straddle center"); + } + + #[test] + fn compose_chrome_rect_empty_when_veil_hidden() { + let bg = Background::Color(Color::BLACK); + let palette = breadlock_ui::theme::Palette::default(); + let mut text = TextRenderer::new(); + let mut pixmap = Pixmap::new(400, 300).unwrap(); + // appear_t = 0 → veil_alpha 0 → nothing drawn, rect stays default. + let inputs = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 0.0, 0.0); + let rect = compose_chrome(&mut pixmap, &mut text, &inputs); + assert!(rect.x1 <= rect.x0 && rect.y1 <= rect.y0, "hidden chrome must yield an empty rect"); + // And the pixmap is fully transparent. + assert!( + pixmap.pixels().iter().all(|p| p.alpha() == 0), + "hidden chrome must leave the pixmap transparent" + ); + } + + #[test] + fn compose_chrome_status_text_expands_the_rect_downward() { + let bg = Background::Color(Color::BLACK); + let palette = breadlock_ui::theme::Palette::default(); + let mut text = TextRenderer::new(); + let mut pixmap = Pixmap::new(400, 300).unwrap(); + let mut with_status = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, true, 0.3, 1.0, 1.0, 0.0); + with_status.status_text = Some("Wrong password"); + let rect = compose_chrome(&mut pixmap, &mut text, &with_status); + // Status sits below the pill: pill bottom is h*0.5 + 24 (half of 48px), + // status adds SPACE_MD + its glyph box after that. + assert!( + rect.y1 > 300.0 * 0.5 + 48.0 + 20.0, + "status must push the rect below the pill, got y1={}", + rect.y1 + ); + } + + #[test] + fn gpu_split_is_pixel_identical_to_full_compose() { + // The GPU path draws the dimmed background in a shader and then + // composites the software chrome (colors pre-faded by the veil alpha) + // over it with premultiplied source-over. That split must produce the + // exact same pixels as the single-pass software compose — this is the + // invariant that keeps the two renderers in sync. + let bg = Background::Color(Color::from_rgba8(40, 60, 80, 255)); + let palette = breadlock_ui::theme::Palette::default(); + let mut text = TextRenderer::new(); + let inputs = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 0.0); + + // Full single-pass compose. + let full = compose(&mut text, &inputs).unwrap(); + + // Split: dim the background, then composite the chrome over it. + let mut split = Pixmap::new(400, 300).unwrap(); + inputs.background.paint(&mut split, inputs.t_secs, inputs.smooth_pan); + let (veil_alpha, _) = overlay_motion(inputs.appear_t, inputs.unlock_t); + if veil_alpha > 0.0 { + dim_rows(&mut split, veil_alpha); + } + let mut chrome = Pixmap::new(400, 300).unwrap(); + let mut text2 = TextRenderer::new(); + compose_chrome(&mut chrome, &mut text2, &inputs); + // Premultiplied source-over, exactly what the GPU's + // glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA) performs. + split.draw_pixmap( + 0, + 0, + chrome.as_ref(), + &tiny_skia::PixmapPaint { + blend_mode: tiny_skia::BlendMode::SourceOver, + ..Default::default() + }, + Transform::default(), + None, + ); + + // The split path rounds twice (chrome into an 8-bit pixmap, then the + // composite into 8-bit) where the single pass rounds once, so + // bit-exact equality is impossible — the invariant is that the split + // stays within a couple of ULPs (measured: max 3 on this input, with + // >95% of pixels bit-identical), and never diverges structurally. + let diff = split + .pixels() + .iter() + .zip(full.pixels()) + .map(|(a, b)| { + (a.red() as i32 - b.red() as i32).abs() + .max((a.green() as i32 - b.green() as i32).abs()) + .max((a.blue() as i32 - b.blue() as i32).abs()) + .max((a.alpha() as i32 - b.alpha() as i32).abs()) + }) + .collect::>(); + let identical = diff.iter().filter(|d| **d == 0).count(); + let max_diff = diff.iter().copied().max().unwrap_or(0); + assert!( + max_diff <= 3, + "GPU-style split must stay within double-rounding ULP range, got max diff {max_diff}" + ); + assert!( + identical > split.pixels().len() * 95 / 100, + "most pixels should be bit-identical, got {identical}/{} identical", + split.pixels().len() + ); + } } + + diff --git a/breadlock/src/state.rs b/breadlock/src/state.rs index 858bc7e..4a98690 100644 --- a/breadlock/src/state.rs +++ b/breadlock/src/state.rs @@ -26,6 +26,10 @@ pub struct LockSurface { pub output: wl_output::WlOutput, pub width: u32, pub height: u32, + /// 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, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -62,6 +66,9 @@ pub struct AppState { 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, pub text_renderer: breadlock_ui::painter::TextRenderer, pub username: String, @@ -227,8 +234,31 @@ impl AppState { status_text: status_text.as_deref(), 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. + if self.gpu.is_some() + && self + .surfaces + .iter() + .any(|s| s.surface.wl_surface() == surface.wl_surface() && s.gpu.is_some()) + { + let renderer = self.gpu.as_mut().expect("checked above"); + let lock_surface = self + .surfaces + .iter_mut() + .find(|s| s.surface.wl_surface() == surface.wl_surface()) + .expect("surface exists"); + let gpu_surface = lock_surface.gpu.as_mut().expect("checked above"); + renderer.render_frame(gpu_surface, &inputs, &mut self.text_renderer); + self.arm_anim_if_needed(qh); + return; + } + let Some(pixmap) = render::compose(&mut self.text_renderer, &inputs) else { return; }; @@ -384,6 +414,19 @@ impl AppState { } } + /// A 60 fps animation is in flight (everything except the slow idle + /// effects: idle breath, Ken Burns pan). 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) -> TimeoutAction { self.redraw_all(qh); if self.unlocking.is_some() && !self.unlock_in_progress() { @@ -392,13 +435,7 @@ impl AppState { } else if self.anim_in_progress() { // Slow effects (idle breath, Ken Burns) don't need 60fps — halve // the redraw cost for them. Everything else stays at ~60Hz. - let fast = 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; + let fast = self.fast_anim_in_progress(); TimeoutAction::ToDuration(Duration::from_millis(if fast { render::ANIM_FRAME_MS } else { From 55cf57a8ba07a7235a64e9e90c8d11224e35ff06 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 21 Aug 2026 15:35:10 +0800 Subject: [PATCH 14/20] Fix scrambled GPU chrome upload and pin veil gradient direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- breadlock/src/gpu.rs | 103 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 93 insertions(+), 10 deletions(-) diff --git a/breadlock/src/gpu.rs b/breadlock/src/gpu.rs index 2b32d1c..6d668ef 100644 --- a/breadlock/src/gpu.rs +++ b/breadlock/src/gpu.rs @@ -68,7 +68,11 @@ uniform float u_veil_alpha; uniform float u_screen_h; void main() { vec4 c = texture2D(u_tex, v_uv) * u_color; - float row = 1.0 - gl_FragCoord.y / u_screen_h; // 1 at top + // Same gradient as the software dim_rows: top = u_dim_top, bottom = + // u_dim_bottom, interpolated by screen-space y. gl_FragCoord has y=0 at + // the BOTTOM of the framebuffer, so `row = 1.0 - y/h` is 1 at the bottom + // and 0 at the top; mixing with `row` yields top = u_dim_top. + float row = 1.0 - gl_FragCoord.y / u_screen_h; // 1 at bottom, 0 at top float dim = mix(u_dim_top, u_dim_bottom, row) * u_veil_alpha; gl_FragColor = vec4(c.rgb * (1.0 - dim), 1.0); }"; @@ -533,24 +537,30 @@ impl GpuRenderer { self.chrome_tex_size = (w, h); } + let rw = (x1 - x0) as usize; + let rh = (y1 - y0) as usize; let data = pixmap.data(); - let stride = w as usize * 4; - let offset = y0 as usize * stride + x0 as usize * 4; - let rw = (x1 - x0) as i32; - let rh = (y1 - y0) as i32; let gl = &self.gl; unsafe { gl.bind_texture(glow::TEXTURE_2D, Some(self.chrome_tex)); + // glTexSubImage2D reads `rw` pixels contiguously per row with no + // knowledge of the source's row stride. We're on GLES2 (where + // GL_UNPACK_ROW_LENGTH doesn't exist), so pack the sub-rect rows + // into a tightly-strided buffer first — otherwise every row after + // the first is shifted by (w - rw) pixels and the chrome texture + // comes out horizontally scrambled (the clock/pill/status rect is + // narrower than the surface, so this always triggered). + let packed = pack_rows(data, w as usize, x0 as usize, y0 as usize, rw, rh); gl.tex_sub_image_2d( glow::TEXTURE_2D, 0, - x0, - y0, - rw, - rh, + x0 as i32, + y0 as i32, + rw as i32, + rh as i32, glow::RGBA, glow::UNSIGNED_BYTE, - glow::PixelUnpackData::Slice(Some(&data[offset..])), + glow::PixelUnpackData::Slice(Some(&packed)), ); gl.use_program(Some(self.chrome_program)); gl.bind_vertex_array(Some(self.quad_vao)); @@ -607,6 +617,18 @@ fn pan_region(wp: (u32, u32), target: (u32, u32), ken_burns: bool, t_secs: f32) (-tx, -ty, scaled_w, scaled_h) } +/// Packs the `(x0, y0, rw, rh)` sub-rect of a `w`-wide RGBA row-major buffer +/// into a tightly-strided `rw`-per-row buffer for `glTexSubImage2D`, which +/// reads rows contiguously and has no stride concept on GLES2. +fn pack_rows(data: &[u8], w: usize, x0: usize, y0: usize, rw: usize, rh: usize) -> Vec { + let mut out = Vec::with_capacity(rw * rh * 4); + for row in 0..rh { + let src = (y0 + row) * w * 4 + x0 * 4; + out.extend_from_slice(&data[src..src + rw * 4]); + } + out +} + fn compile_program(gl: &glow::Context, vs_src: &str, fs_src: &str) -> Option { unsafe { let program = gl.create_program().ok()?; @@ -782,6 +804,42 @@ mod tests { } } + /// The GPU background shader's veil math, replicated in Rust: on GLES, + /// `gl_FragCoord.y` is 0 at the BOTTOM of the framebuffer, so + /// `row = 1.0 - y/h` is 1 at the bottom and 0 at the top, and + /// `dim = mix(u_dim_top, u_dim_bottom, row)` dims the TOP by u_dim_top. + /// This must match the software `dim_rows` (which indexes y=0 at the + /// top) exactly, or the GPU veil renders upside-down. + fn shader_dim_at(frag_y: f32, h: f32) -> f32 { + let row = 1.0 - frag_y / h; // 1 at bottom (frag_y=0), 0 at top + crate::render::DIM_ALPHA_TOP + (crate::render::DIM_ALPHA_BOTTOM - crate::render::DIM_ALPHA_TOP) * row + } + + #[test] + fn veil_gradient_matches_software_dim_rows() { + use crate::render::DIM_ALPHA_BOTTOM; + use crate::render::DIM_ALPHA_TOP; + let h = 720.0; + // Software dim_rows: alpha = lerp(TOP, BOTTOM, y/h) with y=0 at top, + // so the top of the screen gets the DEEPER dim (DIM_ALPHA_TOP). + assert!(DIM_ALPHA_TOP > DIM_ALPHA_BOTTOM); + // Shader at the very top (gl_FragCoord.y = h): row = 0 -> dim = TOP. + assert!((shader_dim_at(h, h) - DIM_ALPHA_TOP).abs() < 1e-6); + // Shader at the very bottom (gl_FragCoord.y = 0): row = 1 -> dim = BOTTOM. + assert!((shader_dim_at(0.0, h) - DIM_ALPHA_BOTTOM).abs() < 1e-6); + // Match the software formula at many interior rows. + for y in 0..=720 { + let yf = y as f32; + let software = DIM_ALPHA_TOP + (DIM_ALPHA_BOTTOM - DIM_ALPHA_TOP) * (yf / h); + // Same physical row: pixmap row y (top-anchored) == frag y = h - y. + let shader = shader_dim_at(h - yf, h); + assert!( + (shader - software).abs() < 1e-5, + "veil mismatch at row {y}: shader {shader} vs software {software}" + ); + } + } + #[test] fn egl_attribs_are_none_terminated_pairs() { assert_eq!(EGL_ATTRIBS.len() % 2, 1, "attribs must be key/value pairs + NONE"); @@ -793,4 +851,29 @@ mod tests { let v: [f32; 12] = [0.0; 12]; assert_eq!(f32s_as_bytes(&v).len(), 12 * 4); } + + #[test] + fn pack_rows_keeps_row_ordering_and_stride() { + // A 4x2 RGBA buffer where pixel (x, y) has value (x, y, 0, 255). + let mut data = vec![0u8; 4 * 2 * 4]; + for y in 0..2 { + for x in 0..4 { + let i = (y * 4 + x) * 4; + data[i] = x as u8; + data[i + 1] = y as u8; + data[i + 2] = 0; + data[i + 3] = 255; + } + } + // Take the 2x1 rect at (1, 0). + let packed = pack_rows(&data, 4, 1, 0, 2, 1); + assert_eq!(packed.len(), 2 * 1 * 4); + assert_eq!(packed[0], 1); + assert_eq!(packed[4], 2); + // Two rows: the second row must NOT be shifted by (w - rw) — the bug. + let packed2 = pack_rows(&data, 4, 1, 0, 2, 2); + assert_eq!(packed2[8], 1, "row 2 col 0 must be x=1, not shifted"); + assert_eq!(packed2[12], 2, "row 2 col 1 must be x=2, not shifted"); + assert_eq!(packed2[9], 1, "row 2 must carry y=1"); + } } From 0b378e84d09bc44a21284a0ffcf4d5b567c1d6f3 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 21 Aug 2026 15:50:44 +0800 Subject: [PATCH 15/20] Add caps-lock chip, hold-to-reveal, idle auto-dim, and attempt counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- breadlock.example.toml | 7 + breadlock/src/bin/breadlock-preview.rs | 60 +++++-- breadlock/src/config.rs | 14 +- breadlock/src/gpu.rs | 2 +- breadlock/src/input/keyboard.rs | 38 +++- breadlock/src/main.rs | 7 + breadlock/src/render.rs | 240 +++++++++++++++++++++++-- breadlock/src/state.rs | 47 ++++- 8 files changed, 380 insertions(+), 35 deletions(-) diff --git a/breadlock.example.toml b/breadlock.example.toml index e9e53e6..188090a 100644 --- a/breadlock.example.toml +++ b/breadlock.example.toml @@ -26,7 +26,14 @@ family = "Varela Round" # How long the "wrong password" state (red pill) shows before input # re-enables, in milliseconds. 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. +reveal_hold = true [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 diff --git a/breadlock/src/bin/breadlock-preview.rs b/breadlock/src/bin/breadlock-preview.rs index 3fb14fd..f9edd65 100644 --- a/breadlock/src/bin/breadlock-preview.rs +++ b/breadlock/src/bin/breadlock-preview.rs @@ -53,6 +53,10 @@ struct Scene { unlock_t: f32, breathe_t: f32, status_t: f32, + caps_lock: bool, + layout_index: u32, + reveal: bool, + idle_dim: f32, } impl Default for Scene { @@ -73,6 +77,10 @@ impl Default for Scene { unlock_t: 0.0, breathe_t: 0.0, status_t: 1.0, + caps_lock: false, + layout_index: 0, + reveal: false, + idle_dim: 0.0, } } } @@ -109,20 +117,24 @@ fn bench(args: &[String]) { font_family: FONT, clock_text: "12:34", date_text: "Friday · Aug 21", - clock_old: None, - password_len: 6, - 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, - appear_t: 1.0, - unlock_t: 0.0, - smooth_pan: true, - }; + 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, + 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. @@ -153,6 +165,11 @@ fn bench(args: &[String]) { 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, @@ -223,6 +240,16 @@ fn main() { 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: 8, 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() }, ]; let mut text = TextRenderer::new(); @@ -238,6 +265,11 @@ fn main() { date_text: scene.date, clock_old: scene.clock_old, password_len: scene.password_len, + password: "hunter2", + 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, diff --git a/breadlock/src/config.rs b/breadlock/src/config.rs index 4cf6252..cb36ce0 100644 --- a/breadlock/src/config.rs +++ b/breadlock/src/config.rs @@ -16,12 +16,17 @@ pub struct Config { pub struct Input { /// How long the "wrong password" shake shows before input re-enables. pub fail_timeout_ms: u64, + /// Hold `Tab` to reveal the typed password as plain characters instead + /// of dots. 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: true, } } } @@ -35,11 +40,18 @@ pub struct Animation { /// 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 } + Self { + breathe: true, + idle_dim_after_secs: 0, + } } } diff --git a/breadlock/src/gpu.rs b/breadlock/src/gpu.rs index 6d668ef..596c804 100644 --- a/breadlock/src/gpu.rs +++ b/breadlock/src/gpu.rs @@ -417,7 +417,7 @@ impl GpuRenderer { fn draw_background(&mut self, w: u32, h: u32, inputs: &FrameInputs) { let gl = &self.gl; - let (veil_alpha, _) = render::overlay_motion(inputs.appear_t, inputs.unlock_t); + let veil_alpha = render::veil_alpha(inputs.appear_t, inputs.unlock_t, inputs.idle_dim); unsafe { gl.use_program(Some(self.bg_program)); gl.bind_vertex_array(Some(self.quad_vao)); diff --git a/breadlock/src/input/keyboard.rs b/breadlock/src/input/keyboard.rs index fb3c10f..07d474c 100644 --- a/breadlock/src/input/keyboard.rs +++ b/breadlock/src/input/keyboard.rs @@ -118,23 +118,38 @@ impl KeyboardHandler for AppState { fn release_key( &mut self, _conn: &Connection, - _qh: &QueueHandle, + qh: &QueueHandle, _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, + qh: &QueueHandle, _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); + } } } @@ -147,6 +162,19 @@ impl AppState { 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; + } + match event.keysym { Keysym::Return | Keysym::KP_Enter => self.submit(), Keysym::BackSpace => { diff --git a/breadlock/src/main.rs b/breadlock/src/main.rs index 4e43ea6..1d57862 100644 --- a/breadlock/src/main.rs +++ b/breadlock/src/main.rs @@ -170,6 +170,7 @@ fn run_lock() { // 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; if state.unlocking.is_none() { state.unlocking = Some(std::time::Instant::now()); } @@ -191,6 +192,7 @@ fn run_lock() { } auth::AuthError::Authenticate | auth::AuthError::AccountInvalid => { tracing::warn!(%err, "authentication failed"); + state.failed_attempts = state.failed_attempts.saturating_add(1); state.auth_state = AuthState::Failed; state.failed_at = Some(std::time::Instant::now()); } @@ -246,6 +248,11 @@ fn run_lock() { + 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, exit: false, }; diff --git a/breadlock/src/render.rs b/breadlock/src/render.rs index 273814e..21b0e65 100644 --- a/breadlock/src/render.rs +++ b/breadlock/src/render.rs @@ -134,6 +134,19 @@ pub struct FrameInputs<'a> { /// Minute-rollover crossfade: `(previous clock text, raw 0..1 progress)`. pub clock_old: Option<(&'a str, f32)>, pub password_len: usize, + /// The actual password text — only read when `reveal` is true (hold-to- + /// reveal renders the plain characters instead of dots). + pub password: &'a str, + /// True while the reveal key (Tab) is held — dots render as the plain + /// characters. + pub reveal: bool, + /// Caps Lock state — shows the caps chip when on. + pub caps_lock: bool, + /// Active keyboard layout index — shown next to the caps chip when non-0. + pub layout_index: u32, + /// Idle auto-dim progress 0..1 (0 = disabled/not idle) — deepens the dim + /// veil after `animation.idle_dim_after_secs` of no keystrokes. + pub idle_dim: f32, /// True while showing a failed attempt (red pill + shake + red status). pub failed: bool, /// Raw 0..1 progress of the wrong-password shake. 0 when not failed. @@ -222,6 +235,26 @@ pub fn overlay_motion(appear_t: f32, unlock_t: f32) -> (f32, f32) { (alpha, y) } +/// How much extra dim the idle auto-dim adds on top of the base veil at full +/// progress (`idle_dim = 1`). Both the software `dim_rows` and the GPU +/// background shader scale by this so the two paths stay identical. +pub(crate) const IDLE_DIM_MAX: f32 = 0.25; +/// How long the idle auto-dim takes to ramp from 0 to full, in milliseconds. +pub(crate) const IDLE_DIM_RAMP_MS: u64 = 8000; + +/// Background dim alpha with the idle auto-dim folded in. The base veil is +/// at most 1.0 once the appear finishes, so the idle deepens *beyond* that +/// (up to `IDLE_DIM_MAX` extra) — it can legally exceed 1.0 because it only +/// scales the background dim, never a color alpha. Shared by the software +/// `dim_rows` and the GPU background shader (`u_veil_alpha`) so an +/// idle-dimmed screen looks identical on both renderers. Rides the same +/// appear/unlock envelope as the base veil so there's no residual darkening +/// as the lock releases. +pub fn veil_alpha(appear_t: f32, unlock_t: f32, idle_dim: f32) -> f32 { + let (base, _) = overlay_motion(appear_t, unlock_t); + base + IDLE_DIM_MAX * idle_dim * base +} + fn faded(mut color: Color, alpha: f32) -> Color { color.apply_opacity(alpha); color @@ -298,32 +331,36 @@ fn compose_impl( } // Overall chrome fade: appear eased in, unlock eased out. The unlock - // `fade` multiplies every element below. + // `fade` multiplies every element below. The dim veil deepens further + // once the idle auto-dim kicks in — chrome alpha stays at the base veil + // (0..1) while only the background dim scales past it. let unlock = ease_out_cubic(inputs.unlock_t); let fade = 1.0 - unlock; - let (veil_alpha, _) = overlay_motion(inputs.appear_t, inputs.unlock_t); - if veil_alpha <= 0.0 { + let (base_veil, _) = overlay_motion(inputs.appear_t, inputs.unlock_t); + let bg_veil = veil_alpha(inputs.appear_t, inputs.unlock_t, inputs.idle_dim); + if base_veil <= 0.0 { return; } let (w, h) = (inputs.width as f32, inputs.height as f32); - let surface_color = faded(tiny_skia_color(&inputs.palette.color0), veil_alpha); - let accent_color = faded(tiny_skia_color(&inputs.palette.color4), veil_alpha); - let green_color = faded(tiny_skia_color(&inputs.palette.color2), veil_alpha); + let surface_color = faded(tiny_skia_color(&inputs.palette.color0), base_veil); + let accent_color = faded(tiny_skia_color(&inputs.palette.color4), base_veil); + let green_color = faded(tiny_skia_color(&inputs.palette.color2), base_veil); let on_surface = faded( tiny_skia_color(breadlock_ui::theme::ink_on(&inputs.palette.color0)), - veil_alpha, + base_veil, ); - let red_color = faded(tiny_skia_color(&inputs.palette.color1), veil_alpha); + let red_color = faded(tiny_skia_color(&inputs.palette.color1), base_veil); // Translucent veil over the (static) wallpaper — a vertical gradient // (deeper at the top) that fades with the whole chrome. Applied in place // as a per-pixel multiply (premultiplied pixels scale by `1 - a` for a // black overlay), which is far cheaper than a full-surface gradient // fill/blit every frame. Skipped in the chrome-only path (the GPU shader - // applies the same veil to the background). - if rects.is_none() && veil_alpha > 0.0 { - dim_rows(pixmap, veil_alpha); + // applies the same veil to the background). `bg_veil` may exceed 1.0 + // (idle auto-dim), which is fine — it only scales a multiply. + if rects.is_none() && base_veil > 0.0 { + dim_rows(pixmap, bg_veil); } // Per-element staggered entrance. @@ -540,15 +577,101 @@ fn compose_impl( } } + // ---- Caps Lock / layout chip: a small centered pill above the password + // pill, only when Caps Lock is on or a non-default layout is active. A + // tiny floating hint so the user can't be confused by all-caps input. + if inputs.caps_lock || inputs.layout_index > 0 { + let mut label = String::new(); + if inputs.caps_lock { + label.push_str("Caps Lock"); + } + if inputs.layout_index > 0 { + if !label.is_empty() { + label.push_str(" · "); + } + label.push_str(&format!("Layout {}", inputs.layout_index + 1)); + } + let chip_size = tokens::FONT_SIZE_SECONDARY as f32; + let chip_w = text.measure_line(&label, inputs.font_family, chip_size) + tokens::SPACE_MD as f32 * 2.0; + let chip_h = chip_size * 1.9; + let chip_x = (w - chip_w) / 2.0; + // Clear of the pill: chip bottom sits a full SPACE_LG above the pill + // top, so the two never touch even with the pill's glow/shadow. + let chip_y = pill_y - chip_h - tokens::SPACE_LG as f32; + let chip_alpha = pill_e * fade; + if let Some(r) = rects.as_deref_mut() { + r.expand(chip_x, chip_y, chip_x + chip_w, chip_y + chip_h); + } + if let Some(path) = rounded_rect(chip_x, chip_y, chip_w, chip_h, chip_h / 2.0) { + let mut paint = Paint::default(); + // Slightly lifted surface color so it reads as a separate chip. + paint.set_color(faded(surface_color, chip_alpha)); + paint.anti_alias = true; + pixmap.fill_path(&path, &paint, tiny_skia::FillRule::Winding, Transform::identity(), None); + let mut stroke = tiny_skia::Stroke::default(); + stroke.width = 1.0; + let mut paint = Paint::default(); + paint.set_color(faded(Color::WHITE, PILL_BORDER_ALPHA * chip_alpha)); + pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None); + } + let (chip_top, chip_height) = text.measure_box(&label, inputs.font_family, chip_size); + let label_y = chip_y + (chip_h - chip_height) / 2.0 - chip_top; + let label_w = text.measure_line(&label, inputs.font_family, chip_size); + text.draw_line( + &mut pixmap, + &label, + inputs.font_family, + chip_size, + faded(on_surface, chip_alpha), + (w - label_w) / 2.0, + label_y, + ); + } + // ---- Password dots — one filled circle per typed character, capped so a // very long password can't overflow the pill. The newest dot pops in with - // an overshoot; the rest sit at rest size. + // an overshoot; the rest sit at rest size. While the reveal key (Tab) is + // held, the plain characters are drawn instead. let max_dots = ((pill_w - tokens::SPACE_LG as f32 * 2.0) / DOT_GAP) .floor() .max(1.0) as usize; let shown_dots = inputs.password_len.min(max_dots); let dot_y = pill_y + pill_h / 2.0; - if shown_dots > 0 { + if inputs.reveal && shown_dots > 0 { + // Hold-to-reveal: render the actual password, centered, capped to + // the pill width (truncate with a trailing ellipsis on overflow). + // Measured into locals first — `text` is borrowed mutably by + // `draw_line`, so all `measure_*` calls must happen up front. + let reveal_size = tokens::FONT_SIZE_BASE as f32; + let reveal_rendered = reveal_fit( + text, + inputs.password, + inputs.font_family, + reveal_size, + pill_w - tokens::SPACE_LG as f32 * 2.0, + ); + let (reveal_top, reveal_height) = + text.measure_box(&reveal_rendered, inputs.font_family, reveal_size); + let reveal_y = pill_y + (pill_h - reveal_height) / 2.0 - reveal_top; + let reveal_w = text.measure_line(&reveal_rendered, inputs.font_family, reveal_size); + if let Some(r) = rects.as_deref_mut() { + r.expand( + pill_x + tokens::SPACE_LG as f32, + pill_y, + pill_x + pill_w - tokens::SPACE_LG as f32, + pill_y + pill_h, + ); + } + text.draw_line( + &mut pixmap, + &reveal_rendered, + inputs.font_family, + reveal_size, + faded(on_surface, pill_alpha), + (w - reveal_w) / 2.0, + reveal_y, + ); + } else if shown_dots > 0 { let start_x = start_x_for(shown_dots, pill_x, pill_w); for i in 0..shown_dots { let newest = i == shown_dots - 1; @@ -678,6 +801,34 @@ fn start_x_for(shown_dots: usize, pill_x: f32, pill_w: f32) -> f32 { pill_x + (pill_w - dots_w) / 2.0 } +/// Truncates a password for the hold-to-reveal view so it fits inside the +/// pill, appending an ellipsis when trimmed. Returns the owned string to +/// render (kept out of `compose` so the borrow of `text` ends before the +/// draw call). +fn reveal_fit( + text: &mut TextRenderer, + password: &str, + font_family: &str, + size: f32, + max_w: f32, +) -> String { + let mut shown = password; + let mut ellipsis = ""; + loop { + let candidate = format!("{shown}{ellipsis}"); + if text.measure_line(&candidate, font_family, size) <= max_w || shown.is_empty() { + return candidate; + } + // Trim one char at a time until it fits. + shown = &shown[..shown + .char_indices() + .nth_back(1) + .map(|(i, _)| i) + .unwrap_or(0)]; + ellipsis = "…"; + } +} + /// Copies a composed frame into a `wl_shm` `Argb8888` buffer, swizzling /// tiny-skia's RGBA byte order to the host-endian `0xAARRGGBB` `wl_shm` /// expects (BGRA bytes on little-endian, which is every target this ships @@ -744,6 +895,11 @@ mod tests { date_text: date, clock_old: None, password_len, + password: "", + reveal: false, + caps_lock: false, + layout_index: 0, + idle_dim: 0.0, failed, failed_t, dot_pop_t, @@ -782,6 +938,11 @@ mod tests { date_text: "Friday · Aug 21", clock_old: None, password_len: 0, + password: "", + reveal: false, + caps_lock: false, + layout_index: 0, + idle_dim: 0.0, failed: false, failed_t: 0.0, dot_pop_t: 1.0, @@ -814,6 +975,59 @@ mod tests { assert!(compose(&mut text, &done).is_some()); } + #[test] + fn veil_alpha_idle_dim_deepens_past_base() { + // Rest pose, no idle: base appear alpha only (1.0). + assert_eq!(veil_alpha(1.0, 0.0, 0.0), 1.0); + // Mid-appear, no idle: base alpha. + let base = veil_alpha(0.5, 0.0, 0.0); + assert!(base > 0.0 && base < 1.0); + // Full idle dim deepens *past* the base (background-only alpha, so + // exceeding 1.0 is legal — it scales the dim multiply, not a color). + let idle = veil_alpha(0.5, 0.0, 1.0); + assert!(idle > base, "idle dim should deepen the veil"); + // At rest with full idle: 1.0 + 0.25 * 1.0. + assert!((veil_alpha(1.0, 0.0, 1.0) - 1.25).abs() < 1e-6); + // Idle dim alone can't darken a screen that hasn't appeared yet + // (base 0 keeps the whole term 0). + assert_eq!(veil_alpha(0.0, 0.0, 1.0), 0.0); + // During unlock, idle dim can't push past the fade-out. + assert_eq!(veil_alpha(1.0, 1.0, 1.0), 0.0); + } + + #[test] + fn reveal_fit_truncates_long_passwords() { + let mut text = TextRenderer::new(); + // Short password fits unchanged. + assert_eq!(reveal_fit(&mut text, "hunter2", "sans-serif", 14.0, 200.0), "hunter2"); + // A very long one is trimmed and ends with an ellipsis. + let long = "a".repeat(200); + let fitted = reveal_fit(&mut text, &long, "sans-serif", 14.0, 60.0); + assert!(fitted.ends_with('…'), "trimmed reveal should end with an ellipsis"); + assert!(fitted.len() < long.len()); + // And it actually fits the budget. + assert!(text.measure_line(&fitted, "sans-serif", 14.0) <= 60.0); + } + + #[test] + fn compose_renders_caps_chip_and_reveal() { + let bg = Background::Color(Color::BLACK); + let palette = breadlock_ui::theme::Palette::default(); + let mut text = TextRenderer::new(); + let mut base = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 0.0); + base.caps_lock = true; + base.password = "hunter2"; + // Caps chip visible, no reveal: dots path. + assert!(compose(&mut text, &base).is_some()); + // Reveal: plain characters instead of dots. + base.reveal = true; + assert!(compose(&mut text, &base).is_some()); + // Non-default layout shows the layout chip too. + base.caps_lock = false; + base.layout_index = 1; + assert!(compose(&mut text, &base).is_some()); + } + #[test] fn ease_out_cubic_bounds_and_shape() { assert_eq!(ease_out_cubic(0.0), 0.0); diff --git a/breadlock/src/state.rs b/breadlock/src/state.rs index 4a98690..c6ade48 100644 --- a/breadlock/src/state.rs +++ b/breadlock/src/state.rs @@ -119,6 +119,23 @@ pub struct AppState { /// 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, + pub exit: bool, } @@ -172,13 +189,36 @@ impl AppState { // While a PAM check runs, the status dots tick to signal progress. let status_text = match self.auth_state { AuthState::Checking => Some(format!("Checking{}", checking_dots(now))), - AuthState::Failed => Some("Wrong password".to_string()), + 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::ConfigError => Some( "PAM config error — check logs (breadlock service not set up correctly)" .to_string(), ), AuthState::Idle => None, }; + // 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)) @@ -224,6 +264,11 @@ impl AppState { date_text: &date_text, clock_old: clock_old.as_ref().map(|(s, t)| (s.as_str(), *t)), password_len: self.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::ConfigError), failed_t, dot_pop_t, From 5475e4da056922c8a9e0ca55ab3393c48109ef97 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 21 Aug 2026 20:30:28 +0800 Subject: [PATCH 16/20] breadgreet: GPU wallpaper with Ken Burns pan + entrance animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- breadgreet/src/main.rs | 145 ++++++++++++++++++++++++++++++++++++++-- breadgreet/src/theme.rs | 3 +- 2 files changed, 142 insertions(+), 6 deletions(-) diff --git a/breadgreet/src/main.rs b/breadgreet/src/main.rs index 1633d43..4d75265 100644 --- a/breadgreet/src/main.rs +++ b/breadgreet/src/main.rs @@ -64,11 +64,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, + } } } } @@ -139,9 +146,46 @@ impl SimpleComponent for App { card.append(&session_widget); let widgets = view_output!(); + + // Layer the window: wallpaper (main child, bottom) → dim veil → the + // clock+card cluster (top). Overlay children stack above the main + // child in `add_overlay` order, so the card ends up on top. + let bg_area = gtk4::DrawingArea::new(); + bg_area.set_hexpand(true); + bg_area.set_vexpand(true); + + let veil = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + veil.set_hexpand(true); + veil.set_vexpand(true); + veil.set_halign(gtk4::Align::Fill); + veil.set_valign(gtk4::Align::Fill); + veil.set_can_focus(false); + veil.add_css_class("login-veil"); + + widgets.overlay.set_child(Some(&bg_area)); + // Overlay children stack above the main child in `add_overlay` + // order; the last one added is topmost. So the veil goes in first, + // then the clock+card cluster on top of it. + widgets.overlay.add_overlay(&veil); + widgets.overlay.add_overlay(&widgets.root_box); + widgets.root_box.append(&clock_lbl); widgets.root_box.append(&card); + // 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()); @@ -274,6 +318,97 @@ impl App { } } +/// 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: >k4::ApplicationWindow, + bg_area: >k4::DrawingArea, + path: Option<&str>, + ken_burns: bool, +) { + let Some(path) = path else { return }; + let pixbuf = match gtk4::gdk_pixbuf::Pixbuf::from_file(path) { + Ok(pixbuf) => pixbuf, + Err(err) => { + tracing::warn!(%err, "failed to load wallpaper"); + return; + } + }; + let (iw, ih) = (pixbuf.width() as f32, pixbuf.height() as f32); + if iw <= 0.0 || ih <= 0.0 { + return; + } + + // Shared pan phase: the tick callback advances it, the draw callback + // reads it. Using a draw callback (rather than a moving widget) means + // the wallpaper never feeds the window's minimum size. + let phase = std::rc::Rc::new(std::cell::Cell::new(0.0f64)); + + let draw_pixbuf = pixbuf.clone(); + let draw_phase = phase.clone(); + bg_area.set_draw_func(move |_area, cr, w, h| { + let (w, h) = (w as f32, h as f32); + if w <= 0.0 || h <= 0.0 { + return; + } + // Cover scale, then the Ken Burns oversize (leaves room to pan). + let cover = (w / iw).max(h / ih); + let scale = if ken_burns { cover * 1.08 } else { cover }; + let dw = iw * scale; + let dh = ih * scale; + // Pan within the oversize margin (0..dw-w, 0..dh-h). + let phase = draw_phase.get(); + let max_x = (dw - w).max(0.0); + let max_y = (dh - h).max(0.0); + let x = max_x * (0.5 + 0.5 * phase.sin() as f32); + let y = max_y * (0.5 + 0.5 * (phase * 0.7).cos() as f32); + + cr.translate(-x as f64, -y as f64); + cr.scale(scale as f64, scale as f64); + cr.set_source_pixbuf(&draw_pixbuf, 0.0, 0.0); + let _ = cr.paint(); + }); + + if !ken_burns { + return; + } + + let area = bg_area.clone(); + let start = std::time::Instant::now(); + window.add_tick_callback(move |_w, _frame_clock| { + let elapsed = start.elapsed().as_secs_f64(); + phase.set(elapsed * std::f64::consts::TAU / 90.0); + area.queue_draw(); + gtk4::glib::ControlFlow::Continue + }); +} + +/// Entrance animation: the clock + card cluster fades in and rises ~24px +/// over ~600ms (ease-out), matching the lock screen's appear motion. +fn setup_entrance(window: >k4::ApplicationWindow, root_box: >k4::Box) { + let root_box = root_box.clone(); + let start = std::time::Instant::now(); + const DURATION_MS: f32 = 600.0; + const RISE_PX: f32 = 24.0; + window.add_tick_callback(move |_w, _frame_clock| { + let t = (start.elapsed().as_secs_f32() * 1000.0) / DURATION_MS; + let t = t.clamp(0.0, 1.0); + // Ease-out cubic. + let e = 1.0 - (1.0 - t).powi(3); + root_box.set_opacity(e as f64); + root_box.set_margin_top((RISE_PX * (1.0 - e)) as i32); + if t >= 1.0 { + gtk4::glib::ControlFlow::Break + } else { + gtk4::glib::ControlFlow::Continue + } + }); +} + /// Owns the single stateful connection to `$GREETD_SOCK` for one login /// attempt and translates the UI's [`GreetdCommand`]s into greetd IPC /// round-trips, forwarding each outcome back as an [`AppInput`]. diff --git a/breadgreet/src/theme.rs b/breadgreet/src/theme.rs index 155e333..3265b69 100644 --- a/breadgreet/src/theme.rs +++ b/breadgreet/src/theme.rs @@ -17,7 +17,8 @@ fn load_css() -> String { .login-status {{ font-size: 12px; opacity: 0.75; margin-top: 8px; }}\ .login-status.error {{ color: {red}; opacity: 1; }}\ .login-session {{ font-size: 12px; opacity: 0.85; margin-top: 12px; }}\ - dropdown.login-session {{ min-height: 32px; }}", + dropdown.login-session {{ min-height: 32px; }}\ + .login-veil {{ background-image: linear-gradient(to bottom, rgba(0,0,0,0.34) 0%, rgba(0,0,0,0.16) 100%); }}", bg = p.background, surface = p.color0, red = p.color1, From 6925e132fd959eedbeea87218b725d550f408e97 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 23 Aug 2026 14:21:19 +0800 Subject: [PATCH 17/20] 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). --- Cargo.lock | 637 +++++++++++++++++++++- EVENTS.md | 2 +- breadlock-ui/Cargo.toml | 3 +- breadlock-ui/src/painter.rs | 195 +++++-- breadlock.example.toml | 23 +- breadlock/Cargo.toml | 2 + breadlock/src/auth/mod.rs | 43 +- breadlock/src/auth/pam.rs | 134 +++++ breadlock/src/background.rs | 94 +++- breadlock/src/bin/breadlock-auth-check.rs | 93 +++- breadlock/src/bin/breadlock-preview.rs | 22 +- breadlock/src/bread_events.rs | 147 ++++- breadlock/src/config.rs | 52 +- breadlock/src/gpu.rs | 270 ++++++--- breadlock/src/input/keyboard.rs | 228 ++++++-- breadlock/src/input/mod.rs | 2 +- breadlock/src/lock/session.rs | 36 +- breadlock/src/lock/surface.rs | 35 +- breadlock/src/main.rs | 63 ++- breadlock/src/render.rs | 416 ++++++++++---- breadlock/src/state.rs | 385 ++++++++++--- breadlock/src/status.rs | 389 +++++++++++++ 22 files changed, 2839 insertions(+), 432 deletions(-) create mode 100644 breadlock/src/status.rs diff --git a/Cargo.lock b/Cargo.lock index 41855fe..cd87b73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,137 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.92" @@ -49,6 +180,12 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.1" @@ -64,6 +201,28 @@ dependencies = [ "serde_core", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bread-shared" version = "0.7.0" @@ -124,6 +283,7 @@ dependencies = [ "chrono", "glow", "khronos-egl", + "libc", "pam-client2", "serde", "serde_json", @@ -134,6 +294,7 @@ dependencies = [ "tracing", "tracing-subscriber", "wayland-client", + "zbus", "zeroize", ] @@ -142,7 +303,6 @@ name = "breadlock-ui" version = "0.2.0" dependencies = [ "bread-theme", - "chrono", "cosmic-text", "serde", "tiny-skia", @@ -255,6 +415,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chrono" version = "0.4.45" @@ -306,6 +472,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -321,12 +496,32 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "cursor-icon" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dirs" version = "5.0.1" @@ -363,6 +558,33 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -379,6 +601,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -524,6 +766,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -621,6 +876,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -876,6 +1141,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -1049,6 +1320,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1079,6 +1363,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "pam-client2" version = "0.5.5" @@ -1123,12 +1417,29 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -1162,6 +1473,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -1204,6 +1524,36 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "rangemap" version = "1.8.0" @@ -1407,6 +1757,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -1425,6 +1786,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1542,6 +1914,12 @@ dependencies = [ "lock_api", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strict-num" version = "0.1.1" @@ -1609,6 +1987,19 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1892,6 +2283,23 @@ version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -2205,7 +2613,25 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets", + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -2223,13 +2649,29 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -2238,42 +2680,90 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.15" @@ -2298,6 +2788,16 @@ version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "xkbcommon" version = "0.8.0" @@ -2324,12 +2824,94 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + [[package]] name = "zeno" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zeroize" version = "1.9.0" @@ -2341,3 +2923,40 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/EVENTS.md b/EVENTS.md index 51f2d63..6e0122d 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -27,7 +27,7 @@ from session lock/unlock. | Event | Data | When | |-------|------|------| | `bread.lock.locked` | `{}` | The compositor accepted the `ext-session-lock-v1` request (`SessionLockHandler::locked`). Not emitted merely because breadlock started or asked to lock. | -| `bread.lock.unlocked` | `{}` | PAM authenticated successfully and breadlock sent `unlock` to the compositor. Not emitted on a compositor-ended lock (`finished`), a dispatch-error exit (fail-secure: the session stays locked), or a failed/typo password. | +| `bread.lock.unlocked` | `{}` | PAM authenticated successfully and breadlock sent `unlock` to the compositor, **or** the compositor ended an already-active lock (`SessionLockHandler::finished` after `locked` — breadlock sends `unlock_and_destroy` then emits this). Not emitted when the lock was never acquired (`finished` before `locked`), on a dispatch-error exit (fail-secure: the session stays locked), or a failed/typo password. | | `bread.lock.lock.done` | `{}` | `bread.command.lock.lock` was honored: the locker was already running, or a locker process was started (same no-args invocation as hypridle's `lock_cmd = breadlock`). This is the command confirmation, not compositor proof — wait on `bread.lock.locked` if you need the session-lock protocol to have completed. | | `bread.lock.lock.failed` | `{ "error": "" }` | `bread.command.lock.lock` was received but the locker could not be started (e.g. this binary is missing from disk). | | `bread.lock.unlock.done` | `{}` | `bread.command.lock.unlock` was honored: no locker was running (already unlocked), or `loginctl unlock-session` was invoked for this session. This is the command confirmation, not compositor proof — wait on `bread.lock.unlocked` if you need PAM + `ext-session-lock-v1` unlock. | diff --git a/breadlock-ui/Cargo.toml b/breadlock-ui/Cargo.toml index 10fe27c..d6088e7 100644 --- a/breadlock-ui/Cargo.toml +++ b/breadlock-ui/Cargo.toml @@ -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"] diff --git a/breadlock-ui/src/painter.rs b/breadlock-ui/src/painter.rs index 5cc0adc..8013304 100644 --- a/breadlock-ui/src/painter.rs +++ b/breadlock-ui/src/painter.rs @@ -5,6 +5,7 @@ //! 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}; @@ -34,9 +35,13 @@ pub struct TextRenderer { font_system: FontSystem, swash_cache: SwashCache, /// Exact glyph-pixel span `(top, height)` per unique `(text, family, - /// size)` — see [`Self::measure_box`]. Keyed by size in centipixels so - /// fractional sizes don't thrash the cache. - boxes: HashMap<(String, String, u32), (f32, f32)>, + /// 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, } impl Default for TextRenderer { @@ -51,14 +56,51 @@ impl TextRenderer { 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 @@ -76,17 +118,46 @@ impl TextRenderer { /// (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) { - let key = (text.to_string(), family.to_string(), (size_px * 100.0) as u32); + 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(text, family, size_px).ceil().max(1.0) as u32; + 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(&mut probe, text, family, size_px, tiny_skia::Color::WHITE, 0.0, 0.0); + 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 { @@ -108,7 +179,17 @@ impl TextRenderer { /// 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) @@ -118,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, @@ -129,7 +212,27 @@ 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(); // cosmic-text's glyph-Mask rendering drops the base color's alpha @@ -141,30 +244,37 @@ impl TextRenderer { 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; - } - // `a` is the glyph coverage; combine it with the requested - // color alpha for the true source alpha. - 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); - }, - ); + 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); + }, + ); + } + } } } @@ -289,6 +399,23 @@ mod tests { ); } + #[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 diff --git a/breadlock.example.toml b/breadlock.example.toml index 188090a..0f0a328 100644 --- a/breadlock.example.toml +++ b/breadlock.example.toml @@ -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,8 +10,9 @@ 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: the -# background redraws continuously at a low frame rate while locked. +# 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] @@ -23,13 +26,13 @@ date_format = "%A · %b %d" 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. -reveal_hold = true +# safe as a reveal gesture. Default off. +reveal_hold = false [animation] # Subtle glow pulse on the password pill every few seconds while idle. @@ -37,3 +40,11 @@ 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 diff --git a/breadlock/Cargo.toml b/breadlock/Cargo.toml index e9e91b4..c27ef81 100644 --- a/breadlock/Cargo.toml +++ b/breadlock/Cargo.toml @@ -33,8 +33,10 @@ 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 diff --git a/breadlock/src/auth/mod.rs b/breadlock/src/auth/mod.rs index d1fad8d..5346c7a 100644 --- a/breadlock/src/auth/mod.rs +++ b/breadlock/src/auth/mod.rs @@ -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( loop_handle: &LoopHandle<'static, Data>, - mut on_result: impl FnMut(&mut Data, AuthResult) + 'static, -) -> Sender { + mut on_result: impl FnMut(&mut Data, u64, AuthResult) + 'static, +) -> Sender { 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( /// conversation consumes it — it is never logged. It's a `Zeroizing` /// 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, - result_tx: Sender, + generation: u64, + result_tx: Sender, ) { 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)); }); } diff --git a/breadlock/src/auth/pam.rs b/breadlock/src/auth/pam.rs index e245dba..40dd7a6 100644 --- a/breadlock/src/auth/pam.rs +++ b/breadlock/src/auth/pam.rs @@ -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 { + 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 { + let mut pwd = std::mem::MaybeUninit::::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 { + 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 { + 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); + } +} diff --git a/breadlock/src/background.rs b/breadlock/src/background.rs index 6af5d92..eb8ceac 100644 --- a/breadlock/src/background.rs +++ b/breadlock/src/background.rs @@ -31,12 +31,18 @@ pub enum Background { /// 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, - cache: RefCell>, + /// Last scaled copies **per target size**. A single slot thrashed every + /// frame under `redraw_all` with two monitors of different sizes. + cache: RefCell>, } struct ScaledBg { @@ -70,8 +76,8 @@ fn blit_translate(target: &mut Pixmap, src: &Pixmap, dx: f32, dy: f32, bilinear: 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 - tw).max(0) as f32); - let sy = (-dy).clamp(0.0, (sh - th).max(0) as f32); + 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; @@ -105,6 +111,7 @@ fn blit_translate(target: &mut Pixmap, src: &Pixmap, dx: f32, dy: f32, bilinear: // 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, @@ -145,10 +152,10 @@ fn blit_translate(target: &mut Pixmap, src: &Pixmap, dx: f32, dy: f32, bilinear: let mut out = 0u32; for c in 0..4 { let shift = c * 8; - let av = ((a >> shift) & 0xFF) as u32; - let bv = ((b >> shift) & 0xFF) as u32; - let dv = ((d >> shift) & 0xFF) as u32; - let ev = ((e >> shift) & 0xFF) as u32; + 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; @@ -227,7 +234,7 @@ impl Background { Ok(pixmap) => Background::Image(ImageBg { source: pixmap, ken_burns: cfg.ken_burns, - cache: RefCell::new(None), + 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"); @@ -268,11 +275,18 @@ impl Background { return; } let mut cache = bg.cache.borrow_mut(); - let stale = cache - .as_ref() - .map(|c| c.target_w != target.width() || c.target_h != target.height()) - .unwrap_or(true); - if stale { + 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; @@ -281,7 +295,6 @@ impl Background { tracing::error!( "failed to allocate {scaled_w}x{scaled_h} scaled wallpaper — falling back to a palette-color background" ); - *cache = None; drop(cache); target.fill(breadlock_ui::theme::tiny_skia_color( &breadlock_ui::theme::Palette::default().background, @@ -292,8 +305,10 @@ impl Background { // 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 mut paint = PixmapPaint::default(); - paint.quality = tiny_skia::FilterQuality::Bilinear; + let paint = PixmapPaint { + quality: tiny_skia::FilterQuality::Bilinear, + ..Default::default() + }; pixmap.draw_pixmap( 0, 0, @@ -302,15 +317,18 @@ impl Background { Transform::from_scale(scale, scale), None, ); - *cache = Some(ScaledBg { + 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: target.width(), - target_h: target.height(), + target_w: tw_px, + target_h: th_px, }); } - let scaled = cache.as_ref().expect("cache populated above"); + let scaled = cache.last().expect("cache populated above"); target.fill(tiny_skia::Color::BLACK); let (tx, ty) = if bg.ken_burns { let phase = t_secs * TAU / KENBURNS_PERIOD_S; @@ -400,7 +418,7 @@ mod tests { let bg = Background::Image(ImageBg { source, ken_burns: true, - cache: RefCell::new(None), + cache: RefCell::new(Vec::new()), }); let mut target = Pixmap::new(60, 30).unwrap(); for i in 0..90 { @@ -426,7 +444,7 @@ mod tests { // 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(), ((0 + 32) / 2) as u8, "0.5px shift averages neighbors"); + 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); } @@ -439,10 +457,40 @@ mod tests { let bg = Background::Image(ImageBg { source, ken_burns: false, - cache: RefCell::new(None), + 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)); + } } diff --git a/breadlock/src/bin/breadlock-auth-check.rs b/breadlock/src/bin/breadlock-auth-check.rs index c671ca8..5efffb0 100644 --- a/breadlock/src/bin/breadlock-auth-check.rs +++ b/breadlock/src/bin/breadlock-auth-check.rs @@ -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 { + 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 { 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 } diff --git a/breadlock/src/bin/breadlock-preview.rs b/breadlock/src/bin/breadlock-preview.rs index f9edd65..598f352 100644 --- a/breadlock/src/bin/breadlock-preview.rs +++ b/breadlock/src/bin/breadlock-preview.rs @@ -40,6 +40,10 @@ struct Scene { 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, @@ -49,6 +53,8 @@ struct Scene { /// 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, @@ -67,12 +73,14 @@ impl Default for Scene { 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, @@ -131,6 +139,7 @@ fn bench(args: &[String]) { breathe_t: 0.0, status_t: 1.0, status_text: None, + info_text: "", appear_t: 1.0, unlock_t: 0.0, smooth_pan: true, @@ -179,6 +188,7 @@ fn bench(args: &[String]) { 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, @@ -230,8 +240,9 @@ fn main() { 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 with the animated ellipsis. - Scene { name: "08-checking", status: Some("Checking…"), status_t: 0.5, ..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. @@ -245,11 +256,13 @@ fn main() { // ---- 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: 8, reveal: true, ..Scene::default() }, + 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(); @@ -265,7 +278,7 @@ fn main() { date_text: scene.date, clock_old: scene.clock_old, password_len: scene.password_len, - password: "hunter2", + password: scene.password, reveal: scene.reveal, caps_lock: scene.caps_lock, layout_index: scene.layout_index, @@ -278,6 +291,7 @@ fn main() { 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, diff --git a/breadlock/src/bread_events.rs b/breadlock/src/bread_events.rs index 5ece110..5bd3e11 100644 --- a/breadlock/src/bread_events.rs +++ b/breadlock/src/bread_events.rs @@ -15,6 +15,7 @@ //! calls compositor `unlock()` — that stays on the PAM path. use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::thread; use bread_utils::bread_client::{BreadClient, BreadEvent, Subscription}; @@ -29,6 +30,26 @@ pub const APP_ID: &str = "lock"; /// locker process can coexist. The locker itself uses [`APP_ID`]. pub const LISTEN_APP: &str = "lock-listen"; +/// Set for the life of `run_lock` so [`locker_is_running`] is true without +/// a second `try_acquire("lock")` from the locker process (flock is +/// per-process, so that check would miss ourselves). +static LOCKER_RUNNING: AtomicBool = AtomicBool::new(false); + +/// RAII flag: [`locker_is_running`] is true until this drops. +pub struct LockerRunningGuard; + +impl Drop for LockerRunningGuard { + fn drop(&mut self) { + LOCKER_RUNNING.store(false, Ordering::SeqCst); + } +} + +/// Mark this process as the locker for the life of the returned guard. +pub fn enter_lock_process() -> LockerRunningGuard { + LOCKER_RUNNING.store(true, Ordering::SeqCst); + LockerRunningGuard +} + pub fn emit_locked() { BreadClient::connect(APP_ID).emit("bread.lock.locked", serde_json::json!({})); } @@ -59,11 +80,10 @@ pub fn emit_unlock_failed(error: &str) { ); } -/// True when another process holds the locker singleton — i.e. breadlock -/// is already locking this session. A `try_acquire` that succeeds is -/// released immediately; this is a check, not a claim. +/// True when this process is the locker, or another process holds the +/// locker singleton — i.e. breadlock is already locking this session. pub fn locker_is_running() -> bool { - singleton_held(APP_ID) + LOCKER_RUNNING.load(Ordering::SeqCst) || singleton_held(APP_ID) } fn singleton_held(app: &str) -> bool { @@ -81,6 +101,8 @@ pub fn start_locker() -> Result<(), String> { let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("breadlock")); let mut child = Command::new(exe) .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) .spawn() .map_err(|e| format!("failed to start breadlock: {e}"))?; thread::spawn(move || { @@ -94,7 +116,7 @@ pub fn start_locker() -> Result<(), String> { /// `ext-session-lock-v1` has been accepted — wait on `bread.lock.locked` /// for the compositor confirmation. pub fn honor_lock_command() { - honor_lock_command_with(start_locker); + honor_lock_command_with(locker_is_running(), start_locker); } /// Session-level unlock (`loginctl unlock-session` on the caller's @@ -122,8 +144,8 @@ pub fn honor_unlock_command() { honor_unlock_command_with(locker_is_running(), unlock_session); } -fn honor_lock_command_with(start: impl FnOnce() -> Result<(), String>) { - if locker_is_running() { +fn honor_lock_command_with(locked: bool, start: impl FnOnce() -> Result<(), String>) { + if locked { tracing::info!("bread.command.lock.lock: already locked"); emit_lock_done(); return; @@ -160,12 +182,16 @@ fn honor_unlock_command_with(locked: bool, unlock: impl FnOnce() -> Result<(), S /// Reacts to `bread.command.lock.*`. Unknown verbs are ignored, not stubbed. pub fn handle_command(event: &BreadEvent) { + handle_command_with(event, honor_lock_command, honor_unlock_command); +} + +fn handle_command_with(event: &BreadEvent, on_lock: impl FnOnce(), on_unlock: impl FnOnce()) { let Some(verb) = event.event.strip_prefix("bread.command.lock.") else { return; }; match verb { - "lock" => honor_lock_command(), - "unlock" => honor_unlock_command(), + "lock" => on_lock(), + "unlock" => on_unlock(), other => tracing::info!(verb = other, "ignoring unknown bread.command.lock verb"), } } @@ -181,6 +207,7 @@ pub fn subscribe_commands() -> Subscription { #[cfg(test)] mod tests { use super::*; + use std::cell::Cell; fn event(name: &str) -> BreadEvent { BreadEvent { @@ -192,9 +219,48 @@ mod tests { #[test] fn handle_command_ignores_unrecognized_verb() { - handle_command(&event("bread.command.lock.pin")); - handle_command(&event("bread.command.clip.clear")); - handle_command(&event("bread.lock.locked")); + let lock = Cell::new(false); + let unlock = Cell::new(false); + handle_command_with( + &event("bread.command.lock.pin"), + || lock.set(true), + || unlock.set(true), + ); + handle_command_with( + &event("bread.command.clip.clear"), + || lock.set(true), + || unlock.set(true), + ); + handle_command_with( + &event("bread.lock.locked"), + || lock.set(true), + || unlock.set(true), + ); + assert!(!lock.get()); + assert!(!unlock.get()); + } + + #[test] + fn handle_command_dispatches_only_lock_and_unlock() { + let lock = Cell::new(0u32); + let unlock = Cell::new(0u32); + handle_command_with( + &event("bread.command.lock.lock"), + || lock.set(lock.get() + 1), + || unlock.set(unlock.get() + 1), + ); + handle_command_with( + &event("bread.command.lock.unlock"), + || lock.set(lock.get() + 1), + || unlock.set(unlock.get() + 1), + ); + handle_command_with( + &event("bread.command.lock.pin"), + || lock.set(lock.get() + 1), + || unlock.set(unlock.get() + 1), + ); + assert_eq!(lock.get(), 1); + assert_eq!(unlock.get(), 1); } #[test] @@ -216,13 +282,33 @@ mod tests { } #[test] - fn honor_lock_command_with_failed_start_does_not_panic() { - honor_lock_command_with(|| Err("boom".into())); + fn honor_lock_command_with_failed_start_runs_start() { + let started = Cell::new(false); + honor_lock_command_with(false, || { + started.set(true); + Err("boom".into()) + }); + assert!(started.get()); } #[test] - fn honor_lock_command_with_successful_start_does_not_panic() { - honor_lock_command_with(|| Ok(())); + 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] @@ -236,12 +322,33 @@ mod tests { } #[test] - fn honor_unlock_command_with_failed_loginctl_does_not_panic() { - honor_unlock_command_with(true, || Err("boom".into())); + fn honor_unlock_command_with_failed_loginctl_runs_unlock() { + let called = Cell::new(false); + honor_unlock_command_with(true, || { + called.set(true); + Err("boom".into()) + }); + assert!(called.get()); } #[test] - fn honor_unlock_command_with_successful_loginctl_does_not_panic() { - honor_unlock_command_with(true, || Ok(())); + fn honor_unlock_command_with_successful_loginctl_runs_unlock() { + let called = Cell::new(false); + honor_unlock_command_with(true, || { + called.set(true); + Ok(()) + }); + assert!(called.get()); + } + + #[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)); } } diff --git a/breadlock/src/config.rs b/breadlock/src/config.rs index cb36ce0..04d70a7 100644 --- a/breadlock/src/config.rs +++ b/breadlock/src/config.rs @@ -9,16 +9,40 @@ pub struct Config { 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. Tab can never be part of a password (it produces no utf8), - /// so holding it is always safe to use as a reveal gesture. + /// 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, } @@ -26,7 +50,7 @@ impl Default for Input { fn default() -> Self { Self { fail_timeout_ms: 800, - reveal_hold: true, + reveal_hold: false, } } } @@ -76,11 +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"; diff --git a/breadlock/src/gpu.rs b/breadlock/src/gpu.rs index 596c804..df6c60d 100644 --- a/breadlock/src/gpu.rs +++ b/breadlock/src/gpu.rs @@ -12,7 +12,9 @@ //! //! If EGL initialization fails for any reason (headless, no GPU, compositor //! without EGL), [`GpuRenderer::new`] returns `None` and the locker falls -//! back to the fully-software path unchanged. +//! back to the fully-software path unchanged. `GpuSurface` destroys its +//! native window and EGL surface on drop (output unplug); `render_frame` +//! returns `false` on make_current/swap failure so the caller can fall back. use crate::render::{self, FrameInputs}; use breadlock_ui::config::{Background as BackgroundConfig, BackgroundMode}; @@ -29,9 +31,11 @@ use wayland_client::{Connection, Proxy}; const KENBURNS_PERIOD_S: f32 = 90.0; const KENBURNS_ZOOM: f32 = 1.06; -const EGL_ATTRIBS: [egl::Int; 11] = [ +const EGL_ATTRIBS: [egl::Int; 13] = [ egl::SURFACE_TYPE, - (egl::WINDOW_BIT | egl::PBUFFER_BIT) as egl::Int, + egl::WINDOW_BIT as egl::Int, + egl::RENDERABLE_TYPE, + egl::OPENGL_ES2_BIT as egl::Int, egl::RED_SIZE, 8, egl::GREEN_SIZE, @@ -101,22 +105,41 @@ pub struct wl_egl_window { extern "C" { fn wl_egl_window_create(surface: *mut wl_surface, width: i32, height: i32) -> *mut wl_egl_window; fn wl_egl_window_resize(window: *mut wl_egl_window, width: i32, height: i32, dx: i32, dy: i32); + fn wl_egl_window_destroy(window: *mut wl_egl_window); } -// EGL objects are intentionally not destroyed on the way out: the process -// exits immediately after unlock, and dropping the pbuffer/context while it -// might still be current would be UB — leaving them for the OS is cleaner. -const _: () = (); +/// EGL entry points captured at surface creation so `Drop` can destroy the +/// window/surface without holding a pointer into `GpuRenderer` (which would +/// dangle if AppState is moved). +struct EglSurfaceFns { + destroy_surface: unsafe extern "system" fn(egl::EGLDisplay, egl::EGLSurface) -> egl::Boolean, + make_current: unsafe extern "system" fn( + egl::EGLDisplay, + egl::EGLSurface, + egl::EGLSurface, + egl::EGLContext, + ) -> egl::Boolean, + get_current_surface: unsafe extern "system" fn(egl::Int) -> egl::EGLSurface, +} /// One EGL-backed lock surface. Created lazily on the first `configure` (the -/// size is unknown before that) and resized on subsequent ones. The process -/// exits right after unlock, so EGL objects are deliberately not destroyed -/// individually. +/// size is unknown before that) and resized on subsequent ones. Dropped on +/// output unplug (`output_destroyed` retains the `LockSurface` out of the +/// vec), so the native window and EGL surface must be destroyed here — +/// process-exit-only was wrong for hotplug. pub struct GpuSurface { egl_window: *mut wl_egl_window, egl_surface: egl::Surface, + display: egl::Display, + egl_fns: Option, width: u32, height: u32, + /// Per-surface chrome texture/pixmap so two outputs of different sizes + /// don't thrash one shared texture (which left undefined texels in the + /// leftover region). + chrome_tex: Option, + chrome_tex_size: (u32, u32), + chrome_pixmap: Option, } impl GpuSurface { @@ -131,6 +154,37 @@ impl GpuSurface { } } +impl Drop for GpuSurface { + fn drop(&mut self) { + // Unbind this surface if it's current, then destroy the EGL surface + // and the native window. Making-current with NO_SURFACE first avoids + // the UB of destroying a current surface. + unsafe { + if let Some(fns) = self.egl_fns.take() { + let surf = self.egl_surface.as_ptr(); + let current = (fns.get_current_surface)(egl::DRAW) == surf + || (fns.get_current_surface)(egl::READ) == surf; + if current { + let _ = (fns.make_current)( + self.display.as_ptr(), + egl::NO_SURFACE, + egl::NO_SURFACE, + egl::NO_CONTEXT, + ); + } + let _ = (fns.destroy_surface)(self.display.as_ptr(), surf); + } + if !self.egl_window.is_null() { + wl_egl_window_destroy(self.egl_window); + self.egl_window = std::ptr::null_mut(); + } + } + // GL chrome texture: deleting it needs a current context we may not + // have (the other output could be current). One leaked texture per + // unplugged output is acceptable; the process still owns the context. + } +} + struct Wallpaper { tex: glow::Texture, size: (u32, u32), @@ -143,11 +197,11 @@ pub struct GpuRenderer { config: egl::Config, context: egl::Context, /// 1x1 pbuffer used to make the context current during setup (before any - /// real lock surface exists). Kept alive for the renderer's lifetime — - /// the read is deliberate: dropping it while the context might still be - /// current on it is undefined behavior. + /// real lock surface exists). `None` when we fell back to a surfaceless + /// context. Kept alive for the renderer's lifetime — dropping it while + /// the context might still be current on it is undefined behavior. #[allow(dead_code)] - setup_surface: egl::Surface, + setup_surface: Option, gl: glow::Context, bg_program: glow::Program, chrome_program: glow::Program, @@ -158,10 +212,6 @@ pub struct GpuRenderer { /// the palette color). white_tex: glow::Texture, bg_color: [f32; 4], - chrome_tex: glow::Texture, - chrome_tex_size: (u32, u32), - /// Reused scratch for the chrome compose. - chrome_pixmap: Option, u_screen: [Option; 2], u_uv_scale: [Option; 2], u_uv_offset: [Option; 2], @@ -184,6 +234,7 @@ impl GpuRenderer { // SAFETY: the display pointer comes from our live wayland connection. let display = unsafe { egl.get_display(conn.display().id().as_ptr() as *mut c_void) }?; egl.initialize(display).ok()?; + egl.bind_api(egl::OPENGL_ES_API).ok()?; let mut configs = Vec::with_capacity(1); egl.choose_config(display, &EGL_ATTRIBS, &mut configs).ok()?; let config = *configs.first()?; @@ -191,14 +242,23 @@ impl GpuRenderer { .create_context(display, config, None, &[egl::CONTEXT_CLIENT_VERSION, 2, egl::NONE]) .ok()?; // A 1x1 pbuffer is enough to make the context current for setup - // before any real lock surface exists (pbuffers size via - // EGL_WIDTH/EGL_HEIGHT). + // before any real lock surface exists. The chosen config is + // WINDOW_BIT-only (more portable than also requiring PBUFFER_BIT), + // so pbuffer creation may fail — fall back to a surfaceless + // context (EGL_KHR_surfaceless_context) in that case. let setup_surface = egl .create_pbuffer_surface(display, config, &[egl::WIDTH, 1, egl::HEIGHT, 1, egl::NONE]) - .ok()?; - if egl - .make_current(display, Some(setup_surface), Some(setup_surface), Some(context)) - .is_err() + .ok(); + let made = match setup_surface { + Some(s) => egl + .make_current(display, Some(s), Some(s), Some(context)) + .is_ok(), + None => false, + }; + if !made + && egl + .make_current(display, None, None, Some(context)) + .is_err() { return None; } @@ -300,16 +360,6 @@ impl GpuRenderer { gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32); } - // Full-size chrome texture (sub-image uploaded per frame). - let chrome_tex = unsafe { gl.create_texture() }.ok()?; - unsafe { - gl.bind_texture(glow::TEXTURE_2D, Some(chrome_tex)); - gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32); - gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32); - gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32); - gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32); - } - let bg = breadlock_ui::theme::tiny_skia_color(&palette.background); let bg_color = [bg.red(), bg.green(), bg.blue(), 1.0]; @@ -344,9 +394,6 @@ impl GpuRenderer { wallpaper, white_tex, bg_color, - chrome_tex, - chrome_tex_size: (0, 0), - chrome_pixmap: None, u_screen, u_uv_scale, u_uv_offset, @@ -379,40 +426,62 @@ impl GpuRenderer { let egl_surface = unsafe { self.egl .create_window_surface(self.display, self.config, egl_window as *mut c_void, None) - } - .ok()?; + }; + let egl_surface = match egl_surface { + Ok(s) => s, + Err(err) => { + tracing::error!(%err, "eglCreateWindowSurface failed"); + // SAFETY: we still own the native window created above. + unsafe { wl_egl_window_destroy(egl_window) }; + return None; + } + }; Some(GpuSurface { egl_window, egl_surface, + display: self.display, + egl_fns: load_egl_surface_fns(&self.egl), width, height, + chrome_tex: None, + chrome_tex_size: (0, 0), + chrome_pixmap: None, }) } /// Renders one frame for `surface`: wallpaper quad (pan + veil in the /// shader), then the software-composed chrome blitted over it. + /// + /// Returns `false` if `make_current` or `swap_buffers` failed (caller + /// could fall back to the software path; `state.rs` currently ignores + /// the result and we just skip the frame). pub fn render_frame( &mut self, surface: &mut GpuSurface, inputs: &FrameInputs, text: &mut TextRenderer, - ) { + ) -> bool { let (w, h) = (surface.width, surface.height); if w == 0 || h == 0 { - return; + return false; } if self .egl .make_current(self.display, Some(surface.egl_surface), Some(surface.egl_surface), Some(self.context)) .is_err() { - return; + tracing::warn!("eglMakeCurrent failed; skipping GPU frame"); + return false; } let gl = &self.gl; unsafe { gl.viewport(0, 0, w as i32, h as i32) }; self.draw_background(w, h, inputs); - self.draw_chrome(w, h, inputs, text); - let _ = self.egl.swap_buffers(self.display, surface.egl_surface); + self.draw_chrome(surface, inputs, text); + if self.egl.swap_buffers(self.display, surface.egl_surface).is_err() { + tracing::warn!("eglSwapBuffers failed; skipping GPU frame"); + return false; + } + true } fn draw_background(&mut self, w: u32, h: u32, inputs: &FrameInputs) { @@ -498,19 +567,21 @@ impl GpuRenderer { } } - fn draw_chrome(&mut self, w: u32, h: u32, inputs: &FrameInputs, text: &mut TextRenderer) { - let dirty = self + fn draw_chrome(&mut self, surface: &mut GpuSurface, inputs: &FrameInputs, text: &mut TextRenderer) { + let (w, h) = (surface.width, surface.height); + let dirty = surface .chrome_pixmap .as_ref() .map(|p| (p.width(), p.height()) != (w, h)) .unwrap_or(true); if dirty { - self.chrome_pixmap = Pixmap::new(w, h); + surface.chrome_pixmap = Pixmap::new(w, h); } - let Some(pixmap) = self.chrome_pixmap.as_mut() else { + let Some(pixmap) = surface.chrome_pixmap.as_mut() else { return; }; let rect = render::compose_chrome(pixmap, text, inputs); + // Empty ChromeRect is (+∞, +∞, −∞, −∞); after clamping, x1 <= x0. let x0 = rect.x0.max(0.0).floor() as i32; let y0 = rect.y0.max(0.0).floor() as i32; let x1 = (rect.x1.min(w as f32)).ceil() as i32; @@ -518,10 +589,47 @@ impl GpuRenderer { if x1 <= x0 || y1 <= y0 { return; } - if self.chrome_tex_size != (w, h) { - let gl = &self.gl; + + let gl = &self.gl; + if surface.chrome_tex.is_none() { + let tex = match unsafe { gl.create_texture() } { + Ok(t) => t, + Err(_) => return, + }; unsafe { - gl.bind_texture(glow::TEXTURE_2D, Some(self.chrome_tex)); + gl.bind_texture(glow::TEXTURE_2D, Some(tex)); + gl.tex_parameter_i32( + glow::TEXTURE_2D, + glow::TEXTURE_MIN_FILTER, + glow::NEAREST as i32, + ); + gl.tex_parameter_i32( + glow::TEXTURE_2D, + glow::TEXTURE_MAG_FILTER, + glow::NEAREST as i32, + ); + gl.tex_parameter_i32( + glow::TEXTURE_2D, + glow::TEXTURE_WRAP_S, + glow::CLAMP_TO_EDGE as i32, + ); + gl.tex_parameter_i32( + glow::TEXTURE_2D, + glow::TEXTURE_WRAP_T, + glow::CLAMP_TO_EDGE as i32, + ); + } + surface.chrome_tex = Some(tex); + surface.chrome_tex_size = (0, 0); + } + let chrome_tex = surface.chrome_tex.expect("set above"); + if surface.chrome_tex_size != (w, h) { + // Allocate with zeros — `tex_image_2d(..., None)` leaves undefined + // texels, which ghosted when we later drew a fullscreen chrome quad + // after only uploading the dirty AABB. + let zeros = vec![0u8; w as usize * h as usize * 4]; + unsafe { + gl.bind_texture(glow::TEXTURE_2D, Some(chrome_tex)); gl.tex_image_2d( glow::TEXTURE_2D, 0, @@ -531,18 +639,17 @@ impl GpuRenderer { 0, glow::RGBA, glow::UNSIGNED_BYTE, - glow::PixelUnpackData::Slice(None), + glow::PixelUnpackData::Slice(Some(&zeros)), ); } - self.chrome_tex_size = (w, h); + surface.chrome_tex_size = (w, h); } let rw = (x1 - x0) as usize; let rh = (y1 - y0) as usize; let data = pixmap.data(); - let gl = &self.gl; unsafe { - gl.bind_texture(glow::TEXTURE_2D, Some(self.chrome_tex)); + gl.bind_texture(glow::TEXTURE_2D, Some(chrome_tex)); // glTexSubImage2D reads `rw` pixels contiguously per row with no // knowledge of the source's row stride. We're on GLES2 (where // GL_UNPACK_ROW_LENGTH doesn't exist), so pack the sub-rect rows @@ -554,8 +661,8 @@ impl GpuRenderer { gl.tex_sub_image_2d( glow::TEXTURE_2D, 0, - x0 as i32, - y0 as i32, + x0, + y0, rw as i32, rh as i32, glow::RGBA, @@ -567,9 +674,17 @@ impl GpuRenderer { gl.bind_buffer(glow::ARRAY_BUFFER, Some(self.quad_vbo)); let wf = w as f32; let hf = h as f32; + let xf0 = x0 as f32; + let yf0 = y0 as f32; + let xf1 = x1 as f32; + let yf1 = y1 as f32; + // Quad covering *only* the current ChromeRect. A shrinking status + // line would otherwise ghost from leftover texels on a fullscreen + // chrome draw. UV still maps pixel coords → [0,1] over the full + // texture, so this sub-rect samples the matching texels. let verts: [f32; 12] = [ - 0.0, 0.0, wf, 0.0, 0.0, hf, // - wf, 0.0, wf, hf, 0.0, hf, + xf0, yf0, xf1, yf0, xf0, yf1, // + xf1, yf0, xf1, yf1, xf0, yf1, ]; gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, f32s_as_bytes(&verts), glow::DYNAMIC_DRAW); if let Some(loc) = self.u_screen[1].as_ref() { @@ -585,7 +700,7 @@ impl GpuRenderer { gl.uniform_1_i32(Some(loc), 0); } gl.active_texture(glow::TEXTURE0); - gl.bind_texture(glow::TEXTURE_2D, Some(self.chrome_tex)); + gl.bind_texture(glow::TEXTURE_2D, Some(chrome_tex)); gl.enable(glow::BLEND); gl.blend_func(glow::ONE, glow::ONE_MINUS_SRC_ALPHA); gl.draw_arrays(glow::TRIANGLES, 0, 6); @@ -617,6 +732,22 @@ fn pan_region(wp: (u32, u32), target: (u32, u32), ken_burns: bool, t_secs: f32) (-tx, -ty, scaled_w, scaled_h) } +/// Resolves the EGL calls `GpuSurface::drop` needs. Function pointers, not a +/// pointer into `GpuRenderer`, so a later move of the renderer is fine. +fn load_egl_surface_fns(egl: &egl::DynamicInstance) -> Option { + unsafe fn load(egl: &egl::DynamicInstance, name: &str) -> Option { + let p = egl.get_proc_address(name)?; + // SAFETY: `name` is an EGL 1.0 core entry point; the signature of `T` + // matches the Khronos spec. Both types are function pointers. + Some(std::mem::transmute_copy::<_, T>(&p)) + } + Some(EglSurfaceFns { + destroy_surface: unsafe { load(egl, "eglDestroySurface") }?, + make_current: unsafe { load(egl, "eglMakeCurrent") }?, + get_current_surface: unsafe { load(egl, "eglGetCurrentSurface") }?, + }) +} + /// Packs the `(x0, y0, rw, rh)` sub-rect of a `w`-wide RGBA row-major buffer /// into a tightly-strided `rw`-per-row buffer for `glTexSubImage2D`, which /// reads rows contiguously and has no stride concept on GLES2. @@ -822,7 +953,7 @@ mod tests { let h = 720.0; // Software dim_rows: alpha = lerp(TOP, BOTTOM, y/h) with y=0 at top, // so the top of the screen gets the DEEPER dim (DIM_ALPHA_TOP). - assert!(DIM_ALPHA_TOP > DIM_ALPHA_BOTTOM); + const { assert!(DIM_ALPHA_TOP > DIM_ALPHA_BOTTOM) }; // Shader at the very top (gl_FragCoord.y = h): row = 0 -> dim = TOP. assert!((shader_dim_at(h, h) - DIM_ALPHA_TOP).abs() < 1e-6); // Shader at the very bottom (gl_FragCoord.y = 0): row = 1 -> dim = BOTTOM. @@ -844,6 +975,21 @@ mod tests { fn egl_attribs_are_none_terminated_pairs() { assert_eq!(EGL_ATTRIBS.len() % 2, 1, "attribs must be key/value pairs + NONE"); assert_eq!(*EGL_ATTRIBS.last().unwrap(), egl::NONE, "attrib list must be NONE-terminated"); + let mut saw_window = false; + let mut saw_es2 = false; + let mut saw_pbuffer = false; + for pair in EGL_ATTRIBS.chunks(2).filter(|c| c.len() == 2) { + if pair[0] == egl::SURFACE_TYPE { + saw_window = pair[1] & egl::WINDOW_BIT as egl::Int != 0; + saw_pbuffer = pair[1] & egl::PBUFFER_BIT as egl::Int != 0; + } + if pair[0] == egl::RENDERABLE_TYPE { + saw_es2 = pair[1] & egl::OPENGL_ES2_BIT as egl::Int != 0; + } + } + assert!(saw_window, "config must request WINDOW_BIT"); + assert!(!saw_pbuffer, "do not require PBUFFER_BIT (not portable)"); + assert!(saw_es2, "config must request EGL_OPENGL_ES2_BIT"); } #[test] @@ -867,7 +1013,7 @@ mod tests { } // Take the 2x1 rect at (1, 0). let packed = pack_rows(&data, 4, 1, 0, 2, 1); - assert_eq!(packed.len(), 2 * 1 * 4); + assert_eq!(packed.len(), 8); assert_eq!(packed[0], 1); assert_eq!(packed[4], 2); // Two rows: the second row must NOT be shifted by (w - rw) — the bug. diff --git a/breadlock/src/input/keyboard.rs b/breadlock/src/input/keyboard.rs index 07d474c..aa037a3 100644 --- a/breadlock/src/input/keyboard.rs +++ b/breadlock/src/input/keyboard.rs @@ -8,7 +8,7 @@ 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 { @@ -25,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, - _seat: wl_seat::WlSeat, + qh: &QueueHandle, + 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, _seat: wl_seat::WlSeat) { + fn remove_seat(&mut self, _conn: &Connection, qh: &QueueHandle, 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); } } @@ -86,11 +78,16 @@ impl KeyboardHandler for AppState { fn leave( &mut self, _conn: &Connection, - _qh: &QueueHandle, + qh: &QueueHandle, _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( @@ -154,11 +151,75 @@ impl KeyboardHandler for AppState { } impl AppState { + fn try_bind_keyboard(&mut self, qh: &QueueHandle, 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) { + if self.keyboard.is_some() { + return; + } + let seats: Vec = 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, event: KeyEvent) { - // Ignore all input while a PAM check is in flight so a fast second - // Enter can't race the first attempt, and while the unlock fade is - // playing (auth already succeeded; surfaces stay up until it ends). - if self.auth_state == AuthState::Checking || self.unlocking.is_some() { + // 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; } @@ -198,6 +259,7 @@ impl AppState { } Keysym::Escape => { self.password.zeroize(); + self.password_display_len = 0; self.clear_failed_state(); } _ => { @@ -205,14 +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(); } - // 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(); } } } @@ -221,11 +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. + // 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; } } @@ -233,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 @@ -241,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); } } diff --git a/breadlock/src/input/mod.rs b/breadlock/src/input/mod.rs index cce11f6..0360497 100644 --- a/breadlock/src/input/mod.rs +++ b/breadlock/src/input/mod.rs @@ -1 +1 @@ -mod keyboard; +pub(crate) mod keyboard; diff --git a/breadlock/src/lock/session.rs b/breadlock/src/lock/session.rs index 3641a49..d49d4eb 100644 --- a/breadlock/src/lock/session.rs +++ b/breadlock/src/lock/session.rs @@ -16,14 +16,27 @@ impl SessionLockHandler for AppState { /// from under us (e.g. protocol error). Either way there's no lock left /// to protect, so the only sane move is to exit — staying resident /// unlocked would be worse than not running at all. + /// + /// If `locked` already arrived, dropping the object sends `destroy()` + /// which is a protocol error; send `unlock_and_destroy` first. fn finished( &mut self, _conn: &Connection, _qh: &QueueHandle, _session_lock: SessionLock, ) { - tracing::warn!("compositor ended the session lock; exiting"); - self.session_lock = None; + // PAM unlock already took the stored lock; don't unlock/emit again. + let Some(lock) = self.session_lock.take() else { + self.exit = true; + return; + }; + if lock.is_locked() { + tracing::warn!("compositor ended an active session lock; unlocking then exiting"); + lock.unlock(); + crate::bread_events::emit_unlocked(); + } else { + tracing::warn!("compositor ended the session lock before it was acquired; exiting"); + } self.exit = true; } @@ -36,22 +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; + 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. + // 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(), width, height), - Some(gs) => gs.resize(width, height), + None => s.gpu = renderer.create_surface(surface.wl_surface(), buf_w, buf_h), + Some(gs) => gs.resize(buf_w, buf_h), } } - } - self.redraw_surface(qh, &surface, width, height); + (buf_w, buf_h) + } else { + (width, height) + }; + self.redraw_surface(qh, &surface, buf_w, buf_h); } } diff --git a/breadlock/src/lock/surface.rs b/breadlock/src/lock/surface.rs index b1d20ac..0a238b1 100644 --- a/breadlock/src/lock/surface.rs +++ b/breadlock/src/lock/surface.rs @@ -9,10 +9,30 @@ impl CompositorHandler for AppState { fn scale_factor_changed( &mut self, _conn: &Connection, - _qh: &QueueHandle, - _surface: &wl_surface::WlSurface, - _new_factor: i32, + qh: &QueueHandle, + 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, 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,7 +102,10 @@ impl OutputHandler for AppState { output, width: 0, height: 0, + scale: 1, gpu: None, + shm_pool: None, + shm_buffer: None, }); } diff --git a/breadlock/src/main.rs b/breadlock/src/main.rs index 1d57862..ae4ab0a 100644 --- a/breadlock/src/main.rs +++ b/breadlock/src/main.rs @@ -7,6 +7,7 @@ mod input; mod lock; mod render; mod state; +mod status; use smithay_client_toolkit::compositor::CompositorState; use smithay_client_toolkit::output::OutputState; @@ -128,18 +129,21 @@ fn run_lock() { None } }; + let _running = bread_events::enter_lock_process(); // Honor bread.command.lock.lock / unlock while this locker is up // (already-locked is bread.lock.lock.done; unlock is loginctl, not // compositor unlock()). Unlocked-path commands need `breadlock listen`. let _commands = bread_events::subscribe_commands(); - let username = std::env::var("USER") - .or_else(|_| std::env::var("LOGNAME")) - .unwrap_or_else(|_| { - tracing::error!("neither $USER nor $LOGNAME is set — refusing to start without a username to authenticate"); - std::process::exit(1); - }); + let username = auth::username_from_process().unwrap_or_else(|| { + tracing::error!( + "could not resolve a username (passwd lookup and $USER/$LOGNAME all failed) — \ + taking the session lock anyway and refusing PAM" + ); + String::new() + }); + let username_missing = username.is_empty(); let config = config::load(); let palette = breadlock_ui::theme::load_palette(); @@ -163,7 +167,10 @@ fn run_lock() { let loop_handle = event_loop.handle(); let auth_result_qh = qh.clone(); - let auth_tx = auth::register(&loop_handle, move |state: &mut AppState, result| { + let auth_tx = auth::register(&loop_handle, move |state: &mut AppState, generation, result| { + if generation != state.auth_generation { + return; + } match result { Ok(()) => { // Keep the lock surfaces up and fade the overlay out. @@ -171,6 +178,8 @@ fn run_lock() { // 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()); } @@ -187,14 +196,16 @@ fn run_lock() { %err, "PAM context initialization failed — check /etc/pam.d/breadlock exists and is valid; authentication cannot succeed until this is fixed" ); - state.auth_state = AuthState::ConfigError; - state.failed_at = Some(std::time::Instant::now()); + state.enter_fail(AuthState::ConfigError); } - auth::AuthError::Authenticate | auth::AuthError::AccountInvalid => { + auth::AuthError::Authenticate => { tracing::warn!(%err, "authentication failed"); state.failed_attempts = state.failed_attempts.saturating_add(1); - state.auth_state = AuthState::Failed; - state.failed_at = Some(std::time::Instant::now()); + 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()); @@ -203,6 +214,17 @@ fn run_lock() { state.redraw_all(&auth_result_qh); }); + // D-Bus status (now-playing / battery): the poller posts snapshots here + // and each one triggers a redraw so the line under the clock stays live. + let status_qh = qh.clone(); + let status_tx = status::register(&loop_handle, move |state: &mut AppState, info| { + if state.status_info != info { + state.status_info = info; + state.redraw_all(&status_qh); + } + }); + status::spawn_poller(status_tx, config.status.now_playing, config.status.battery); + let compositor_state = CompositorState::bind(&globals, &qh).expect("compositor global not advertised"); let output_state = OutputState::new(&globals, &qh); @@ -221,6 +243,7 @@ fn run_lock() { session_lock: None, surfaces: Vec::new(), keyboard: None, + keyboard_seat: None, config, palette, background, @@ -230,16 +253,20 @@ fn run_lock() { // 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_anim_started: None, + clock_from: None, status_anim_started: None, last_auth_state: AuthState::Idle, breathe_started: None, @@ -253,6 +280,7 @@ fn run_lock() { reveal_held: false, last_activity: std::time::Instant::now(), failed_attempts: 0, + status_info: status::StatusInfo::default(), exit: false, }; @@ -272,11 +300,18 @@ fn run_lock() { output, width: 0, height: 0, + scale: 1, gpu: None, + shm_pool: None, + shm_buffer: None, }); } app_state.session_lock = Some(session_lock); + if username_missing { + app_state.enter_fail(AuthState::ConfigError); + } + WaylandSource::new(conn, event_queue) .insert(loop_handle.clone()) .expect("failed to register the Wayland source on the event loop"); diff --git a/breadlock/src/render.rs b/breadlock/src/render.rs index 21b0e65..27290c8 100644 --- a/breadlock/src/render.rs +++ b/breadlock/src/render.rs @@ -12,7 +12,7 @@ //! little-endian machines. [`blit_to_shm`] does the swizzle. use crate::background::Background; -use breadlock_ui::painter::{rounded_rect, tokens, TextRenderer}; +use breadlock_ui::painter::{rounded_rect, tokens, TextRenderer, Weight}; use breadlock_ui::theme::tiny_skia_color; use std::f32::consts::PI; use std::time::Instant; @@ -86,7 +86,9 @@ fn dim_rows(pixmap: &mut Pixmap, veil_alpha: f32) { data[i] = (data[i] as f32 * k) as u8; data[i + 1] = (data[i + 1] as f32 * k) as u8; data[i + 2] = (data[i + 2] as f32 * k) as u8; - data[i + 3] = (data[i + 3] as f32 * k) as u8; + // Keep alpha: the GPU veil shader writes A=1, and scaling A here + // made the software path translucent against a compositor that + // expected an opaque lock surface. } } } @@ -164,6 +166,9 @@ pub struct FrameInputs<'a> { /// Status-line slide-in progress (0..1, 1 settled). pub status_t: f32, pub status_text: Option<&'a str>, + /// Now-playing / battery line under the clock (D-Bus status). Empty + /// string hides it. + pub info_text: &'a str, /// Raw 0..1 lock-appear progress (pre-ease). 1 is rest pose. pub appear_t: f32, /// Raw 0..1 unlock-fade progress (pre-ease). 0 when not unlocking. @@ -225,11 +230,23 @@ fn staggered_t(appear_t: f32, delay_ms: u64) -> f32 { ((appear_t * APPEAR_MS as f32 - delay_ms as f32) / window).clamp(0.0, 1.0) } +/// Maps raw unlock progress so the first [`FLASH_FRAC`] stays at rest pose +/// (green flash on top of a fully-opaque chrome) and only the remainder +/// eases the fade/drift. +fn unlock_fade_t(unlock_t: f32) -> f32 { + if unlock_t <= FLASH_FRAC { + 0.0 + } else { + ((unlock_t - FLASH_FRAC) / (1.0 - FLASH_FRAC)).clamp(0.0, 1.0) + } +} + /// Overlay alpha and y-offset (positive is down) from raw 0..1 progress — /// used for the full-screen dim veil, which fades with the whole chrome. +/// Unlock fade starts *after* the success flash (see [`unlock_fade_t`]). pub fn overlay_motion(appear_t: f32, unlock_t: f32) -> (f32, f32) { let appear = ease_out_cubic(appear_t); - let unlock = ease_out_cubic(unlock_t); + let unlock = ease_out_cubic(unlock_fade_t(unlock_t)); let alpha = (appear * (1.0 - unlock)).clamp(0.0, 1.0); let y = APPEAR_SLIDE_PX * (1.0 - appear) - UNLOCK_DRIFT_PX * unlock; (alpha, y) @@ -275,7 +292,11 @@ fn lerp_color(a: Color, b: Color, t: f32) -> Color { /// Bounding rect of the lock-screen chrome (clock, date, pill, status) in /// surface pixels — the GPU path uses it to know which region of the chrome /// texture was drawn (and therefore needs uploading each frame). -#[derive(Debug, Clone, Copy, Default)] +/// +/// Empty is `(+∞, +∞, −∞, −∞)` so [`ChromeRect::expand`] can seed from the +/// first real box. `(0,0,0,0)` as a start made `0.min(clock_x)` stick at the +/// origin and the GPU uploaded a huge/wrong dirty rect. +#[derive(Debug, Clone, Copy)] pub struct ChromeRect { pub x0: f32, pub y0: f32, @@ -283,6 +304,17 @@ pub struct ChromeRect { pub y1: f32, } +impl Default for ChromeRect { + fn default() -> Self { + Self { + x0: f32::INFINITY, + y0: f32::INFINITY, + x1: f32::NEG_INFINITY, + y1: f32::NEG_INFINITY, + } + } +} + impl ChromeRect { fn expand(&mut self, x0: f32, y0: f32, x1: f32, y1: f32) { self.x0 = self.x0.min(x0); @@ -290,6 +322,11 @@ impl ChromeRect { self.x1 = self.x1.max(x1); self.y1 = self.y1.max(y1); } + + #[allow(dead_code)] + fn is_empty(&self) -> bool { + self.x1 <= self.x0 || self.y1 <= self.y0 + } } /// Composes one frame. Returns `None` only if `width`/`height` are degenerate @@ -302,8 +339,9 @@ pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option /// Composes only the chrome (clock/date/pill/status) into a transparent /// `pixmap`, returning the bounding rect of everything drawn. The background -/// and veil are the GPU's job in the accelerated path; colors are still -/// pre-faded by the veil alpha so the software and GPU paths match. +/// and veil are the GPU's job in the accelerated path; each element applies +/// its appear/unlock alpha once (the veil lives on the wallpaper, not baked +/// into chrome colors). pub fn compose_chrome( pixmap: &mut Pixmap, text: &mut TextRenderer, @@ -319,7 +357,7 @@ pub fn compose_chrome( /// background/veil are skipped (chrome-only) and each drawn element's box is /// recorded. fn compose_impl( - mut pixmap: &mut Pixmap, + pixmap: &mut Pixmap, text: &mut TextRenderer, inputs: &FrameInputs, mut rects: Option<&mut ChromeRect>, @@ -330,27 +368,28 @@ fn compose_impl( .paint(pixmap, inputs.t_secs, inputs.smooth_pan); } - // Overall chrome fade: appear eased in, unlock eased out. The unlock - // `fade` multiplies every element below. The dim veil deepens further - // once the idle auto-dim kicks in — chrome alpha stays at the base veil - // (0..1) while only the background dim scales past it. - let unlock = ease_out_cubic(inputs.unlock_t); + // Overall chrome fade: appear eased in, unlock eased out *after* the + // success flash. Skipping when `base_veil == 0` at appear t=0 flashed + // undimmed wallpaper (and left the GPU chrome rect empty); only skip + // once the unlock has fully finished. + if inputs.unlock_t >= 1.0 { + return; + } + let unlock = ease_out_cubic(unlock_fade_t(inputs.unlock_t)); let fade = 1.0 - unlock; let (base_veil, _) = overlay_motion(inputs.appear_t, inputs.unlock_t); let bg_veil = veil_alpha(inputs.appear_t, inputs.unlock_t, inputs.idle_dim); - if base_veil <= 0.0 { - return; - } let (w, h) = (inputs.width as f32, inputs.height as f32); - let surface_color = faded(tiny_skia_color(&inputs.palette.color0), base_veil); - let accent_color = faded(tiny_skia_color(&inputs.palette.color4), base_veil); - let green_color = faded(tiny_skia_color(&inputs.palette.color2), base_veil); - let on_surface = faded( - tiny_skia_color(breadlock_ui::theme::ink_on(&inputs.palette.color0)), - base_veil, - ); - let red_color = faded(tiny_skia_color(&inputs.palette.color1), base_veil); + // Palette colors are *not* pre-multiplied by the veil: each element + // applies its appear/unlock alpha once via `faded(..., elem_alpha)`. + // Pre-fading here and again with `pill_alpha` made the pill/status + // fainter than the clock (which only faded once). + let surface_color = tiny_skia_color(&inputs.palette.color0); + let accent_color = tiny_skia_color(&inputs.palette.color4); + let green_color = tiny_skia_color(&inputs.palette.color2); + let on_surface = tiny_skia_color(breadlock_ui::theme::ink_on(&inputs.palette.color0)); + let red_color = tiny_skia_color(&inputs.palette.color1); // Translucent veil over the (static) wallpaper — a vertical gradient // (deeper at the top) that fades with the whole chrome. Applied in place @@ -389,65 +428,84 @@ fn compose_impl( if let Some(r) = rects.as_deref_mut() { let old_w = inputs .clock_old - .map(|(t, _)| text.measure_line(t, inputs.font_family, clock_size)) + .map(|(t, _)| text.measure_line_weighted(t, inputs.font_family, clock_size, Weight::BOLD)) .unwrap_or(0.0); - let new_w = text.measure_line(inputs.clock_text, inputs.font_family, clock_size); + let new_w = text.measure_line_weighted( + inputs.clock_text, + inputs.font_family, + clock_size, + Weight::BOLD, + ); let cw = old_w.max(new_w); r.expand((w - cw) / 2.0, clock_y, (w + cw) / 2.0, clock_y + clock_size); } match inputs.clock_old { Some((old, t)) => { let t = t.clamp(0.0, 1.0); - let old_w = text.measure_line(old, inputs.font_family, clock_size); - text.draw_line( - &mut pixmap, + let old_w = text.measure_line_weighted(old, inputs.font_family, clock_size, Weight::BOLD); + text.draw_line_weighted( + pixmap, old, inputs.font_family, clock_size, faded(Color::WHITE, clock_alpha * (1.0 - t)), (w - old_w) / 2.0, clock_y - 6.0 * t, + Weight::BOLD, ); - let new_w = text.measure_line(inputs.clock_text, inputs.font_family, clock_size); - text.draw_line( - &mut pixmap, + let new_w = text.measure_line_weighted( + inputs.clock_text, + inputs.font_family, + clock_size, + Weight::BOLD, + ); + text.draw_line_weighted( + pixmap, inputs.clock_text, inputs.font_family, clock_size, faded(Color::WHITE, clock_alpha * t), (w - new_w) / 2.0, clock_y + 6.0 * (1.0 - t), + Weight::BOLD, ); } None => { - let clock_w = text.measure_line(inputs.clock_text, inputs.font_family, clock_size); - text.draw_line( - &mut pixmap, + let clock_w = text.measure_line_weighted( + inputs.clock_text, + inputs.font_family, + clock_size, + Weight::BOLD, + ); + text.draw_line_weighted( + pixmap, inputs.clock_text, inputs.font_family, clock_size, faded(Color::WHITE, clock_alpha), (w - clock_w) / 2.0, clock_y, + Weight::BOLD, ); } } - // ---- Date line under the clock (hidden when date_text is empty). The - // clock's `origin_y` anchors its *top*, so the date is placed below the - // clock's actual glyph box (exact per font, cached) with a small gap. + // ---- Date line under the clock (hidden when date_text is empty) and + // now-playing / battery under that. Info is *not* nested under the date + // — an empty `date_format` used to hide the status line too. + let date_size = (w * 0.016).clamp(DATE_SIZE_MIN, DATE_SIZE_MAX); + let (clock_top, clock_height) = + text.measure_box_weighted(inputs.clock_text, inputs.font_family, clock_size, Weight::BOLD); + let mut below_y = clock_y_rest + elem_y(date_e, DRIFT_DATE) + clock_top + clock_height + + tokens::SPACE_SM as f32; if !inputs.date_text.is_empty() { - let date_size = (w * 0.016).clamp(DATE_SIZE_MIN, DATE_SIZE_MAX); - let (clock_top, clock_height) = - text.measure_box(inputs.clock_text, inputs.font_family, clock_size); - let date_y = clock_y_rest + elem_y(date_e, DRIFT_DATE) + clock_top + clock_height - + tokens::SPACE_SM as f32; + let date_y = below_y; let date_w = text.measure_line(inputs.date_text, inputs.font_family, date_size); - if let Some(r) = rects.as_deref_mut() { + if let Some(r) = rects.as_mut() { r.expand((w - date_w) / 2.0, date_y, (w + date_w) / 2.0, date_y + date_size); } text.draw_line( - &mut pixmap, + pixmap, inputs.date_text, inputs.font_family, date_size, @@ -455,6 +513,37 @@ fn compose_impl( (w - date_w) / 2.0, date_y, ); + below_y = date_y + date_size + tokens::SPACE_XS as f32; + } + if !inputs.info_text.is_empty() { + let info_size = date_size * 0.85; + let info_y = below_y; + let info_anim = ease_out_cubic(staggered_t(inputs.appear_t, DATE_DELAY_MS + 120)); + let info_shown = ellipsize( + text, + inputs.info_text, + inputs.font_family, + info_size, + w * 0.70, + ); + let info_w = text.measure_line(&info_shown, inputs.font_family, info_size); + if let Some(r) = rects.as_mut() { + r.expand( + (w - info_w) / 2.0, + info_y, + (w + info_w) / 2.0, + info_y + info_size, + ); + } + text.draw_line( + pixmap, + &info_shown, + inputs.font_family, + info_size, + faded(Color::WHITE, info_anim * fade * 0.6), + (w - info_w) / 2.0, + info_y, + ); } // ---- Password pill, centered. Red while failed (tinting in smoothly over @@ -493,14 +582,23 @@ fn compose_impl( cx * (1.0 - scale) + shake_x, cy * (1.0 - scale), ); + // Map a point through the same scale-about-center + shake as the pill + // path, so dots/caret/hint/reveal travel with it. + let map_pill = |x: f32, y: f32| { + ( + x * scale + cx * (1.0 - scale) + shake_x, + y * scale + cy * (1.0 - scale), + ) + }; // Chrome rect: pad for the shadow layers, breath/success rings, the - // shake offset and the scale overshoot. + // shake offset and the scale overshoot. Include shake so a failed + // frame's dirty AABB actually moves with the pill. if let Some(r) = rects.as_deref_mut() { const PILL_PAD: f32 = 26.0; r.expand( - pill_x - PILL_PAD, + pill_x + shake_x - PILL_PAD, pill_y - PILL_PAD, - pill_x + pill_w + PILL_PAD, + pill_x + shake_x + pill_w + PILL_PAD, pill_y + pill_h + PILL_PAD, ); } @@ -545,8 +643,10 @@ fn compose_impl( // Hairline border for depth — dropped on the wrong/success states // (the sketch sets `border-color: transparent` there). if !inputs.failed && inputs.unlock_t == 0.0 { - let mut stroke = tiny_skia::Stroke::default(); - stroke.width = 1.0; + let stroke = tiny_skia::Stroke { + width: 1.0, + ..Default::default() + }; let mut paint = Paint::default(); paint.set_color(faded( Color::WHITE, @@ -558,8 +658,10 @@ fn compose_impl( // Idle breath: a faint accent ring blooms around the pill at the // breath peak (matches the sketch's `breathe` keyframes). if inputs.breathe_t > 0.0 && !inputs.failed && inputs.unlock_t == 0.0 { - let mut stroke = tiny_skia::Stroke::default(); - stroke.width = 1.5; + let stroke = tiny_skia::Stroke { + width: 1.5, + ..Default::default() + }; let mut paint = Paint::default(); paint.set_color(faded(accent_color, BREATHE_RING_ALPHA * inputs.breathe_t * pill_alpha)); pixmap.stroke_path(&path, &paint, &stroke, pill_xf, None); @@ -569,8 +671,10 @@ fn compose_impl( // `FLASH_MS` of the unlock. if inputs.unlock_t > 0.0 && inputs.unlock_t < FLASH_FRAC { let flash_t = inputs.unlock_t / FLASH_FRAC; - let mut stroke = tiny_skia::Stroke::default(); - stroke.width = 2.0 + 16.0 * flash_t; + let stroke = tiny_skia::Stroke { + width: 2.0 + 16.0 * flash_t, + ..Default::default() + }; let mut paint = Paint::default(); paint.set_color(faded(green_color, 0.55 * (1.0 - flash_t) * pill_alpha)); pixmap.stroke_path(&path, &paint, &stroke, pill_xf, None); @@ -599,7 +703,7 @@ fn compose_impl( // top, so the two never touch even with the pill's glow/shadow. let chip_y = pill_y - chip_h - tokens::SPACE_LG as f32; let chip_alpha = pill_e * fade; - if let Some(r) = rects.as_deref_mut() { + if let Some(r) = rects.as_mut() { r.expand(chip_x, chip_y, chip_x + chip_w, chip_y + chip_h); } if let Some(path) = rounded_rect(chip_x, chip_y, chip_w, chip_h, chip_h / 2.0) { @@ -608,8 +712,10 @@ fn compose_impl( paint.set_color(faded(surface_color, chip_alpha)); paint.anti_alias = true; pixmap.fill_path(&path, &paint, tiny_skia::FillRule::Winding, Transform::identity(), None); - let mut stroke = tiny_skia::Stroke::default(); - stroke.width = 1.0; + let stroke = tiny_skia::Stroke { + width: 1.0, + ..Default::default() + }; let mut paint = Paint::default(); paint.set_color(faded(Color::WHITE, PILL_BORDER_ALPHA * chip_alpha)); pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None); @@ -618,7 +724,7 @@ fn compose_impl( let label_y = chip_y + (chip_h - chip_height) / 2.0 - chip_top; let label_w = text.measure_line(&label, inputs.font_family, chip_size); text.draw_line( - &mut pixmap, + pixmap, &label, inputs.font_family, chip_size, @@ -637,13 +743,16 @@ fn compose_impl( .max(1.0) as usize; let shown_dots = inputs.password_len.min(max_dots); let dot_y = pill_y + pill_h / 2.0; - if inputs.reveal && shown_dots > 0 { + // Skip in-pill text while the appear scale is ~0 (cosmic-text panics on + // a zero font size). Paths still go through `pill_xf` and collapse. + let contents_live = scale >= 0.05; + if inputs.reveal && shown_dots > 0 && contents_live { // Hold-to-reveal: render the actual password, centered, capped to // the pill width (truncate with a trailing ellipsis on overflow). // Measured into locals first — `text` is borrowed mutably by // `draw_line`, so all `measure_*` calls must happen up front. let reveal_size = tokens::FONT_SIZE_BASE as f32; - let reveal_rendered = reveal_fit( + let reveal_rendered = ellipsize( text, inputs.password, inputs.font_family, @@ -654,22 +763,23 @@ fn compose_impl( text.measure_box(&reveal_rendered, inputs.font_family, reveal_size); let reveal_y = pill_y + (pill_h - reveal_height) / 2.0 - reveal_top; let reveal_w = text.measure_line(&reveal_rendered, inputs.font_family, reveal_size); - if let Some(r) = rects.as_deref_mut() { + let (rx, ry) = map_pill((w - reveal_w) / 2.0, reveal_y); + if let Some(r) = rects.as_mut() { r.expand( - pill_x + tokens::SPACE_LG as f32, + pill_x + shake_x + tokens::SPACE_LG as f32, pill_y, - pill_x + pill_w - tokens::SPACE_LG as f32, + pill_x + shake_x + pill_w - tokens::SPACE_LG as f32, pill_y + pill_h, ); } text.draw_line( - &mut pixmap, + pixmap, &reveal_rendered, inputs.font_family, - reveal_size, + reveal_size * scale, faded(on_surface, pill_alpha), - (w - reveal_w) / 2.0, - reveal_y, + rx, + ry, ); } else if shown_dots > 0 { let start_x = start_x_for(shown_dots, pill_x, pill_w); @@ -702,7 +812,7 @@ fn compose_impl( &path, &paint, tiny_skia::FillRule::Winding, - Transform::identity(), + pill_xf, None, ); } @@ -713,24 +823,26 @@ fn compose_impl( // caret only appears with the first typed character, so the pill reads as // an input field rather than an empty dark bar. Centered on the exact // glyph box (origin anchors the text top, not the baseline). - if shown_dots == 0 && !inputs.failed { + if shown_dots == 0 && !inputs.failed && contents_live { let hint = "Enter password"; let hint_size = tokens::FONT_SIZE_BASE as f32; let hint_w = text.measure_line(hint, inputs.font_family, hint_size); let (hint_top, hint_height) = text.measure_box(hint, inputs.font_family, hint_size); let hint_y = pill_y + (pill_h - hint_height) / 2.0 - hint_top; + let (hx, hy) = map_pill((w - hint_w) / 2.0, hint_y); text.draw_line( - &mut pixmap, + pixmap, hint, inputs.font_family, - hint_size, + hint_size * scale, faded(on_surface, pill_alpha * 0.5), - (w - hint_w) / 2.0, - hint_y, + hx, + hy, ); - } else if shown_dots > 0 { + } else if shown_dots > 0 && !inputs.reveal { // ---- Caret after the last dot: solid for half a second after a - // keystroke, then blinking at ~1.8 Hz. + // keystroke, then blinking at ~1.8 Hz. Hidden during hold-to-reveal + // (it used to sit at the last *dot* slot, overlapping the text). let caret_x = start_x_for(shown_dots, pill_x, pill_w) + (shown_dots - 1) as f32 * DOT_GAP + DOT_R @@ -761,7 +873,7 @@ fn compose_impl( &path, &paint, tiny_skia::FillRule::Winding, - Transform::identity(), + pill_xf, None, ); } @@ -779,11 +891,11 @@ fn compose_impl( let color = if inputs.failed { red_color } else { on_surface }; let status_y = pill_y_rest + pill_h + tokens::SPACE_MD as f32 + elem_y(status_e, DRIFT_STATUS) + STATUS_SLIDE_PX * (1.0 - status_anim); - if let Some(r) = rects.as_deref_mut() { + if let Some(r) = rects.as_mut() { r.expand((w - status_w) / 2.0, status_y, (w + status_w) / 2.0, status_y + status_size); } text.draw_line( - &mut pixmap, + pixmap, status, inputs.font_family, status_size, @@ -801,11 +913,11 @@ fn start_x_for(shown_dots: usize, pill_x: f32, pill_w: f32) -> f32 { pill_x + (pill_w - dots_w) / 2.0 } -/// Truncates a password for the hold-to-reveal view so it fits inside the -/// pill, appending an ellipsis when trimmed. Returns the owned string to -/// render (kept out of `compose` so the borrow of `text` ends before the -/// draw call). -fn reveal_fit( +/// Truncates `password` (or any single-line string) so it fits in `max_w`, +/// appending an ellipsis when trimmed. Shared by hold-to-reveal and the +/// now-playing/battery line. Kept out of `compose` so the borrow of `text` +/// ends before the draw call. +fn ellipsize( text: &mut TextRenderer, password: &str, font_family: &str, @@ -862,6 +974,9 @@ mod tests { // Bottom row is y/h = 0.75 → dim = 0.34 + (0.16 - 0.34) * 0.75 = 0.205. let expected = (255.0 * (1.0 - 0.205)) as u8; assert_eq!(bottom.red(), expected); + // GPU veil keeps A=1; software must not scale alpha. + assert_eq!(top.alpha(), 255); + assert_eq!(bottom.alpha(), 255); } #[test] @@ -873,6 +988,7 @@ mod tests { assert_eq!(p.pixels(), before.as_slice()); } + #[allow(clippy::too_many_arguments)] fn inputs<'a>( bg: &'a Background, palette: &'a breadlock_ui::theme::Palette, @@ -908,6 +1024,7 @@ mod tests { breathe_t: 0.0, status_t: 1.0, status_text: None, + info_text: "", appear_t, unlock_t, smooth_pan: false, @@ -951,6 +1068,7 @@ mod tests { breathe_t: 0.0, status_t: 1.0, status_text: None, + info_text: "", appear_t: 1.0, unlock_t: 0.0, smooth_pan: false, @@ -966,13 +1084,26 @@ mod tests { let mut text = TextRenderer::new(); // Wrong-password shake mid-flight. let failed = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, true, 0.3, 0.4, 1.0, 0.0); - assert!(compose(&mut text, &failed).is_some()); - // Success flash phase of the unlock. + let failed_px = compose(&mut text, &failed).expect("failed compose"); + assert_eq!((failed_px.width(), failed_px.height()), (400, 300)); + assert!(failed_px.pixels().iter().any(|p| p.alpha() > 0)); + // Success flash phase of the unlock: still fully opaque chrome (flash + // holds rest pose), not already faded. let success = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 0.12); - assert!(compose(&mut text, &success).is_some()); + let success_px = compose(&mut text, &success).expect("success compose"); + let rest = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 0.0); + let rest_px = compose(&mut text, &rest).expect("rest compose"); + // Flash frame should not be a near-empty fade — plenty of chrome left. + let flash_lit = success_px.pixels().iter().filter(|p| p.alpha() > 0).count(); + let rest_lit = rest_px.pixels().iter().filter(|p| p.alpha() > 0).count(); + assert!( + flash_lit as f32 > rest_lit as f32 * 0.5, + "success flash should keep chrome visible, lit {flash_lit} vs rest {rest_lit}" + ); // Fully faded unlock returns just the background. let done = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 1.0); - assert!(compose(&mut text, &done).is_some()); + let done_px = compose(&mut text, &done).expect("done compose"); + assert_eq!((done_px.width(), done_px.height()), (400, 300)); } #[test] @@ -999,10 +1130,10 @@ mod tests { fn reveal_fit_truncates_long_passwords() { let mut text = TextRenderer::new(); // Short password fits unchanged. - assert_eq!(reveal_fit(&mut text, "hunter2", "sans-serif", 14.0, 200.0), "hunter2"); + assert_eq!(ellipsize(&mut text, "hunter2", "sans-serif", 14.0, 200.0), "hunter2"); // A very long one is trimmed and ends with an ellipsis. let long = "a".repeat(200); - let fitted = reveal_fit(&mut text, &long, "sans-serif", 14.0, 60.0); + let fitted = ellipsize(&mut text, &long, "sans-serif", 14.0, 60.0); assert!(fitted.ends_with('…'), "trimmed reveal should end with an ellipsis"); assert!(fitted.len() < long.len()); // And it actually fits the budget. @@ -1098,6 +1229,19 @@ mod tests { assert!(y < 0.0, "unlock should drift up from rest, got y={y}"); } + #[test] + fn overlay_motion_holds_rest_during_success_flash() { + // First FLASH_FRAC of unlock_t is the green flash at full opacity. + let (a_rest, y_rest) = overlay_motion(1.0, 0.0); + let (a_flash, y_flash) = overlay_motion(1.0, FLASH_FRAC * 0.5); + assert!((a_flash - a_rest).abs() < 1e-6, "flash must not fade chrome, got {a_flash}"); + assert!((y_flash - y_rest).abs() < 1e-6, "flash must not drift chrome, got {y_flash}"); + // After the flash, fade/drift begin. + let (a_fade, y_fade) = overlay_motion(1.0, (FLASH_FRAC + 1.0) * 0.5); + assert!(a_fade < a_rest, "post-flash should fade, got {a_fade}"); + assert!(y_fade < y_rest, "post-flash should drift up, got {y_fade}"); + } + #[test] fn compose_chrome_rect_contains_clock_and_pill() { let bg = Background::Color(Color::BLACK); @@ -1116,25 +1260,109 @@ mod tests { assert!(rect.y1 > 300.0 * 0.5 + 24.0, "rect must cover the pill band"); // Both are horizontally centered. assert!(rect.x0 < 200.0 && rect.x1 > 200.0, "rect must straddle center"); + // Origin-stuck Default(0,0,…) used to pass the checks above (0.min + // never leaves 0, and 0 < 200 && x1 > 200 still holds). Fail that. + assert!( + rect.x0 > 0.0 && rect.y0 > 0.0, + "chrome rect must not be stuck at the origin, got {rect:?}" + ); } #[test] - fn compose_chrome_rect_empty_when_veil_hidden() { + fn compose_chrome_rect_empty_when_unlock_finished() { let bg = Background::Color(Color::BLACK); let palette = breadlock_ui::theme::Palette::default(); let mut text = TextRenderer::new(); let mut pixmap = Pixmap::new(400, 300).unwrap(); - // appear_t = 0 → veil_alpha 0 → nothing drawn, rect stays default. - let inputs = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 0.0, 0.0); + // unlock_t = 1 → chrome is gone; appear_t = 0 still draws (invisible) + // chrome so the GPU dirty rect is valid from the first frame. + let inputs = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 1.0); let rect = compose_chrome(&mut pixmap, &mut text, &inputs); - assert!(rect.x1 <= rect.x0 && rect.y1 <= rect.y0, "hidden chrome must yield an empty rect"); - // And the pixmap is fully transparent. + assert!(rect.is_empty(), "finished unlock must yield an empty rect, got {rect:?}"); assert!( pixmap.pixels().iter().all(|p| p.alpha() == 0), - "hidden chrome must leave the pixmap transparent" + "finished unlock must leave the pixmap transparent" ); } + #[test] + fn compose_chrome_rect_valid_at_appear_start() { + let bg = Background::Color(Color::BLACK); + let palette = breadlock_ui::theme::Palette::default(); + let mut text = TextRenderer::new(); + let mut pixmap = Pixmap::new(400, 300).unwrap(); + let inputs = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 0.0, 0.0); + let rect = compose_chrome(&mut pixmap, &mut text, &inputs); + assert!( + !rect.is_empty() && rect.x0 > 0.0 && rect.y0 > 0.0, + "appear t=0 must still produce a real chrome rect, got {rect:?}" + ); + } + + #[test] + fn compose_chrome_failed_shake_shifts_rect_x() { + let bg = Background::Color(Color::BLACK); + let palette = breadlock_ui::theme::Palette::default(); + let mut text = TextRenderer::new(); + let mut pixmap = Pixmap::new(400, 300).unwrap(); + let rest = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, true, 0.0, 1.0, 1.0, 0.0); + let r0 = compose_chrome(&mut pixmap, &mut text, &rest); + let mid = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, true, 0.3, 1.0, 1.0, 0.0); + let r1 = compose_chrome(&mut pixmap, &mut text, &mid); + assert!( + (r0.x0 - r1.x0).abs() > 0.5 || (r0.x1 - r1.x1).abs() > 0.5, + "failed shake must shift chrome rect x, rest={r0:?} shaken={r1:?}" + ); + // Chrome-only pixmaps: full `compose()` is dominated by the opaque + // wallpaper, so a 4px pill shake would not move that centroid. + let mut rest_px = Pixmap::new(400, 300).unwrap(); + compose_chrome(&mut rest_px, &mut text, &rest); + let mut shaken_px = Pixmap::new(400, 300).unwrap(); + compose_chrome(&mut shaken_px, &mut text, &mid); + let centroid_x = |p: &Pixmap| -> f32 { + let mut sx = 0.0f32; + let mut n = 0.0f32; + for (i, px) in p.pixels().iter().enumerate() { + if px.alpha() > 32 { + sx += (i as u32 % p.width()) as f32; + n += 1.0; + } + } + if n > 0.0 { sx / n } else { 0.0 } + }; + let dx = (centroid_x(&shaken_px) - centroid_x(&rest_px)).abs(); + assert!( + dx > 0.2, + "pill contents should shake with the pill, centroid dx={dx}" + ); + } + + #[test] + fn compose_chrome_info_text_draws_without_date() { + let bg = Background::Color(Color::BLACK); + let palette = breadlock_ui::theme::Palette::default(); + let mut text = TextRenderer::new(); + let mut pixmap = Pixmap::new(400, 300).unwrap(); + let mut with_info = inputs(&bg, &palette, "12:34", "", 0, false, 0.0, 1.0, 1.0, 0.0); + with_info.info_text = "Battery 87% · charging"; + let with = compose_chrome(&mut pixmap, &mut text, &with_info); + let mut pixmap2 = Pixmap::new(400, 300).unwrap(); + let without_info = inputs(&bg, &palette, "12:34", "", 0, false, 0.0, 1.0, 1.0, 0.0); + let without = compose_chrome(&mut pixmap2, &mut text, &without_info); + // The pill still sits below the info line, so y1 is pill-dominated. + // The info line must still produce extra chrome pixels and a rect + // that is not stuck at the origin. + let lit = |p: &Pixmap| p.pixels().iter().filter(|px| px.alpha() > 32).count(); + assert!( + lit(&pixmap) > lit(&pixmap2), + "info line must draw extra chrome when date_text is empty (with {} lit, without {})", + lit(&pixmap), + lit(&pixmap2) + ); + assert!(with.x0 > 0.0 && with.y0 > 0.0 && !with.is_empty()); + assert!(without.x0 > 0.0 && !without.is_empty()); + } + #[test] fn compose_chrome_status_text_expands_the_rect_downward() { let bg = Background::Color(Color::BLACK); diff --git a/breadlock/src/state.rs b/breadlock/src/state.rs index c6ade48..7a67bfb 100644 --- a/breadlock/src/state.rs +++ b/breadlock/src/state.rs @@ -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, Instant}; -use wayland_client::protocol::{wl_keyboard, wl_output, wl_shm}; +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,27 +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, + /// Reused shm pool + current buffer (software path). Not recreated every + /// frame; SlotPool waits for compositor release before reuse. + pub shm_pool: Option, + pub shm_buffer: Option, } #[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, } @@ -62,6 +76,9 @@ pub struct AppState { pub session_lock: Option, pub surfaces: Vec, pub keyboard: Option, + /// Seat that owns [`Self::keyboard`]. `remove_capability` only releases + /// the keyboard if that seat lost Keyboard. + pub keyboard_seat: Option, pub config: Config, pub palette: breadlock_ui::theme::Palette, @@ -79,8 +96,19 @@ pub struct AppState { /// explicit zeroing — see `input/keyboard.rs` — since `Zeroizing` only /// hooks `Drop`, not in-place mutation. pub password: zeroize::Zeroizing, + /// 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, + pub auth_tx: Sender, + /// 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, /// Monotonic clock reference — drives the idle caret blink cadence. pub started: Instant, @@ -101,8 +129,9 @@ pub struct AppState { /// Clock text drawn last frame; a change starts a minute-rollover /// crossfade instead of a hard text swap. pub last_clock_text: String, - /// When the current clock crossfade started. - pub clock_anim_started: Option, + /// 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`). @@ -135,6 +164,9 @@ pub struct AppState { /// 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, } @@ -188,7 +220,10 @@ impl AppState { }; // While a PAM check runs, the status dots tick to signal progress. let status_text = match self.auth_state { - AuthState::Checking => Some(format!("Checking{}", checking_dots(now))), + 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" @@ -200,12 +235,26 @@ impl AppState { "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 { @@ -224,17 +273,26 @@ impl AppState { .map(|t| render::unit_progress(t, render::STATUS_SLIDE_MS)) .unwrap_or(1.0); - // Minute rollover: keep the previous clock text for a short crossfade. - let clock_old = if !self.last_clock_text.is_empty() && clock_text != self.last_clock_text { - self.clock_anim_started = Some(now); - Some((self.last_clock_text.clone(), 0.0)) - } else { - self.clock_anim_started - .map(|t| render::unit_progress(t, render::CLOCK_CROSSFADE_MS)) - .filter(|t| *t < 1.0) - .map(|t| (self.last_clock_text.clone(), t)) - }; + // 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 @@ -253,6 +311,12 @@ impl AppState { .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, @@ -262,14 +326,17 @@ impl AppState { font_family: &self.config.appearance.font.family, clock_text: &clock_text, date_text: &date_text, - clock_old: clock_old.as_ref().map(|(s, t)| (s.as_str(), *t)), - password_len: self.password.len(), + 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::ConfigError), + 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()), @@ -277,6 +344,7 @@ impl AppState { 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(), @@ -286,58 +354,140 @@ impl AppState { // shader) and the software-composed chrome on top. Disjoint-field // borrows of `self` make `gpu` + `surfaces` + `text_renderer` // simultaneously mutable. - if self.gpu.is_some() + let wants_gpu = self.gpu.is_some() && self .surfaces .iter() - .any(|s| s.surface.wl_surface() == surface.wl_surface() && s.gpu.is_some()) - { - let renderer = self.gpu.as_mut().expect("checked above"); - let lock_surface = self + .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()) - .expect("surface exists"); - let gpu_surface = lock_surface.gpu.as_mut().expect("checked above"); - renderer.render_frame(gpu_surface, &inputs, &mut self.text_renderer); - self.arm_anim_if_needed(qh); - return; + 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(); - - self.arm_anim_if_needed(qh); } fn palette_for_surface(&self, surface: &SessionLockSurface) -> breadlock_ui::theme::Palette { @@ -356,7 +506,14 @@ 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); @@ -389,8 +546,9 @@ impl AppState { } fn clock_fade_in_progress(&self) -> bool { - self.clock_anim_started - .map(|t| t.elapsed() < Duration::from_millis(render::CLOCK_CROSSFADE_MS)) + self.clock_from + .as_ref() + .map(|(_, t)| t.elapsed() < Duration::from_millis(render::CLOCK_CROSSFADE_MS)) .unwrap_or(false) } @@ -417,10 +575,33 @@ impl AppState { .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) which run at a reduced cadence — see `tick_animation`. + /// 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() @@ -432,6 +613,8 @@ impl AppState { || 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. @@ -460,8 +643,9 @@ impl AppState { } /// A 60 fps animation is in flight (everything except the slow idle - /// effects: idle breath, Ken Burns pan). Drives both the timer cadence - /// and whether background frames get sub-pixel panning. + /// 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() @@ -478,8 +662,9 @@ impl AppState { self.anim_timer_armed = false; TimeoutAction::Drop } else if self.anim_in_progress() { - // Slow effects (idle breath, Ken Burns) don't need 60fps — halve - // the redraw cost for them. Everything else stays at ~60Hz. + // 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 @@ -509,28 +694,39 @@ impl AppState { self.exit = true; } - /// 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. + /// 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) { 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 the monotonic clock). -fn checking_dots(now: Instant) -> &'static str { - match (now.elapsed().as_secs_f32() * 2.0) as usize % 4 { +/// 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 => "..", @@ -538,6 +734,16 @@ fn checking_dots(now: Instant) -> &'static str { } } +/// 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 { fn shm_state(&mut self) -> &mut Shm { &mut self.shm @@ -550,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)); + } +} diff --git a/breadlock/src/status.rs b/breadlock/src/status.rs new file mode 100644 index 0000000..84bba49 --- /dev/null +++ b/breadlock/src/status.rs @@ -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( + loop_handle: &LoopHandle<'static, Data>, + mut on_update: impl FnMut(&mut Data, StatusInfo) + 'static, +) -> Sender { + 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, now_playing: bool, battery: bool) { + if !now_playing && !battery { + return; + } + std::thread::spawn(move || { + let mut session: Option = None; + let mut system: Option = 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, + system: &mut Option, + 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) -> 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 { + let names = conn + .call_method( + Some("org.freedesktop.DBus"), + "/org/freedesktop/DBus", + Some("org.freedesktop.DBus"), + "ListNames", + &(), + ) + .and_then(|reply| reply.body().deserialize::>()) + .map_err(|_| ())?; + + let mut paused: Option = 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, Option)> { + let props = conn + .call_method( + Some(name), + "/org/mpris/MediaPlayer2", + Some("org.freedesktop.DBus.Properties"), + "GetAll", + &("org.mpris.MediaPlayer2.Player",), + ) + .ok()?; + let dict: HashMap = 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::().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::>() + .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::>().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) -> 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 { + let path = conn + .call_method( + Some("org.freedesktop.UPower"), + "/org/freedesktop/UPower", + Some("org.freedesktop.UPower"), + "GetDisplayDevice", + &(), + ) + .and_then(|reply| reply.body().deserialize::()) + .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::>() + }) + .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::().ok()) + .unwrap_or(false); + if let Some(kind) = props.get("Type").and_then(|v| v.downcast_ref::().ok()) { + if kind != UPOWER_TYPE_BATTERY { + return Ok(String::new()); + } + } + let Some(pct) = props + .get("Percentage") + .and_then(|v| v.downcast_ref::().ok()) + else { + return Ok(String::new()); + }; + let state = props + .get("State") + .and_then(|v| v.downcast_ref::().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); + } +} From 495d7f8446180cd84729cc9ca4c2691889f59f09 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 23 Aug 2026 14:21:29 +0800 Subject: [PATCH 18/20] Fix greeter session start and login flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- breadgreet.example.toml | 3 +- breadgreet/src/config.rs | 11 +- breadgreet/src/greetd/client.rs | 87 ++++++--- breadgreet/src/greetd/mod.rs | 197 ++++++++++++++++++- breadgreet/src/main.rs | 305 ++++++++++++++++++++---------- breadgreet/src/sessions.rs | 137 +++++++++++++- breadgreet/src/theme.rs | 27 ++- breadlock-ui/src/config.rs | 47 +++-- breadlock-ui/src/desktop_entry.rs | 117 +++++++++++- 9 files changed, 763 insertions(+), 168 deletions(-) diff --git a/breadgreet.example.toml b/breadgreet.example.toml index 150d830..3c86ecc 100644 --- a/breadgreet.example.toml +++ b/breadgreet.example.toml @@ -9,7 +9,8 @@ mode = "color" path = "" blur = false -# (Ken Burns is a breadlock-only background effect; not used by the greeter.) +# 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] diff --git a/breadgreet/src/config.rs b/breadgreet/src/config.rs index bd9aa32..01213f7 100644 --- a/breadgreet/src/config.rs +++ b/breadgreet/src/config.rs @@ -42,12 +42,15 @@ pub fn load() -> Config { breadlock_ui::config::load_or_default(&xdg_config_path()) } -fn xdg_config_path() -> PathBuf { - let base = std::env::var_os("XDG_CONFIG_HOME") +pub(crate) fn xdg_config_dir() -> PathBuf { + std::env::var_os("XDG_CONFIG_HOME") .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) - .unwrap_or_else(|| PathBuf::from(".")); - base.join("breadgreet").join("breadgreet.toml") + .unwrap_or_else(|| PathBuf::from(".")) +} + +fn xdg_config_path() -> PathBuf { + xdg_config_dir().join("breadgreet").join("breadgreet.toml") } #[cfg(test)] diff --git a/breadgreet/src/greetd/client.rs b/breadgreet/src/greetd/client.rs index 721acd3..a58a51c 100644 --- a/breadgreet/src/greetd/client.rs +++ b/breadgreet/src/greetd/client.rs @@ -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) -> Result { + pub(crate) async fn connect_to(path: impl AsRef) -> Result { 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) { + 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) { 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(); } } diff --git a/breadgreet/src/greetd/mod.rs b/breadgreet/src/greetd/mod.rs index 01b15c6..81a8d7c 100644 --- a/breadgreet/src/greetd/mod.rs +++ b/breadgreet/src/greetd/mod.rs @@ -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), + StartSession { cmd: Vec, env: Vec }, + 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(cmd_rx: mpsc::UnboundedReceiver, emit: E) +where + E: FnMut(Event) + Send + 'static, +{ + run_actor_with(cmd_rx, emit, Client::connect).await; +} + +async fn run_actor_with( + mut cmd_rx: mpsc::UnboundedReceiver, + mut emit: E, + mut connect: C, +) where + E: FnMut(Event), + C: FnMut() -> Fut, + Fut: Future>, +{ + let mut client: Option = 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), +} + +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(); + } +} diff --git a/breadgreet/src/main.rs b/breadgreet/src/main.rs index 4d75265..146f0c2 100644 --- a/breadgreet/src/main.rs +++ b/breadgreet/src/main.rs @@ -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), - StartSession { cmd: Vec, env: Vec }, -} +/// Extra zoom beyond plain cover-fit — matches breadlock's `KENBURNS_ZOOM`. +const KENBURNS_ZOOM: f32 = 1.06; #[derive(Debug, Clone)] enum Stage { @@ -27,6 +23,8 @@ enum Stage { /// A request is in flight — input is disabled so a second Enter can't /// race it. Working, + /// `StartSession` has been sent — Escape must not cancel. + Starting, } #[derive(Debug)] @@ -39,10 +37,13 @@ enum AppInput { SessionStarted, /// Picker changed; `u32::MAX` (`INVALID_LIST_POSITION`) is ignored. SessionSelected(u32), + /// Escape — abort the in-progress PAM conversation. + Cancel, } struct App { clock_lbl: gtk4::Label, + date_lbl: gtk4::Label, status_lbl: gtk4::Label, entry: gtk4::Entry, stage: Stage, @@ -50,7 +51,11 @@ struct App { sessions: Vec, selected: usize, clock_format: String, - cmd_tx: mpsc::UnboundedSender, + 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, } #[relm4::component] @@ -102,9 +107,27 @@ impl SimpleComponent for App { .and_then(|chosen| sessions.iter().position(|s| s.stem == chosen.stem)) .unwrap_or(0); + if config.appearance.background.blur { + tracing::warn!( + "background.blur is not implemented yet (planned v2 feature, needs a wlr-screencopy \ + capture) — showing the configured background unblurred" + ); + } + let clock_lbl = gtk4::Label::new(None); clock_lbl.add_css_class("login-clock"); + let date_lbl = gtk4::Label::new(None); + date_lbl.add_css_class("login-date"); + if config.appearance.clock.date_format.is_empty() { + date_lbl.set_visible(false); + clock_lbl.set_margin_bottom(20); + } else { + clock_lbl.set_margin_bottom(4); + date_lbl.set_margin_bottom(16); + date_lbl.set_label(¤t_time(&config.appearance.clock.date_format)); + } + let entry = gtk4::Entry::new(); entry.add_css_class("login-entry"); entry.set_placeholder_text(Some("Username")); @@ -117,6 +140,12 @@ impl SimpleComponent for App { let status_lbl = gtk4::Label::new(None); status_lbl.add_css_class("login-status"); + if sessions.is_empty() { + entry.set_sensitive(false); + status_lbl.set_label("No session found — cannot log in"); + status_lbl.add_css_class("error"); + } + let session_widget: gtk4::Widget = if sessions.is_empty() { let session_lbl = gtk4::Label::new(Some("No session found — cannot log in")); session_lbl.add_css_class("login-session"); @@ -170,8 +199,24 @@ impl SimpleComponent for App { 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; @@ -189,12 +234,13 @@ impl SimpleComponent for App { 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, @@ -202,12 +248,16 @@ impl SimpleComponent for App { sessions, selected, clock_format: config.appearance.clock.format.clone(), + date_format: config.appearance.clock.date_format.clone(), + pam_status_held: false, cmd_tx, }; model .clock_lbl .set_label(¤t_time(&model.clock_format)); - model.entry.grab_focus(); + if !model.sessions.is_empty() { + model.entry.grab_focus(); + } ComponentParts { model, widgets } } @@ -216,24 +266,20 @@ impl SimpleComponent for App { match msg { AppInput::ClockTick => { self.clock_lbl.set_label(¤t_time(&self.clock_format)); + if !self.date_format.is_empty() { + self.date_lbl.set_label(¤t_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; @@ -241,19 +287,24 @@ impl SimpleComponent for App { self.selected = idx; } } + AppInput::Cancel => self.cancel_auth(), } } } impl App { fn handle_submit(&mut self) { - if matches!(self.stage, Stage::Working) { + if matches!(self.stage, Stage::Working | Stage::Starting) { return; } let text = self.entry.text().to_string(); match &self.stage { Stage::Username => { + if self.sessions.is_empty() { + self.show_error("No session found — cannot log in"); + return; + } if text.is_empty() { return; } @@ -261,61 +312,120 @@ 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.sessions.get(self.selected) else { - self.status_lbl.set_label("No session available to start"); - 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(), - }); + fn dispatch(&mut self, cmd: greetd::Command) { + if self.cmd_tx.send(cmd).is_err() { + self.show_error("Cannot reach greetd"); + } } + + fn show_error(&mut self, description: &str) { + self.status_lbl.set_label(description); + if description.is_empty() { + self.status_lbl.remove_css_class("error"); + } else { + self.status_lbl.add_css_class("error"); + } + self.reset_to_username(); + } + + fn reset_to_username(&mut self) { + self.entry.set_text(""); + self.entry.set_visibility(true); + self.entry.set_placeholder_text(Some("Username")); + self.entry.set_sensitive(!self.sessions.is_empty()); + self.stage = Stage::Username; + self.username.clear(); + self.pam_status_held = false; + if !self.sessions.is_empty() { + self.entry.grab_focus(); + } + } +} + +/// Secret/Visible answers are always `Some`, including the empty string. +/// greetd/PAM treat `None` as a conversation cancel. +fn prompt_answer(text: String) -> Option { + Some(text) } /// Paints the configured wallpaper full-screen behind the login card. The @@ -357,7 +467,11 @@ fn setup_wallpaper( } // Cover scale, then the Ken Burns oversize (leaves room to pan). let cover = (w / iw).max(h / ih); - let scale = if ken_burns { cover * 1.08 } else { cover }; + 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). @@ -391,9 +505,12 @@ fn setup_wallpaper( /// over ~600ms (ease-out), matching the lock screen's appear motion. fn setup_entrance(window: >k4::ApplicationWindow, root_box: >k4::Box) { let root_box = root_box.clone(); - let start = std::time::Instant::now(); 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); @@ -409,54 +526,35 @@ fn setup_entrance(window: >k4::ApplicationWindow, root_box: >k4::Box) { }); } -/// Owns the single stateful connection to `$GREETD_SOCK` for one login -/// attempt and translates the UI's [`GreetdCommand`]s into greetd IPC -/// round-trips, forwarding each outcome back as an [`AppInput`]. +/// Owns the single stateful connection to `$GREETD_SOCK` and translates the +/// UI's [`greetd::Command`]s into greetd IPC round-trips, forwarding each +/// outcome back as an [`AppInput`]. fn spawn_greetd_actor( - mut cmd_rx: mpsc::UnboundedReceiver, + cmd_rx: mpsc::UnboundedReceiver, sender: ComponentSender, ) { + let input = sender.input_sender().clone(); relm4::spawn(async move { - let mut client = match Client::connect().await { - Ok(client) => client, - Err(err) => { - sender.input(AppInput::Error(format!("Cannot reach greetd: {err}"))); - return; - } - }; - - 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), - } - } + 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, }; - - match result { - Ok(outcome) => sender.input(AppInput::Outcome(outcome)), - Err(err) => { - tracing::warn!(%err, "greetd reported an error"); - client.cancel_session().await; - sender.input(AppInput::Error(err.to_string())); - } - } - } + let _ = input.send(msg); + }) + .await; }); } fn spawn_clock_ticker(sender: ComponentSender) { + 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; + } } }); } @@ -473,3 +571,14 @@ fn main() { let app = RelmApp::new("sh.breadway.breadgreet"); app.run::(()); } + +#[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())); + } +} diff --git a/breadgreet/src/sessions.rs b/breadgreet/src/sessions.rs index 3fe4d72..be39dc9 100644 --- a/breadgreet/src/sessions.rs +++ b/breadgreet/src/sessions.rs @@ -9,6 +9,12 @@ use breadlock_ui::desktop_entry::scan_dir; use std::path::Path; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionKind { + Wayland, + X11, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct Session { /// `.desktop` file stem (`bos` for `bos.desktop`) — used to match @@ -16,22 +22,50 @@ pub struct Session { pub stem: String, pub name: String, pub exec: Vec, + /// 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 { + 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 { let mut all = Vec::new(); - for dir in wayland_dirs.iter().chain(xsessions_dirs) { + collect_into(&mut all, wayland_dirs, SessionKind::Wayland); + collect_into(&mut all, xsessions_dirs, SessionKind::X11); + all +} + +fn collect_into(all: &mut Vec, dirs: &[String], kind: SessionKind) { + for dir in dirs { for (stem, entry) in scan_dir(Path::new(dir)) { all.push(Session { stem, name: entry.name, exec: split_exec(&entry.exec), + kind, }); } } - all } /// Index of the configured default stem, or `0` if it is absent. Callers @@ -54,18 +88,67 @@ pub fn discover( all.into_iter().nth(idx) } -/// Splits a `.desktop` `Exec=` line into an argv. Only handles plain -/// whitespace-separated commands (BOS's own `hyprland.desktop` is -/// `Exec=Hyprland`) — full field-code (`%f`, `%u`, …) and quoting support -/// isn't needed for a greeter that never launches file-manager-style -/// entries. +/// Splits a `.desktop` `Exec=` line into an argv. Double-quoted arguments +/// are one token (Freedesktop Exec quoting). Whole-argument field codes +/// (`%f`, `%F`, …) are dropped; `%%` is a literal `%`. fn split_exec(exec: &str) -> Vec { - 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 { + let mut args = Vec::new(); + let mut current = String::new(); + let mut in_quote = false; + let mut chars = exec.chars().peekable(); + + while let Some(c) = chars.next() { + match c { + '"' => in_quote = !in_quote, + '\\' if in_quote => { + if let Some(n) = chars.next() { + current.push(n); + } + } + c if c.is_whitespace() && !in_quote => { + if !current.is_empty() { + args.push(std::mem::take(&mut current)); + } + } + _ => current.push(c), + } + } + if !current.is_empty() { + args.push(current); + } + args +} + +fn is_field_code(arg: &str) -> bool { + matches!( + arg, + "%f" | "%F" | "%u" | "%U" | "%d" | "%D" | "%n" | "%N" | "%i" | "%c" | "%k" | "%v" | "%m" + ) +} + +fn unescape_percent(arg: &str) -> String { + let mut out = String::with_capacity(arg.len()); + let mut chars = arg.chars().peekable(); + while let Some(c) = chars.next() { + if c == '%' && chars.peek() == Some(&'%') { + chars.next(); + out.push('%'); + } else { + out.push(c); + } + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -76,6 +159,20 @@ mod tests { assert_eq!(split_exec("gnome-session %U"), vec!["gnome-session"]); } + #[test] + fn split_exec_quoted_arguments() { + assert_eq!( + split_exec(r#"wrapper "my session" --flag"#), + vec!["wrapper", "my session", "--flag"] + ); + } + + #[test] + fn split_exec_double_percent_is_literal() { + assert_eq!(split_exec("echo %%"), vec!["echo", "%"]); + assert_eq!(split_exec(r#"echo "100%%""#), vec!["echo", "100%"]); + } + #[test] fn discover_returns_none_when_no_directories_exist() { assert!(discover( @@ -109,6 +206,7 @@ mod tests { assert_eq!(session.stem, "hyprland"); assert_eq!(session.name, "Hyprland"); assert_eq!(session.exec, vec!["Hyprland"]); + assert_eq!(session.kind, SessionKind::Wayland); std::fs::remove_dir_all(&dir).ok(); } @@ -131,6 +229,23 @@ mod tests { let stems: Vec<&str> = listed.iter().map(|s| s.stem.as_str()).collect(); assert_eq!(stems, vec!["bos", "hyprland", "openbox"]); assert_eq!(listed[0].exec, vec!["/usr/local/bin/bos-session"]); + assert_eq!(listed[0].kind, SessionKind::Wayland); + assert_eq!(listed[2].kind, SessionKind::X11); + assert!( + listed[2] + .start_env() + .contains(&"XDG_SESSION_TYPE=x11".to_string()) + ); + assert!( + listed[0] + .start_env() + .contains(&"XDG_SESSION_TYPE=wayland".to_string()) + ); + assert!( + listed[0] + .start_env() + .contains(&"XDG_SESSION_DESKTOP=bos".to_string()) + ); std::fs::remove_dir_all(&wayland).ok(); std::fs::remove_dir_all(&x11).ok(); @@ -143,11 +258,13 @@ mod tests { stem: "aaa".into(), name: "A".into(), exec: vec!["a".into()], + kind: SessionKind::Wayland, }, Session { stem: "bos".into(), name: "BOS".into(), exec: vec!["bos-session".into()], + kind: SessionKind::Wayland, }, ]; assert_eq!(default_index(&sessions, "bos"), 1); diff --git a/breadgreet/src/theme.rs b/breadgreet/src/theme.rs index 3265b69..96b4360 100644 --- a/breadgreet/src/theme.rs +++ b/breadgreet/src/theme.rs @@ -6,13 +6,23 @@ thread_local! { static USER_PROVIDER: RefCell> = 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; }}\ @@ -24,14 +34,17 @@ fn load_css() -> String { 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)); } diff --git a/breadlock-ui/src/config.rs b/breadlock-ui/src/config.rs index 179a5f7..bce474b 100644 --- a/breadlock-ui/src/config.rs +++ b/breadlock-ui/src/config.rs @@ -80,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(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)] @@ -112,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(); } } diff --git a/breadlock-ui/src/desktop_entry.rs b/breadlock-ui/src/desktop_entry.rs index 8c66c78..927b67e 100644 --- a/breadlock-ui/src/desktop_entry.rs +++ b/breadlock-ui/src/desktop_entry.rs @@ -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, } /// 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 { 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 { "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(); + } } From 4f513c99265f95e23dc14d8d342f4c595ef17215 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 23 Aug 2026 14:21:39 +0800 Subject: [PATCH 19/20] 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. --- .forgejo/workflows/package.yml | 3 +-- README.md | 14 ++++++++------ packaging/arch/PKGBUILD | 7 +++++-- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 0705ebb..c8b1545 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -34,8 +34,7 @@ jobs: sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD chown -R builder:builder /home/builder/src - # --nocheck: packaging builds the artifact; tests belong in a CI job. - su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck" + su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm" PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1) mkdir -p /tmp/breadlock-pkg cp "$PKG" /tmp/breadlock-pkg/ diff --git a/README.md b/README.md index bfcefb8..95fe9eb 100644 --- a/README.md +++ b/README.md @@ -30,15 +30,15 @@ breadlock/ ├── breadlock-ui/ shared: bread-theme wrapper, TOML config, .desktop parsing, │ software-rendering primitives (tiny-skia + cosmic-text, │ behind the "paint" feature — only breadlock needs them) -├── breadlock/ the locker (SCTK + PAM) +├── breadlock/ the locker (SCTK + PAM; EGL wallpaper + software chrome) └── breadgreet/ the greeter (GTK4 + relm4 + greetd_ipc) ``` ### breadlock - **Protocol**: `ext-session-lock-v1` via [`smithay-client-toolkit`](https://docs.rs/smithay-client-toolkit) — GTK has no session-lock support, so this is a raw Wayland client, not a layer-shell surface like breadbar. -- **Rendering**: fully software — `tiny-skia` composites each frame (background, rounded password pill, clock, status line) into a `wl_shm` buffer; `cosmic-text` shapes and rasterizes text (loads "Varela Round" by family name). No EGL/GL. -- **Background**: a solid palette color or a static PNG (cover-fit). `background.blur` is **not implemented** — the key is accepted and logs a warning; the surface is drawn unblurred. Live blur-of-desktop (hyprlock-style) would need a `wlr-screencopy` capture. +- **Rendering**: hybrid — wallpaper via EGL/GLES2 (`wl_egl_window` wrapping the lock surface); chrome (password pill, clock, status line) is still software (`tiny-skia` + `cosmic-text`, "Varela Round" by family name) and blitted over the GPU frame. If EGL init fails, the locker falls back to a fully-software `wl_shm` path. +- **Background**: a solid palette color or a PNG (cover-fit). Ken Burns (`background.ken_burns`) is opt-in: a slow pan+zoom on image backgrounds — cheap on the GPU path, a continuous software redraw if EGL is unavailable. `background.blur` is **not implemented** — the key is accepted and logs a warning; the surface is drawn unblurred. Live blur-of-desktop (hyprlock-style) would need a `wlr-screencopy` capture. - **Auth**: [`pam-client2`](https://crates.io/crates/pam-client2) against the `breadlock` PAM service (`packaging/pam.d/breadlock`, installed to `/etc/pam.d/breadlock` by the package). Runs on its own OS thread — libpam's conversation callback is blocking FFI — and reports back through a `calloop::channel` registered on the render loop. ### breadgreet @@ -51,6 +51,8 @@ breadlock/ Copy [`breadlock.example.toml`](breadlock.example.toml) to `~/.config/breadlock/breadlock.toml` and [`breadgreet.example.toml`](breadgreet.example.toml) to `/etc/greetd/breadgreet.toml` (or `~/.config/breadgreet/breadgreet.toml` for local testing under a normal session — `breadgreet` checks the system path first since it typically runs as the dedicated `greeter` user). Every field is optional; both binaries run with sensible defaults and no config at all. +`breadlock.toml`'s `[status]` table (both flags default on) shows now-playing (MPRIS) and battery (upower) as a small line under the clock. Polled on a background thread; degrades silently if D-Bus or the service is missing. + ## Building ```sh @@ -58,13 +60,13 @@ cargo build --release --bin breadlock --bin breadgreet cargo test --workspace ``` -Requires GTK4 (≥ 4.12), `libxkbcommon`, and PAM development headers. On Arch: +Requires GTK4 (≥ 4.12), `libxkbcommon`, PAM development headers, `git` (workspace crates `bread-theme` / `bread-utils` are git deps), and `pkg-config` (gtk4-rs; also provided by `base-devel`). On Arch: ```sh -sudo pacman -S gtk4 wayland libxkbcommon pam rust cargo +sudo pacman -S gtk4 wayland libxkbcommon pam rust cargo git pkg-config ``` -`breadlock-auth-check` is a third, dev-only binary in the `breadlock` package (see Verification below) — not installed by the package, build it explicitly with `cargo build --bin breadlock-auth-check` if you need it. +`breadlock-auth-check` and `breadlock-preview` are extra, dev-only binaries in the `breadlock` package (see Verification below) — not installed by the package. Build them explicitly with `cargo build --bin breadlock-auth-check` or `--bin breadlock-preview` if you need them. ## Packaging diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 94db441..95e9628 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Breadway 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 } From 94289865c056815952416906b821fc50d9001922 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 23 Aug 2026 14:38:50 +0800 Subject: [PATCH 20/20] 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. --- EVENTS.md | 27 ++++----- README.md | 18 +++--- breadlock/src/bread_events.rs | 104 +++++++++++++++------------------- breadlock/src/main.rs | 13 +++-- 4 files changed, 80 insertions(+), 82 deletions(-) diff --git a/EVENTS.md b/EVENTS.md index 6e0122d..8ab67b6 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -30,22 +30,23 @@ from session lock/unlock. | `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": "" }` | `bread.command.lock.lock` was received but the locker could not be started (e.g. this binary is missing from disk). | -| `bread.lock.unlock.done` | `{}` | `bread.command.lock.unlock` was honored: no locker was running (already unlocked), or `loginctl unlock-session` was invoked for this session. This is the command confirmation, not compositor proof — wait on `bread.lock.unlocked` if you need PAM + `ext-session-lock-v1` unlock. | -| `bread.lock.unlock.failed` | `{ "error": "" }` | `bread.command.lock.unlock` was received but `loginctl unlock-session` could not be run (binary missing, non-zero exit). | +| `bread.lock.unlock.done` | `{}` | `bread.command.lock.unlock` was honored because no locker was running (session already unlocked). This is **not** passwordless compositor unlock and is **not** emitted merely because a bus client asked to unlock. For PAM + `ext-session-lock-v1` unlock, wait on `bread.lock.unlocked`. | +| `bread.lock.unlock.failed` | `{ "error": "" }` | `bread.command.lock.unlock` was received while the locker is running. The bus cannot bypass PAM; authenticate at the lock screen. | ## Commands honored (`bread.command.lock.*`) | Verb | Effect | |------|--------| | `lock` | If a locker is already running, emit `bread.lock.lock.done` and do nothing else. Otherwise start `breadlock` the same way hypridle does (`lock_cmd = breadlock`: this binary, no args) and emit `done` or `failed`. | -| `unlock` | If no locker is running, emit `bread.lock.unlock.done` (already unlocked). Otherwise run `loginctl unlock-session` on the caller's session and emit `done` or `failed`. This is session-level (logind), not a passwordless PAM bypass: breadlock does not call compositor `unlock()` for this verb. | +| `unlock` | If no locker is running, emit `bread.lock.unlock.done` (already unlocked) and do **not** call loginctl. If the locker is running, emit `bread.lock.unlock.failed` — bus clients must never trigger unlock. Compositor `unlock()` stays on the PAM path only. | A Lua workflow that wants the session locked should `bread.wait` / `bread.wait_any` on `bread.lock.lock.done` (or `.failed`) with a timeout. To know the compositor actually locked, wait on `bread.lock.locked`. -The same pattern applies to unlock: wait on `bread.lock.unlock.done` / -`.failed` for the command, and on `bread.lock.unlocked` for PAM + -compositor unlock. +Unlock from the bus is not a substitute for PAM: wait on +`bread.lock.unlock.done` / `.failed` for the command ack (`.done` only +means already unlocked), and on `bread.lock.unlocked` for a typed +password + compositor unlock. ### Who is listening @@ -60,8 +61,8 @@ rule). Two subscribers exist: idempotent `done`. 2. **The locker process** — always subscribes once the lock screen is up, so `lock` during an active lock is an idempotent `done`, and - `unlock` runs `loginctl unlock-session` rather than compositor - `unlock()`. + `unlock` is `.failed` (cannot bypass PAM). Never compositor + `unlock()`, never `loginctl unlock-session`. ### Session-level equivalent @@ -72,11 +73,11 @@ bread command bus. It is the session-level equivalent of same `ext-session-lock-v1` request. Prefer `loginctl lock-session` from a keybind; prefer the bus command from a Lua workflow. -`loginctl unlock-session` is the matching session-level unlock. The -bus verb invokes that same command. Compositor unlock after a typed -password is still PAM on this process (`bread.lock.unlocked`); a -dispatch-error or crash path still does **not** call compositor -`unlock()` (fail-secure). +The bus unlock verb does **not** call `loginctl unlock-session` and +does **not** replace PAM. Super+L / hypridle remain `loginctl +lock-session`. Compositor unlock after a typed password is still PAM +on this process (`bread.lock.unlocked`); a dispatch-error or crash +path still does **not** call compositor `unlock()` (fail-secure). ### Not implemented: `pin` / `blur` diff --git a/README.md b/README.md index 95fe9eb..c8e418c 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,13 @@ honors `bread.command.lock.lock` / `bread.command.lock.unlock` (emits `bread.lock.lock.done` / `.failed` and `bread.lock.unlock.done` / `.failed`). Run `breadlock listen` so both commands work while unlocked; the locker also subscribes while the session is locked. -Unlock is `loginctl unlock-session` at the session level — not a -passwordless compositor `unlock()`. Super+L remains -`loginctl lock-session` (hypridle then runs `breadlock`). See -[EVENTS.md](EVENTS.md). `breadgreet` is not on the bus. There is no -`bakery.toml` (PAM / pacman exception). +Unlock is fail-secure: already-unlocked acks `bread.lock.unlock.done`; +a running locker refuses with `bread.lock.unlock.failed` (only PAM at +the lock screen unlocks). The bus never calls compositor `unlock()` or +`loginctl unlock-session`. Super+L remains `loginctl lock-session` +(hypridle then runs `breadlock`). See [EVENTS.md](EVENTS.md). +`breadgreet` is not on the bus. There is no `bakery.toml` (PAM / +pacman exception). ## Architecture @@ -89,8 +91,10 @@ lock_cmd = breadlock `bread.command.lock.lock` and `bread.command.lock.unlock`. It is not started by hypridle; add it to session startup (`exec-once = breadlock listen`) if a Lua workflow should be able to -lock or unlock the session while it is unlocked. Session-level unlock -is `loginctl unlock-session` (the bus verb runs that). +lock the session while it is unlocked, or to ack already-unlocked. +`bread.command.lock.unlock` does not replace PAM and does not run +`loginctl unlock-session`. Super+L / hypridle remain +`loginctl lock-session`. ## Verification (why this is safe to test without a lockout risk) diff --git a/breadlock/src/bread_events.rs b/breadlock/src/bread_events.rs index 5bd3e11..1a681aa 100644 --- a/breadlock/src/bread_events.rs +++ b/breadlock/src/bread_events.rs @@ -10,9 +10,10 @@ //! locked (already-locked is `bread.lock.lock.done`). `breadlock listen` //! is the unlocked-path subscriber: it starts this same binary the way //! hypridle's `lock_cmd = breadlock` does, and treats unlock as already -//! unlocked (`bread.lock.unlock.done`). Session-level equivalents are -//! `loginctl lock-session` / `loginctl unlock-session`. Unlock never -//! calls compositor `unlock()` — that stays on the PAM path. +//! unlocked (`bread.lock.unlock.done`). If the locker is running, unlock +//! is `bread.lock.unlock.failed` — only PAM at the lock screen may +//! unlock. Super+L / hypridle remain `loginctl lock-session`. Bus unlock +//! never calls compositor `unlock()` or `loginctl unlock-session`. use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -119,29 +120,16 @@ pub fn honor_lock_command() { honor_lock_command_with(locker_is_running(), start_locker); } -/// Session-level unlock (`loginctl unlock-session` on the caller's -/// session). Does not send compositor `unlock` and does not skip PAM — -/// that stays on the typed-password path. `done` means the command was -/// acted on (or the session was already unlocked), not that -/// `ext-session-lock-v1` has been released — wait on -/// `bread.lock.unlocked` for the compositor confirmation. -fn unlock_session() -> Result<(), String> { - let status = Command::new("loginctl") - .arg("unlock-session") - .stdin(Stdio::null()) - .status() - .map_err(|e| format!("failed to run loginctl unlock-session: {e}"))?; - if status.success() { - Ok(()) - } else { - Err(format!("loginctl unlock-session exited with {status}")) - } -} +/// Payload on `bread.lock.unlock.failed` while the locker is running. +/// Bus clients cannot unlock; only PAM at the lock screen can. +const UNLOCK_REFUSED_WHILE_LOCKED: &str = + "bus unlock cannot bypass PAM; authenticate at the lock screen"; -/// Honor `bread.command.lock.unlock`: already unlocked is success; -/// otherwise ask logind to unlock this session. +/// Honor `bread.command.lock.unlock`. Fail-secure: never compositor +/// `unlock()`, never `loginctl unlock-session`. Already unlocked is +/// `.done`; a running locker is `.failed`. pub fn honor_unlock_command() { - honor_unlock_command_with(locker_is_running(), unlock_session); + honor_unlock_command_with(locker_is_running(), emit_unlock_done, emit_unlock_failed); } fn honor_lock_command_with(locked: bool, start: impl FnOnce() -> Result<(), String>) { @@ -162,22 +150,21 @@ fn honor_lock_command_with(locked: bool, start: impl FnOnce() -> Result<(), Stri } } -fn honor_unlock_command_with(locked: bool, unlock: impl FnOnce() -> Result<(), String>) { +fn honor_unlock_command_with( + locked: bool, + emit_done: impl FnOnce(), + emit_failed: impl FnOnce(&str), +) { if !locked { tracing::info!("bread.command.lock.unlock: already unlocked"); - emit_unlock_done(); + emit_done(); return; } - match unlock() { - Ok(()) => { - tracing::info!("bread.command.lock.unlock: loginctl unlock-session"); - emit_unlock_done(); - } - Err(error) => { - tracing::error!(%error, "bread.command.lock.unlock: failed"); - emit_unlock_failed(&error); - } - } + tracing::error!( + error = UNLOCK_REFUSED_WHILE_LOCKED, + "bread.command.lock.unlock: refused while locked" + ); + emit_failed(UNLOCK_REFUSED_WHILE_LOCKED); } /// Reacts to `bread.command.lock.*`. Unknown verbs are ignored, not stubbed. @@ -312,33 +299,36 @@ mod tests { } #[test] - fn honor_unlock_command_already_unlocked_does_not_call_loginctl() { - let called = std::cell::Cell::new(false); - honor_unlock_command_with(false, || { - called.set(true); - Err("should not run".into()) - }); - assert!(!called.get()); + 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_with_failed_loginctl_runs_unlock() { - let called = Cell::new(false); - honor_unlock_command_with(true, || { - called.set(true); - Err("boom".into()) - }); - assert!(called.get()); + 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_with_successful_loginctl_runs_unlock() { - let called = Cell::new(false); - honor_unlock_command_with(true, || { - called.set(true); - Ok(()) - }); - assert!(called.get()); + 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] diff --git a/breadlock/src/main.rs b/breadlock/src/main.rs index ae4ab0a..665743a 100644 --- a/breadlock/src/main.rs +++ b/breadlock/src/main.rs @@ -55,7 +55,8 @@ fn print_usage() { (no args) lock this session — hypridle lock_cmd / Super+L via loginctl lock-session\n\ listen subscribe to bread.command.lock.lock / unlock so both work while unlocked\n\ \n\ - Session-level: loginctl lock-session / unlock-session.\n\ + Session-level lock: loginctl lock-session (hypridle then runs breadlock).\n\ + Bus unlock does not replace PAM — type the password at the lock screen.\n\ See EVENTS.md for the bus contract." ); } @@ -80,8 +81,9 @@ fn main() { /// Long-running subscriber so `bread.command.lock.lock` / `.unlock` work /// while the session is unlocked. The locker process also subscribes; /// this path is what actually starts breadlock (the same no-args -/// invocation hypridle uses) and what runs `loginctl unlock-session` -/// when a locker is up. One listen process per session. +/// invocation hypridle uses). Unlock while a locker is running is +/// refused (`.failed`); only PAM may unlock. One listen process per +/// session. fn run_listen() { let _guard = match try_acquire(bread_events::LISTEN_APP) { Ok(Acquire::Acquired(g)) => g, @@ -132,8 +134,9 @@ fn run_lock() { let _running = bread_events::enter_lock_process(); // Honor bread.command.lock.lock / unlock while this locker is up - // (already-locked is bread.lock.lock.done; unlock is loginctl, not - // compositor unlock()). Unlocked-path commands need `breadlock listen`. + // (already-locked is bread.lock.lock.done; unlock is .failed — + // never compositor unlock() or loginctl). Unlocked-path commands + // need `breadlock listen`. let _commands = bread_events::subscribe_commands(); let username = auth::username_from_process().unwrap_or_else(|| {