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:
parent
76544398d3
commit
80caedc7fd
7 changed files with 401 additions and 28 deletions
|
|
@ -7,10 +7,10 @@ on:
|
||||||
jobs:
|
jobs:
|
||||||
package:
|
package:
|
||||||
runs-on: [self-hosted, hestia]
|
runs-on: [self-hosted, hestia]
|
||||||
# Forgejo's Arch package registry does not GPG-sign packages for pacman.
|
# Forgejo's Arch package registry does not GPG-sign a pacman db.
|
||||||
# BOS therefore uses SigLevel=Never on [Breadway.os.git.breadway.dev]
|
# BOS ISO [breadway] stays SigLevel = Never until a signed db exists.
|
||||||
# until a signed repo exists. Keep publishing here — do not flip this
|
# Do not flip that here — flipping without a signed db breaks pacman.
|
||||||
# job to a different registry just to get signatures.
|
# Keep publishing the unsigned .pkg.tar.zst to the registry below.
|
||||||
container:
|
container:
|
||||||
image: archlinux:latest
|
image: archlinux:latest
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -37,8 +37,50 @@ jobs:
|
||||||
# --nocheck: packaging builds the artifact; tests belong in a CI job.
|
# --nocheck: packaging builds the artifact; tests belong in a CI job.
|
||||||
su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck"
|
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)
|
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 \
|
curl -fsS -X PUT \
|
||||||
-H "Authorization: token ${PUBLISH_TOKEN}" \
|
-H "Authorization: token ${PUBLISH_TOKEN}" \
|
||||||
-H "Content-Type: application/octet-stream" \
|
-H "Content-Type: application/octet-stream" \
|
||||||
--data-binary "@${PKG}" \
|
--data-binary "@${PKG}" \
|
||||||
"https://git.breadway.dev/api/packages/Breadway/arch/os"
|
"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
8
Cargo.lock
generated
|
|
@ -77,8 +77,8 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bread-theme"
|
name = "bread-theme"
|
||||||
version = "0.3.1"
|
version = "0.7.2"
|
||||||
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886"
|
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs",
|
"dirs",
|
||||||
"gtk4",
|
"gtk4",
|
||||||
|
|
@ -88,8 +88,8 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bread-utils"
|
name = "bread-utils"
|
||||||
version = "0.3.1"
|
version = "0.7.2"
|
||||||
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886"
|
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bread-shared",
|
"bread-shared",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ members = ["breadlock-ui", "breadlock", "breadgreet"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
bread-theme = { 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.1" }
|
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
|
|
|
||||||
62
EVENTS.md
62
EVENTS.md
|
|
@ -9,9 +9,13 @@ follows.
|
||||||
|
|
||||||
App id: **`lock`**. Transport: `bread-utils`'s `bread_client` module
|
App id: **`lock`**. Transport: `bread-utils`'s `bread_client` module
|
||||||
(feature `bread-client`) — `breadlock` links it directly. Each `emit` is
|
(feature `bread-client`) — `breadlock` links it directly. Each `emit` is
|
||||||
its own short-lived connection (`BreadClient::emit` is fire-and-forget);
|
its own short-lived connection (`BreadClient::emit` is fire-and-forget).
|
||||||
there is no long-running subscription half because breadlock has no
|
Commands are received on a `BreadClient::subscribe` background thread
|
||||||
command verbs (see below).
|
(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
|
`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
|
||||||
|
|
@ -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.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.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.*`)
|
## Commands honored (`bread.command.lock.*`)
|
||||||
|
|
||||||
None. breadlock is started by hypridle / `loginctl lock-session` (or
|
| Verb | Effect |
|
||||||
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.
|
| `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
|
A Lua workflow that wants the session locked should `bread.wait` /
|
||||||
(accepted, warned, surface drawn unblurred). That is appearance config,
|
`bread.wait_any` on `bread.lock.lock.done` (or `.failed`) with a timeout.
|
||||||
not a bus command — do not invent `bread.command.lock.blur` for it.
|
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
|
## Fail-safe behavior
|
||||||
|
|
||||||
- If breadd isn't installed or isn't running, `emit` is a silent no-op
|
- 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.
|
actual lock/unlock path is entirely unaffected either way.
|
||||||
- There is no command subscription, so a breadd restart while the lock
|
- If breadd restarts, the command subscription reconnects automatically
|
||||||
screen is up changes nothing on this side.
|
(`BreadClient::subscribe`'s background thread has its own backoff loop);
|
||||||
|
no restart of the locker or of `breadlock listen` is needed.
|
||||||
|
|
|
||||||
16
README.md
16
README.md
|
|
@ -12,9 +12,14 @@ Both use [`bread-theme`](https://git.breadway.dev/Breadway/bread-ecosystem) for
|
||||||
## bread event integration
|
## bread event integration
|
||||||
|
|
||||||
`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`. See
|
running, it publishes `bread.lock.locked` / `bread.lock.unlocked` and
|
||||||
[EVENTS.md](EVENTS.md) for the bus contract. `breadgreet` is not on the
|
honors `bread.command.lock.lock` (emits `bread.lock.lock.done` /
|
||||||
bus. There is no `bakery.toml` (PAM / pacman exception).
|
`.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
|
## Architecture
|
||||||
|
|
||||||
|
|
@ -76,6 +81,11 @@ command = "cage -s -- breadgreet"
|
||||||
lock_cmd = breadlock
|
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)
|
## 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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,32 @@
|
||||||
//! `bread.lock.*` event integration — optional, non-blocking. See
|
//! `bread.lock.*` event integration — optional, non-blocking. See
|
||||||
//! `EVENTS.md` at the repo root for the full contract. breadlock works
|
//! `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
|
//! fire-and-forget (`BreadClient::emit` never blocks or errors this
|
||||||
//! 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
|
||||||
|
//! 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
|
/// 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";
|
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() {
|
pub fn emit_locked() {
|
||||||
BreadClient::connect(APP_ID).emit("bread.lock.locked", serde_json::json!({}));
|
BreadClient::connect(APP_ID).emit("bread.lock.locked", serde_json::json!({}));
|
||||||
}
|
}
|
||||||
|
|
@ -18,3 +34,138 @@ pub fn emit_locked() {
|
||||||
pub fn emit_unlocked() {
|
pub fn emit_unlocked() {
|
||||||
BreadClient::connect(APP_ID).emit("bread.lock.unlocked", serde_json::json!({}));
|
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(()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,116 @@ use wayland_client::globals::registry_queue_init;
|
||||||
use wayland_client::{protocol::wl_buffer, Connection, QueueHandle};
|
use wayland_client::{protocol::wl_buffer, Connection, QueueHandle};
|
||||||
|
|
||||||
use background::Background;
|
use background::Background;
|
||||||
|
use bread_utils::singleton::{try_acquire, Acquire};
|
||||||
use state::{AppState, AuthState, LockSurface};
|
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() {
|
fn main() {
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||||
.init();
|
.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")
|
let username = std::env::var("USER")
|
||||||
.or_else(|_| std::env::var("LOGNAME"))
|
.or_else(|_| std::env::var("LOGNAME"))
|
||||||
.unwrap_or_else(|_| {
|
.unwrap_or_else(|_| {
|
||||||
|
|
@ -204,3 +307,32 @@ smithay_client_toolkit::delegate_seat!(AppState);
|
||||||
smithay_client_toolkit::delegate_keyboard!(AppState);
|
smithay_client_toolkit::delegate_keyboard!(AppState);
|
||||||
smithay_client_toolkit::delegate_registry!(AppState);
|
smithay_client_toolkit::delegate_registry!(AppState);
|
||||||
wayland_client::delegate_noop!(AppState: ignore wl_buffer::WlBuffer);
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue