diff --git a/EVENTS.md b/EVENTS.md index 9b5801c..51f2d63 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -14,8 +14,8 @@ Commands are received on a `BreadClient::subscribe` background thread (reconnect/backoff) from two places: - the locker process itself, while the session is locked -- `breadlock listen`, a tiny long-running subscriber so the command - works while unlocked +- `breadlock listen`, a tiny long-running subscriber so lock/unlock + work while unlocked `breadgreet` is not wired to the bus. It runs under greetd (typically as the dedicated greeter user, before a user session exists), so breadd is @@ -30,29 +30,38 @@ from session lock/unlock. | `bread.lock.unlocked` | `{}` | PAM authenticated successfully and breadlock sent `unlock` to the compositor. Not emitted on a compositor-ended lock (`finished`), a dispatch-error exit (fail-secure: the session stays locked), or a failed/typo password. | | `bread.lock.lock.done` | `{}` | `bread.command.lock.lock` was honored: the locker was already running, or a locker process was started (same no-args invocation as hypridle's `lock_cmd = breadlock`). This is the command confirmation, not compositor proof — wait on `bread.lock.locked` if you need the session-lock protocol to have completed. | | `bread.lock.lock.failed` | `{ "error": "" }` | `bread.command.lock.lock` was received but the locker could not be started (e.g. this binary is missing from disk). | +| `bread.lock.unlock.done` | `{}` | `bread.command.lock.unlock` was honored: no locker was running (already unlocked), or `loginctl unlock-session` was invoked for this session. This is the command confirmation, not compositor proof — wait on `bread.lock.unlocked` if you need PAM + `ext-session-lock-v1` unlock. | +| `bread.lock.unlock.failed` | `{ "error": "" }` | `bread.command.lock.unlock` was received but `loginctl unlock-session` could not be run (binary missing, non-zero exit). | ## Commands honored (`bread.command.lock.*`) | Verb | Effect | |------|--------| | `lock` | If a locker is already running, emit `bread.lock.lock.done` and do nothing else. Otherwise start `breadlock` the same way hypridle does (`lock_cmd = breadlock`: this binary, no args) and emit `done` or `failed`. | +| `unlock` | If no locker is running, emit `bread.lock.unlock.done` (already unlocked). Otherwise run `loginctl unlock-session` on the caller's session and emit `done` or `failed`. This is session-level (logind), not a passwordless PAM bypass: breadlock does not call compositor `unlock()` for this verb. | A Lua workflow that wants the session locked should `bread.wait` / `bread.wait_any` on `bread.lock.lock.done` (or `.failed`) with a timeout. To know the compositor actually locked, wait on `bread.lock.locked`. +The same pattern applies to unlock: wait on `bread.lock.unlock.done` / +`.failed` for the command, and on `bread.lock.unlocked` for PAM + +compositor unlock. ### Who is listening -`bread.command.lock.lock` is a silent no-op if nobody is subscribed -(bread's usual "no listener, no-op" rule). Two subscribers exist: +`bread.command.lock.lock` / `bread.command.lock.unlock` are a silent +no-op if nobody is subscribed (bread's usual "no listener, no-op" +rule). Two subscribers exist: 1. **`breadlock listen`** — run this for the unlocked path (Hyprland `exec-once = breadlock listen`, a bread module, or equivalent). Without it, a command sent while the session is unlocked has no - process to receive it. + process to receive it. Unlock while already unlocked is an + idempotent `done`. 2. **The locker process** — always subscribes once the lock screen is - up, so a command received during an active lock is an idempotent - `done`. + up, so `lock` during an active lock is an idempotent `done`, and + `unlock` runs `loginctl unlock-session` rather than compositor + `unlock()`. ### Session-level equivalent @@ -63,13 +72,18 @@ bread command bus. It is the session-level equivalent of same `ext-session-lock-v1` request. Prefer `loginctl lock-session` from a keybind; prefer the bus command from a Lua workflow. -### Not implemented: `unlock` / `pin` / `blur` +`loginctl unlock-session` is the matching session-level unlock. The +bus verb invokes that same command. Compositor unlock after a typed +password is still PAM on this process (`bread.lock.unlocked`); a +dispatch-error or crash path still does **not** call compositor +`unlock()` (fail-secure). -Unlock is PAM on this process only — there is no `bread.command.lock.unlock` -and none is stubbed. `background.blur` in `breadlock.toml` remains a -documented locker no-op (accepted, warned, surface drawn unblurred). -That is appearance config, not a bus command — do not invent -`bread.command.lock.blur` for it. +### Not implemented: `pin` / `blur` + +`background.blur` in `breadlock.toml` remains a documented locker +no-op (accepted, warned, surface drawn unblurred). That is appearance +config, not a bus command — do not invent `bread.command.lock.blur` +for it. ## Fail-safe behavior diff --git a/README.md b/README.md index 663f279..cc6ec11 100644 --- a/README.md +++ b/README.md @@ -13,13 +13,15 @@ Both use [`bread-theme`](https://git.breadway.dev/Breadway/bread-ecosystem) for `breadlock` works the same with or without `breadd`. When `breadd` is running, it publishes `bread.lock.locked` / `bread.lock.unlocked` and -honors `bread.command.lock.lock` (emits `bread.lock.lock.done` / -`.failed`). Run `breadlock listen` so the command works while unlocked; -the locker also subscribes while the session is locked. Super+L remains -`loginctl lock-session` (hypridle then runs `breadlock`) — that is the -session-level equivalent, not a bus command. See [EVENTS.md](EVENTS.md). -`breadgreet` is not on the bus. There is no `bakery.toml` (PAM / pacman -exception). +honors `bread.command.lock.lock` / `bread.command.lock.unlock` (emits +`bread.lock.lock.done` / `.failed` and `bread.lock.unlock.done` / +`.failed`). Run `breadlock listen` so both commands work while +unlocked; the locker also subscribes while the session is locked. +Unlock is `loginctl unlock-session` at the session level — not a +passwordless compositor `unlock()`. Super+L remains +`loginctl lock-session` (hypridle then runs `breadlock`). See +[EVENTS.md](EVENTS.md). `breadgreet` is not on the bus. There is no +`bakery.toml` (PAM / pacman exception). ## Architecture @@ -82,9 +84,11 @@ lock_cmd = breadlock ``` `breadlock listen` is the unlocked-path subscriber for -`bread.command.lock.lock`. It is not started by hypridle; add it to -session startup (`exec-once = breadlock listen`) if a Lua workflow -should be able to lock the session while it is unlocked. +`bread.command.lock.lock` and `bread.command.lock.unlock`. It is not +started by hypridle; add it to session startup +(`exec-once = breadlock listen`) if a Lua workflow should be able to +lock or unlock the session while it is unlocked. Session-level unlock +is `loginctl unlock-session` (the bus verb runs that). ## Verification (why this is safe to test without a lockout risk) diff --git a/breadlock/src/bread_events.rs b/breadlock/src/bread_events.rs index b8a8fd7..5ece110 100644 --- a/breadlock/src/bread_events.rs +++ b/breadlock/src/bread_events.rs @@ -5,12 +5,14 @@ //! process) so a missing or restarting breadd never affects locking //! itself. //! -//! `bread.command.lock.lock` is the one verb this process honors. The -//! locker subscribes while the session is locked (already-locked is -//! `bread.lock.lock.done`). `breadlock listen` is the unlocked-path -//! subscriber: it starts this same binary the way hypridle's -//! `lock_cmd = breadlock` does. Session-level equivalent of Super+L is -//! `loginctl lock-session`. +//! `bread.command.lock.lock` and `bread.command.lock.unlock` are the +//! verbs this process honors. The locker subscribes while the session is +//! locked (already-locked is `bread.lock.lock.done`). `breadlock listen` +//! is the unlocked-path subscriber: it starts this same binary the way +//! hypridle's `lock_cmd = breadlock` does, and treats unlock as already +//! unlocked (`bread.lock.unlock.done`). Session-level equivalents are +//! `loginctl lock-session` / `loginctl unlock-session`. Unlock never +//! calls compositor `unlock()` — that stays on the PAM path. use std::process::{Command, Stdio}; use std::thread; @@ -46,6 +48,17 @@ pub fn emit_lock_failed(error: &str) { ); } +pub fn emit_unlock_done() { + BreadClient::connect(APP_ID).emit("bread.lock.unlock.done", serde_json::json!({})); +} + +pub fn emit_unlock_failed(error: &str) { + BreadClient::connect(APP_ID).emit( + "bread.lock.unlock.failed", + serde_json::json!({ "error": error }), + ); +} + /// True when another process holds the locker singleton — i.e. breadlock /// is already locking this session. A `try_acquire` that succeeds is /// released immediately; this is a check, not a claim. @@ -84,6 +97,31 @@ pub fn honor_lock_command() { honor_lock_command_with(start_locker); } +/// Session-level unlock (`loginctl unlock-session` on the caller's +/// session). Does not send compositor `unlock` and does not skip PAM — +/// that stays on the typed-password path. `done` means the command was +/// acted on (or the session was already unlocked), not that +/// `ext-session-lock-v1` has been released — wait on +/// `bread.lock.unlocked` for the compositor confirmation. +fn unlock_session() -> Result<(), String> { + let status = Command::new("loginctl") + .arg("unlock-session") + .stdin(Stdio::null()) + .status() + .map_err(|e| format!("failed to run loginctl unlock-session: {e}"))?; + if status.success() { + Ok(()) + } else { + Err(format!("loginctl unlock-session exited with {status}")) + } +} + +/// Honor `bread.command.lock.unlock`: already unlocked is success; +/// otherwise ask logind to unlock this session. +pub fn honor_unlock_command() { + honor_unlock_command_with(locker_is_running(), unlock_session); +} + fn honor_lock_command_with(start: impl FnOnce() -> Result<(), String>) { if locker_is_running() { tracing::info!("bread.command.lock.lock: already locked"); @@ -102,6 +140,24 @@ fn honor_lock_command_with(start: impl FnOnce() -> Result<(), String>) { } } +fn honor_unlock_command_with(locked: bool, unlock: impl FnOnce() -> Result<(), String>) { + if !locked { + tracing::info!("bread.command.lock.unlock: already unlocked"); + emit_unlock_done(); + return; + } + match unlock() { + Ok(()) => { + tracing::info!("bread.command.lock.unlock: loginctl unlock-session"); + emit_unlock_done(); + } + Err(error) => { + tracing::error!(%error, "bread.command.lock.unlock: failed"); + emit_unlock_failed(&error); + } + } +} + /// Reacts to `bread.command.lock.*`. Unknown verbs are ignored, not stubbed. pub fn handle_command(event: &BreadEvent) { let Some(verb) = event.event.strip_prefix("bread.command.lock.") else { @@ -109,6 +165,7 @@ pub fn handle_command(event: &BreadEvent) { }; match verb { "lock" => honor_lock_command(), + "unlock" => honor_unlock_command(), other => tracing::info!(verb = other, "ignoring unknown bread.command.lock verb"), } } @@ -135,7 +192,6 @@ mod tests { #[test] fn handle_command_ignores_unrecognized_verb() { - handle_command(&event("bread.command.lock.unlock")); handle_command(&event("bread.command.lock.pin")); handle_command(&event("bread.command.clip.clear")); handle_command(&event("bread.lock.locked")); @@ -168,4 +224,24 @@ mod tests { fn honor_lock_command_with_successful_start_does_not_panic() { honor_lock_command_with(|| Ok(())); } + + #[test] + fn honor_unlock_command_already_unlocked_does_not_call_loginctl() { + let called = std::cell::Cell::new(false); + honor_unlock_command_with(false, || { + called.set(true); + Err("should not run".into()) + }); + assert!(!called.get()); + } + + #[test] + fn honor_unlock_command_with_failed_loginctl_does_not_panic() { + honor_unlock_command_with(true, || Err("boom".into())); + } + + #[test] + fn honor_unlock_command_with_successful_loginctl_does_not_panic() { + honor_unlock_command_with(true, || Ok(())); + } } diff --git a/breadlock/src/main.rs b/breadlock/src/main.rs index 7a2c661..18cd008 100644 --- a/breadlock/src/main.rs +++ b/breadlock/src/main.rs @@ -51,9 +51,9 @@ fn print_usage() { "Usage: breadlock [listen]\n\ \n\ (no args) lock this session — hypridle lock_cmd / Super+L via loginctl lock-session\n\ - listen subscribe to bread.command.lock.lock so the command works while unlocked\n\ + listen subscribe to bread.command.lock.lock / unlock so both work while unlocked\n\ \n\ - Session-level equivalent of Super+L: loginctl lock-session (hypridle then runs breadlock).\n\ + Session-level: loginctl lock-session / unlock-session.\n\ See EVENTS.md for the bus contract." ); } @@ -75,10 +75,11 @@ fn main() { } } -/// Long-running subscriber so `bread.command.lock.lock` works while the -/// session is unlocked. The locker process also subscribes; this path is -/// what actually starts breadlock (the same no-args invocation hypridle -/// uses). One listen process per session. +/// Long-running subscriber so `bread.command.lock.lock` / `.unlock` work +/// while the session is unlocked. The locker process also subscribes; +/// this path is what actually starts breadlock (the same no-args +/// invocation hypridle uses) and what runs `loginctl unlock-session` +/// when a locker is up. One listen process per session. fn run_listen() { let _guard = match try_acquire(bread_events::LISTEN_APP) { Ok(Acquire::Acquired(g)) => g, @@ -106,7 +107,7 @@ fn run_listen() { } let _commands = bread_events::subscribe_commands(); - tracing::info!("listening for bread.command.lock.lock"); + tracing::info!("listening for bread.command.lock.lock / unlock"); loop { std::thread::sleep(Duration::from_secs(3600)); } @@ -127,8 +128,9 @@ fn run_lock() { } }; - // Honor bread.command.lock.lock while this locker is up (already-locked - // is bread.lock.lock.done). Unlocked commands need `breadlock listen`. + // Honor bread.command.lock.lock / unlock while this locker is up + // (already-locked is bread.lock.lock.done; unlock is loginctl, not + // compositor unlock()). Unlocked-path commands need `breadlock listen`. let _commands = bread_events::subscribe_commands(); let username = std::env::var("USER")