Initial commit
This commit is contained in:
commit
9f97f2c989
37 changed files with 4871 additions and 0 deletions
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/target/
|
||||
|
||||
# Editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS artifacts
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Environment / secrets
|
||||
.env
|
||||
.env.*
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# Claude Code session data
|
||||
.claude/
|
||||
2254
Cargo.lock
generated
Normal file
2254
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
18
Cargo.toml
Normal file
18
Cargo.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[workspace]
|
||||
members = ["breadlock-ui", "breadlock", "breadgreet"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.dependencies]
|
||||
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.9" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "0.8"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "net", "sync", "signal"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
thiserror = "2"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
strip = "symbols"
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Breadway
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
83
README.md
Normal file
83
README.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# 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.
|
||||
|
||||
Two binaries, one workspace:
|
||||
|
||||
- **`breadlock`** — locks the *already running* Hyprland session via `ext-session-lock-v1`. Drop-in for `hyprlock`.
|
||||
- **`breadgreet`** — a graphical greeter that speaks `greetd`'s own IPC protocol (the same architecture as `gtkgreet`/`regreet`). `greetd` keeps owning PAM auth, VT switching, and session launching; `breadgreet` only draws the login UI and relays the conversation. This is a deliberate choice over reimplementing a display manager from scratch — `greetd` is already installed and battle-tested.
|
||||
|
||||
Both use [`bread-theme`](https://github.com/Breadway/bread-ecosystem) for palette loading, matching the rest of the bread* ecosystem (breadbar, breadbox, bos-settings).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
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)
|
||||
└── breadgreet/ the greeter (GTK4 + relm4 + greetd_ipc)
|
||||
```
|
||||
|
||||
### breadlock
|
||||
|
||||
- **Protocol**: `ext-session-lock-v1` via [`smithay-client-toolkit`](https://docs.rs/smithay-client-toolkit) — GTK has no session-lock support, so this is a raw Wayland client, not a layer-shell surface like breadbar.
|
||||
- **Rendering**: fully software — `tiny-skia` composites each frame (background, rounded password pill, clock, status line) into a `wl_shm` buffer; `cosmic-text` shapes and rasterizes text (loads "Varela Round" by family name). No EGL/GL.
|
||||
- **Background**: a solid palette color or a static PNG (cover-fit). Live blur-of-desktop (hyprlock-style) is a **v2 follow-up** — it needs a `wlr-screencopy` capture (`libwayshot` is the right crate when this gets picked up); `background.blur = true` is accepted today but just logs a warning.
|
||||
- **Auth**: [`pam-client2`](https://crates.io/crates/pam-client2) against the `breadlock` PAM service (`packaging/pam.d/breadlock`, installed to `/etc/pam.d/breadlock` by the package). Runs on its own OS thread — libpam's conversation callback is blocking FFI — and reports back through a `calloop::channel` registered on the render loop.
|
||||
|
||||
### breadgreet
|
||||
|
||||
- **Protocol**: [`greetd_ipc`](https://crates.io/crates/greetd_ipc) (greetd's own crate) over the Unix socket at `$GREETD_SOCK`: `CreateSession` → answer each `AuthMessage` via `PostAuthMessageResponse` → `StartSession` hands the resolved session command to `greetd`, which execs it and owns the VT switch away.
|
||||
- **UI**: GTK4 + [relm4](https://relm4.org/), matching breadbar's stack — **without** `gtk4-layer-shell`. `greetd` hosts the greeter under a single-client kiosk compositor (`cage -s`), which already fullscreens its one client, so layer-shell's multi-surface/anchor semantics don't apply. Confirmed against ReGreet's real dependency list, which has no layer-shell dependency either.
|
||||
- **Sessions**: scans `/usr/share/wayland-sessions` and `/usr/share/xsessions` for `.desktop` entries and auto-selects the configured default (or the only one found). BOS ships one session today, so there's no picker UI in v1 — a natural v2 addition if that changes.
|
||||
|
||||
## Config
|
||||
|
||||
Copy [`breadlock.example.toml`](breadlock.example.toml) to `~/.config/breadlock/breadlock.toml` and [`breadgreet.example.toml`](breadgreet.example.toml) to `/etc/greetd/breadgreet.toml` (or `~/.config/breadgreet/breadgreet.toml` for local testing under a normal session — `breadgreet` checks the system path first since it typically runs as the dedicated `greeter` user). Every field is optional; both binaries run with sensible defaults and no config at all.
|
||||
|
||||
## Building
|
||||
|
||||
```sh
|
||||
cargo build --release --bin breadlock --bin breadgreet
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Requires GTK4 (≥ 4.12), `libxkbcommon`, and PAM development headers. On Arch:
|
||||
|
||||
```sh
|
||||
sudo pacman -S gtk4 wayland libxkbcommon pam rust cargo
|
||||
```
|
||||
|
||||
`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.
|
||||
|
||||
## Packaging
|
||||
|
||||
`packaging/arch/PKGBUILD` builds and installs both binaries plus `/etc/pam.d/breadlock`. `bakery.toml` is the bread-ecosystem package index entry.
|
||||
|
||||
**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:
|
||||
|
||||
```toml
|
||||
# /etc/greetd/config.toml — replace the current tuigreet line
|
||||
[default_session]
|
||||
command = "cage -s -- breadgreet"
|
||||
```
|
||||
|
||||
```
|
||||
# hyprland.conf
|
||||
bind = SUPER, L, exec, 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.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
15
bakery.toml
Normal file
15
bakery.toml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
name = "breadlock"
|
||||
description = "Session locker and greetd greeter for Hyprland / Wayland"
|
||||
binaries = ["breadlock", "breadgreet"]
|
||||
system_deps = ["pam", "wayland", "libxkbcommon", "gtk4"]
|
||||
optional_system_deps = ["cage", "hyprland"]
|
||||
bread_deps = []
|
||||
|
||||
[config]
|
||||
dir = "~/.config/breadlock"
|
||||
example = "breadlock.example.toml"
|
||||
|
||||
[install]
|
||||
post_install = [
|
||||
"echo 'breadlock installed. /etc/pam.d/breadlock is installed by the package; wiring greetd (cage -s -- breadgreet) and a lock keybind/hypridle is a separate manual step.'",
|
||||
]
|
||||
26
breadgreet.example.toml
Normal file
26
breadgreet.example.toml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# breadgreet commonly runs as the dedicated `greeter` system user (per
|
||||
# greetd's own convention), so it checks /etc/greetd/breadgreet.toml first;
|
||||
# copy this there for a real install. For local dev/testing under a normal
|
||||
# user session it falls back to ~/.config/breadgreet/breadgreet.toml.
|
||||
#
|
||||
# Every field is optional and defaults to the value shown here.
|
||||
|
||||
[background]
|
||||
mode = "color"
|
||||
path = ""
|
||||
blur = false
|
||||
|
||||
[clock]
|
||||
format = "%H:%M"
|
||||
|
||||
[font]
|
||||
family = "Varela Round"
|
||||
|
||||
[sessions]
|
||||
# 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"
|
||||
25
breadgreet/Cargo.toml
Normal file
25
breadgreet/Cargo.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
[package]
|
||||
name = "breadgreet"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["Breadway <rileyhorsham@gmail.com>"]
|
||||
description = "Graphical greetd greeter for Hyprland / Wayland"
|
||||
|
||||
[[bin]]
|
||||
name = "breadgreet"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
breadlock-ui = { path = "../breadlock-ui" }
|
||||
bread-theme = { workspace = true, features = ["gtk"] }
|
||||
gtk4 = { version = "0.11", features = ["v4_12"] }
|
||||
relm4 = { version = "0.11", features = ["macros"] }
|
||||
greetd_ipc = { version = "0.10", default-features = false, features = ["tokio-codec"] }
|
||||
chrono = "0.4"
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
toml.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
thiserror.workspace = true
|
||||
63
breadgreet/src/config.rs
Normal file
63
breadgreet/src/config.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
use breadlock_ui::config::Appearance;
|
||||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
#[serde(flatten)]
|
||||
pub appearance: Appearance,
|
||||
pub sessions: Sessions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Sessions {
|
||||
pub wayland_dirs: Vec<String>,
|
||||
pub xsessions_dirs: Vec<String>,
|
||||
/// `.desktop` file stem (without extension) to auto-select.
|
||||
pub default: String,
|
||||
}
|
||||
|
||||
impl Default for Sessions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
wayland_dirs: vec!["/usr/share/wayland-sessions".to_string()],
|
||||
xsessions_dirs: vec!["/usr/share/xsessions".to_string()],
|
||||
default: "hyprland".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `breadgreet` commonly runs as the dedicated `greeter` system user (per
|
||||
/// BOS's `/etc/greetd/config.toml` `user = "greeter"`), so a fixed system
|
||||
/// path is checked first; XDG is the fallback for local dev/testing under a
|
||||
/// normal user session.
|
||||
pub fn load() -> Config {
|
||||
let system_path = std::path::Path::new("/etc/greetd/breadgreet.toml");
|
||||
if system_path.exists() {
|
||||
return breadlock_ui::config::load_or_default(system_path);
|
||||
}
|
||||
breadlock_ui::config::load_or_default(&xdg_config_path())
|
||||
}
|
||||
|
||||
fn xdg_config_path() -> PathBuf {
|
||||
let base = 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")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_sessions_match_standard_greetd_dirs() {
|
||||
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");
|
||||
}
|
||||
}
|
||||
250
breadgreet/src/greetd/client.rs
Normal file
250
breadgreet/src/greetd/client.rs
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
//! Thin async wrapper around `greetd_ipc`'s wire protocol — connects to
|
||||
//! `$GREETD_SOCK`, and turns greetd's `Request`/`Response` enums into a
|
||||
//! small state machine ([`Outcome`]) the UI drives.
|
||||
|
||||
use greetd_ipc::codec::TokioCodec;
|
||||
use greetd_ipc::{AuthMessageType, ErrorType, Request, Response};
|
||||
use tokio::net::UnixStream;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GreetdError {
|
||||
#[error("$GREETD_SOCK is not set — breadgreet must be launched by greetd")]
|
||||
NoSocketEnv,
|
||||
#[error("failed to connect to greetd socket: {0}")]
|
||||
Connect(#[source] std::io::Error),
|
||||
#[error("greetd IPC error: {0}")]
|
||||
Codec(#[from] greetd_ipc::codec::Error),
|
||||
#[error("{description}")]
|
||||
Greetd {
|
||||
description: String,
|
||||
is_auth_error: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// One step of a PAM conversation, as relayed by greetd.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AuthPrompt {
|
||||
/// Answer should be shown as typed (e.g. a username confirmation).
|
||||
Visible(String),
|
||||
/// Answer should be masked (a password).
|
||||
Secret(String),
|
||||
/// Informational message — no answer needed, just display and continue.
|
||||
Info(String),
|
||||
/// Non-fatal error message from a PAM module — display and continue.
|
||||
Error(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Outcome {
|
||||
/// Login flow complete — call [`Client::start_session`] next.
|
||||
Success,
|
||||
/// Another prompt to answer via [`Client::respond`].
|
||||
Prompt(AuthPrompt),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Client {
|
||||
stream: UnixStream,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Connects to the Unix socket greetd set in `$GREETD_SOCK` when it
|
||||
/// launched this process.
|
||||
pub async fn connect() -> Result<Self, GreetdError> {
|
||||
let path = std::env::var_os("GREETD_SOCK").ok_or(GreetdError::NoSocketEnv)?;
|
||||
Self::connect_to(path).await
|
||||
}
|
||||
|
||||
/// Connects to an explicit socket path, bypassing `$GREETD_SOCK` —
|
||||
/// used directly by tests against a mock server so they don't need to
|
||||
/// mutate process-global environment state (which parallel `cargo test`
|
||||
/// threads would race on).
|
||||
async fn connect_to(path: impl AsRef<std::path::Path>) -> Result<Self, GreetdError> {
|
||||
let stream = UnixStream::connect(path)
|
||||
.await
|
||||
.map_err(GreetdError::Connect)?;
|
||||
Ok(Self { stream })
|
||||
}
|
||||
|
||||
/// Starts a login attempt for `username`. Answer any resulting
|
||||
/// [`Outcome::Prompt`] via [`Self::respond`] until [`Outcome::Success`].
|
||||
pub async fn create_session(&mut self, username: &str) -> Result<Outcome, GreetdError> {
|
||||
let req = Request::CreateSession {
|
||||
username: username.to_string(),
|
||||
};
|
||||
self.roundtrip(req).await
|
||||
}
|
||||
|
||||
/// Answers the most recent [`AuthPrompt`]. `None` for prompts that don't
|
||||
/// need an answer (info/error messages).
|
||||
pub async fn respond(&mut self, answer: Option<String>) -> Result<Outcome, GreetdError> {
|
||||
self.roundtrip(Request::PostAuthMessageResponse { response: answer })
|
||||
.await
|
||||
}
|
||||
|
||||
/// Hands the chosen session off to greetd, which execs it and owns the
|
||||
/// VT switch away from the greeter. Only valid after an [`Outcome::Success`].
|
||||
pub async fn start_session(
|
||||
&mut self,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
) -> Result<(), GreetdError> {
|
||||
Request::StartSession { cmd, env }
|
||||
.write_to(&mut self.stream)
|
||||
.await?;
|
||||
match Response::read_from(&mut self.stream).await? {
|
||||
Response::Success => Ok(()),
|
||||
Response::Error {
|
||||
error_type,
|
||||
description,
|
||||
} => Err(GreetdError::Greetd {
|
||||
description,
|
||||
is_auth_error: matches!(error_type, ErrorType::AuthError),
|
||||
}),
|
||||
Response::AuthMessage { .. } => Err(GreetdError::Greetd {
|
||||
description: "greetd sent an unexpected auth message after StartSession"
|
||||
.to_string(),
|
||||
is_auth_error: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Aborts an in-progress login flow (e.g. the user hit Escape). Per the
|
||||
/// protocol this can only be called before `StartSession` — best-effort,
|
||||
/// errors here don't matter to the caller since the flow is being torn
|
||||
/// down either way.
|
||||
pub async fn cancel_session(&mut self) {
|
||||
let _ = Request::CancelSession.write_to(&mut self.stream).await;
|
||||
let _ = Response::read_from(&mut self.stream).await;
|
||||
}
|
||||
|
||||
async fn roundtrip(&mut self, req: Request) -> Result<Outcome, GreetdError> {
|
||||
req.write_to(&mut self.stream).await?;
|
||||
match Response::read_from(&mut self.stream).await? {
|
||||
Response::Success => Ok(Outcome::Success),
|
||||
Response::AuthMessage {
|
||||
auth_message_type,
|
||||
auth_message,
|
||||
} => Ok(Outcome::Prompt(match auth_message_type {
|
||||
AuthMessageType::Visible => AuthPrompt::Visible(auth_message),
|
||||
AuthMessageType::Secret => AuthPrompt::Secret(auth_message),
|
||||
AuthMessageType::Info => AuthPrompt::Info(auth_message),
|
||||
AuthMessageType::Error => AuthPrompt::Error(auth_message),
|
||||
})),
|
||||
Response::Error {
|
||||
error_type,
|
||||
description,
|
||||
} => Err(GreetdError::Greetd {
|
||||
description,
|
||||
is_auth_error: matches!(error_type, ErrorType::AuthError),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Exercises the framing/state-machine logic against a mock Unix-socket
|
||||
//! server speaking the real greetd_ipc wire format — no real greetd or
|
||||
//! 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 tokio::net::UnixListener;
|
||||
|
||||
async fn mock_server(path: std::path::PathBuf, script: Vec<Response>) {
|
||||
let listener = UnixListener::bind(&path).unwrap();
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
for response in script {
|
||||
// Drain the request that prompted this response — we don't need
|
||||
// to inspect it, just keep the framing in lockstep.
|
||||
let _ = Request::read_from(&mut stream).await;
|
||||
response.write_to(&mut stream).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
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]));
|
||||
|
||||
// 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));
|
||||
|
||||
server.await.unwrap();
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[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(),
|
||||
vec![
|
||||
Response::AuthMessage {
|
||||
auth_message_type: AuthMessageType::Secret,
|
||||
auth_message: "Password:".to_string(),
|
||||
},
|
||||
Response::Success,
|
||||
],
|
||||
));
|
||||
|
||||
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();
|
||||
let Outcome::Prompt(AuthPrompt::Secret(msg)) = outcome else {
|
||||
panic!("expected a secret prompt, got {outcome:?}");
|
||||
};
|
||||
assert_eq!(msg, "Password:");
|
||||
|
||||
let outcome = client.respond(Some("hunter2".to_string())).await.unwrap();
|
||||
assert!(matches!(outcome, Outcome::Success));
|
||||
|
||||
server.await.unwrap();
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[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(),
|
||||
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();
|
||||
match err {
|
||||
GreetdError::Greetd { is_auth_error, .. } => assert!(is_auth_error),
|
||||
other => panic!("expected a greetd auth error, got {other:?}"),
|
||||
}
|
||||
|
||||
server.await.unwrap();
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
3
breadgreet/src/greetd/mod.rs
Normal file
3
breadgreet/src/greetd/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
mod client;
|
||||
|
||||
pub use client::{AuthPrompt, Client, Outcome};
|
||||
304
breadgreet/src/main.rs
Normal file
304
breadgreet/src/main.rs
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
mod config;
|
||||
mod greetd;
|
||||
mod sessions;
|
||||
mod theme;
|
||||
|
||||
use greetd::{AuthPrompt, Client, Outcome};
|
||||
use gtk4::prelude::*;
|
||||
use relm4::prelude::*;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// Commands sent from the UI thread to the greetd actor task (see
|
||||
/// [`spawn_greetd_actor`]), which owns the single stateful connection to
|
||||
/// `$GREETD_SOCK` for the lifetime of one login attempt.
|
||||
enum GreetdCommand {
|
||||
CreateSession(String),
|
||||
Respond(Option<String>),
|
||||
StartSession { cmd: Vec<String>, env: Vec<String> },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Stage {
|
||||
/// Waiting for a username in `entry`.
|
||||
Username,
|
||||
/// greetd/PAM asked a question; `entry` holds the answer (masking is
|
||||
/// applied imperatively on the entry widget when the prompt arrives).
|
||||
Prompt,
|
||||
/// A request is in flight — input is disabled so a second Enter can't
|
||||
/// race it.
|
||||
Working,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum AppInput {
|
||||
ClockTick,
|
||||
/// Enter pressed in the entry — behavior depends on `Stage`.
|
||||
Submit,
|
||||
Outcome(Outcome),
|
||||
Error(String),
|
||||
SessionStarted,
|
||||
}
|
||||
|
||||
struct App {
|
||||
clock_lbl: gtk4::Label,
|
||||
status_lbl: gtk4::Label,
|
||||
entry: gtk4::Entry,
|
||||
stage: Stage,
|
||||
username: String,
|
||||
session: Option<sessions::Session>,
|
||||
clock_format: String,
|
||||
cmd_tx: mpsc::UnboundedSender<GreetdCommand>,
|
||||
}
|
||||
|
||||
#[relm4::component]
|
||||
impl SimpleComponent for App {
|
||||
type Init = ();
|
||||
type Input = AppInput;
|
||||
type Output = ();
|
||||
|
||||
view! {
|
||||
gtk4::ApplicationWindow {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn init(
|
||||
_init: Self::Init,
|
||||
root: Self::Root,
|
||||
sender: ComponentSender<Self>,
|
||||
) -> ComponentParts<Self> {
|
||||
root.fullscreen();
|
||||
|
||||
let config = config::load();
|
||||
let session = sessions::discover(
|
||||
&config.sessions.wayland_dirs,
|
||||
&config.sessions.xsessions_dirs,
|
||||
&config.sessions.default,
|
||||
);
|
||||
|
||||
let clock_lbl = gtk4::Label::new(None);
|
||||
clock_lbl.add_css_class("login-clock");
|
||||
|
||||
let entry = gtk4::Entry::new();
|
||||
entry.add_css_class("login-entry");
|
||||
entry.set_placeholder_text(Some("Username"));
|
||||
entry.set_width_chars(24);
|
||||
{
|
||||
let sender = sender.clone();
|
||||
entry.connect_activate(move |_| sender.input(AppInput::Submit));
|
||||
}
|
||||
|
||||
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 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);
|
||||
|
||||
let widgets = view_output!();
|
||||
widgets.root_box.append(&clock_lbl);
|
||||
widgets.root_box.append(&card);
|
||||
|
||||
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
|
||||
spawn_greetd_actor(cmd_rx, sender.clone());
|
||||
|
||||
theme::apply();
|
||||
spawn_clock_ticker(sender.clone());
|
||||
|
||||
let model = App {
|
||||
clock_lbl,
|
||||
status_lbl,
|
||||
entry,
|
||||
stage: Stage::Username,
|
||||
username: String::new(),
|
||||
session,
|
||||
clock_format: config.appearance.clock.format.clone(),
|
||||
cmd_tx,
|
||||
};
|
||||
model
|
||||
.clock_lbl
|
||||
.set_label(¤t_time(&model.clock_format));
|
||||
|
||||
ComponentParts { model, widgets }
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Input, _sender: ComponentSender<Self>) {
|
||||
match msg {
|
||||
AppInput::ClockTick => {
|
||||
self.clock_lbl.set_label(¤t_time(&self.clock_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::SessionStarted => {
|
||||
// greetd now owns the VT switch to the started session —
|
||||
// nothing left for the greeter to do.
|
||||
self.status_lbl.set_label("Starting session…");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn handle_submit(&mut self) {
|
||||
if matches!(self.stage, Stage::Working) {
|
||||
return;
|
||||
}
|
||||
let text = self.entry.text().to_string();
|
||||
|
||||
match &self.stage {
|
||||
Stage::Username => {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.username = text;
|
||||
self.entry.set_text("");
|
||||
self.entry.set_sensitive(false);
|
||||
self.stage = Stage::Working;
|
||||
let _ = self
|
||||
.cmd_tx
|
||||
.send(GreetdCommand::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));
|
||||
}
|
||||
Stage::Working => {}
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
self.status_lbl.set_label(&message);
|
||||
let _ = self.cmd_tx.send(GreetdCommand::Respond(None));
|
||||
}
|
||||
AuthPrompt::Visible(message) => {
|
||||
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;
|
||||
}
|
||||
AuthPrompt::Secret(message) => {
|
||||
self.status_lbl.set_label(&message);
|
||||
self.entry.set_visibility(false);
|
||||
self.entry.set_placeholder_text(Some(&message));
|
||||
self.entry.set_sensitive(true);
|
||||
self.entry.grab_focus();
|
||||
self.stage = Stage::Prompt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_session(&mut self) {
|
||||
let Some(session) = &self.session 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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the single stateful connection to `$GREETD_SOCK` for one login
|
||||
/// attempt and translates the UI's [`GreetdCommand`]s into greetd IPC
|
||||
/// round-trips, forwarding each outcome back as an [`AppInput`].
|
||||
fn spawn_greetd_actor(
|
||||
mut cmd_rx: mpsc::UnboundedReceiver<GreetdCommand>,
|
||||
sender: ComponentSender<App>,
|
||||
) {
|
||||
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),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_clock_ticker(sender: ComponentSender<App>) {
|
||||
relm4::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
sender.input(AppInput::ClockTick);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn current_time(format: &str) -> String {
|
||||
chrono::Local::now().format(format).to_string()
|
||||
}
|
||||
|
||||
fn main() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
let app = RelmApp::new("sh.breadway.breadgreet");
|
||||
app.run::<App>(());
|
||||
}
|
||||
94
breadgreet/src/sessions.rs
Normal file
94
breadgreet/src/sessions.rs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
//! 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`.
|
||||
|
||||
use breadlock_ui::desktop_entry::{scan_dir, DesktopEntry};
|
||||
use std::path::Path;
|
||||
|
||||
pub struct Session {
|
||||
pub name: String,
|
||||
pub exec: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// installed — the greeter has no session to offer.
|
||||
pub fn discover(
|
||||
wayland_dirs: &[String],
|
||||
xsessions_dirs: &[String],
|
||||
default: &str,
|
||||
) -> Option<Session> {
|
||||
let mut all: Vec<(String, DesktopEntry)> = Vec::new();
|
||||
for dir in wayland_dirs.iter().chain(xsessions_dirs) {
|
||||
all.extend(scan_dir(Path::new(dir)));
|
||||
}
|
||||
|
||||
let chosen = all
|
||||
.iter()
|
||||
.find(|(stem, _)| stem == default)
|
||||
.or_else(|| all.first())?;
|
||||
|
||||
Some(Session {
|
||||
name: chosen.1.name.clone(),
|
||||
exec: split_exec(&chosen.1.exec),
|
||||
})
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn split_exec(exec: &str) -> Vec<String> {
|
||||
exec.split_whitespace()
|
||||
.filter(|arg| !arg.starts_with('%'))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn split_exec_drops_field_codes() {
|
||||
assert_eq!(split_exec("Hyprland"), vec!["Hyprland"]);
|
||||
assert_eq!(split_exec("gnome-session %U"), vec!["gnome-session"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_returns_none_when_no_directories_exist() {
|
||||
assert!(discover(
|
||||
&["/nonexistent/a".to_string()],
|
||||
&["/nonexistent/b".to_string()],
|
||||
"hyprland"
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_prefers_configured_default_over_first_entry() {
|
||||
let dir = std::env::temp_dir().join("breadgreet-test-sessions-discover");
|
||||
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();
|
||||
|
||||
let dir_str = dir.to_str().unwrap().to_string();
|
||||
let session = discover(&[dir_str], &[], "hyprland").unwrap();
|
||||
assert_eq!(session.name, "Hyprland");
|
||||
assert_eq!(session.exec, vec!["Hyprland"]);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
35
breadgreet/src/theme.rs
Normal file
35
breadgreet/src/theme.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
use bread_theme::{gtk as bgtk, ink_on, load_palette};
|
||||
use gtk4::CssProvider;
|
||||
use std::cell::RefCell;
|
||||
|
||||
thread_local! {
|
||||
static USER_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
fn load_css() -> String {
|
||||
let p = load_palette();
|
||||
format!(
|
||||
"window.breadgreet {{ background-color: {bg}; color: {on_bg}; }}\
|
||||
.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-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; }}",
|
||||
bg = p.background,
|
||||
surface = p.color0,
|
||||
red = p.color1,
|
||||
on_bg = ink_on(&p.background),
|
||||
on_surface = ink_on(&p.color0),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn apply() {
|
||||
bgtk::apply_shared();
|
||||
bgtk::apply_app_css(load_css);
|
||||
|
||||
let home = std::env::var("HOME").unwrap_or_default();
|
||||
let user_path = std::path::PathBuf::from(format!("{home}/.config/breadgreet/style.css"));
|
||||
USER_PROVIDER.with(|cell| bgtk::apply_user_css(&user_path, cell));
|
||||
}
|
||||
20
breadlock-ui/Cargo.toml
Normal file
20
breadlock-ui/Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[package]
|
||||
name = "breadlock-ui"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["Breadway <rileyhorsham@gmail.com>"]
|
||||
|
||||
[dependencies]
|
||||
bread-theme.workspace = true
|
||||
serde.workspace = true
|
||||
toml.workspace = true
|
||||
|
||||
# Software-rendering primitives (breadlock only — breadgreet draws via GTK/CSS
|
||||
# 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"]
|
||||
111
breadlock-ui/src/config.rs
Normal file
111
breadlock-ui/src/config.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
|
||||
/// Appearance settings shared by `breadlock.toml` and `breadgreet.toml`.
|
||||
/// `breadgreet` embeds this and adds its own `[sessions]` table.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Appearance {
|
||||
pub background: Background,
|
||||
pub clock: Clock,
|
||||
pub font: Font,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BackgroundMode {
|
||||
Color,
|
||||
Image,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Background {
|
||||
pub mode: BackgroundMode,
|
||||
pub path: String,
|
||||
/// v2 feature flag — no-op (with a warning) in v1, which only supports a
|
||||
/// static color or image background.
|
||||
pub blur: bool,
|
||||
}
|
||||
|
||||
impl Default for Background {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: BackgroundMode::Color,
|
||||
path: String::new(),
|
||||
blur: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Clock {
|
||||
pub format: String,
|
||||
}
|
||||
|
||||
impl Default for Clock {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
format: "%H:%M".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Font {
|
||||
pub family: String,
|
||||
}
|
||||
|
||||
impl Default for Font {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
family: bread_theme::tokens::FONT_FAMILY
|
||||
.split(',')
|
||||
.next()
|
||||
.unwrap_or("Varela Round")
|
||||
.trim()
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn load_or_default<T: serde::de::DeserializeOwned + Default>(path: &Path) -> T {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|s| toml::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_match_design_system() {
|
||||
let a = Appearance::default();
|
||||
assert_eq!(a.background.mode, BackgroundMode::Color);
|
||||
assert_eq!(a.clock.format, "%H:%M");
|
||||
assert_eq!(a.font.family, "Varela Round");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_falls_back_to_default() {
|
||||
let a: Appearance = load_or_default(Path::new("/nonexistent/breadlock-test.toml"));
|
||||
assert_eq!(a.font.family, "Varela Round");
|
||||
}
|
||||
|
||||
#[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);
|
||||
assert_eq!(a.clock.format, "%I:%M %p");
|
||||
assert_eq!(a.background.mode, BackgroundMode::Color);
|
||||
std::fs::remove_file(&dir).ok();
|
||||
}
|
||||
}
|
||||
134
breadlock-ui/src/desktop_entry.rs
Normal file
134
breadlock-ui/src/desktop_entry.rs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
//! 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.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DesktopEntry {
|
||||
pub name: String,
|
||||
pub exec: String,
|
||||
pub entry_type: String,
|
||||
}
|
||||
|
||||
/// Parses the `[Desktop Entry]` section of a `.desktop` file's contents.
|
||||
/// Returns `None` if `Name=` or `Exec=` is missing.
|
||||
pub fn parse(contents: &str) -> Option<DesktopEntry> {
|
||||
let mut name = None;
|
||||
let mut exec = None;
|
||||
let mut entry_type = None;
|
||||
let mut in_desktop_entry = false;
|
||||
|
||||
for line in contents.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some(section) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
|
||||
in_desktop_entry = section == "Desktop Entry";
|
||||
continue;
|
||||
}
|
||||
if !in_desktop_entry {
|
||||
continue;
|
||||
}
|
||||
if let Some((key, value)) = line.split_once('=') {
|
||||
match key.trim() {
|
||||
"Name" => name = Some(value.trim().to_string()),
|
||||
"Exec" => exec = Some(value.trim().to_string()),
|
||||
"Type" => entry_type = Some(value.trim().to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(DesktopEntry {
|
||||
name: name?,
|
||||
exec: exec?,
|
||||
entry_type: entry_type.unwrap_or_else(|| "Application".to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
/// 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).
|
||||
pub fn scan_dir(dir: &Path) -> Vec<(String, DesktopEntry)> {
|
||||
let Ok(read_dir) = std::fs::read_dir(dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut entries: Vec<(String, DesktopEntry)> = read_dir
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().extension().is_some_and(|ext| ext == "desktop"))
|
||||
.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)?))
|
||||
})
|
||||
.collect();
|
||||
|
||||
entries.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
entries
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const HYPRLAND_DESKTOP: &str = "[Desktop Entry]\n\
|
||||
Name=Hyprland\n\
|
||||
Comment=An intelligent dynamic tiling Wayland compositor\n\
|
||||
Exec=Hyprland\n\
|
||||
Type=Application\n";
|
||||
|
||||
#[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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_keys_outside_desktop_entry_section() {
|
||||
let contents = "[Desktop Action foo]\nName=Not this one\n\
|
||||
[Desktop Entry]\nName=Real\nExec=real-cmd\n";
|
||||
let e = parse(contents).unwrap();
|
||||
assert_eq!(e.name, "Real");
|
||||
assert_eq!(e.exec, "real-cmd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_exec_returns_none() {
|
||||
assert!(parse("[Desktop Entry]\nName=Broken\n").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_type_defaults_to_application() {
|
||||
let e = parse("[Desktop Entry]\nName=X\nExec=x\n").unwrap();
|
||||
assert_eq!(e.entry_type, "Application");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_dir_on_missing_directory_returns_empty() {
|
||||
assert!(scan_dir(Path::new("/nonexistent/wayland-sessions")).is_empty());
|
||||
}
|
||||
|
||||
#[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();
|
||||
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();
|
||||
|
||||
let found = scan_dir(&dir);
|
||||
assert_eq!(found.len(), 2);
|
||||
assert_eq!(found[0].0, "aaa");
|
||||
assert_eq!(found[1].0, "zzz");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
6
breadlock-ui/src/lib.rs
Normal file
6
breadlock-ui/src/lib.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
pub mod config;
|
||||
pub mod desktop_entry;
|
||||
pub mod theme;
|
||||
|
||||
#[cfg(feature = "paint")]
|
||||
pub mod painter;
|
||||
171
breadlock-ui/src/painter.rs
Normal file
171
breadlock-ui/src/painter.rs
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
//! Software-rendering primitives shared by `breadlock`'s frame composition:
|
||||
//! rounded-rect paths (radius tokens from [`bread_theme::tokens`]) and text
|
||||
//! layout/rasterization via `cosmic-text`, blitted into a `tiny-skia`
|
||||
//! `Pixmap`. Only linked into `breadlock` — `breadgreet` draws through GTK/CSS
|
||||
//! instead and doesn't need a font-shaping stack.
|
||||
|
||||
pub use bread_theme::tokens;
|
||||
use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping, SwashCache};
|
||||
use tiny_skia::{Path, PathBuilder, Pixmap, PremultipliedColorU8};
|
||||
|
||||
/// Builds a rounded-rectangle path. `radius` is clamped so it never exceeds
|
||||
/// half the shorter side (a degenerate radius would otherwise self-intersect).
|
||||
pub fn rounded_rect(x: f32, y: f32, w: f32, h: f32, radius: f32) -> Option<Path> {
|
||||
let r = radius.max(0.0).min(w / 2.0).min(h / 2.0);
|
||||
let mut pb = PathBuilder::new();
|
||||
pb.move_to(x + r, y);
|
||||
pb.line_to(x + w - r, y);
|
||||
pb.quad_to(x + w, y, x + w, y + r);
|
||||
pb.line_to(x + w, y + h - r);
|
||||
pb.quad_to(x + w, y + h, x + w - r, y + h);
|
||||
pb.line_to(x + r, y + h);
|
||||
pb.quad_to(x, y + h, x, y + h - r);
|
||||
pb.line_to(x, y + r);
|
||||
pb.quad_to(x, y, x + r, y);
|
||||
pb.close();
|
||||
pb.finish()
|
||||
}
|
||||
|
||||
/// Owns the font database and glyph raster cache. Expensive to create
|
||||
/// (`FontSystem::new()` scans installed fonts), so construct once and reuse
|
||||
/// across every frame.
|
||||
pub struct TextRenderer {
|
||||
font_system: FontSystem,
|
||||
swash_cache: SwashCache,
|
||||
}
|
||||
|
||||
impl Default for TextRenderer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl TextRenderer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
font_system: FontSystem::new(),
|
||||
swash_cache: SwashCache::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn shape_line(&mut self, text: &str, family: &str, size_px: f32, max_width: f32) -> Buffer {
|
||||
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));
|
||||
buffer.set_text(&mut self.font_system, text, &attrs, Shaping::Advanced);
|
||||
buffer.shape_until_scroll(&mut self.font_system, false);
|
||||
buffer
|
||||
}
|
||||
|
||||
/// 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);
|
||||
buffer
|
||||
.layout_runs()
|
||||
.map(|run| run.line_w)
|
||||
.fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn draw_line(
|
||||
&mut self,
|
||||
pixmap: &mut Pixmap,
|
||||
text: &str,
|
||||
family: &str,
|
||||
size_px: f32,
|
||||
color: tiny_skia::Color,
|
||||
origin_x: f32,
|
||||
origin_y: f32,
|
||||
) {
|
||||
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());
|
||||
|
||||
let (width, height) = (pixmap.width() as i32, pixmap.height() as i32);
|
||||
let ox = origin_x as i32;
|
||||
let oy = origin_y as i32;
|
||||
buffer.draw(
|
||||
&mut self.font_system,
|
||||
&mut self.swash_cache,
|
||||
text_color,
|
||||
|x, y, _w, _h, glyph_color| {
|
||||
let (px, py) = (ox + x, oy + y);
|
||||
if px < 0 || py < 0 || px >= width || py >= height {
|
||||
return;
|
||||
}
|
||||
let (r, g, b, a) = glyph_color.as_rgba_tuple();
|
||||
if a == 0 {
|
||||
return;
|
||||
}
|
||||
blend_over_opaque(pixmap, px as u32, py as u32, r, g, b, a);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
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 {
|
||||
pixels[idx] = blended;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rounded_rect_produces_closed_path() {
|
||||
let path = rounded_rect(0.0, 0.0, 100.0, 40.0, tokens::RADIUS_SECONDARY as f32).unwrap();
|
||||
assert!(!path.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_rect_clamps_oversized_radius() {
|
||||
// radius larger than half the shorter side must not panic or produce garbage
|
||||
let path = rounded_rect(0.0, 0.0, 10.0, 10.0, 999.0);
|
||||
assert!(path.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_renderer_draws_without_panicking_on_tiny_pixmap() {
|
||||
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",
|
||||
"sans-serif",
|
||||
12.0,
|
||||
tiny_skia::Color::WHITE,
|
||||
2.0,
|
||||
2.0,
|
||||
);
|
||||
// No panic and the pixmap remains fully opaque is the property under test —
|
||||
// exact glyph coverage depends on whatever fonts are installed on the CI host.
|
||||
assert!(pixmap.pixels().iter().all(|p| p.alpha() == 255));
|
||||
}
|
||||
}
|
||||
32
breadlock-ui/src/theme.rs
Normal file
32
breadlock-ui/src/theme.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
pub use bread_theme::{ink_on, load_palette, Palette};
|
||||
|
||||
/// Parse a `#rrggbb` hex colour. Falls back to opaque black on malformed input
|
||||
/// (palette slots are always produced by [`bread_theme`], which guarantees
|
||||
/// valid hex, but a user-supplied override in a future config field might not).
|
||||
pub fn parse_hex(hex: &str) -> (u8, u8, u8) {
|
||||
let h = hex.trim_start_matches('#');
|
||||
let byte = |i: usize| u8::from_str_radix(h.get(i..i + 2).unwrap_or("00"), 16).unwrap_or(0);
|
||||
(byte(0), byte(2), byte(4))
|
||||
}
|
||||
|
||||
#[cfg(feature = "paint")]
|
||||
pub fn tiny_skia_color(hex: &str) -> tiny_skia::Color {
|
||||
let (r, g, b) = parse_hex(hex);
|
||||
tiny_skia::Color::from_rgba8(r, g, b, 255)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_known_hex() {
|
||||
assert_eq!(parse_hex("#1e1e2e"), (0x1e, 0x1e, 0x2e));
|
||||
assert_eq!(parse_hex("89b4fa"), (0x89, 0xb4, 0xfa));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_hex_falls_back_to_black() {
|
||||
assert_eq!(parse_hex("nope"), (0, 0, 0));
|
||||
}
|
||||
}
|
||||
22
breadlock.example.toml
Normal file
22
breadlock.example.toml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# 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.
|
||||
|
||||
[background]
|
||||
# "color" (bread-theme palette background) or "image" (a PNG, cover-fit)
|
||||
mode = "color"
|
||||
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
|
||||
|
||||
[clock]
|
||||
# strftime format
|
||||
format = "%H:%M"
|
||||
|
||||
[font]
|
||||
family = "Varela Round"
|
||||
|
||||
[input]
|
||||
# How long the "wrong password" state (red pill) shows before input
|
||||
# re-enables, in milliseconds.
|
||||
fail_timeout_ms = 800
|
||||
31
breadlock/Cargo.toml
Normal file
31
breadlock/Cargo.toml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
[package]
|
||||
name = "breadlock"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["Breadway <rileyhorsham@gmail.com>"]
|
||||
description = "Session locker for Hyprland / Wayland (ext-session-lock-v1)"
|
||||
|
||||
[[bin]]
|
||||
name = "breadlock"
|
||||
path = "src/main.rs"
|
||||
|
||||
# Dev-only harness: exercises the PAM auth flow against a typed password with
|
||||
# no Wayland surface involved at all — see the "safe verification" plan in
|
||||
# the repo's design notes. Not installed by the package.
|
||||
[[bin]]
|
||||
name = "breadlock-auth-check"
|
||||
path = "src/bin/breadlock-auth-check.rs"
|
||||
|
||||
[dependencies]
|
||||
breadlock-ui = { path = "../breadlock-ui", features = ["paint"] }
|
||||
smithay-client-toolkit = "0.20"
|
||||
wayland-client = "0.31"
|
||||
tiny-skia = "0.12"
|
||||
chrono = "0.4"
|
||||
pam-client2 = { version = "0.5", default-features = false }
|
||||
serde.workspace = true
|
||||
toml.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
thiserror.workspace = true
|
||||
44
breadlock/src/auth/mod.rs
Normal file
44
breadlock/src/auth/mod.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
//! Bridges blocking PAM auth (see [`pam`]) onto the calloop event loop that
|
||||
//! drives the Wayland connection and rendering.
|
||||
//!
|
||||
//! Each attempt runs on its own throwaway OS thread (libpam's conversation
|
||||
//! callback is synchronous FFI and must never block the render loop); the
|
||||
//! result is posted back through a `calloop::channel` registered once at
|
||||
//! startup, so the main loop just gets an ordinary event.
|
||||
|
||||
pub mod pam;
|
||||
|
||||
pub use pam::AuthError;
|
||||
|
||||
use smithay_client_toolkit::reexports::calloop::channel::{self, Sender};
|
||||
use smithay_client_toolkit::reexports::calloop::LoopHandle;
|
||||
|
||||
pub type AuthResult = Result<(), AuthError>;
|
||||
|
||||
/// Registers the receiving half of the auth-result channel on the event
|
||||
/// loop and returns the `Sender` to hand to [`spawn_check`] on each attempt.
|
||||
pub fn register<Data: 'static>(
|
||||
loop_handle: &LoopHandle<'static, Data>,
|
||||
mut on_result: impl FnMut(&mut Data, AuthResult) + 'static,
|
||||
) -> Sender<AuthResult> {
|
||||
let (tx, channel) = channel::channel();
|
||||
loop_handle
|
||||
.insert_source(channel, move |event, _, data| {
|
||||
if let channel::Event::Msg(result) = event {
|
||||
on_result(data, result);
|
||||
}
|
||||
})
|
||||
.expect("failed to register auth-result channel on event loop");
|
||||
tx
|
||||
}
|
||||
|
||||
/// Spawns a PAM check for `username`/`password` on its own thread; the
|
||||
/// outcome arrives later as an event on the loop registered via
|
||||
/// [`register`]. `password` is moved in and dropped as soon as the PAM
|
||||
/// conversation consumes it — it is never logged.
|
||||
pub fn spawn_check(username: String, password: String, result_tx: Sender<AuthResult>) {
|
||||
std::thread::spawn(move || {
|
||||
let result = pam::check(&username, &password);
|
||||
let _ = result_tx.send(result);
|
||||
});
|
||||
}
|
||||
35
breadlock/src/auth/pam.rs
Normal file
35
breadlock/src/auth/pam.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
//! Blocking PAM authentication. libpam's conversation callback is synchronous
|
||||
//! FFI, so [`check`] must never be called on the render/event-loop thread —
|
||||
//! see [`super`] for the thread hand-off.
|
||||
|
||||
use pam_client2::conv_mock::Conversation;
|
||||
use pam_client2::{Context, Flag};
|
||||
|
||||
/// The PAM service name — matches `/etc/pam.d/breadlock`
|
||||
/// (packaging/pam.d/breadlock), which is what actually determines the auth
|
||||
/// stack (pam_unix, pam_faillock, etc.). This string is just the lookup key.
|
||||
const SERVICE: &str = "breadlock";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum AuthError {
|
||||
#[error("PAM authentication failed")]
|
||||
Authenticate,
|
||||
#[error("PAM account validation failed (expired, locked, etc.)")]
|
||||
AccountInvalid,
|
||||
#[error("failed to initialize PAM context")]
|
||||
ContextInit,
|
||||
}
|
||||
|
||||
/// Verifies `password` for `username` via PAM. Runs `authenticate` +
|
||||
/// `acct_mgmt` (no `open_session` — the graphical session is already open;
|
||||
/// this only re-proves who's sitting at the keyboard).
|
||||
pub fn check(username: &str, password: &str) -> Result<(), AuthError> {
|
||||
let conv = Conversation::with_credentials(username, password);
|
||||
let mut ctx =
|
||||
Context::new(SERVICE, Some(username), conv).map_err(|_| AuthError::ContextInit)?;
|
||||
ctx.authenticate(Flag::NONE)
|
||||
.map_err(|_| AuthError::Authenticate)?;
|
||||
ctx.acct_mgmt(Flag::NONE)
|
||||
.map_err(|_| AuthError::AccountInvalid)?;
|
||||
Ok(())
|
||||
}
|
||||
68
breadlock/src/background.rs
Normal file
68
breadlock/src/background.rs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
//! 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.
|
||||
|
||||
use breadlock_ui::config::{Background as BackgroundConfig, BackgroundMode};
|
||||
use tiny_skia::{Pixmap, PixmapPaint, Transform};
|
||||
|
||||
pub enum Background {
|
||||
Color(tiny_skia::Color),
|
||||
Image(Pixmap),
|
||||
}
|
||||
|
||||
impl Background {
|
||||
pub fn load(cfg: &BackgroundConfig, palette: &breadlock_ui::theme::Palette) -> Self {
|
||||
let fallback =
|
||||
|| Background::Color(breadlock_ui::theme::tiny_skia_color(&palette.background));
|
||||
|
||||
if cfg.blur {
|
||||
tracing::warn!(
|
||||
"background.blur is not implemented yet (planned v2 feature, needs a wlr-screencopy \
|
||||
capture) — showing the configured background unblurred"
|
||||
);
|
||||
}
|
||||
|
||||
match cfg.mode {
|
||||
BackgroundMode::Color => fallback(),
|
||||
BackgroundMode::Image => {
|
||||
if cfg.path.is_empty() {
|
||||
tracing::warn!("background.mode = \"image\" but background.path is empty, falling back to palette color");
|
||||
return fallback();
|
||||
}
|
||||
match Pixmap::load_png(&cfg.path) {
|
||||
Ok(pixmap) => Background::Image(pixmap),
|
||||
Err(err) => {
|
||||
tracing::warn!(path = %cfg.path, %err, "failed to load background image (PNG only in v1), falling back to palette color");
|
||||
fallback()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
match self {
|
||||
Background::Color(c) => target.fill(*c),
|
||||
Background::Image(source) => {
|
||||
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);
|
||||
target.fill(tiny_skia::Color::BLACK);
|
||||
target.draw_pixmap(
|
||||
0,
|
||||
0,
|
||||
source.as_ref(),
|
||||
&PixmapPaint::default(),
|
||||
Transform::from_scale(scale, scale),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
58
breadlock/src/bin/breadlock-auth-check.rs
Normal file
58
breadlock/src/bin/breadlock-auth-check.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
//! Dev-only harness: exercises the exact PAM flow `breadlock` uses
|
||||
//! (`auth::pam::check`) against a typed password, with no Wayland surface
|
||||
//! involved at all. This is the safest way to validate the
|
||||
//! `/etc/pam.d/breadlock` service file and the auth logic in isolation — a
|
||||
//! bad PAM config here just prints an error, it can never lock a session.
|
||||
//!
|
||||
//! Not installed by the package; run from a build tree with
|
||||
//! `cargo run --bin breadlock-auth-check`.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
#[path = "../auth/pam.rs"]
|
||||
mod pam;
|
||||
|
||||
fn main() {
|
||||
let username = std::env::var("USER").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 password = rpassword_prompt();
|
||||
|
||||
match pam::check(&username, &password) {
|
||||
Ok(()) => println!("OK: {username} authenticated"),
|
||||
Err(e) => {
|
||||
eprintln!("FAILED: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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 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()
|
||||
}
|
||||
56
breadlock/src/config.rs
Normal file
56
breadlock/src/config.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
use breadlock_ui::config::Appearance;
|
||||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
#[serde(flatten)]
|
||||
pub appearance: Appearance,
|
||||
pub input: Input,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Input {
|
||||
/// How long the "wrong password" shake shows before input re-enables.
|
||||
pub fail_timeout_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for Input {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
fail_timeout_ms: 800,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load() -> Config {
|
||||
breadlock_ui::config::load_or_default(&config_path())
|
||||
}
|
||||
|
||||
fn config_path() -> PathBuf {
|
||||
let base = 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("breadlock").join("breadlock.toml")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_config_has_expected_fail_timeout() {
|
||||
assert_eq!(Config::default().input.fail_timeout_ms, 800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flattened_appearance_parses_alongside_input() {
|
||||
let toml = "[clock]\nformat = \"%H:%M:%S\"\n[input]\nfail_timeout_ms = 1200\n";
|
||||
let cfg: Config = toml::from_str(toml).unwrap();
|
||||
assert_eq!(cfg.appearance.clock.format, "%H:%M:%S");
|
||||
assert_eq!(cfg.input.fail_timeout_ms, 1200);
|
||||
}
|
||||
}
|
||||
167
breadlock/src/input/keyboard.rs
Normal file
167
breadlock/src/input/keyboard.rs
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
use smithay_client_toolkit::seat::keyboard::{
|
||||
KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers,
|
||||
};
|
||||
use smithay_client_toolkit::seat::{Capability, SeatHandler, SeatState};
|
||||
use wayland_client::protocol::{wl_keyboard, wl_seat, wl_surface};
|
||||
use wayland_client::{Connection, QueueHandle};
|
||||
|
||||
use crate::auth;
|
||||
use crate::state::{AppState, AuthState};
|
||||
|
||||
impl SeatHandler for AppState {
|
||||
fn seat_state(&mut self) -> &mut SeatState {
|
||||
&mut self.seat_state
|
||||
}
|
||||
|
||||
fn new_seat(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _seat: wl_seat::WlSeat) {}
|
||||
|
||||
fn new_capability(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
seat: wl_seat::WlSeat,
|
||||
capability: Capability,
|
||||
) {
|
||||
if capability == Capability::Keyboard && self.keyboard.is_none() {
|
||||
match self.seat_state.get_keyboard(qh, &seat, None) {
|
||||
Ok(keyboard) => self.keyboard = Some(keyboard),
|
||||
Err(err) => tracing::error!(%err, "failed to bind keyboard"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_capability(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_seat: wl_seat::WlSeat,
|
||||
capability: Capability,
|
||||
) {
|
||||
if capability == Capability::Keyboard {
|
||||
if let Some(keyboard) = self.keyboard.take() {
|
||||
keyboard.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_seat(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _seat: wl_seat::WlSeat) {
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyboardHandler for AppState {
|
||||
fn enter(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_keyboard: &wl_keyboard::WlKeyboard,
|
||||
_surface: &wl_surface::WlSurface,
|
||||
_serial: u32,
|
||||
_raw: &[u32],
|
||||
_keysyms: &[Keysym],
|
||||
) {
|
||||
}
|
||||
|
||||
fn leave(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_keyboard: &wl_keyboard::WlKeyboard,
|
||||
_surface: &wl_surface::WlSurface,
|
||||
_serial: u32,
|
||||
) {
|
||||
}
|
||||
|
||||
fn press_key(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
_keyboard: &wl_keyboard::WlKeyboard,
|
||||
_serial: u32,
|
||||
event: KeyEvent,
|
||||
) {
|
||||
self.handle_key(qh, event);
|
||||
}
|
||||
|
||||
fn repeat_key(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
_keyboard: &wl_keyboard::WlKeyboard,
|
||||
_serial: u32,
|
||||
event: KeyEvent,
|
||||
) {
|
||||
self.handle_key(qh, event);
|
||||
}
|
||||
|
||||
fn release_key(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_keyboard: &wl_keyboard::WlKeyboard,
|
||||
_serial: u32,
|
||||
_event: KeyEvent,
|
||||
) {
|
||||
}
|
||||
|
||||
fn update_modifiers(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_keyboard: &wl_keyboard::WlKeyboard,
|
||||
_serial: u32,
|
||||
_modifiers: Modifiers,
|
||||
_raw_modifiers: RawModifiers,
|
||||
_layout: u32,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn handle_key(&mut self, qh: &QueueHandle<Self>, event: KeyEvent) {
|
||||
// Ignore all input while a PAM check is in flight so a fast second
|
||||
// Enter can't race the first attempt.
|
||||
if self.auth_state == AuthState::Checking {
|
||||
return;
|
||||
}
|
||||
|
||||
match event.keysym {
|
||||
Keysym::Return | Keysym::KP_Enter => self.submit(),
|
||||
Keysym::BackSpace => {
|
||||
self.password.pop();
|
||||
self.clear_failed_state();
|
||||
}
|
||||
Keysym::Escape => {
|
||||
self.password.clear();
|
||||
self.clear_failed_state();
|
||||
}
|
||||
_ => {
|
||||
if let Some(text) = event.utf8 {
|
||||
// Return/BackSpace/Escape are handled above by keysym;
|
||||
// this guards against a compositor also sending utf8 for
|
||||
// those (defensive — filters any stray control chars).
|
||||
for ch in text.chars().filter(|c| !c.is_control()) {
|
||||
self.password.push(ch);
|
||||
}
|
||||
self.clear_failed_state();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.redraw_all(qh);
|
||||
}
|
||||
|
||||
fn clear_failed_state(&mut self) {
|
||||
if self.auth_state == AuthState::Failed {
|
||||
self.auth_state = AuthState::Idle;
|
||||
}
|
||||
}
|
||||
|
||||
fn submit(&mut self) {
|
||||
if self.password.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.auth_state = AuthState::Checking;
|
||||
let password = std::mem::take(&mut self.password);
|
||||
auth::spawn_check(self.username.clone(), password, self.auth_tx.clone());
|
||||
}
|
||||
}
|
||||
1
breadlock/src/input/mod.rs
Normal file
1
breadlock/src/input/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
mod keyboard;
|
||||
2
breadlock/src/lock/mod.rs
Normal file
2
breadlock/src/lock/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
mod session;
|
||||
mod surface;
|
||||
48
breadlock/src/lock/session.rs
Normal file
48
breadlock/src/lock/session.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
use smithay_client_toolkit::session_lock::{
|
||||
SessionLock, SessionLockHandler, SessionLockSurface, SessionLockSurfaceConfigure,
|
||||
};
|
||||
use wayland_client::{Connection, QueueHandle};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
impl SessionLockHandler for AppState {
|
||||
fn locked(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, session_lock: SessionLock) {
|
||||
tracing::info!("session locked");
|
||||
self.session_lock = Some(session_lock);
|
||||
}
|
||||
|
||||
/// The compositor denied the lock request, or ended an active lock out
|
||||
/// from under us (e.g. protocol error). Either way there's no lock left
|
||||
/// to protect, so the only sane move is to exit — staying resident
|
||||
/// unlocked would be worse than not running at all.
|
||||
fn finished(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_session_lock: SessionLock,
|
||||
) {
|
||||
tracing::warn!("compositor ended the session lock; exiting");
|
||||
self.session_lock = None;
|
||||
self.exit = true;
|
||||
}
|
||||
|
||||
fn configure(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
surface: SessionLockSurface,
|
||||
configure: SessionLockSurfaceConfigure,
|
||||
_serial: u32,
|
||||
) {
|
||||
let (width, height) = configure.new_size;
|
||||
if let Some(s) = self
|
||||
.surfaces
|
||||
.iter_mut()
|
||||
.find(|s| s.surface.wl_surface() == surface.wl_surface())
|
||||
{
|
||||
s.width = width;
|
||||
s.height = height;
|
||||
}
|
||||
self.redraw_surface(qh, &surface, width, height);
|
||||
}
|
||||
}
|
||||
96
breadlock/src/lock/surface.rs
Normal file
96
breadlock/src/lock/surface.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
use smithay_client_toolkit::compositor::{CompositorHandler, CompositorState};
|
||||
use smithay_client_toolkit::output::{OutputHandler, OutputState};
|
||||
use wayland_client::protocol::{wl_output, wl_surface};
|
||||
use wayland_client::{Connection, QueueHandle};
|
||||
|
||||
use crate::state::{AppState, LockSurface};
|
||||
|
||||
impl CompositorHandler for AppState {
|
||||
fn scale_factor_changed(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_surface: &wl_surface::WlSurface,
|
||||
_new_factor: i32,
|
||||
) {
|
||||
}
|
||||
|
||||
fn transform_changed(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_surface: &wl_surface::WlSurface,
|
||||
_new_transform: wl_output::Transform,
|
||||
) {
|
||||
}
|
||||
|
||||
fn frame(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_surface: &wl_surface::WlSurface,
|
||||
_time: u32,
|
||||
) {
|
||||
}
|
||||
|
||||
fn surface_enter(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_surface: &wl_surface::WlSurface,
|
||||
_output: &wl_output::WlOutput,
|
||||
) {
|
||||
}
|
||||
|
||||
fn surface_leave(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_surface: &wl_surface::WlSurface,
|
||||
_output: &wl_output::WlOutput,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
impl OutputHandler for AppState {
|
||||
fn output_state(&mut self) -> &mut OutputState {
|
||||
&mut self.output_state
|
||||
}
|
||||
|
||||
/// A monitor appeared. If we're already locked, give it a lock surface
|
||||
/// too — the initial set (for outputs present at lock time) is created
|
||||
/// once in `main`, right after `SessionLockState::lock`.
|
||||
fn new_output(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
output: wl_output::WlOutput,
|
||||
) {
|
||||
let Some(session_lock) = self.session_lock.clone() else {
|
||||
return;
|
||||
};
|
||||
let surface = CompositorState::create_surface(&self.compositor_state, qh);
|
||||
let lock_surface = session_lock.create_lock_surface(surface, &output, qh);
|
||||
self.surfaces.push(LockSurface {
|
||||
surface: lock_surface,
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
}
|
||||
|
||||
fn update_output(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_output: wl_output::WlOutput,
|
||||
) {
|
||||
}
|
||||
|
||||
fn output_destroyed(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_output: wl_output::WlOutput,
|
||||
) {
|
||||
}
|
||||
}
|
||||
151
breadlock/src/main.rs
Normal file
151
breadlock/src/main.rs
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
mod auth;
|
||||
mod background;
|
||||
mod config;
|
||||
mod input;
|
||||
mod lock;
|
||||
mod render;
|
||||
mod state;
|
||||
|
||||
use smithay_client_toolkit::compositor::CompositorState;
|
||||
use smithay_client_toolkit::output::OutputState;
|
||||
use smithay_client_toolkit::reexports::calloop::timer::{TimeoutAction, Timer};
|
||||
use smithay_client_toolkit::reexports::calloop::EventLoop;
|
||||
use smithay_client_toolkit::reexports::calloop_wayland_source::WaylandSource;
|
||||
use smithay_client_toolkit::registry::RegistryState;
|
||||
use smithay_client_toolkit::seat::SeatState;
|
||||
use smithay_client_toolkit::session_lock::SessionLockState;
|
||||
use smithay_client_toolkit::shm::Shm;
|
||||
use std::time::Duration;
|
||||
use wayland_client::globals::registry_queue_init;
|
||||
use wayland_client::{protocol::wl_buffer, Connection, QueueHandle};
|
||||
|
||||
use background::Background;
|
||||
use state::{AppState, AuthState, LockSurface};
|
||||
|
||||
fn main() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
let username = std::env::var("USER")
|
||||
.or_else(|_| std::env::var("LOGNAME"))
|
||||
.unwrap_or_else(|_| {
|
||||
tracing::error!("neither $USER nor $LOGNAME is set — refusing to start without a username to authenticate");
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
let config = config::load();
|
||||
let palette = breadlock_ui::theme::load_palette();
|
||||
let background = Background::load(&config.appearance.background, &palette);
|
||||
|
||||
let conn = Connection::connect_to_env().expect("failed to connect to the Wayland display — breadlock must run inside an active Wayland session");
|
||||
let (globals, event_queue) =
|
||||
registry_queue_init::<AppState>(&conn).expect("failed to initialize Wayland registry");
|
||||
let qh: QueueHandle<AppState> = event_queue.handle();
|
||||
let mut event_loop: EventLoop<AppState> =
|
||||
EventLoop::try_new().expect("failed to create the calloop event loop");
|
||||
let loop_handle = event_loop.handle();
|
||||
|
||||
let auth_result_qh = qh.clone();
|
||||
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();
|
||||
}
|
||||
state.exit = true;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, "authentication failed");
|
||||
state.auth_state = AuthState::Failed;
|
||||
state.schedule_clear_failed(auth_result_qh.clone());
|
||||
}
|
||||
}
|
||||
state.redraw_all(&auth_result_qh);
|
||||
});
|
||||
|
||||
let compositor_state =
|
||||
CompositorState::bind(&globals, &qh).expect("compositor global not advertised");
|
||||
let output_state = OutputState::new(&globals, &qh);
|
||||
let shm = Shm::bind(&globals, &qh).expect("wl_shm global not advertised");
|
||||
let session_lock_state = SessionLockState::new(&globals, &qh);
|
||||
|
||||
let mut app_state = AppState {
|
||||
loop_handle: loop_handle.clone(),
|
||||
conn: conn.clone(),
|
||||
compositor_state,
|
||||
output_state,
|
||||
registry_state: RegistryState::new(&globals),
|
||||
seat_state: SeatState::new(&globals, &qh),
|
||||
shm,
|
||||
session_lock_state,
|
||||
session_lock: None,
|
||||
surfaces: Vec::new(),
|
||||
keyboard: None,
|
||||
config,
|
||||
palette,
|
||||
background,
|
||||
text_renderer: breadlock_ui::painter::TextRenderer::new(),
|
||||
username,
|
||||
password: String::new(),
|
||||
auth_state: AuthState::Idle,
|
||||
auth_tx,
|
||||
exit: false,
|
||||
};
|
||||
|
||||
// Request the lock immediately — locking the session is the entire
|
||||
// purpose of this process, not a toggle. Per ext-session-lock-v1, a lock
|
||||
// surface must exist for every output *before* the compositor sends
|
||||
// `locked` (see SessionLockHandler::locked's doc comment upstream).
|
||||
let session_lock = app_state
|
||||
.session_lock_state
|
||||
.lock(&qh)
|
||||
.expect("compositor does not support ext-session-lock-v1 — cannot lock the session");
|
||||
for output in app_state.output_state.outputs() {
|
||||
let surface = app_state.compositor_state.create_surface(&qh);
|
||||
let lock_surface = session_lock.create_lock_surface(surface, &output, &qh);
|
||||
app_state.surfaces.push(LockSurface {
|
||||
surface: lock_surface,
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
}
|
||||
app_state.session_lock = Some(session_lock);
|
||||
|
||||
WaylandSource::new(conn, event_queue)
|
||||
.insert(loop_handle.clone())
|
||||
.expect("failed to register the Wayland source on the event loop");
|
||||
|
||||
// Redraw every surface once a second so the clock stays live even with
|
||||
// no keyboard input.
|
||||
loop_handle
|
||||
.insert_source(
|
||||
Timer::from_duration(Duration::from_secs(1)),
|
||||
move |_, _, state| {
|
||||
state.redraw_all(&qh);
|
||||
TimeoutAction::ToDuration(Duration::from_secs(1))
|
||||
},
|
||||
)
|
||||
.expect("failed to register the clock-tick timer");
|
||||
|
||||
while !app_state.exit {
|
||||
if let Err(err) = event_loop.dispatch(Duration::from_millis(250), &mut app_state) {
|
||||
tracing::error!(%err, "event loop dispatch failed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure the compositor actually receives the unlock/destroy
|
||||
// requests queued above before the process exits.
|
||||
let _ = app_state.conn.roundtrip();
|
||||
}
|
||||
|
||||
smithay_client_toolkit::delegate_compositor!(AppState);
|
||||
smithay_client_toolkit::delegate_output!(AppState);
|
||||
smithay_client_toolkit::delegate_session_lock!(AppState);
|
||||
smithay_client_toolkit::delegate_shm!(AppState);
|
||||
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);
|
||||
179
breadlock/src/render.rs
Normal file
179
breadlock/src/render.rs
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
//! 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.
|
||||
//!
|
||||
//! 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
|
||||
//! little-endian machines. [`blit_to_shm`] does the swizzle.
|
||||
|
||||
use crate::background::Background;
|
||||
use breadlock_ui::painter::{rounded_rect, tokens, TextRenderer};
|
||||
use breadlock_ui::theme::tiny_skia_color;
|
||||
use tiny_skia::{Color, Paint, Pixmap};
|
||||
|
||||
pub struct FrameInputs<'a> {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub background: &'a Background,
|
||||
pub palette: &'a breadlock_ui::theme::Palette,
|
||||
pub font_family: &'a str,
|
||||
pub clock_text: &'a str,
|
||||
pub password_len: usize,
|
||||
/// True while showing a failed-attempt state (red pill). No animated
|
||||
/// shake in v1 — just a color/status-text indicator.
|
||||
pub failed: bool,
|
||||
pub status_text: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// 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<Pixmap> {
|
||||
let mut pixmap = Pixmap::new(inputs.width, inputs.height)?;
|
||||
inputs.background.paint(&mut 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);
|
||||
|
||||
// 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,
|
||||
Color::WHITE,
|
||||
(w - clock_w) / 2.0,
|
||||
h * 0.28,
|
||||
);
|
||||
|
||||
// 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;
|
||||
|
||||
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.anti_alias = true;
|
||||
pixmap.fill_path(
|
||||
&path,
|
||||
&paint,
|
||||
tiny_skia::FillRule::Winding,
|
||||
tiny_skia::Transform::identity(),
|
||||
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)
|
||||
.floor()
|
||||
.max(1.0) as usize;
|
||||
let shown_dots = inputs.password_len.min(max_dots);
|
||||
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;
|
||||
for i in 0..shown_dots {
|
||||
if let Some(path) =
|
||||
tiny_skia::PathBuilder::from_circle(start_x + i as f32 * dot_gap, dot_y, dot_r)
|
||||
{
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color(if inputs.failed {
|
||||
Color::WHITE
|
||||
} else {
|
||||
accent_color
|
||||
});
|
||||
paint.anti_alias = true;
|
||||
pixmap.fill_path(
|
||||
&path,
|
||||
&paint,
|
||||
tiny_skia::FillRule::Winding,
|
||||
tiny_skia::Transform::identity(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Status line (e.g. "wrong password" / "checking…") below the pill.
|
||||
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);
|
||||
text.draw_line(
|
||||
&mut pixmap,
|
||||
status,
|
||||
inputs.font_family,
|
||||
status_size,
|
||||
on_surface,
|
||||
(w - status_w) / 2.0,
|
||||
pill_y + pill_h + tokens::SPACE_MD as f32,
|
||||
);
|
||||
}
|
||||
|
||||
Some(pixmap)
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// on).
|
||||
pub fn blit_to_shm(pixmap: &Pixmap, shm_bytes: &mut [u8]) {
|
||||
for (src, dst) in pixmap.pixels().iter().zip(shm_bytes.chunks_exact_mut(4)) {
|
||||
dst[0] = src.blue();
|
||||
dst[1] = src.green();
|
||||
dst[2] = src.red();
|
||||
dst[3] = src.alpha();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn blit_swizzles_rgba_to_bgra() {
|
||||
let mut pixmap = Pixmap::new(1, 1).unwrap();
|
||||
pixmap.fill(Color::from_rgba8(10, 20, 30, 255));
|
||||
let mut shm = vec![0u8; 4];
|
||||
blit_to_shm(&pixmap, &mut shm);
|
||||
assert_eq!(shm, vec![30, 20, 10, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_handles_empty_password_and_no_status() {
|
||||
let bg = Background::Color(Color::BLACK);
|
||||
let palette = breadlock_ui::theme::Palette::default();
|
||||
let mut text = TextRenderer::new();
|
||||
let inputs = FrameInputs {
|
||||
width: 400,
|
||||
height: 300,
|
||||
background: &bg,
|
||||
palette: &palette,
|
||||
font_family: "sans-serif",
|
||||
clock_text: "12:34",
|
||||
password_len: 0,
|
||||
failed: false,
|
||||
status_text: None,
|
||||
};
|
||||
let pixmap = compose(&mut text, &inputs).unwrap();
|
||||
assert_eq!((pixmap.width(), pixmap.height()), (400, 300));
|
||||
}
|
||||
}
|
||||
174
breadlock/src/state.rs
Normal file
174
breadlock/src/state.rs
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
use smithay_client_toolkit::compositor::CompositorState;
|
||||
use smithay_client_toolkit::output::OutputState;
|
||||
use smithay_client_toolkit::reexports::calloop::channel::Sender;
|
||||
use smithay_client_toolkit::reexports::calloop::timer::{TimeoutAction, Timer};
|
||||
use smithay_client_toolkit::reexports::calloop::LoopHandle;
|
||||
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::{Shm, ShmHandler};
|
||||
use std::time::Duration;
|
||||
use wayland_client::protocol::{wl_keyboard, wl_shm};
|
||||
use wayland_client::{Connection, QueueHandle};
|
||||
|
||||
use crate::auth::AuthResult;
|
||||
use crate::background::Background;
|
||||
use crate::config::Config;
|
||||
use crate::render;
|
||||
|
||||
/// Per-output lock surface plus the size the compositor last `configure`d it
|
||||
/// to (0x0 until the first configure arrives).
|
||||
pub struct LockSurface {
|
||||
pub surface: SessionLockSurface,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
#[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.
|
||||
Checking,
|
||||
Failed,
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
pub loop_handle: LoopHandle<'static, AppState>,
|
||||
pub conn: Connection,
|
||||
pub compositor_state: CompositorState,
|
||||
pub output_state: OutputState,
|
||||
pub registry_state: RegistryState,
|
||||
pub seat_state: SeatState,
|
||||
pub shm: Shm,
|
||||
pub session_lock_state: SessionLockState,
|
||||
pub session_lock: Option<SessionLock>,
|
||||
pub surfaces: Vec<LockSurface>,
|
||||
pub keyboard: Option<wl_keyboard::WlKeyboard>,
|
||||
|
||||
pub config: Config,
|
||||
pub palette: breadlock_ui::theme::Palette,
|
||||
pub background: Background,
|
||||
pub text_renderer: breadlock_ui::painter::TextRenderer,
|
||||
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub auth_state: AuthState,
|
||||
pub auth_tx: Sender<AuthResult>,
|
||||
|
||||
pub exit: bool,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Composes and uploads a fresh frame for one lock surface. A `0x0` size
|
||||
/// (surfaces are created at that size before their first `configure`) is
|
||||
/// skipped rather than allocating a degenerate shm pool.
|
||||
pub fn redraw_surface(
|
||||
&mut self,
|
||||
qh: &QueueHandle<Self>,
|
||||
surface: &SessionLockSurface,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) {
|
||||
if width == 0 || height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
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::Idle => None,
|
||||
};
|
||||
|
||||
let inputs = render::FrameInputs {
|
||||
width,
|
||||
height,
|
||||
background: &self.background,
|
||||
palette: &self.palette,
|
||||
font_family: &self.config.appearance.font.family,
|
||||
clock_text: &clock_text,
|
||||
password_len: self.password.len(),
|
||||
failed: self.auth_state == AuthState::Failed,
|
||||
status_text: status_text.as_deref(),
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
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,
|
||||
);
|
||||
|
||||
surface.wl_surface().attach(Some(&buffer), 0, 0);
|
||||
surface
|
||||
.wl_surface()
|
||||
.damage_buffer(0, 0, width as i32, height as i32);
|
||||
surface.wl_surface().commit();
|
||||
buffer.destroy();
|
||||
}
|
||||
|
||||
/// Redraws every currently-configured surface — used for the clock tick
|
||||
/// and after any password/auth-state change.
|
||||
pub fn redraw_all(&mut self, qh: &QueueHandle<Self>) {
|
||||
let surfaces: Vec<(SessionLockSurface, u32, u32)> = self
|
||||
.surfaces
|
||||
.iter()
|
||||
.map(|s| (s.surface.clone(), s.width, s.height))
|
||||
.collect();
|
||||
for (surface, width, height) in surfaces {
|
||||
self.redraw_surface(qh, &surface, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn schedule_clear_failed(&self, qh: QueueHandle<Self>) {
|
||||
let timeout = Duration::from_millis(self.config.input.fail_timeout_ms);
|
||||
let _ =
|
||||
self.loop_handle
|
||||
.insert_source(Timer::from_duration(timeout), move |_, _, state| {
|
||||
if state.auth_state == AuthState::Failed {
|
||||
state.auth_state = AuthState::Idle;
|
||||
state.redraw_all(&qh);
|
||||
}
|
||||
TimeoutAction::Drop
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl ShmHandler for AppState {
|
||||
fn shm_state(&mut self) -> &mut Shm {
|
||||
&mut self.shm
|
||||
}
|
||||
}
|
||||
|
||||
impl ProvidesRegistryState for AppState {
|
||||
fn registry(&mut self) -> &mut RegistryState {
|
||||
&mut self.registry_state
|
||||
}
|
||||
registry_handlers![OutputState, SeatState];
|
||||
}
|
||||
41
packaging/arch/PKGBUILD
Normal file
41
packaging/arch/PKGBUILD
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Maintainer: Breadway <rileyhorsham@gmail.com>
|
||||
|
||||
pkgname=breadlock
|
||||
pkgver=0.1.0
|
||||
pkgrel=1
|
||||
pkgdesc="Session locker and greetd greeter for Hyprland / Wayland"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/Breadway/breadlock"
|
||||
license=('MIT')
|
||||
# Some Rust deps build vendored C/asm into static archives; makepkg's default
|
||||
# -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read, causing
|
||||
# undefined-symbol errors. Disable LTO. (Same issue as breadbar/breadbox.)
|
||||
options=(!lto !debug)
|
||||
depends=('pam' 'wayland' 'libxkbcommon' 'gtk4')
|
||||
optdepends=(
|
||||
'cage: minimal Wayland compositor to host breadgreet under greetd'
|
||||
'hyprland: the session breadlock protects and breadgreet launches'
|
||||
)
|
||||
makedepends=('rust' 'cargo')
|
||||
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.
|
||||
cargo build --release --locked --bin breadlock --bin breadgreet
|
||||
}
|
||||
|
||||
check() {
|
||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
||||
cargo test --release --locked --workspace
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
||||
install -Dm755 target/release/breadlock "${pkgdir}/usr/bin/breadlock"
|
||||
install -Dm755 target/release/breadgreet "${pkgdir}/usr/bin/breadgreet"
|
||||
install -Dm644 packaging/pam.d/breadlock "${pkgdir}/etc/pam.d/breadlock"
|
||||
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||
}
|
||||
12
packaging/pam.d/breadlock
Normal file
12
packaging/pam.d/breadlock
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
#%PAM-1.0
|
||||
# Installed to /etc/pam.d/breadlock by the breadlock package.
|
||||
#
|
||||
# Mirrors this stack's real system-auth (so pam_faillock lockout protection
|
||||
# applies here too) rather than the thinner `login`-only include some lock
|
||||
# screens use. The extra `session` line avoids a known pam_end()-on-
|
||||
# uninitialized-handle crash class seen in minimal PAM files for lock
|
||||
# screens; breadlock never opens a real session (the graphical session is
|
||||
# already open), so no `session required pam_unix.so` line is needed.
|
||||
auth include system-auth
|
||||
account include system-auth
|
||||
session optional pam_keyinit.so force revoke
|
||||
Loading…
Add table
Add a link
Reference in a new issue