Honor bread.command.lock.lock; pin theme to v0.7.2

Pin bread-theme/bread-utils to bread-ecosystem tag v0.7.2.
Subscribe from the locker and from `breadlock listen` so the command
works while unlocked; start breadlock the same way hypridle does.
Emit bread.lock.lock.done / .failed. Document loginctl lock-session
as the Super+L equivalent.

Optional GPG detach-sign of the .pkg.tar.zst when GPG_PRIVATE_KEY is
set; ISO [breadway] stays SigLevel = Never until a signed db exists.
This commit is contained in:
Breadway 2026-08-15 22:51:35 +08:00
parent 76544398d3
commit 80caedc7fd
7 changed files with 401 additions and 28 deletions

View file

@ -7,10 +7,10 @@ on:
jobs:
package:
runs-on: [self-hosted, hestia]
# Forgejo's Arch package registry does not GPG-sign packages for pacman.
# BOS therefore uses SigLevel=Never on [Breadway.os.git.breadway.dev]
# until a signed repo exists. Keep publishing here — do not flip this
# job to a different registry just to get signatures.
# Forgejo's Arch package registry does not GPG-sign a pacman db.
# BOS ISO [breadway] stays SigLevel = Never until a signed db exists.
# Do not flip that here — flipping without a signed db breaks pacman.
# Keep publishing the unsigned .pkg.tar.zst to the registry below.
container:
image: archlinux:latest
steps:
@ -37,8 +37,50 @@ jobs:
# --nocheck: packaging builds the artifact; tests belong in a CI job.
su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck"
PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1)
mkdir -p /tmp/breadlock-pkg
cp "$PKG" /tmp/breadlock-pkg/
echo "${VERSION}" > /tmp/breadlock-pkg/VERSION
curl -fsS -X PUT \
-H "Authorization: token ${PUBLISH_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${PKG}" \
"https://git.breadway.dev/api/packages/Breadway/arch/os"
# Optional detach-sign. secrets.GPG_PRIVATE_KEY is the same BOS
# release-signing key (releases@breadway.dev). If the secret is
# missing, skip — the registry PUT above already published the
# unsigned package. A lone .sig is not a signed repo: ISO
# [breadway] stays SigLevel = Never until a signed db exists.
# The .sig is uploaded as a generic-package artifact next to that
# PUT, not injected into the Arch repo (which would not make
# pacman verify anything without a signed db).
- name: Detach-sign package (optional)
env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
if [ -z "${GPG_PRIVATE_KEY:-}" ]; then
echo "GPG_PRIVATE_KEY unset; skipping detach-sign."
echo "ISO [breadway] stays SigLevel = Never until a signed db exists."
exit 0
fi
PKG=$(find /tmp/breadlock-pkg -name '*.pkg.tar.zst' | head -1)
if [ -z "$PKG" ]; then
echo "no package in /tmp/breadlock-pkg; cannot sign" >&2
exit 1
fi
VERSION=$(cat /tmp/breadlock-pkg/VERSION)
pacman -S --noconfirm --needed gnupg
export GNUPGHOME=/tmp/gnupg-breadlock
mkdir -m 700 -p "$GNUPGHOME"
echo "$GPG_PRIVATE_KEY" | gpg --batch --import
gpg --batch --yes --detach-sign -o "${PKG}.sig" "$PKG"
echo "Signed $(basename "$PKG") -> $(basename "$PKG").sig"
# Generic package: workflow artifact alongside the Arch PUT.
# Does not change [breadway] / pacman SigLevel.
curl -fsS -X PUT \
-H "Authorization: token ${PUBLISH_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${PKG}.sig" \
"https://git.breadway.dev/api/packages/Breadway/generic/breadlock/${VERSION}/$(basename "$PKG").sig"

8
Cargo.lock generated
View file

@ -77,8 +77,8 @@ dependencies = [
[[package]]
name = "bread-theme"
version = "0.3.1"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886"
version = "0.7.2"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73"
dependencies = [
"dirs",
"gtk4",
@ -88,8 +88,8 @@ dependencies = [
[[package]]
name = "bread-utils"
version = "0.3.1"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886"
version = "0.7.2"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73"
dependencies = [
"bread-shared",
"dirs",

View file

@ -3,8 +3,8 @@ members = ["breadlock-ui", "breadlock", "breadgreet"]
resolver = "2"
[workspace.dependencies]
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1" }
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1" }
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"

View file

@ -9,9 +9,13 @@ follows.
App id: **`lock`**. Transport: `bread-utils`'s `bread_client` module
(feature `bread-client`) — `breadlock` links it directly. Each `emit` is
its own short-lived connection (`BreadClient::emit` is fire-and-forget);
there is no long-running subscription half because breadlock has no
command verbs (see below).
its own short-lived connection (`BreadClient::emit` is fire-and-forget).
Commands are received on a `BreadClient::subscribe` background thread
(reconnect/backoff) from two places:
- the locker process itself, while the session is locked
- `breadlock listen`, a tiny long-running subscriber so the command
works while unlocked
`breadgreet` is not wired to the bus. It runs under greetd (typically as
the dedicated greeter user, before a user session exists), so breadd is
@ -24,21 +28,55 @@ from session lock/unlock.
|-------|------|------|
| `bread.lock.locked` | `{}` | The compositor accepted the `ext-session-lock-v1` request (`SessionLockHandler::locked`). Not emitted merely because breadlock started or asked to lock. |
| `bread.lock.unlocked` | `{}` | PAM authenticated successfully and breadlock sent `unlock` to the compositor. Not emitted on a compositor-ended lock (`finished`), a dispatch-error exit (fail-secure: the session stays locked), or a failed/typo password. |
| `bread.lock.lock.done` | `{}` | `bread.command.lock.lock` was honored: the locker was already running, or a locker process was started (same no-args invocation as hypridle's `lock_cmd = breadlock`). This is the command confirmation, not compositor proof — wait on `bread.lock.locked` if you need the session-lock protocol to have completed. |
| `bread.lock.lock.failed` | `{ "error": "<message>" }` | `bread.command.lock.lock` was received but the locker could not be started (e.g. this binary is missing from disk). |
## Commands honored (`bread.command.lock.*`)
None. breadlock is started by hypridle / `loginctl lock-session` (or
directly) and unlocks only via PAM on this process. There is no
`lock`/`unlock`/`pin`/`blur` verb, and none is stubbed as a no-op.
| Verb | Effect |
|------|--------|
| `lock` | If a locker is already running, emit `bread.lock.lock.done` and do nothing else. Otherwise start `breadlock` the same way hypridle does (`lock_cmd = breadlock`: this binary, no args) and emit `done` or `failed`. |
`background.blur` in `breadlock.toml` remains a documented locker no-op
(accepted, warned, surface drawn unblurred). That is appearance config,
not a bus command — do not invent `bread.command.lock.blur` for it.
A Lua workflow that wants the session locked should `bread.wait` /
`bread.wait_any` on `bread.lock.lock.done` (or `.failed`) with a timeout.
To know the compositor actually locked, wait on `bread.lock.locked`.
### Who is listening
`bread.command.lock.lock` is a silent no-op if nobody is subscribed
(bread's usual "no listener, no-op" rule). Two subscribers exist:
1. **`breadlock listen`** — run this for the unlocked path (Hyprland
`exec-once = breadlock listen`, a bread module, or equivalent).
Without it, a command sent while the session is unlocked has no
process to receive it.
2. **The locker process** — always subscribes once the lock screen is
up, so a command received during an active lock is an idempotent
`done`.
### Session-level equivalent
Super+L on BOS is `loginctl lock-session`. hypridle picks that up and
runs `lock_cmd = breadlock`. That path does **not** go through the
bread command bus. It is the session-level equivalent of
`bread.command.lock.lock` + `breadlock listen`: same locker binary,
same `ext-session-lock-v1` request. Prefer `loginctl lock-session`
from a keybind; prefer the bus command from a Lua workflow.
### Not implemented: `unlock` / `pin` / `blur`
Unlock is PAM on this process only — there is no `bread.command.lock.unlock`
and none is stubbed. `background.blur` in `breadlock.toml` remains a
documented locker no-op (accepted, warned, surface drawn unblurred).
That is appearance config, not a bus command — do not invent
`bread.command.lock.blur` for it.
## Fail-safe behavior
- If breadd isn't installed or isn't running, `emit` is a silent no-op
(`BreadClient::emit` never blocks or errors the caller) — breadlock's
(`BreadClient::emit` never blocks or errors the caller) and the
command subscription simply never receives anything — breadlock's
actual lock/unlock path is entirely unaffected either way.
- There is no command subscription, so a breadd restart while the lock
screen is up changes nothing on this side.
- If breadd restarts, the command subscription reconnects automatically
(`BreadClient::subscribe`'s background thread has its own backoff loop);
no restart of the locker or of `breadlock listen` is needed.

View file

@ -12,9 +12,14 @@ Both use [`bread-theme`](https://git.breadway.dev/Breadway/bread-ecosystem) for
## bread event integration
`breadlock` works the same with or without `breadd`. When `breadd` is
running, it publishes `bread.lock.locked` / `bread.lock.unlocked`. See
[EVENTS.md](EVENTS.md) for the bus contract. `breadgreet` is not on the
bus. There is no `bakery.toml` (PAM / pacman exception).
running, it publishes `bread.lock.locked` / `bread.lock.unlocked` and
honors `bread.command.lock.lock` (emits `bread.lock.lock.done` /
`.failed`). Run `breadlock listen` so the command works while unlocked;
the locker also subscribes while the session is locked. Super+L remains
`loginctl lock-session` (hypridle then runs `breadlock`) — that is the
session-level equivalent, not a bus command. See [EVENTS.md](EVENTS.md).
`breadgreet` is not on the bus. There is no `bakery.toml` (PAM / pacman
exception).
## Architecture
@ -76,6 +81,11 @@ command = "cage -s -- breadgreet"
lock_cmd = breadlock
```
`breadlock listen` is the unlocked-path subscriber for
`bread.command.lock.lock`. It is not started by hypridle; add it to
session startup (`exec-once = breadlock listen`) if a Lua workflow
should be able to lock the session while it is unlocked.
## Verification (why this is safe to test without a lockout risk)
1. **PAM logic in isolation first**: `cargo run --bin breadlock-auth-check` exercises the exact PAM flow `breadlock` uses, against a typed password, with **no Wayland surface at all**. A bad `/etc/pam.d/breadlock` just prints an error here — it can never lock a session.

View file

@ -1,16 +1,32 @@
//! `bread.lock.*` event integration — optional, non-blocking. See
//! `EVENTS.md` at the repo root for the full contract. breadlock works
//! identically with or without breadd running; every call here is
//! identically with or without breadd running; every `emit` here is
//! fire-and-forget (`BreadClient::emit` never blocks or errors this
//! process) so a missing or restarting breadd never affects locking
//! itself.
//!
//! `bread.command.lock.lock` is the one verb this process honors. The
//! locker subscribes while the session is locked (already-locked is
//! `bread.lock.lock.done`). `breadlock listen` is the unlocked-path
//! subscriber: it starts this same binary the way hypridle's
//! `lock_cmd = breadlock` does. Session-level equivalent of Super+L is
//! `loginctl lock-session`.
use bread_utils::bread_client::BreadClient;
use std::process::{Command, Stdio};
use std::thread;
use bread_utils::bread_client::{BreadClient, BreadEvent, Subscription};
use bread_utils::singleton::{try_acquire, Acquire};
/// This app's id in bread's sibling-app namespace registry
/// (`bread_shared::apps::KNOWN_APPS`) — events publish as `bread.lock.*`.
/// (`bread_shared::apps::KNOWN_APPS`) — events publish as `bread.lock.*`,
/// commands arrive on `bread.command.lock.*`.
pub const APP_ID: &str = "lock";
/// Distinct singleton for `breadlock listen` so a listen process and a
/// locker process can coexist. The locker itself uses [`APP_ID`].
pub const LISTEN_APP: &str = "lock-listen";
pub fn emit_locked() {
BreadClient::connect(APP_ID).emit("bread.lock.locked", serde_json::json!({}));
}
@ -18,3 +34,138 @@ pub fn emit_locked() {
pub fn emit_unlocked() {
BreadClient::connect(APP_ID).emit("bread.lock.unlocked", serde_json::json!({}));
}
pub fn emit_lock_done() {
BreadClient::connect(APP_ID).emit("bread.lock.lock.done", serde_json::json!({}));
}
pub fn emit_lock_failed(error: &str) {
BreadClient::connect(APP_ID).emit(
"bread.lock.lock.failed",
serde_json::json!({ "error": error }),
);
}
/// True when another process holds the locker singleton — i.e. breadlock
/// is already locking this session. A `try_acquire` that succeeds is
/// released immediately; this is a check, not a claim.
pub fn locker_is_running() -> bool {
singleton_held(APP_ID)
}
fn singleton_held(app: &str) -> bool {
match try_acquire(app) {
Ok(Acquire::HeldByOther(_)) => true,
Ok(Acquire::Acquired(_guard)) => false,
Err(_) => false,
}
}
/// Start a locker the same way hypridle's `lock_cmd = breadlock` does:
/// this binary, no args. The child is reaped on a background thread so
/// a later unlock cannot leave a zombie under `breadlock listen`.
pub fn start_locker() -> Result<(), String> {
let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("breadlock"));
let mut child = Command::new(exe)
.stdin(Stdio::null())
.spawn()
.map_err(|e| format!("failed to start breadlock: {e}"))?;
thread::spawn(move || {
let _ = child.wait();
});
Ok(())
}
/// Honor `bread.command.lock.lock`: already locked is success; otherwise
/// start the locker. `done` means the command was acted on, not that
/// `ext-session-lock-v1` has been accepted — wait on `bread.lock.locked`
/// for the compositor confirmation.
pub fn honor_lock_command() {
honor_lock_command_with(start_locker);
}
fn honor_lock_command_with(start: impl FnOnce() -> Result<(), String>) {
if locker_is_running() {
tracing::info!("bread.command.lock.lock: already locked");
emit_lock_done();
return;
}
match start() {
Ok(()) => {
tracing::info!("bread.command.lock.lock: started breadlock");
emit_lock_done();
}
Err(error) => {
tracing::error!(%error, "bread.command.lock.lock: failed to start breadlock");
emit_lock_failed(&error);
}
}
}
/// Reacts to `bread.command.lock.*`. Unknown verbs are ignored, not stubbed.
pub fn handle_command(event: &BreadEvent) {
let Some(verb) = event.event.strip_prefix("bread.command.lock.") else {
return;
};
match verb {
"lock" => honor_lock_command(),
other => tracing::info!(verb = other, "ignoring unknown bread.command.lock verb"),
}
}
/// Subscribe to commands addressed to this app. Keep the handle alive
/// for as long as this process should honor them.
pub fn subscribe_commands() -> Subscription {
BreadClient::connect(APP_ID).subscribe("bread.command.lock.**", |event| {
handle_command(&event);
})
}
#[cfg(test)]
mod tests {
use super::*;
fn event(name: &str) -> BreadEvent {
BreadEvent {
event: name.to_string(),
timestamp: 0,
data: serde_json::json!({}),
}
}
#[test]
fn handle_command_ignores_unrecognized_verb() {
handle_command(&event("bread.command.lock.unlock"));
handle_command(&event("bread.command.lock.pin"));
handle_command(&event("bread.command.clip.clear"));
handle_command(&event("bread.lock.locked"));
}
#[test]
fn singleton_held_is_false_when_nothing_holds_the_name() {
let app = format!("breadlock-test-held-false-{}", std::process::id());
assert!(!singleton_held(&app));
}
#[test]
fn singleton_held_is_true_while_this_process_holds_the_name() {
let app = format!("breadlock-test-held-true-{}", std::process::id());
let guard = match try_acquire(&app).unwrap() {
Acquire::Acquired(g) => g,
Acquire::HeldByOther(_) => panic!("expected to be the first instance"),
};
assert!(singleton_held(&app));
drop(guard);
assert!(!singleton_held(&app));
}
#[test]
fn honor_lock_command_with_failed_start_does_not_panic() {
honor_lock_command_with(|| Err("boom".into()));
}
#[test]
fn honor_lock_command_with_successful_start_does_not_panic() {
honor_lock_command_with(|| Ok(()));
}
}

View file

@ -21,13 +21,116 @@ use wayland_client::globals::registry_queue_init;
use wayland_client::{protocol::wl_buffer, Connection, QueueHandle};
use background::Background;
use bread_utils::singleton::{try_acquire, Acquire};
use state::{AppState, AuthState, LockSurface};
#[derive(Debug, PartialEq, Eq)]
enum Mode {
Lock,
Listen,
Help,
}
fn parse_mode<I, S>(args: I) -> Result<Mode, String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut args = args.into_iter();
match args.next().as_ref().map(|s| s.as_ref()) {
None => Ok(Mode::Lock),
Some("listen") if args.next().is_none() => Ok(Mode::Listen),
Some("-h" | "--help" | "help") => Ok(Mode::Help),
Some("listen") => Err("listen takes no arguments".into()),
Some(other) => Err(format!("unknown argument '{other}'")),
}
}
fn print_usage() {
eprintln!(
"Usage: breadlock [listen]\n\
\n\
(no args) lock this session hypridle lock_cmd / Super+L via loginctl lock-session\n\
listen subscribe to bread.command.lock.lock so the command works while unlocked\n\
\n\
Session-level equivalent of Super+L: loginctl lock-session (hypridle then runs breadlock).\n\
See EVENTS.md for the bus contract."
);
}
fn main() {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
match parse_mode(std::env::args().skip(1)) {
Ok(Mode::Lock) => run_lock(),
Ok(Mode::Listen) => run_listen(),
Ok(Mode::Help) => print_usage(),
Err(err) => {
eprintln!("breadlock: {err}");
print_usage();
std::process::exit(2);
}
}
}
/// Long-running subscriber so `bread.command.lock.lock` works while the
/// session is unlocked. The locker process also subscribes; this path is
/// what actually starts breadlock (the same no-args invocation hypridle
/// uses). One listen process per session.
fn run_listen() {
let _guard = match try_acquire(bread_events::LISTEN_APP) {
Ok(Acquire::Acquired(g)) => g,
Ok(Acquire::HeldByOther(pid)) => {
tracing::info!(?pid, "breadlock listen already running");
return;
}
Err(err) => {
tracing::error!(%err, "failed to acquire listen singleton");
std::process::exit(1);
}
};
// Common when started early in the session (exec-once). The locker we
// spawn needs WAYLAND_DISPLAY; we stay up either way so a later command
// still has a subscriber.
for _ in 0..20 {
if std::env::var_os("WAYLAND_DISPLAY").is_some() {
break;
}
std::thread::sleep(Duration::from_millis(500));
}
if std::env::var_os("WAYLAND_DISPLAY").is_none() {
tracing::warn!("WAYLAND_DISPLAY not set; spawned breadlock will fail until it is");
}
let _commands = bread_events::subscribe_commands();
tracing::info!("listening for bread.command.lock.lock");
loop {
std::thread::sleep(Duration::from_secs(3600));
}
}
fn run_lock() {
let _locker_guard = match try_acquire(bread_events::APP_ID) {
Ok(Acquire::Acquired(g)) => Some(g),
Ok(Acquire::HeldByOther(pid)) => {
tracing::info!(?pid, "session already locked by another breadlock; exiting");
return;
}
Err(err) => {
// Refusing to lock because flock failed would be worse than
// running without the singleton — hypridle still needs a locker.
tracing::warn!(%err, "could not acquire lock singleton; continuing");
None
}
};
// Honor bread.command.lock.lock while this locker is up (already-locked
// is bread.lock.lock.done). Unlocked commands need `breadlock listen`.
let _commands = bread_events::subscribe_commands();
let username = std::env::var("USER")
.or_else(|_| std::env::var("LOGNAME"))
.unwrap_or_else(|_| {
@ -204,3 +307,32 @@ smithay_client_toolkit::delegate_seat!(AppState);
smithay_client_toolkit::delegate_keyboard!(AppState);
smithay_client_toolkit::delegate_registry!(AppState);
wayland_client::delegate_noop!(AppState: ignore wl_buffer::WlBuffer);
#[cfg(test)]
mod tests {
use super::{parse_mode, Mode};
#[test]
fn parse_mode_no_args_is_lock() {
let args: [&str; 0] = [];
assert_eq!(parse_mode(args), Ok(Mode::Lock));
}
#[test]
fn parse_mode_listen() {
assert_eq!(parse_mode(["listen"]), Ok(Mode::Listen));
}
#[test]
fn parse_mode_help() {
assert_eq!(parse_mode(["--help"]), Ok(Mode::Help));
assert_eq!(parse_mode(["-h"]), Ok(Mode::Help));
assert_eq!(parse_mode(["help"]), Ok(Mode::Help));
}
#[test]
fn parse_mode_rejects_unknown_and_extra_listen_args() {
assert!(parse_mode(["unlock"]).is_err());
assert!(parse_mode(["listen", "--foreground"]).is_err());
}
}