Bus clients must not unlock a locked session. Already-unlocked acks .unlock.done; a running locker emits .failed (PAM only). Super+L / hypridle remain loginctl lock-session.
6 KiB
breadlock — bread event integration
breadlock is a standalone session locker: it works exactly the same with
or without breadd running. When breadd is present, breadlock
publishes events into the shared bread automation fabric. See the parent
bread repo's Documentation.md — specifically its "Namespaces" and
"Integrating a bread* app" sections — for the general convention this
follows.
App id: lock. Transport: bread-utils's bread_client module
(feature bread-client) — breadlock links it directly. Each emit is
its own short-lived connection (BreadClient::emit is fire-and-forget).
Commands are received on a BreadClient::subscribe background thread
(reconnect/backoff) from two places:
- the locker process itself, while the session is locked
breadlock listen, a tiny long-running subscriber so lock/unlock work while unlocked
breadgreet is not wired to the bus. It runs under greetd (typically as
the dedicated greeter user, before a user session exists), so breadd is
usually not there to receive anything, and login is a different lifecycle
from session lock/unlock.
Events published (bread.lock.*)
| Event | Data | When |
|---|---|---|
bread.lock.locked |
{} |
The compositor accepted the ext-session-lock-v1 request (SessionLockHandler::locked). Not emitted merely because breadlock started or asked to lock. |
bread.lock.unlocked |
{} |
PAM authenticated successfully and breadlock sent unlock to the compositor, or the compositor ended an already-active lock (SessionLockHandler::finished after locked — breadlock sends unlock_and_destroy then emits this). Not emitted when the lock was never acquired (finished before locked), on a dispatch-error exit (fail-secure: the session stays locked), or a failed/typo password. |
bread.lock.lock.done |
{} |
bread.command.lock.lock was honored: the locker was already running, or a locker process was started (same no-args invocation as hypridle's lock_cmd = breadlock). This is the command confirmation, not compositor proof — wait on bread.lock.locked if you need the session-lock protocol to have completed. |
bread.lock.lock.failed |
{ "error": "<message>" } |
bread.command.lock.lock was received but the locker could not be started (e.g. this binary is missing from disk). |
bread.lock.unlock.done |
{} |
bread.command.lock.unlock was honored because no locker was running (session already unlocked). This is not passwordless compositor unlock and is not emitted merely because a bus client asked to unlock. For PAM + ext-session-lock-v1 unlock, wait on bread.lock.unlocked. |
bread.lock.unlock.failed |
{ "error": "<message>" } |
bread.command.lock.unlock was received while the locker is running. The bus cannot bypass PAM; authenticate at the lock screen. |
Commands honored (bread.command.lock.*)
| Verb | Effect |
|---|---|
lock |
If a locker is already running, emit bread.lock.lock.done and do nothing else. Otherwise start breadlock the same way hypridle does (lock_cmd = breadlock: this binary, no args) and emit done or failed. |
unlock |
If no locker is running, emit bread.lock.unlock.done (already unlocked) and do not call loginctl. If the locker is running, emit bread.lock.unlock.failed — bus clients must never trigger unlock. Compositor unlock() stays on the PAM path only. |
A Lua workflow that wants the session locked should bread.wait /
bread.wait_any on bread.lock.lock.done (or .failed) with a timeout.
To know the compositor actually locked, wait on bread.lock.locked.
Unlock from the bus is not a substitute for PAM: wait on
bread.lock.unlock.done / .failed for the command ack (.done only
means already unlocked), and on bread.lock.unlocked for a typed
password + compositor unlock.
Who is listening
bread.command.lock.lock / bread.command.lock.unlock are a silent
no-op if nobody is subscribed (bread's usual "no listener, no-op"
rule). Two subscribers exist:
breadlock listen— run this for the unlocked path (Hyprlandexec-once = breadlock listen, a bread module, or equivalent). Without it, a command sent while the session is unlocked has no process to receive it. Unlock while already unlocked is an idempotentdone.- The locker process — always subscribes once the lock screen is
up, so
lockduring an active lock is an idempotentdone, andunlockis.failed(cannot bypass PAM). Never compositorunlock(), neverloginctl unlock-session.
Session-level equivalent
Super+L on BOS is loginctl lock-session. hypridle picks that up and
runs lock_cmd = breadlock. That path does not go through the
bread command bus. It is the session-level equivalent of
bread.command.lock.lock + breadlock listen: same locker binary,
same ext-session-lock-v1 request. Prefer loginctl lock-session
from a keybind; prefer the bus command from a Lua workflow.
The bus unlock verb does not call loginctl unlock-session and
does not replace PAM. Super+L / hypridle remain loginctl lock-session. Compositor unlock after a typed password is still PAM
on this process (bread.lock.unlocked); a dispatch-error or crash
path still does not call compositor unlock() (fail-secure).
Not implemented: pin / blur
background.blur in breadlock.toml remains a documented locker
no-op (accepted, warned, surface drawn unblurred). That is appearance
config, not a bus command — do not invent bread.command.lock.blur
for it.
Fail-safe behavior
- If breadd isn't installed or isn't running,
emitis a silent no-op (BreadClient::emitnever blocks or errors the caller) and the command subscription simply never receives anything — breadlock's actual lock/unlock path is entirely unaffected either way. - If breadd restarts, the command subscription reconnects automatically
(
BreadClient::subscribe's background thread has its own backoff loop); no restart of the locker or ofbreadlock listenis needed.