Honor bread.command.lock.unlock via loginctl unlock-session
Subscribe the same locker and breadlock listen handlers. Already unlocked is bread.lock.unlock.done; otherwise run loginctl unlock-session and emit done / failed. Do not call compositor unlock() — PAM stays the compositor path (fail-secure). Document in EVENTS.md and README; drop the "Not implemented: unlock" paragraph. pin/blur stay unimplemented.
This commit is contained in:
parent
495fe1aaad
commit
c2b04a2620
4 changed files with 135 additions and 39 deletions
40
EVENTS.md
40
EVENTS.md
|
|
@ -14,8 +14,8 @@ Commands are received on a `BreadClient::subscribe` background thread
|
||||||
(reconnect/backoff) from two places:
|
(reconnect/backoff) from two places:
|
||||||
|
|
||||||
- the locker process itself, while the session is locked
|
- the locker process itself, while the session is locked
|
||||||
- `breadlock listen`, a tiny long-running subscriber so the command
|
- `breadlock listen`, a tiny long-running subscriber so lock/unlock
|
||||||
works while unlocked
|
work while unlocked
|
||||||
|
|
||||||
`breadgreet` is not wired to the bus. It runs under greetd (typically as
|
`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
|
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.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.done` | `{}` | `bread.command.lock.lock` was honored: the locker was already running, or a locker process was started (same no-args invocation as hypridle's `lock_cmd = breadlock`). This is the command confirmation, not compositor proof — wait on `bread.lock.locked` if you need the session-lock protocol to have completed. |
|
||||||
| `bread.lock.lock.failed` | `{ "error": "<message>" }` | `bread.command.lock.lock` was received but the locker could not be started (e.g. this binary is missing from disk). |
|
| `bread.lock.lock.failed` | `{ "error": "<message>" }` | `bread.command.lock.lock` was received but the locker could not be started (e.g. this binary is missing from disk). |
|
||||||
|
| `bread.lock.unlock.done` | `{}` | `bread.command.lock.unlock` was honored: no locker was running (already unlocked), or `loginctl unlock-session` was invoked for this session. This is the command confirmation, not compositor proof — wait on `bread.lock.unlocked` if you need PAM + `ext-session-lock-v1` unlock. |
|
||||||
|
| `bread.lock.unlock.failed` | `{ "error": "<message>" }` | `bread.command.lock.unlock` was received but `loginctl unlock-session` could not be run (binary missing, non-zero exit). |
|
||||||
|
|
||||||
## Commands honored (`bread.command.lock.*`)
|
## Commands honored (`bread.command.lock.*`)
|
||||||
|
|
||||||
| Verb | Effect |
|
| 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`. |
|
| `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` /
|
A Lua workflow that wants the session locked should `bread.wait` /
|
||||||
`bread.wait_any` on `bread.lock.lock.done` (or `.failed`) with a timeout.
|
`bread.wait_any` on `bread.lock.lock.done` (or `.failed`) with a timeout.
|
||||||
To know the compositor actually locked, wait on `bread.lock.locked`.
|
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
|
### Who is listening
|
||||||
|
|
||||||
`bread.command.lock.lock` is a silent no-op if nobody is subscribed
|
`bread.command.lock.lock` / `bread.command.lock.unlock` are a silent
|
||||||
(bread's usual "no listener, no-op" rule). Two subscribers exist:
|
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
|
1. **`breadlock listen`** — run this for the unlocked path (Hyprland
|
||||||
`exec-once = breadlock listen`, a bread module, or equivalent).
|
`exec-once = breadlock listen`, a bread module, or equivalent).
|
||||||
Without it, a command sent while the session is unlocked has no
|
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
|
2. **The locker process** — always subscribes once the lock screen is
|
||||||
up, so a command received during an active lock is an idempotent
|
up, so `lock` during an active lock is an idempotent `done`, and
|
||||||
`done`.
|
`unlock` runs `loginctl unlock-session` rather than compositor
|
||||||
|
`unlock()`.
|
||||||
|
|
||||||
### Session-level equivalent
|
### 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`
|
same `ext-session-lock-v1` request. Prefer `loginctl lock-session`
|
||||||
from a keybind; prefer the bus command from a Lua workflow.
|
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`
|
### Not implemented: `pin` / `blur`
|
||||||
and none is stubbed. `background.blur` in `breadlock.toml` remains a
|
|
||||||
documented locker no-op (accepted, warned, surface drawn unblurred).
|
`background.blur` in `breadlock.toml` remains a documented locker
|
||||||
That is appearance config, not a bus command — do not invent
|
no-op (accepted, warned, surface drawn unblurred). That is appearance
|
||||||
`bread.command.lock.blur` for it.
|
config, not a bus command — do not invent `bread.command.lock.blur`
|
||||||
|
for it.
|
||||||
|
|
||||||
## Fail-safe behavior
|
## Fail-safe behavior
|
||||||
|
|
||||||
|
|
|
||||||
24
README.md
24
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
|
`breadlock` works the same with or without `breadd`. When `breadd` is
|
||||||
running, it publishes `bread.lock.locked` / `bread.lock.unlocked` and
|
running, it publishes `bread.lock.locked` / `bread.lock.unlocked` and
|
||||||
honors `bread.command.lock.lock` (emits `bread.lock.lock.done` /
|
honors `bread.command.lock.lock` / `bread.command.lock.unlock` (emits
|
||||||
`.failed`). Run `breadlock listen` so the command works while unlocked;
|
`bread.lock.lock.done` / `.failed` and `bread.lock.unlock.done` /
|
||||||
the locker also subscribes while the session is locked. Super+L remains
|
`.failed`). Run `breadlock listen` so both commands work while
|
||||||
`loginctl lock-session` (hypridle then runs `breadlock`) — that is the
|
unlocked; the locker also subscribes while the session is locked.
|
||||||
session-level equivalent, not a bus command. See [EVENTS.md](EVENTS.md).
|
Unlock is `loginctl unlock-session` at the session level — not a
|
||||||
`breadgreet` is not on the bus. There is no `bakery.toml` (PAM / pacman
|
passwordless compositor `unlock()`. Super+L remains
|
||||||
exception).
|
`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
|
## Architecture
|
||||||
|
|
||||||
|
|
@ -82,9 +84,11 @@ lock_cmd = breadlock
|
||||||
```
|
```
|
||||||
|
|
||||||
`breadlock listen` is the unlocked-path subscriber for
|
`breadlock listen` is the unlocked-path subscriber for
|
||||||
`bread.command.lock.lock`. It is not started by hypridle; add it to
|
`bread.command.lock.lock` and `bread.command.lock.unlock`. It is not
|
||||||
session startup (`exec-once = breadlock listen`) if a Lua workflow
|
started by hypridle; add it to session startup
|
||||||
should be able to lock the session while it is unlocked.
|
(`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)
|
## Verification (why this is safe to test without a lockout risk)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,14 @@
|
||||||
//! process) so a missing or restarting breadd never affects locking
|
//! process) so a missing or restarting breadd never affects locking
|
||||||
//! itself.
|
//! itself.
|
||||||
//!
|
//!
|
||||||
//! `bread.command.lock.lock` is the one verb this process honors. The
|
//! `bread.command.lock.lock` and `bread.command.lock.unlock` are the
|
||||||
//! locker subscribes while the session is locked (already-locked is
|
//! verbs this process honors. The locker subscribes while the session is
|
||||||
//! `bread.lock.lock.done`). `breadlock listen` is the unlocked-path
|
//! locked (already-locked is `bread.lock.lock.done`). `breadlock listen`
|
||||||
//! subscriber: it starts this same binary the way hypridle's
|
//! is the unlocked-path subscriber: it starts this same binary the way
|
||||||
//! `lock_cmd = breadlock` does. Session-level equivalent of Super+L is
|
//! hypridle's `lock_cmd = breadlock` does, and treats unlock as already
|
||||||
//! `loginctl lock-session`.
|
//! 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::process::{Command, Stdio};
|
||||||
use std::thread;
|
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
|
/// True when another process holds the locker singleton — i.e. breadlock
|
||||||
/// is already locking this session. A `try_acquire` that succeeds is
|
/// is already locking this session. A `try_acquire` that succeeds is
|
||||||
/// released immediately; this is a check, not a claim.
|
/// released immediately; this is a check, not a claim.
|
||||||
|
|
@ -84,6 +97,31 @@ pub fn honor_lock_command() {
|
||||||
honor_lock_command_with(start_locker);
|
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>) {
|
fn honor_lock_command_with(start: impl FnOnce() -> Result<(), String>) {
|
||||||
if locker_is_running() {
|
if locker_is_running() {
|
||||||
tracing::info!("bread.command.lock.lock: already locked");
|
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.
|
/// Reacts to `bread.command.lock.*`. Unknown verbs are ignored, not stubbed.
|
||||||
pub fn handle_command(event: &BreadEvent) {
|
pub fn handle_command(event: &BreadEvent) {
|
||||||
let Some(verb) = event.event.strip_prefix("bread.command.lock.") else {
|
let Some(verb) = event.event.strip_prefix("bread.command.lock.") else {
|
||||||
|
|
@ -109,6 +165,7 @@ pub fn handle_command(event: &BreadEvent) {
|
||||||
};
|
};
|
||||||
match verb {
|
match verb {
|
||||||
"lock" => honor_lock_command(),
|
"lock" => honor_lock_command(),
|
||||||
|
"unlock" => honor_unlock_command(),
|
||||||
other => tracing::info!(verb = other, "ignoring unknown bread.command.lock verb"),
|
other => tracing::info!(verb = other, "ignoring unknown bread.command.lock verb"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -135,7 +192,6 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn handle_command_ignores_unrecognized_verb() {
|
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.lock.pin"));
|
||||||
handle_command(&event("bread.command.clip.clear"));
|
handle_command(&event("bread.command.clip.clear"));
|
||||||
handle_command(&event("bread.lock.locked"));
|
handle_command(&event("bread.lock.locked"));
|
||||||
|
|
@ -168,4 +224,24 @@ mod tests {
|
||||||
fn honor_lock_command_with_successful_start_does_not_panic() {
|
fn honor_lock_command_with_successful_start_does_not_panic() {
|
||||||
honor_lock_command_with(|| Ok(()));
|
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(()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,9 +51,9 @@ fn print_usage() {
|
||||||
"Usage: breadlock [listen]\n\
|
"Usage: breadlock [listen]\n\
|
||||||
\n\
|
\n\
|
||||||
(no args) lock this session — hypridle lock_cmd / Super+L via loginctl lock-session\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\
|
\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."
|
See EVENTS.md for the bus contract."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -75,10 +75,11 @@ fn main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Long-running subscriber so `bread.command.lock.lock` works while the
|
/// Long-running subscriber so `bread.command.lock.lock` / `.unlock` work
|
||||||
/// session is unlocked. The locker process also subscribes; this path is
|
/// while the session is unlocked. The locker process also subscribes;
|
||||||
/// what actually starts breadlock (the same no-args invocation hypridle
|
/// this path is what actually starts breadlock (the same no-args
|
||||||
/// uses). One listen process per session.
|
/// invocation hypridle uses) and what runs `loginctl unlock-session`
|
||||||
|
/// when a locker is up. One listen process per session.
|
||||||
fn run_listen() {
|
fn run_listen() {
|
||||||
let _guard = match try_acquire(bread_events::LISTEN_APP) {
|
let _guard = match try_acquire(bread_events::LISTEN_APP) {
|
||||||
Ok(Acquire::Acquired(g)) => g,
|
Ok(Acquire::Acquired(g)) => g,
|
||||||
|
|
@ -106,7 +107,7 @@ fn run_listen() {
|
||||||
}
|
}
|
||||||
|
|
||||||
let _commands = bread_events::subscribe_commands();
|
let _commands = bread_events::subscribe_commands();
|
||||||
tracing::info!("listening for bread.command.lock.lock");
|
tracing::info!("listening for bread.command.lock.lock / unlock");
|
||||||
loop {
|
loop {
|
||||||
std::thread::sleep(Duration::from_secs(3600));
|
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
|
// Honor bread.command.lock.lock / unlock while this locker is up
|
||||||
// is bread.lock.lock.done). Unlocked commands need `breadlock listen`.
|
// (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 _commands = bread_events::subscribe_commands();
|
||||||
|
|
||||||
let username = std::env::var("USER")
|
let username = std::env::var("USER")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue