From 80caedc7fd40b6c34a0502478206be50c8d6249d Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:51:35 +0800 Subject: [PATCH] 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. --- .forgejo/workflows/package.yml | 50 ++++++++++- Cargo.lock | 8 +- Cargo.toml | 4 +- EVENTS.md | 62 ++++++++++--- README.md | 16 +++- breadlock/src/bread_events.rs | 157 ++++++++++++++++++++++++++++++++- breadlock/src/main.rs | 132 +++++++++++++++++++++++++++ 7 files changed, 401 insertions(+), 28 deletions(-) diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 9cfa436..0705ebb 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -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" diff --git a/Cargo.lock b/Cargo.lock index 5f96f51..536cd82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 0cfefb2..7402de6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/EVENTS.md b/EVENTS.md index 208e0f7..9b5801c 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -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": "" }` | `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. diff --git a/README.md b/README.md index a59182f..230f94d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/breadlock/src/bread_events.rs b/breadlock/src/bread_events.rs index 7cc1492..b8a8fd7 100644 --- a/breadlock/src/bread_events.rs +++ b/breadlock/src/bread_events.rs @@ -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(())); + } +} diff --git a/breadlock/src/main.rs b/breadlock/src/main.rs index 0e9a65f..7a2c661 100644 --- a/breadlock/src/main.rs +++ b/breadlock/src/main.rs @@ -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(args: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + 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()); + } +}