Compare commits
No commits in common. "main" and "v0.2.0" have entirely different histories.
41 changed files with 653 additions and 8506 deletions
21
.forgejo/workflows/mirror.yml
Normal file
21
.forgejo/workflows/mirror.yml
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
name: Mirror to GitHub
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['**']
|
||||||
|
tags: ['**']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
mirror:
|
||||||
|
runs-on: [self-hosted, hestia]
|
||||||
|
steps:
|
||||||
|
- name: Mirror to GitHub
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git
|
||||||
|
cd repo.git
|
||||||
|
# Mirror only branches and tags (not refs/pull/*, which GitHub rejects);
|
||||||
|
# --prune deletes GitHub refs that no longer exist on Forgejo.
|
||||||
|
git push --prune \
|
||||||
|
"https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadlock.git" \
|
||||||
|
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
|
||||||
|
|
@ -7,10 +7,6 @@ on:
|
||||||
jobs:
|
jobs:
|
||||||
package:
|
package:
|
||||||
runs-on: [self-hosted, hestia]
|
runs-on: [self-hosted, hestia]
|
||||||
# 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:
|
container:
|
||||||
image: archlinux:latest
|
image: archlinux:latest
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -34,52 +30,11 @@ jobs:
|
||||||
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD
|
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD
|
||||||
sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD
|
sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD
|
||||||
chown -R builder:builder /home/builder/src
|
chown -R builder:builder /home/builder/src
|
||||||
su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm"
|
# --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)
|
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"
|
|
||||||
|
|
|
||||||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -19,12 +19,3 @@ Thumbs.db
|
||||||
|
|
||||||
# Claude Code session data
|
# Claude Code session data
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
# Local hygiene notes (not for commit)
|
|
||||||
CLAUDE.md
|
|
||||||
|
|
||||||
# graphify knowledge-graph output (local tool cache, not for commit)
|
|
||||||
graphify-out/
|
|
||||||
|
|
||||||
# breadlock-preview PNG output (dev-only animation harness)
|
|
||||||
preview/
|
|
||||||
|
|
|
||||||
26
AGENTS.md
26
AGENTS.md
|
|
@ -1,26 +0,0 @@
|
||||||
# AGENTS.md — Repo hygiene
|
|
||||||
|
|
||||||
Scope: this file covers *repo hygiene* — branching, remotes, CI, cleanup. It is not project documentation.
|
|
||||||
|
|
||||||
## Branch model
|
|
||||||
- Single-trunk: `main` only. No `dev` or `beta` branch. Land small changes directly, or use short-lived `feature/x`/`fix/x` branches for anything non-trivial and merge back to `main`.
|
|
||||||
- This replaced an earlier three-branch (`dev`/`beta`/`main`) model after `main` silently rotted across the ecosystem. Don't recreate those branches.
|
|
||||||
|
|
||||||
## Channel
|
|
||||||
- **Pacman-only, permanently.** There is no `bakery.toml` on purpose: breadlock installs a root-owned `/etc/pam.d/breadlock` PAM service (and `breadgreet` is a greetd greeter). Bakery has no privileged-install path. Do not add `bakery.toml`.
|
|
||||||
- Releases are `v*` tags. `.forgejo/workflows/package.yml` builds the `[breadway]` pacman package. There are no bakery tracks (`dev`/`beta`/`stable` indexes) for this repo.
|
|
||||||
|
|
||||||
## Remotes
|
|
||||||
- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative. Push here.
|
|
||||||
- `github` — GitHub mirror (push-mirror; do not push to it by hand).
|
|
||||||
|
|
||||||
## CI
|
|
||||||
- `.forgejo/workflows/package.yml` triggers only on `push: tags: ['v*']` — regular pushes to `main` run nothing. Tag a release to trigger packaging.
|
|
||||||
- No build/lint/test CI runs on ordinary commits or PRs — test locally before merging.
|
|
||||||
|
|
||||||
## Cleanup
|
|
||||||
- Delete feature/fix branches (local + remote) once merged. Check with `git branch --merged main`.
|
|
||||||
|
|
||||||
## Don't
|
|
||||||
- Don't add `bakery.toml`.
|
|
||||||
- Don't embed credentials in remote URLs — SSH or a credential helper only.
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
# Contributing
|
|
||||||
|
|
||||||
`breadlock` / `breadgreet` — session locker and greetd greeter for Hyprland.
|
|
||||||
|
|
||||||
Single-trunk, same as the rest of the ecosystem: one long-lived branch
|
|
||||||
(`main`), short-lived `feature/<name>` / `fix/<name>` branches, merge back.
|
|
||||||
No `dev` or `beta` branch.
|
|
||||||
|
|
||||||
This repo is a deliberate **pacman-only** exception. There is no
|
|
||||||
`bakery.toml` — breadlock needs a root-owned `/etc/pam.d/breadlock` PAM
|
|
||||||
service, which bakery cannot install. Don't add one. Releases are `v*`
|
|
||||||
tags that fire `.forgejo/workflows/package.yml` into the `[breadway]`
|
|
||||||
pacman repo. There are no bakery tracks.
|
|
||||||
|
|
||||||
See `AGENTS.md` for remotes and CI details.
|
|
||||||
|
|
||||||
## Local development
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cargo build --release --bin breadlock --bin breadgreet
|
|
||||||
cargo test --workspace
|
|
||||||
```
|
|
||||||
980
Cargo.lock
generated
980
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -3,8 +3,7 @@ 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.4" }
|
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10" }
|
||||||
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"
|
||||||
|
|
|
||||||
97
EVENTS.md
97
EVENTS.md
|
|
@ -1,97 +0,0 @@
|
||||||
# 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:
|
|
||||||
|
|
||||||
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. Unlock while already unlocked is an
|
|
||||||
idempotent `done`.
|
|
||||||
2. **The locker process** — always subscribes once the lock screen is
|
|
||||||
up, so `lock` during an active lock is an idempotent `done`, and
|
|
||||||
`unlock` is `.failed` (cannot bypass PAM). Never compositor
|
|
||||||
`unlock()`, never `loginctl 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, `emit` is a silent no-op
|
|
||||||
(`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.
|
|
||||||
- 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.
|
|
||||||
57
README.md
57
README.md
|
|
@ -1,30 +1,14 @@
|
||||||
# breadlock
|
# breadlock
|
||||||
|
|
||||||
Session locker and graphical [greetd](https://git.sr.ht/~kennylevinsen/greetd) greeter for [Hyprland](https://hyprland.org/) on Wayland — the bread-ecosystem replacement for `hyprlock` and `tuigreet`. BOS already ships both binaries: `breadgreet` under `cage` via greetd, and `breadlock` via hypridle (`SUPER+L` is `loginctl lock-session`).
|
Session locker and graphical [greetd](https://git.sr.ht/~kennylevinsen/greetd) greeter for [Hyprland](https://hyprland.org/) on Wayland — the bread-ecosystem replacement for `hyprlock` and the TUI greeter (`tuigreet`) BOS currently ships.
|
||||||
|
|
||||||
Two binaries, one workspace:
|
Two binaries, one workspace:
|
||||||
|
|
||||||
- **`breadlock`** — locks the *already running* Hyprland session via `ext-session-lock-v1`. Drop-in for `hyprlock`.
|
- **`breadlock`** — locks the *already running* Hyprland session via `ext-session-lock-v1`. Drop-in for `hyprlock`.
|
||||||
- **`breadgreet`** — a graphical greeter that speaks `greetd`'s own IPC protocol (the same architecture as `gtkgreet`/`regreet`). `greetd` keeps owning PAM auth, VT switching, and session launching; `breadgreet` only draws the login UI and relays the conversation. This is a deliberate choice over reimplementing a display manager from scratch — `greetd` is already installed and battle-tested.
|
- **`breadgreet`** — a graphical greeter that speaks `greetd`'s own IPC protocol (the same architecture as `gtkgreet`/`regreet`). `greetd` keeps owning PAM auth, VT switching, and session launching; `breadgreet` only draws the login UI and relays the conversation. This is a deliberate choice over reimplementing a display manager from scratch — `greetd` is already installed and battle-tested.
|
||||||
|
|
||||||
Both use [`bread-theme`](https://git.breadway.dev/Breadway/bread-ecosystem) for palette loading, matching the rest of the bread* ecosystem (breadbar, breadbox, bos-settings).
|
Both use [`bread-theme`](https://git.breadway.dev/Breadway/bread-ecosystem) for palette loading, matching the rest of the bread* ecosystem (breadbar, breadbox, bos-settings).
|
||||||
|
|
||||||
## bread event integration
|
|
||||||
|
|
||||||
`breadlock` works the same with or without `breadd`. When `breadd` is
|
|
||||||
running, it publishes `bread.lock.locked` / `bread.lock.unlocked` and
|
|
||||||
honors `bread.command.lock.lock` / `bread.command.lock.unlock` (emits
|
|
||||||
`bread.lock.lock.done` / `.failed` and `bread.lock.unlock.done` /
|
|
||||||
`.failed`). Run `breadlock listen` so both commands work while
|
|
||||||
unlocked; the locker also subscribes while the session is locked.
|
|
||||||
Unlock is fail-secure: already-unlocked acks `bread.lock.unlock.done`;
|
|
||||||
a running locker refuses with `bread.lock.unlock.failed` (only PAM at
|
|
||||||
the lock screen unlocks). The bus never calls compositor `unlock()` or
|
|
||||||
`loginctl unlock-session`. Super+L remains `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
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -32,29 +16,27 @@ breadlock/
|
||||||
├── breadlock-ui/ shared: bread-theme wrapper, TOML config, .desktop parsing,
|
├── breadlock-ui/ shared: bread-theme wrapper, TOML config, .desktop parsing,
|
||||||
│ software-rendering primitives (tiny-skia + cosmic-text,
|
│ software-rendering primitives (tiny-skia + cosmic-text,
|
||||||
│ behind the "paint" feature — only breadlock needs them)
|
│ behind the "paint" feature — only breadlock needs them)
|
||||||
├── breadlock/ the locker (SCTK + PAM; EGL wallpaper + software chrome)
|
├── breadlock/ the locker (SCTK + PAM)
|
||||||
└── breadgreet/ the greeter (GTK4 + relm4 + greetd_ipc)
|
└── breadgreet/ the greeter (GTK4 + relm4 + greetd_ipc)
|
||||||
```
|
```
|
||||||
|
|
||||||
### breadlock
|
### breadlock
|
||||||
|
|
||||||
- **Protocol**: `ext-session-lock-v1` via [`smithay-client-toolkit`](https://docs.rs/smithay-client-toolkit) — GTK has no session-lock support, so this is a raw Wayland client, not a layer-shell surface like breadbar.
|
- **Protocol**: `ext-session-lock-v1` via [`smithay-client-toolkit`](https://docs.rs/smithay-client-toolkit) — GTK has no session-lock support, so this is a raw Wayland client, not a layer-shell surface like breadbar.
|
||||||
- **Rendering**: hybrid — wallpaper via EGL/GLES2 (`wl_egl_window` wrapping the lock surface); chrome (password pill, clock, status line) is still software (`tiny-skia` + `cosmic-text`, "Varela Round" by family name) and blitted over the GPU frame. If EGL init fails, the locker falls back to a fully-software `wl_shm` path.
|
- **Rendering**: fully software — `tiny-skia` composites each frame (background, rounded password pill, clock, status line) into a `wl_shm` buffer; `cosmic-text` shapes and rasterizes text (loads "Varela Round" by family name). No EGL/GL.
|
||||||
- **Background**: a solid palette color or a PNG (cover-fit). Ken Burns (`background.ken_burns`) is opt-in: a slow pan+zoom on image backgrounds — cheap on the GPU path, a continuous software redraw if EGL is unavailable. `background.blur` is **not implemented** — the key is accepted and logs a warning; the surface is drawn unblurred. Live blur-of-desktop (hyprlock-style) would need a `wlr-screencopy` capture.
|
- **Background**: a solid palette color or a static PNG (cover-fit). Live blur-of-desktop (hyprlock-style) is a **v2 follow-up** — it needs a `wlr-screencopy` capture (`libwayshot` is the right crate when this gets picked up); `background.blur = true` is accepted today but just logs a warning.
|
||||||
- **Auth**: [`pam-client2`](https://crates.io/crates/pam-client2) against the `breadlock` PAM service (`packaging/pam.d/breadlock`, installed to `/etc/pam.d/breadlock` by the package). Runs on its own OS thread — libpam's conversation callback is blocking FFI — and reports back through a `calloop::channel` registered on the render loop.
|
- **Auth**: [`pam-client2`](https://crates.io/crates/pam-client2) against the `breadlock` PAM service (`packaging/pam.d/breadlock`, installed to `/etc/pam.d/breadlock` by the package). Runs on its own OS thread — libpam's conversation callback is blocking FFI — and reports back through a `calloop::channel` registered on the render loop.
|
||||||
|
|
||||||
### breadgreet
|
### breadgreet
|
||||||
|
|
||||||
- **Protocol**: [`greetd_ipc`](https://crates.io/crates/greetd_ipc) (greetd's own crate) over the Unix socket at `$GREETD_SOCK`: `CreateSession` → answer each `AuthMessage` via `PostAuthMessageResponse` → `StartSession` hands the resolved session command to `greetd`, which execs it and owns the VT switch away.
|
- **Protocol**: [`greetd_ipc`](https://crates.io/crates/greetd_ipc) (greetd's own crate) over the Unix socket at `$GREETD_SOCK`: `CreateSession` → answer each `AuthMessage` via `PostAuthMessageResponse` → `StartSession` hands the resolved session command to `greetd`, which execs it and owns the VT switch away.
|
||||||
- **UI**: GTK4 + [relm4](https://relm4.org/), matching breadbar's stack — **without** `gtk4-layer-shell`. `greetd` hosts the greeter under a single-client kiosk compositor (`cage -s`), which already fullscreens its one client, so layer-shell's multi-surface/anchor semantics don't apply. Confirmed against ReGreet's real dependency list, which has no layer-shell dependency either.
|
- **UI**: GTK4 + [relm4](https://relm4.org/), matching breadbar's stack — **without** `gtk4-layer-shell`. `greetd` hosts the greeter under a single-client kiosk compositor (`cage -s`), which already fullscreens its one client, so layer-shell's multi-surface/anchor semantics don't apply. Confirmed against ReGreet's real dependency list, which has no layer-shell dependency either.
|
||||||
- **Sessions**: scans `/usr/share/wayland-sessions` and `/usr/share/xsessions` for `.desktop` entries and shows a keyboard-accessible picker. The configured default (compiled-in: `bos`) is pre-selected when that stem exists; otherwise the first discovered session. `StartSession` is the chosen entry's `Exec=` argv.
|
- **Sessions**: scans `/usr/share/wayland-sessions` and `/usr/share/xsessions` for `.desktop` entries and auto-selects the configured default (or the only one found). BOS ships one session today, so there's no picker UI in v1 — a natural v2 addition if that changes.
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
Copy [`breadlock.example.toml`](breadlock.example.toml) to `~/.config/breadlock/breadlock.toml` and [`breadgreet.example.toml`](breadgreet.example.toml) to `/etc/greetd/breadgreet.toml` (or `~/.config/breadgreet/breadgreet.toml` for local testing under a normal session — `breadgreet` checks the system path first since it typically runs as the dedicated `greeter` user). Every field is optional; both binaries run with sensible defaults and no config at all.
|
Copy [`breadlock.example.toml`](breadlock.example.toml) to `~/.config/breadlock/breadlock.toml` and [`breadgreet.example.toml`](breadgreet.example.toml) to `/etc/greetd/breadgreet.toml` (or `~/.config/breadgreet/breadgreet.toml` for local testing under a normal session — `breadgreet` checks the system path first since it typically runs as the dedicated `greeter` user). Every field is optional; both binaries run with sensible defaults and no config at all.
|
||||||
|
|
||||||
`breadlock.toml`'s `[status]` table (both flags default on) shows now-playing (MPRIS) and battery (upower) as a small line under the clock. Polled on a background thread; degrades silently if D-Bus or the service is missing.
|
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|
@ -62,46 +44,39 @@ cargo build --release --bin breadlock --bin breadgreet
|
||||||
cargo test --workspace
|
cargo test --workspace
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires GTK4 (≥ 4.12), `libxkbcommon`, PAM development headers, `git` (workspace crates `bread-theme` / `bread-utils` are git deps), and `pkg-config` (gtk4-rs; also provided by `base-devel`). On Arch:
|
Requires GTK4 (≥ 4.12), `libxkbcommon`, and PAM development headers. On Arch:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo pacman -S gtk4 wayland libxkbcommon pam rust cargo git pkg-config
|
sudo pacman -S gtk4 wayland libxkbcommon pam rust cargo
|
||||||
```
|
```
|
||||||
|
|
||||||
`breadlock-auth-check` and `breadlock-preview` are extra, dev-only binaries in the `breadlock` package (see Verification below) — not installed by the package. Build them explicitly with `cargo build --bin breadlock-auth-check` or `--bin breadlock-preview` if you need them.
|
`breadlock-auth-check` is a third, dev-only binary in the `breadlock` package (see Verification below) — not installed by the package, build it explicitly with `cargo build --bin breadlock-auth-check` if you need it.
|
||||||
|
|
||||||
## Packaging
|
## Packaging
|
||||||
|
|
||||||
`packaging/arch/PKGBUILD` builds and installs both binaries plus `/etc/pam.d/breadlock`, published to the `[breadway]` pacman repo by `.forgejo/workflows/package.yml`. breadlock is a deliberate **pacman-only** exception — there is no `bakery.toml` on purpose. A PAM service and greetd greeter need a root-owned install (`/etc/pam.d/breadlock`), which bakery has no privileged path for.
|
`packaging/arch/PKGBUILD` builds and installs both binaries plus `/etc/pam.d/breadlock`, published to the `[breadway]` pacman repo by `.forgejo/workflows/package.yml`. breadlock is pacman-only — it is not in bread-ecosystem's registry and has no `bakery.toml`; a PAM/greeter component gets installed through the package manager, not the bakery curl-script channel.
|
||||||
|
|
||||||
BOS already wires the packaged binaries (this repo still does not ship those system files):
|
**Not included, by design**: this repo does not touch `/etc/greetd/config.toml`, install a lock keybind, or wire up `hypridle`. Once packaged, wiring BOS to actually use these binaries means:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
# /etc/greetd/config.toml — BOS default
|
# /etc/greetd/config.toml — replace the current tuigreet line
|
||||||
[default_session]
|
[default_session]
|
||||||
command = "cage -s -- breadgreet"
|
command = "cage -s -- breadgreet"
|
||||||
```
|
```
|
||||||
|
|
||||||
```
|
```
|
||||||
# hypridle lock_cmd (BOS). SUPER+L is loginctl lock-session, which hypridle picks up.
|
# hyprland.conf
|
||||||
lock_cmd = breadlock
|
bind = SUPER, L, exec, breadlock
|
||||||
```
|
```
|
||||||
|
|
||||||
`breadlock listen` is the unlocked-path subscriber for
|
That's a separate, later BOS task — deliberately kept out of this change so the existing `tuigreet` login path stays untouched and available as a fallback while these binaries are tested.
|
||||||
`bread.command.lock.lock` and `bread.command.lock.unlock`. 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, or to ack already-unlocked.
|
|
||||||
`bread.command.lock.unlock` does not replace PAM and does not run
|
|
||||||
`loginctl unlock-session`. Super+L / hypridle remain
|
|
||||||
`loginctl lock-session`.
|
|
||||||
|
|
||||||
## 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.
|
||||||
2. **Locker rendering/lock lifecycle nested, never against the live session**: run `breadlock` inside a nested Hyprland instance or under `cage -- breadlock`. `ext-session-lock-v1` only ever affects the compositor instance the client is connected to (scoped to `$WAYLAND_DISPLAY`), so a nested lock can never lock the real outer session. Verify the full type-password → PAM check → unlock cycle there, including the wrong-password path, before ever binding a real keybind.
|
2. **Locker rendering/lock lifecycle nested, never against the live session**: run `breadlock` inside a nested Hyprland instance or under `cage -- breadlock`. `ext-session-lock-v1` only ever affects the compositor instance the client is connected to (scoped to `$WAYLAND_DISPLAY`), so a nested lock can never lock the real outer session. Verify the full type-password → PAM check → unlock cycle there, including the wrong-password path, before ever binding a real keybind.
|
||||||
3. **If testing against a live session**: keep a second TTY or SSH session open the whole time. Killing the `breadlock` process is **not** a safe unlock path — per the protocol, an abnormally-terminated lock client is expected to leave the compositor still locked. The real recovery path is "kill it, then use the second session to restart Hyprland or switch VT."
|
3. **If testing against a live session**: keep a second TTY or SSH session open the whole time. Killing the `breadlock` process is **not** a safe unlock path — per the protocol, an abnormally-terminated lock client is expected to leave the compositor still locked. The real recovery path is "kill it, then use the second session to restart Hyprland or switch VT."
|
||||||
4. **breadgreet**: `cargo test -p breadgreet` runs the `greetd_ipc` framing/state-machine tests against a mock Unix-socket server — no real `greetd` or PAM involved. Manual testing against a real `greetd` should happen on a disposable VT, not by replacing the live BOS `cage -s -- breadgreet` session on VT1.
|
4. **breadgreet**: `cargo test -p breadgreet` runs the `greetd_ipc` framing/state-machine tests against a mock Unix-socket server — no real `greetd` or PAM involved. Manual testing against a real `greetd` should happen on a disposable VT, leaving the existing `tuigreet` config on VT1 untouched as a fallback.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,9 @@
|
||||||
mode = "color"
|
mode = "color"
|
||||||
path = ""
|
path = ""
|
||||||
blur = false
|
blur = false
|
||||||
# Slow Ken Burns pan on image backgrounds (gentle drift + zoom). Opt-in: the
|
|
||||||
# background redraws continuously at a low frame rate.
|
|
||||||
ken_burns = false
|
|
||||||
|
|
||||||
[clock]
|
[clock]
|
||||||
format = "%H:%M"
|
format = "%H:%M"
|
||||||
# strftime format for the date line under the clock; empty string hides it
|
|
||||||
date_format = "%A · %b %d"
|
|
||||||
|
|
||||||
[font]
|
[font]
|
||||||
family = "Varela Round"
|
family = "Varela Round"
|
||||||
|
|
@ -25,8 +20,7 @@ family = "Varela Round"
|
||||||
# Directories scanned for .desktop session entries, in order.
|
# Directories scanned for .desktop session entries, in order.
|
||||||
wayland_dirs = ["/usr/share/wayland-sessions"]
|
wayland_dirs = ["/usr/share/wayland-sessions"]
|
||||||
xsessions_dirs = ["/usr/share/xsessions"]
|
xsessions_dirs = ["/usr/share/xsessions"]
|
||||||
# .desktop file stem (without extension) pre-selected in the picker.
|
# .desktop file stem (without extension) to auto-select. Falls back to the
|
||||||
# Falls back to the first entry found if this isn't present. BOS ships
|
# first entry found if this isn't present. v1 has no session picker UI —
|
||||||
# bos.desktop (Exec=bos-session); leaving this as "bos" is what the ISO
|
# BOS only ships one session (Hyprland) today.
|
||||||
# config expects so Hyprland's own hyprland.desktop is not picked first.
|
default = "hyprland"
|
||||||
default = "bos"
|
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,7 @@ pub struct Config {
|
||||||
pub struct Sessions {
|
pub struct Sessions {
|
||||||
pub wayland_dirs: Vec<String>,
|
pub wayland_dirs: Vec<String>,
|
||||||
pub xsessions_dirs: Vec<String>,
|
pub xsessions_dirs: Vec<String>,
|
||||||
/// `.desktop` file stem (without extension) to pre-select in the picker.
|
/// `.desktop` file stem (without extension) to auto-select.
|
||||||
/// Falls back to the first discovered session if this stem is missing.
|
|
||||||
pub default: String,
|
pub default: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -25,7 +24,7 @@ impl Default for Sessions {
|
||||||
Self {
|
Self {
|
||||||
wayland_dirs: vec!["/usr/share/wayland-sessions".to_string()],
|
wayland_dirs: vec!["/usr/share/wayland-sessions".to_string()],
|
||||||
xsessions_dirs: vec!["/usr/share/xsessions".to_string()],
|
xsessions_dirs: vec!["/usr/share/xsessions".to_string()],
|
||||||
default: "bos".to_string(),
|
default: "hyprland".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -42,15 +41,12 @@ pub fn load() -> Config {
|
||||||
breadlock_ui::config::load_or_default(&xdg_config_path())
|
breadlock_ui::config::load_or_default(&xdg_config_path())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn xdg_config_dir() -> PathBuf {
|
fn xdg_config_path() -> PathBuf {
|
||||||
std::env::var_os("XDG_CONFIG_HOME")
|
let base = std::env::var_os("XDG_CONFIG_HOME")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
|
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
|
||||||
.unwrap_or_else(|| PathBuf::from("."))
|
.unwrap_or_else(|| PathBuf::from("."));
|
||||||
}
|
base.join("breadgreet").join("breadgreet.toml")
|
||||||
|
|
||||||
fn xdg_config_path() -> PathBuf {
|
|
||||||
xdg_config_dir().join("breadgreet").join("breadgreet.toml")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -62,6 +58,6 @@ mod tests {
|
||||||
let s = Sessions::default();
|
let s = Sessions::default();
|
||||||
assert_eq!(s.wayland_dirs, vec!["/usr/share/wayland-sessions"]);
|
assert_eq!(s.wayland_dirs, vec!["/usr/share/wayland-sessions"]);
|
||||||
assert_eq!(s.xsessions_dirs, vec!["/usr/share/xsessions"]);
|
assert_eq!(s.xsessions_dirs, vec!["/usr/share/xsessions"]);
|
||||||
assert_eq!(s.default, "bos");
|
assert_eq!(s.default, "hyprland");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ impl Client {
|
||||||
/// used directly by tests against a mock server so they don't need to
|
/// used directly by tests against a mock server so they don't need to
|
||||||
/// mutate process-global environment state (which parallel `cargo test`
|
/// mutate process-global environment state (which parallel `cargo test`
|
||||||
/// threads would race on).
|
/// threads would race on).
|
||||||
pub(crate) async fn connect_to(path: impl AsRef<std::path::Path>) -> Result<Self, GreetdError> {
|
async fn connect_to(path: impl AsRef<std::path::Path>) -> Result<Self, GreetdError> {
|
||||||
let stream = UnixStream::connect(path)
|
let stream = UnixStream::connect(path)
|
||||||
.await
|
.await
|
||||||
.map_err(GreetdError::Connect)?;
|
.map_err(GreetdError::Connect)?;
|
||||||
|
|
@ -149,21 +149,10 @@ mod tests {
|
||||||
//! PAM involved. This is the safe way to test this module: a bug here
|
//! PAM involved. This is the safe way to test this module: a bug here
|
||||||
//! just fails a test, it can never affect a real login.
|
//! just fails a test, it can never affect a real login.
|
||||||
use super::*;
|
use super::*;
|
||||||
use greetd_ipc::codec::TokioCodec;
|
|
||||||
use greetd_ipc::{AuthMessageType, ErrorType, Request, Response};
|
|
||||||
use tokio::net::UnixListener;
|
use tokio::net::UnixListener;
|
||||||
|
|
||||||
fn bind_socket(name: &str) -> (std::path::PathBuf, UnixListener) {
|
async fn mock_server(path: std::path::PathBuf, script: Vec<Response>) {
|
||||||
let path = std::env::temp_dir().join(format!(
|
|
||||||
"breadgreet-test-{name}-{}.sock",
|
|
||||||
std::process::id()
|
|
||||||
));
|
|
||||||
std::fs::remove_file(&path).ok();
|
|
||||||
let listener = UnixListener::bind(&path).unwrap();
|
let listener = UnixListener::bind(&path).unwrap();
|
||||||
(path, listener)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn serve(listener: UnixListener, script: Vec<Response>) {
|
|
||||||
let (mut stream, _) = listener.accept().await.unwrap();
|
let (mut stream, _) = listener.accept().await.unwrap();
|
||||||
for response in script {
|
for response in script {
|
||||||
// Drain the request that prompted this response — we don't need
|
// Drain the request that prompted this response — we don't need
|
||||||
|
|
@ -173,11 +162,21 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn socket_path(name: &str) -> std::path::PathBuf {
|
||||||
|
std::env::temp_dir().join(format!(
|
||||||
|
"breadgreet-test-{name}-{}.sock",
|
||||||
|
std::process::id()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn create_session_success_flows_straight_through() {
|
async fn create_session_success_flows_straight_through() {
|
||||||
let (path, listener) = bind_socket("success");
|
let path = socket_path("success");
|
||||||
let server = tokio::spawn(serve(listener, vec![Response::Success]));
|
std::fs::remove_file(&path).ok();
|
||||||
|
let server = tokio::spawn(mock_server(path.clone(), vec![Response::Success]));
|
||||||
|
|
||||||
|
// Give the listener a moment to bind before connecting.
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||||
let mut client = Client::connect_to(&path).await.unwrap();
|
let mut client = Client::connect_to(&path).await.unwrap();
|
||||||
let outcome = client.create_session("bob").await.unwrap();
|
let outcome = client.create_session("bob").await.unwrap();
|
||||||
assert!(matches!(outcome, Outcome::Success));
|
assert!(matches!(outcome, Outcome::Success));
|
||||||
|
|
@ -188,9 +187,10 @@ mod tests {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn create_session_prompts_for_password_then_succeeds() {
|
async fn create_session_prompts_for_password_then_succeeds() {
|
||||||
let (path, listener) = bind_socket("prompt");
|
let path = socket_path("prompt");
|
||||||
let server = tokio::spawn(serve(
|
std::fs::remove_file(&path).ok();
|
||||||
listener,
|
let server = tokio::spawn(mock_server(
|
||||||
|
path.clone(),
|
||||||
vec![
|
vec![
|
||||||
Response::AuthMessage {
|
Response::AuthMessage {
|
||||||
auth_message_type: AuthMessageType::Secret,
|
auth_message_type: AuthMessageType::Secret,
|
||||||
|
|
@ -200,6 +200,7 @@ mod tests {
|
||||||
],
|
],
|
||||||
));
|
));
|
||||||
|
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||||
let mut client = Client::connect_to(&path).await.unwrap();
|
let mut client = Client::connect_to(&path).await.unwrap();
|
||||||
|
|
||||||
let outcome = client.create_session("bob").await.unwrap();
|
let outcome = client.create_session("bob").await.unwrap();
|
||||||
|
|
@ -217,15 +218,17 @@ mod tests {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn auth_error_is_reported_as_such() {
|
async fn auth_error_is_reported_as_such() {
|
||||||
let (path, listener) = bind_socket("autherr");
|
let path = socket_path("autherr");
|
||||||
let server = tokio::spawn(serve(
|
std::fs::remove_file(&path).ok();
|
||||||
listener,
|
let server = tokio::spawn(mock_server(
|
||||||
|
path.clone(),
|
||||||
vec![Response::Error {
|
vec![Response::Error {
|
||||||
error_type: ErrorType::AuthError,
|
error_type: ErrorType::AuthError,
|
||||||
description: "denied".to_string(),
|
description: "denied".to_string(),
|
||||||
}],
|
}],
|
||||||
));
|
));
|
||||||
|
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||||
let mut client = Client::connect_to(&path).await.unwrap();
|
let mut client = Client::connect_to(&path).await.unwrap();
|
||||||
|
|
||||||
let err = client.create_session("bob").await.unwrap_err();
|
let err = client.create_session("bob").await.unwrap_err();
|
||||||
|
|
@ -239,43 +242,9 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn connect_to_missing_socket_fails() {
|
async fn connect_without_greetd_sock_env_fails_cleanly() {
|
||||||
let err = Client::connect_to("/no/such/breadgreet-test.sock")
|
std::env::remove_var("GREETD_SOCK");
|
||||||
.await
|
let err = Client::connect().await.unwrap_err();
|
||||||
.unwrap_err();
|
assert!(matches!(err, GreetdError::NoSocketEnv));
|
||||||
assert!(matches!(err, GreetdError::Connect(_)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn empty_password_is_sent_as_some_empty_string() {
|
|
||||||
let (path, listener) = bind_socket("empty-pw");
|
|
||||||
let server = tokio::spawn(async move {
|
|
||||||
let (mut stream, _) = listener.accept().await.unwrap();
|
|
||||||
let _ = Request::read_from(&mut stream).await;
|
|
||||||
Response::AuthMessage {
|
|
||||||
auth_message_type: AuthMessageType::Secret,
|
|
||||||
auth_message: "Password:".to_string(),
|
|
||||||
}
|
|
||||||
.write_to(&mut stream)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let req = Request::read_from(&mut stream).await.unwrap();
|
|
||||||
match req {
|
|
||||||
Request::PostAuthMessageResponse { response } => {
|
|
||||||
assert_eq!(response, Some(String::new()));
|
|
||||||
}
|
|
||||||
other => panic!("expected PostAuthMessageResponse, got {other:?}"),
|
|
||||||
}
|
|
||||||
Response::Success.write_to(&mut stream).await.unwrap();
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut client = Client::connect_to(&path).await.unwrap();
|
|
||||||
let outcome = client.create_session("bob").await.unwrap();
|
|
||||||
assert!(matches!(outcome, Outcome::Prompt(AuthPrompt::Secret(_))));
|
|
||||||
let outcome = client.respond(Some(String::new())).await.unwrap();
|
|
||||||
assert!(matches!(outcome, Outcome::Success));
|
|
||||||
|
|
||||||
server.await.unwrap();
|
|
||||||
std::fs::remove_file(&path).ok();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,198 +1,3 @@
|
||||||
mod client;
|
mod client;
|
||||||
|
|
||||||
pub use client::{AuthPrompt, Client, GreetdError, Outcome};
|
pub use client::{AuthPrompt, Client, Outcome};
|
||||||
|
|
||||||
use std::future::Future;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
|
|
||||||
/// Commands sent from the UI thread to the greetd actor, which owns the
|
|
||||||
/// single stateful connection to `$GREETD_SOCK`.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum Command {
|
|
||||||
CreateSession(String),
|
|
||||||
Respond(Option<String>),
|
|
||||||
StartSession { cmd: Vec<String>, env: Vec<String> },
|
|
||||||
CancelSession,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum Event {
|
|
||||||
Outcome(Outcome),
|
|
||||||
Error(String),
|
|
||||||
SessionStarted,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Owns the greetd connection for the life of the greeter. Connect failures
|
|
||||||
/// and a later-dead socket are reported as [`Event::Error`]; the actor stays
|
|
||||||
/// alive and reconnects on the next command so the UI cannot freeze with a
|
|
||||||
/// dropped `cmd_rx`.
|
|
||||||
pub async fn run_actor<E>(cmd_rx: mpsc::UnboundedReceiver<Command>, emit: E)
|
|
||||||
where
|
|
||||||
E: FnMut(Event) + Send + 'static,
|
|
||||||
{
|
|
||||||
run_actor_with(cmd_rx, emit, Client::connect).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_actor_with<E, C, Fut>(
|
|
||||||
mut cmd_rx: mpsc::UnboundedReceiver<Command>,
|
|
||||||
mut emit: E,
|
|
||||||
mut connect: C,
|
|
||||||
) where
|
|
||||||
E: FnMut(Event),
|
|
||||||
C: FnMut() -> Fut,
|
|
||||||
Fut: Future<Output = Result<Client, GreetdError>>,
|
|
||||||
{
|
|
||||||
let mut client: Option<Client> = match connect().await {
|
|
||||||
Ok(c) => Some(c),
|
|
||||||
Err(err) => {
|
|
||||||
emit(Event::Error(format!("Cannot reach greetd: {err}")));
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
while let Some(cmd) = cmd_rx.recv().await {
|
|
||||||
if matches!(cmd, Command::CancelSession) {
|
|
||||||
if let Some(c) = client.as_mut() {
|
|
||||||
c.cancel_session().await;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if client.is_none() {
|
|
||||||
match connect().await {
|
|
||||||
Ok(c) => client = Some(c),
|
|
||||||
Err(err) => {
|
|
||||||
emit(Event::Error(format!("Cannot reach greetd: {err}")));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = exec_cmd(client.as_mut().expect("just connected"), cmd).await;
|
|
||||||
match result {
|
|
||||||
CmdResult::Idle => {}
|
|
||||||
CmdResult::Started => emit(Event::SessionStarted),
|
|
||||||
CmdResult::Roundtrip(Ok(outcome)) => emit(Event::Outcome(outcome)),
|
|
||||||
CmdResult::Roundtrip(Err(err)) => {
|
|
||||||
if is_connection_error(&err) {
|
|
||||||
client = None;
|
|
||||||
} else if let Some(c) = client.as_mut() {
|
|
||||||
c.cancel_session().await;
|
|
||||||
}
|
|
||||||
emit(Event::Error(err.to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum CmdResult {
|
|
||||||
Idle,
|
|
||||||
Started,
|
|
||||||
Roundtrip(Result<Outcome, GreetdError>),
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn exec_cmd(client: &mut Client, cmd: Command) -> CmdResult {
|
|
||||||
match cmd {
|
|
||||||
Command::CancelSession => {
|
|
||||||
client.cancel_session().await;
|
|
||||||
CmdResult::Idle
|
|
||||||
}
|
|
||||||
Command::CreateSession(username) => {
|
|
||||||
CmdResult::Roundtrip(client.create_session(&username).await)
|
|
||||||
}
|
|
||||||
Command::Respond(answer) => CmdResult::Roundtrip(client.respond(answer).await),
|
|
||||||
Command::StartSession { cmd, env } => match client.start_session(cmd, env).await {
|
|
||||||
Ok(()) => CmdResult::Started,
|
|
||||||
Err(err) => CmdResult::Roundtrip(Err(err)),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_connection_error(err: &GreetdError) -> bool {
|
|
||||||
matches!(
|
|
||||||
err,
|
|
||||||
GreetdError::Connect(_) | GreetdError::Codec(_) | GreetdError::NoSocketEnv
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use greetd_ipc::codec::TokioCodec;
|
|
||||||
use greetd_ipc::{Request, Response};
|
|
||||||
use tokio::net::UnixListener;
|
|
||||||
|
|
||||||
fn sock(name: &str) -> std::path::PathBuf {
|
|
||||||
std::env::temp_dir().join(format!(
|
|
||||||
"breadgreet-actor-{name}-{}.sock",
|
|
||||||
std::process::id()
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn connect_failure_does_not_drop_the_actor() {
|
|
||||||
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
|
|
||||||
let (ev_tx, mut ev_rx) = mpsc::unbounded_channel();
|
|
||||||
|
|
||||||
let path = sock("retry");
|
|
||||||
std::fs::remove_file(&path).ok();
|
|
||||||
|
|
||||||
let attempts = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
|
|
||||||
let attempts_c = attempts.clone();
|
|
||||||
let path_c = path.clone();
|
|
||||||
|
|
||||||
let actor = tokio::spawn(async move {
|
|
||||||
run_actor_with(
|
|
||||||
cmd_rx,
|
|
||||||
move |ev| {
|
|
||||||
let _ = ev_tx.send(ev);
|
|
||||||
},
|
|
||||||
move || {
|
|
||||||
let n = attempts_c.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
|
||||||
let path = path_c.clone();
|
|
||||||
async move {
|
|
||||||
if n == 0 {
|
|
||||||
Client::connect_to("/no/such/breadgreet-actor.sock").await
|
|
||||||
} else {
|
|
||||||
Client::connect_to(&path).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
});
|
|
||||||
|
|
||||||
let ev = ev_rx.recv().await.expect("startup connect error");
|
|
||||||
match ev {
|
|
||||||
Event::Error(msg) => assert!(
|
|
||||||
msg.contains("Cannot reach greetd"),
|
|
||||||
"unexpected error: {msg}"
|
|
||||||
),
|
|
||||||
other => panic!("expected Error, got {other:?}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
let listener = UnixListener::bind(&path).unwrap();
|
|
||||||
let server = tokio::spawn(async move {
|
|
||||||
let (mut stream, _) = listener.accept().await.unwrap();
|
|
||||||
let _ = Request::read_from(&mut stream).await;
|
|
||||||
Response::Success.write_to(&mut stream).await.unwrap();
|
|
||||||
});
|
|
||||||
|
|
||||||
cmd_tx
|
|
||||||
.send(Command::CreateSession("bob".into()))
|
|
||||||
.unwrap();
|
|
||||||
let ev = ev_rx.recv().await.expect("actor should retry after bind");
|
|
||||||
match ev {
|
|
||||||
Event::Outcome(Outcome::Success) => {}
|
|
||||||
other => panic!("expected Success, got {other:?}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
drop(cmd_tx);
|
|
||||||
server.await.unwrap();
|
|
||||||
actor.await.unwrap();
|
|
||||||
assert!(
|
|
||||||
attempts.load(std::sync::atomic::Ordering::SeqCst) >= 2,
|
|
||||||
"actor should reconnect after the first failed connect"
|
|
||||||
);
|
|
||||||
std::fs::remove_file(&path).ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,19 @@ mod greetd;
|
||||||
mod sessions;
|
mod sessions;
|
||||||
mod theme;
|
mod theme;
|
||||||
|
|
||||||
use greetd::{AuthPrompt, Outcome};
|
use greetd::{AuthPrompt, Client, Outcome};
|
||||||
use gtk4::gdk::Key;
|
|
||||||
use gtk4::glib::Propagation;
|
|
||||||
use gtk4::prelude::*;
|
use gtk4::prelude::*;
|
||||||
use relm4::prelude::*;
|
use relm4::prelude::*;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
/// Extra zoom beyond plain cover-fit — matches breadlock's `KENBURNS_ZOOM`.
|
/// Commands sent from the UI thread to the greetd actor task (see
|
||||||
const KENBURNS_ZOOM: f32 = 1.06;
|
/// [`spawn_greetd_actor`]), which owns the single stateful connection to
|
||||||
|
/// `$GREETD_SOCK` for the lifetime of one login attempt.
|
||||||
|
enum GreetdCommand {
|
||||||
|
CreateSession(String),
|
||||||
|
Respond(Option<String>),
|
||||||
|
StartSession { cmd: Vec<String>, env: Vec<String> },
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
enum Stage {
|
enum Stage {
|
||||||
|
|
@ -23,8 +27,6 @@ enum Stage {
|
||||||
/// A request is in flight — input is disabled so a second Enter can't
|
/// A request is in flight — input is disabled so a second Enter can't
|
||||||
/// race it.
|
/// race it.
|
||||||
Working,
|
Working,
|
||||||
/// `StartSession` has been sent — Escape must not cancel.
|
|
||||||
Starting,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|
@ -35,27 +37,17 @@ enum AppInput {
|
||||||
Outcome(Outcome),
|
Outcome(Outcome),
|
||||||
Error(String),
|
Error(String),
|
||||||
SessionStarted,
|
SessionStarted,
|
||||||
/// Picker changed; `u32::MAX` (`INVALID_LIST_POSITION`) is ignored.
|
|
||||||
SessionSelected(u32),
|
|
||||||
/// Escape — abort the in-progress PAM conversation.
|
|
||||||
Cancel,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct App {
|
struct App {
|
||||||
clock_lbl: gtk4::Label,
|
clock_lbl: gtk4::Label,
|
||||||
date_lbl: gtk4::Label,
|
|
||||||
status_lbl: gtk4::Label,
|
status_lbl: gtk4::Label,
|
||||||
entry: gtk4::Entry,
|
entry: gtk4::Entry,
|
||||||
stage: Stage,
|
stage: Stage,
|
||||||
username: String,
|
username: String,
|
||||||
sessions: Vec<sessions::Session>,
|
session: Option<sessions::Session>,
|
||||||
selected: usize,
|
|
||||||
clock_format: String,
|
clock_format: String,
|
||||||
date_format: String,
|
cmd_tx: mpsc::UnboundedSender<GreetdCommand>,
|
||||||
/// Last status line was a PAM Info/Error — keep it when the next
|
|
||||||
/// Secret/Visible prompt arrives.
|
|
||||||
pam_status_held: bool,
|
|
||||||
cmd_tx: mpsc::UnboundedSender<greetd::Command>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[relm4::component]
|
#[relm4::component]
|
||||||
|
|
@ -69,18 +61,11 @@ impl SimpleComponent for App {
|
||||||
add_css_class: "breadgreet",
|
add_css_class: "breadgreet",
|
||||||
set_title: Some("breadgreet"),
|
set_title: Some("breadgreet"),
|
||||||
|
|
||||||
#[name = "overlay"]
|
#[name = "root_box"]
|
||||||
gtk4::Overlay {
|
gtk4::Box {
|
||||||
// The relm4 view macro supports a single `set_child` per
|
set_orientation: gtk4::Orientation::Vertical,
|
||||||
// widget, so `root_box` is declared as the overlay's child
|
set_halign: gtk4::Align::Center,
|
||||||
// here; the wallpaper (main child) and veil layers are
|
set_valign: gtk4::Align::Center,
|
||||||
// stacked in `init` via `set_child` + `add_overlay`.
|
|
||||||
#[name = "root_box"]
|
|
||||||
gtk4::Box {
|
|
||||||
set_orientation: gtk4::Orientation::Vertical,
|
|
||||||
set_halign: gtk4::Align::Center,
|
|
||||||
set_valign: gtk4::Align::Center,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -93,41 +78,15 @@ impl SimpleComponent for App {
|
||||||
root.fullscreen();
|
root.fullscreen();
|
||||||
|
|
||||||
let config = config::load();
|
let config = config::load();
|
||||||
let sessions = sessions::list(
|
let session = sessions::discover(
|
||||||
&config.sessions.wayland_dirs,
|
|
||||||
&config.sessions.xsessions_dirs,
|
|
||||||
);
|
|
||||||
// Same default rule as `discover()`: configured stem (compiled-in
|
|
||||||
// `bos`), else the first listed session.
|
|
||||||
let selected = sessions::discover(
|
|
||||||
&config.sessions.wayland_dirs,
|
&config.sessions.wayland_dirs,
|
||||||
&config.sessions.xsessions_dirs,
|
&config.sessions.xsessions_dirs,
|
||||||
&config.sessions.default,
|
&config.sessions.default,
|
||||||
)
|
);
|
||||||
.and_then(|chosen| sessions.iter().position(|s| s.stem == chosen.stem))
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
if config.appearance.background.blur {
|
|
||||||
tracing::warn!(
|
|
||||||
"background.blur is not implemented yet (planned v2 feature, needs a wlr-screencopy \
|
|
||||||
capture) — showing the configured background unblurred"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let clock_lbl = gtk4::Label::new(None);
|
let clock_lbl = gtk4::Label::new(None);
|
||||||
clock_lbl.add_css_class("login-clock");
|
clock_lbl.add_css_class("login-clock");
|
||||||
|
|
||||||
let date_lbl = gtk4::Label::new(None);
|
|
||||||
date_lbl.add_css_class("login-date");
|
|
||||||
if config.appearance.clock.date_format.is_empty() {
|
|
||||||
date_lbl.set_visible(false);
|
|
||||||
clock_lbl.set_margin_bottom(20);
|
|
||||||
} else {
|
|
||||||
clock_lbl.set_margin_bottom(4);
|
|
||||||
date_lbl.set_margin_bottom(16);
|
|
||||||
date_lbl.set_label(¤t_time(&config.appearance.clock.date_format));
|
|
||||||
}
|
|
||||||
|
|
||||||
let entry = gtk4::Entry::new();
|
let entry = gtk4::Entry::new();
|
||||||
entry.add_css_class("login-entry");
|
entry.add_css_class("login-entry");
|
||||||
entry.set_placeholder_text(Some("Username"));
|
entry.set_placeholder_text(Some("Username"));
|
||||||
|
|
@ -140,124 +99,41 @@ impl SimpleComponent for App {
|
||||||
let status_lbl = gtk4::Label::new(None);
|
let status_lbl = gtk4::Label::new(None);
|
||||||
status_lbl.add_css_class("login-status");
|
status_lbl.add_css_class("login-status");
|
||||||
|
|
||||||
if sessions.is_empty() {
|
let session_lbl = gtk4::Label::new(session.as_ref().map(|s| s.name.as_str()));
|
||||||
entry.set_sensitive(false);
|
session_lbl.add_css_class("login-session");
|
||||||
status_lbl.set_label("No session found — cannot log in");
|
if session.is_none() {
|
||||||
status_lbl.add_css_class("error");
|
session_lbl.set_label("No session found — cannot log in");
|
||||||
}
|
}
|
||||||
|
|
||||||
let session_widget: gtk4::Widget = if sessions.is_empty() {
|
|
||||||
let session_lbl = gtk4::Label::new(Some("No session found — cannot log in"));
|
|
||||||
session_lbl.add_css_class("login-session");
|
|
||||||
session_lbl.upcast()
|
|
||||||
} else {
|
|
||||||
let names: Vec<&str> = sessions.iter().map(|s| s.name.as_str()).collect();
|
|
||||||
let dropdown = gtk4::DropDown::from_strings(&names);
|
|
||||||
dropdown.add_css_class("login-session");
|
|
||||||
dropdown.set_hexpand(true);
|
|
||||||
dropdown.set_focusable(true);
|
|
||||||
dropdown.set_tooltip_text(Some("Session"));
|
|
||||||
dropdown.update_property(&[gtk4::accessible::Property::Label("Session")]);
|
|
||||||
dropdown.set_selected(selected as u32);
|
|
||||||
{
|
|
||||||
let sender = sender.clone();
|
|
||||||
dropdown.connect_selected_notify(move |dd| {
|
|
||||||
sender.input(AppInput::SessionSelected(dd.selected()));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
dropdown.upcast()
|
|
||||||
};
|
|
||||||
|
|
||||||
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 8);
|
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 8);
|
||||||
card.add_css_class("login-card");
|
card.add_css_class("login-card");
|
||||||
card.append(&entry);
|
card.append(&entry);
|
||||||
card.append(&status_lbl);
|
card.append(&status_lbl);
|
||||||
card.append(&session_widget);
|
card.append(&session_lbl);
|
||||||
|
|
||||||
let widgets = view_output!();
|
let widgets = view_output!();
|
||||||
|
|
||||||
// Layer the window: wallpaper (main child, bottom) → dim veil → the
|
|
||||||
// clock+card cluster (top). Overlay children stack above the main
|
|
||||||
// child in `add_overlay` order, so the card ends up on top.
|
|
||||||
let bg_area = gtk4::DrawingArea::new();
|
|
||||||
bg_area.set_hexpand(true);
|
|
||||||
bg_area.set_vexpand(true);
|
|
||||||
|
|
||||||
let veil = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
|
||||||
veil.set_hexpand(true);
|
|
||||||
veil.set_vexpand(true);
|
|
||||||
veil.set_halign(gtk4::Align::Fill);
|
|
||||||
veil.set_valign(gtk4::Align::Fill);
|
|
||||||
veil.set_can_focus(false);
|
|
||||||
veil.add_css_class("login-veil");
|
|
||||||
|
|
||||||
widgets.overlay.set_child(Some(&bg_area));
|
|
||||||
// Overlay children stack above the main child in `add_overlay`
|
|
||||||
// order; the last one added is topmost. So the veil goes in first,
|
|
||||||
// then the clock+card cluster on top of it.
|
|
||||||
widgets.overlay.add_overlay(&veil);
|
|
||||||
widgets.overlay.add_overlay(&widgets.root_box);
|
|
||||||
|
|
||||||
widgets.root_box.append(&clock_lbl);
|
widgets.root_box.append(&clock_lbl);
|
||||||
widgets.root_box.append(&date_lbl);
|
|
||||||
widgets.root_box.append(&card);
|
widgets.root_box.append(&card);
|
||||||
|
|
||||||
{
|
|
||||||
let tx = sender.input_sender().clone();
|
|
||||||
let key = gtk4::EventControllerKey::new();
|
|
||||||
key.set_propagation_phase(gtk4::PropagationPhase::Capture);
|
|
||||||
key.connect_key_pressed(move |_, keyval, _, _| {
|
|
||||||
if keyval == Key::Escape {
|
|
||||||
let _ = tx.send(AppInput::Cancel);
|
|
||||||
Propagation::Stop
|
|
||||||
} else {
|
|
||||||
Propagation::Proceed
|
|
||||||
}
|
|
||||||
});
|
|
||||||
root.add_controller(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wallpaper behind the card: cover-fit, Ken Burns pan when enabled
|
|
||||||
// (driven by a frame-clock tick callback), plus an entrance fade+rise.
|
|
||||||
let ken_burns = config.appearance.background.ken_burns;
|
|
||||||
let wallpaper_path = if config.appearance.background.mode
|
|
||||||
== breadlock_ui::config::BackgroundMode::Image
|
|
||||||
&& !config.appearance.background.path.is_empty()
|
|
||||||
{
|
|
||||||
Some(config.appearance.background.path.clone())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
setup_wallpaper(&root, &bg_area, wallpaper_path.as_deref(), ken_burns);
|
|
||||||
setup_entrance(&root, &widgets.root_box);
|
|
||||||
|
|
||||||
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
|
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
|
||||||
spawn_greetd_actor(cmd_rx, sender.clone());
|
spawn_greetd_actor(cmd_rx, sender.clone());
|
||||||
|
|
||||||
theme::apply(&config.appearance.font.family);
|
theme::apply();
|
||||||
bread_theme::gtk::bind_window_auto(&root);
|
|
||||||
spawn_clock_ticker(sender.clone());
|
spawn_clock_ticker(sender.clone());
|
||||||
|
|
||||||
let model = App {
|
let model = App {
|
||||||
clock_lbl,
|
clock_lbl,
|
||||||
date_lbl,
|
|
||||||
status_lbl,
|
status_lbl,
|
||||||
entry,
|
entry,
|
||||||
stage: Stage::Username,
|
stage: Stage::Username,
|
||||||
username: String::new(),
|
username: String::new(),
|
||||||
sessions,
|
session,
|
||||||
selected,
|
|
||||||
clock_format: config.appearance.clock.format.clone(),
|
clock_format: config.appearance.clock.format.clone(),
|
||||||
date_format: config.appearance.clock.date_format.clone(),
|
|
||||||
pam_status_held: false,
|
|
||||||
cmd_tx,
|
cmd_tx,
|
||||||
};
|
};
|
||||||
model
|
model
|
||||||
.clock_lbl
|
.clock_lbl
|
||||||
.set_label(¤t_time(&model.clock_format));
|
.set_label(¤t_time(&model.clock_format));
|
||||||
if !model.sessions.is_empty() {
|
|
||||||
model.entry.grab_focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
ComponentParts { model, widgets }
|
ComponentParts { model, widgets }
|
||||||
}
|
}
|
||||||
|
|
@ -266,45 +142,38 @@ impl SimpleComponent for App {
|
||||||
match msg {
|
match msg {
|
||||||
AppInput::ClockTick => {
|
AppInput::ClockTick => {
|
||||||
self.clock_lbl.set_label(¤t_time(&self.clock_format));
|
self.clock_lbl.set_label(¤t_time(&self.clock_format));
|
||||||
if !self.date_format.is_empty() {
|
|
||||||
self.date_lbl.set_label(¤t_time(&self.date_format));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
AppInput::Submit => self.handle_submit(),
|
AppInput::Submit => self.handle_submit(),
|
||||||
AppInput::Outcome(Outcome::Success) => self.start_session(),
|
AppInput::Outcome(Outcome::Success) => self.start_session(),
|
||||||
AppInput::Outcome(Outcome::Prompt(prompt)) => self.handle_prompt(prompt),
|
AppInput::Outcome(Outcome::Prompt(prompt)) => self.handle_prompt(prompt),
|
||||||
AppInput::Error(description) => self.show_error(&description),
|
AppInput::Error(description) => {
|
||||||
|
self.status_lbl.set_label(&description);
|
||||||
|
self.status_lbl.add_css_class("error");
|
||||||
|
self.entry.set_text("");
|
||||||
|
self.entry.set_visibility(true);
|
||||||
|
self.entry.set_placeholder_text(Some("Username"));
|
||||||
|
self.entry.set_sensitive(true);
|
||||||
|
self.stage = Stage::Username;
|
||||||
|
self.username.clear();
|
||||||
|
}
|
||||||
AppInput::SessionStarted => {
|
AppInput::SessionStarted => {
|
||||||
// greetd waits for this process to exit before exec'ing the
|
// greetd now owns the VT switch to the started session —
|
||||||
// session (cage + gtkgreet/tuigreet all quit here).
|
// nothing left for the greeter to do.
|
||||||
self.status_lbl.set_label("Starting session…");
|
self.status_lbl.set_label("Starting session…");
|
||||||
relm4::main_application().quit();
|
|
||||||
std::process::exit(0);
|
|
||||||
}
|
}
|
||||||
AppInput::SessionSelected(idx) => {
|
|
||||||
let idx = idx as usize;
|
|
||||||
if idx < self.sessions.len() {
|
|
||||||
self.selected = idx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AppInput::Cancel => self.cancel_auth(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
fn handle_submit(&mut self) {
|
fn handle_submit(&mut self) {
|
||||||
if matches!(self.stage, Stage::Working | Stage::Starting) {
|
if matches!(self.stage, Stage::Working) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let text = self.entry.text().to_string();
|
let text = self.entry.text().to_string();
|
||||||
|
|
||||||
match &self.stage {
|
match &self.stage {
|
||||||
Stage::Username => {
|
Stage::Username => {
|
||||||
if self.sessions.is_empty() {
|
|
||||||
self.show_error("No session found — cannot log in");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if text.is_empty() {
|
if text.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -312,249 +181,111 @@ impl App {
|
||||||
self.entry.set_text("");
|
self.entry.set_text("");
|
||||||
self.entry.set_sensitive(false);
|
self.entry.set_sensitive(false);
|
||||||
self.stage = Stage::Working;
|
self.stage = Stage::Working;
|
||||||
self.status_lbl.set_label("");
|
let _ = self
|
||||||
self.status_lbl.remove_css_class("error");
|
.cmd_tx
|
||||||
self.pam_status_held = false;
|
.send(GreetdCommand::CreateSession(self.username.clone()));
|
||||||
self.dispatch(greetd::Command::CreateSession(self.username.clone()));
|
|
||||||
}
|
}
|
||||||
Stage::Prompt => {
|
Stage::Prompt => {
|
||||||
self.entry.set_text("");
|
self.entry.set_text("");
|
||||||
self.entry.set_sensitive(false);
|
self.entry.set_sensitive(false);
|
||||||
self.stage = Stage::Working;
|
self.stage = Stage::Working;
|
||||||
self.dispatch(greetd::Command::Respond(prompt_answer(text)));
|
let answer = if text.is_empty() { None } else { Some(text) };
|
||||||
|
let _ = self.cmd_tx.send(GreetdCommand::Respond(answer));
|
||||||
}
|
}
|
||||||
Stage::Working | Stage::Starting => {}
|
Stage::Working => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_prompt(&mut self, prompt: AuthPrompt) {
|
fn handle_prompt(&mut self, prompt: AuthPrompt) {
|
||||||
|
self.status_lbl.remove_css_class("error");
|
||||||
match prompt {
|
match prompt {
|
||||||
AuthPrompt::Info(message) => {
|
AuthPrompt::Info(message) | AuthPrompt::Error(message) => {
|
||||||
self.status_lbl.remove_css_class("error");
|
// No answer needed — display and immediately continue the
|
||||||
|
// conversation with an empty response.
|
||||||
self.status_lbl.set_label(&message);
|
self.status_lbl.set_label(&message);
|
||||||
self.pam_status_held = true;
|
let _ = self.cmd_tx.send(GreetdCommand::Respond(None));
|
||||||
self.dispatch(greetd::Command::Respond(None));
|
|
||||||
}
|
}
|
||||||
AuthPrompt::Error(message) => {
|
AuthPrompt::Visible(message) => {
|
||||||
self.status_lbl.add_css_class("error");
|
|
||||||
self.status_lbl.set_label(&message);
|
self.status_lbl.set_label(&message);
|
||||||
self.pam_status_held = true;
|
self.entry.set_visibility(true);
|
||||||
self.dispatch(greetd::Command::Respond(None));
|
self.entry.set_placeholder_text(Some(&message));
|
||||||
|
self.entry.set_sensitive(true);
|
||||||
|
self.entry.grab_focus();
|
||||||
|
self.stage = Stage::Prompt;
|
||||||
|
}
|
||||||
|
AuthPrompt::Secret(message) => {
|
||||||
|
self.status_lbl.set_label(&message);
|
||||||
|
self.entry.set_visibility(false);
|
||||||
|
self.entry.set_placeholder_text(Some(&message));
|
||||||
|
self.entry.set_sensitive(true);
|
||||||
|
self.entry.grab_focus();
|
||||||
|
self.stage = Stage::Prompt;
|
||||||
}
|
}
|
||||||
AuthPrompt::Visible(message) => self.show_auth_entry(&message, true),
|
|
||||||
AuthPrompt::Secret(message) => self.show_auth_entry(&message, false),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn show_auth_entry(&mut self, message: &str, visible: bool) {
|
|
||||||
if !self.pam_status_held {
|
|
||||||
self.status_lbl.remove_css_class("error");
|
|
||||||
self.status_lbl.set_label(message);
|
|
||||||
}
|
|
||||||
self.pam_status_held = false;
|
|
||||||
self.entry.set_visibility(visible);
|
|
||||||
self.entry.set_placeholder_text(Some(message));
|
|
||||||
self.entry.set_sensitive(true);
|
|
||||||
self.entry.grab_focus();
|
|
||||||
self.stage = Stage::Prompt;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn start_session(&mut self) {
|
fn start_session(&mut self) {
|
||||||
let (cmd, env) = match self.sessions.get(self.selected) {
|
let Some(session) = &self.session else {
|
||||||
Some(session) => (session.exec.clone(), session.start_env()),
|
self.status_lbl.set_label("No session available to start");
|
||||||
None => {
|
self.status_lbl.add_css_class("error");
|
||||||
self.dispatch(greetd::Command::CancelSession);
|
return;
|
||||||
self.show_error("No session available to start");
|
};
|
||||||
|
self.status_lbl.set_label("Starting session…");
|
||||||
|
let _ = self.cmd_tx.send(GreetdCommand::StartSession {
|
||||||
|
cmd: session.exec.clone(),
|
||||||
|
env: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owns the single stateful connection to `$GREETD_SOCK` for one login
|
||||||
|
/// attempt and translates the UI's [`GreetdCommand`]s into greetd IPC
|
||||||
|
/// round-trips, forwarding each outcome back as an [`AppInput`].
|
||||||
|
fn spawn_greetd_actor(
|
||||||
|
mut cmd_rx: mpsc::UnboundedReceiver<GreetdCommand>,
|
||||||
|
sender: ComponentSender<App>,
|
||||||
|
) {
|
||||||
|
relm4::spawn(async move {
|
||||||
|
let mut client = match Client::connect().await {
|
||||||
|
Ok(client) => client,
|
||||||
|
Err(err) => {
|
||||||
|
sender.input(AppInput::Error(format!("Cannot reach greetd: {err}")));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
self.status_lbl.remove_css_class("error");
|
|
||||||
self.status_lbl.set_label("Starting session…");
|
|
||||||
self.entry.set_sensitive(false);
|
|
||||||
self.stage = Stage::Starting;
|
|
||||||
self.dispatch(greetd::Command::StartSession { cmd, env });
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cancel_auth(&mut self) {
|
while let Some(cmd) = cmd_rx.recv().await {
|
||||||
match self.stage {
|
let result = match cmd {
|
||||||
Stage::Starting => {}
|
GreetdCommand::CreateSession(username) => client.create_session(&username).await,
|
||||||
Stage::Username => {
|
GreetdCommand::Respond(answer) => client.respond(answer).await,
|
||||||
self.entry.set_text("");
|
GreetdCommand::StartSession { cmd, env } => {
|
||||||
}
|
match client.start_session(cmd, env).await {
|
||||||
Stage::Prompt | Stage::Working => {
|
Ok(()) => {
|
||||||
self.status_lbl.set_label("");
|
sender.input(AppInput::SessionStarted);
|
||||||
self.status_lbl.remove_css_class("error");
|
continue;
|
||||||
self.reset_to_username();
|
}
|
||||||
if self.cmd_tx.send(greetd::Command::CancelSession).is_err() {
|
Err(err) => Err(err),
|
||||||
self.show_error("Cannot reach greetd");
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(outcome) => sender.input(AppInput::Outcome(outcome)),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(%err, "greetd reported an error");
|
||||||
|
client.cancel_session().await;
|
||||||
|
sender.input(AppInput::Error(err.to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fn dispatch(&mut self, cmd: greetd::Command) {
|
|
||||||
if self.cmd_tx.send(cmd).is_err() {
|
|
||||||
self.show_error("Cannot reach greetd");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn show_error(&mut self, description: &str) {
|
|
||||||
self.status_lbl.set_label(description);
|
|
||||||
if description.is_empty() {
|
|
||||||
self.status_lbl.remove_css_class("error");
|
|
||||||
} else {
|
|
||||||
self.status_lbl.add_css_class("error");
|
|
||||||
}
|
|
||||||
self.reset_to_username();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reset_to_username(&mut self) {
|
|
||||||
self.entry.set_text("");
|
|
||||||
self.entry.set_visibility(true);
|
|
||||||
self.entry.set_placeholder_text(Some("Username"));
|
|
||||||
self.entry.set_sensitive(!self.sessions.is_empty());
|
|
||||||
self.stage = Stage::Username;
|
|
||||||
self.username.clear();
|
|
||||||
self.pam_status_held = false;
|
|
||||||
if !self.sessions.is_empty() {
|
|
||||||
self.entry.grab_focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Secret/Visible answers are always `Some`, including the empty string.
|
|
||||||
/// greetd/PAM treat `None` as a conversation cancel.
|
|
||||||
fn prompt_answer(text: String) -> Option<String> {
|
|
||||||
Some(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Paints the configured wallpaper full-screen behind the login card. The
|
|
||||||
/// image is loaded once as a `gdk_pixbuf::Pixbuf` and drawn by a
|
|
||||||
/// `GtkDrawingArea` draw callback, so the pan costs no layout passes — the
|
|
||||||
/// drawing area fills the window and the draw callback applies the cover
|
|
||||||
/// scale + Ken Burns offset itself. A missing/unreadable file or a non-image
|
|
||||||
/// background leaves the card on the palette background color.
|
|
||||||
fn setup_wallpaper(
|
|
||||||
window: >k4::ApplicationWindow,
|
|
||||||
bg_area: >k4::DrawingArea,
|
|
||||||
path: Option<&str>,
|
|
||||||
ken_burns: bool,
|
|
||||||
) {
|
|
||||||
let Some(path) = path else { return };
|
|
||||||
let pixbuf = match gtk4::gdk_pixbuf::Pixbuf::from_file(path) {
|
|
||||||
Ok(pixbuf) => pixbuf,
|
|
||||||
Err(err) => {
|
|
||||||
tracing::warn!(%err, "failed to load wallpaper");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let (iw, ih) = (pixbuf.width() as f32, pixbuf.height() as f32);
|
|
||||||
if iw <= 0.0 || ih <= 0.0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared pan phase: the tick callback advances it, the draw callback
|
|
||||||
// reads it. Using a draw callback (rather than a moving widget) means
|
|
||||||
// the wallpaper never feeds the window's minimum size.
|
|
||||||
let phase = std::rc::Rc::new(std::cell::Cell::new(0.0f64));
|
|
||||||
|
|
||||||
let draw_pixbuf = pixbuf.clone();
|
|
||||||
let draw_phase = phase.clone();
|
|
||||||
bg_area.set_draw_func(move |_area, cr, w, h| {
|
|
||||||
let (w, h) = (w as f32, h as f32);
|
|
||||||
if w <= 0.0 || h <= 0.0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Cover scale, then the Ken Burns oversize (leaves room to pan).
|
|
||||||
let cover = (w / iw).max(h / ih);
|
|
||||||
let scale = if ken_burns {
|
|
||||||
cover * KENBURNS_ZOOM
|
|
||||||
} else {
|
|
||||||
cover
|
|
||||||
};
|
|
||||||
let dw = iw * scale;
|
|
||||||
let dh = ih * scale;
|
|
||||||
// Pan within the oversize margin (0..dw-w, 0..dh-h).
|
|
||||||
let phase = draw_phase.get();
|
|
||||||
let max_x = (dw - w).max(0.0);
|
|
||||||
let max_y = (dh - h).max(0.0);
|
|
||||||
let x = max_x * (0.5 + 0.5 * phase.sin() as f32);
|
|
||||||
let y = max_y * (0.5 + 0.5 * (phase * 0.7).cos() as f32);
|
|
||||||
|
|
||||||
cr.translate(-x as f64, -y as f64);
|
|
||||||
cr.scale(scale as f64, scale as f64);
|
|
||||||
cr.set_source_pixbuf(&draw_pixbuf, 0.0, 0.0);
|
|
||||||
let _ = cr.paint();
|
|
||||||
});
|
|
||||||
|
|
||||||
if !ken_burns {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let area = bg_area.clone();
|
|
||||||
let start = std::time::Instant::now();
|
|
||||||
window.add_tick_callback(move |_w, _frame_clock| {
|
|
||||||
let elapsed = start.elapsed().as_secs_f64();
|
|
||||||
phase.set(elapsed * std::f64::consts::TAU / 90.0);
|
|
||||||
area.queue_draw();
|
|
||||||
gtk4::glib::ControlFlow::Continue
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Entrance animation: the clock + card cluster fades in and rises ~24px
|
|
||||||
/// over ~600ms (ease-out), matching the lock screen's appear motion.
|
|
||||||
fn setup_entrance(window: >k4::ApplicationWindow, root_box: >k4::Box) {
|
|
||||||
let root_box = root_box.clone();
|
|
||||||
const DURATION_MS: f32 = 600.0;
|
|
||||||
const RISE_PX: f32 = 24.0;
|
|
||||||
// First mapped frame must not be fully opaque — start hidden, then tick.
|
|
||||||
root_box.set_opacity(0.0);
|
|
||||||
root_box.set_margin_top(RISE_PX as i32);
|
|
||||||
let start = std::time::Instant::now();
|
|
||||||
window.add_tick_callback(move |_w, _frame_clock| {
|
|
||||||
let t = (start.elapsed().as_secs_f32() * 1000.0) / DURATION_MS;
|
|
||||||
let t = t.clamp(0.0, 1.0);
|
|
||||||
// Ease-out cubic.
|
|
||||||
let e = 1.0 - (1.0 - t).powi(3);
|
|
||||||
root_box.set_opacity(e as f64);
|
|
||||||
root_box.set_margin_top((RISE_PX * (1.0 - e)) as i32);
|
|
||||||
if t >= 1.0 {
|
|
||||||
gtk4::glib::ControlFlow::Break
|
|
||||||
} else {
|
|
||||||
gtk4::glib::ControlFlow::Continue
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Owns the single stateful connection to `$GREETD_SOCK` and translates the
|
|
||||||
/// UI's [`greetd::Command`]s into greetd IPC round-trips, forwarding each
|
|
||||||
/// outcome back as an [`AppInput`].
|
|
||||||
fn spawn_greetd_actor(
|
|
||||||
cmd_rx: mpsc::UnboundedReceiver<greetd::Command>,
|
|
||||||
sender: ComponentSender<App>,
|
|
||||||
) {
|
|
||||||
let input = sender.input_sender().clone();
|
|
||||||
relm4::spawn(async move {
|
|
||||||
greetd::run_actor(cmd_rx, move |event| {
|
|
||||||
let msg = match event {
|
|
||||||
greetd::Event::Outcome(outcome) => AppInput::Outcome(outcome),
|
|
||||||
greetd::Event::Error(description) => AppInput::Error(description),
|
|
||||||
greetd::Event::SessionStarted => AppInput::SessionStarted,
|
|
||||||
};
|
|
||||||
let _ = input.send(msg);
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_clock_ticker(sender: ComponentSender<App>) {
|
fn spawn_clock_ticker(sender: ComponentSender<App>) {
|
||||||
let tx = sender.input_sender().clone();
|
|
||||||
relm4::spawn(async move {
|
relm4::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||||
if tx.send(AppInput::ClockTick).is_err() {
|
sender.input(AppInput::ClockTick);
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -571,14 +302,3 @@ fn main() {
|
||||||
let app = RelmApp::new("sh.breadway.breadgreet");
|
let app = RelmApp::new("sh.breadway.breadgreet");
|
||||||
app.run::<App>(());
|
app.run::<App>(());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_password_is_some_empty_string() {
|
|
||||||
assert_eq!(prompt_answer(String::new()), Some(String::new()));
|
|
||||||
assert_eq!(prompt_answer("hunter2".into()), Some("hunter2".into()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,77 +1,15 @@
|
||||||
//! Session discovery: scans the standard greetd-greeter session directories
|
//! Session discovery: scans the standard greetd-greeter session directories
|
||||||
//! for `.desktop` entries, lists them for the picker, and resolves the
|
//! for `.desktop` entries. BOS effectively ships one session (Hyprland via
|
||||||
//! chosen entry's `Exec=` line for `greetd`'s `StartSession`.
|
//! `bos-session`), so v1 has no picker UI — it just auto-selects the
|
||||||
//!
|
//! configured default (or the only entry found) and resolves its `Exec=`
|
||||||
//! Default selection matches by `.desktop` file stem (`bos` compiled-in,
|
//! line to hand to `greetd`'s `StartSession`.
|
||||||
//! overridable via `[sessions].default`). If that stem is missing, the
|
|
||||||
//! first entry from `wayland_dirs` then `xsessions_dirs` is used.
|
|
||||||
|
|
||||||
use breadlock_ui::desktop_entry::scan_dir;
|
use breadlock_ui::desktop_entry::{scan_dir, DesktopEntry};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum SessionKind {
|
|
||||||
Wayland,
|
|
||||||
X11,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
/// `.desktop` file stem (`bos` for `bos.desktop`) — used to match
|
|
||||||
/// `[sessions].default`.
|
|
||||||
pub stem: String,
|
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub exec: Vec<String>,
|
pub exec: Vec<String>,
|
||||||
/// Which directory list this entry came from — drives `XDG_SESSION_TYPE`.
|
|
||||||
pub kind: SessionKind,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Session {
|
|
||||||
/// Environment greetd should apply to the started session.
|
|
||||||
pub fn start_env(&self) -> Vec<String> {
|
|
||||||
let session_type = match self.kind {
|
|
||||||
SessionKind::Wayland => "wayland",
|
|
||||||
SessionKind::X11 => "x11",
|
|
||||||
};
|
|
||||||
let desktop = if self.stem.is_empty() {
|
|
||||||
self.name.as_str()
|
|
||||||
} else {
|
|
||||||
self.stem.as_str()
|
|
||||||
};
|
|
||||||
vec![
|
|
||||||
format!("XDG_SESSION_TYPE={session_type}"),
|
|
||||||
format!("XDG_SESSION_DESKTOP={desktop}"),
|
|
||||||
format!("XDG_CURRENT_DESKTOP={desktop}"),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every installed session, `wayland_dirs` first then `xsessions_dirs`.
|
|
||||||
/// Each directory is sorted by stem (see [`scan_dir`]).
|
|
||||||
pub fn list(wayland_dirs: &[String], xsessions_dirs: &[String]) -> Vec<Session> {
|
|
||||||
let mut all = Vec::new();
|
|
||||||
collect_into(&mut all, wayland_dirs, SessionKind::Wayland);
|
|
||||||
collect_into(&mut all, xsessions_dirs, SessionKind::X11);
|
|
||||||
all
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_into(all: &mut Vec<Session>, dirs: &[String], kind: SessionKind) {
|
|
||||||
for dir in dirs {
|
|
||||||
for (stem, entry) in scan_dir(Path::new(dir)) {
|
|
||||||
all.push(Session {
|
|
||||||
stem,
|
|
||||||
name: entry.name,
|
|
||||||
exec: split_exec(&entry.exec),
|
|
||||||
kind,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Index of the configured default stem, or `0` if it is absent. Callers
|
|
||||||
/// with an empty list should not use this as a subscript.
|
|
||||||
pub fn default_index(sessions: &[Session], default: &str) -> usize {
|
|
||||||
sessions.iter().position(|s| s.stem == default).unwrap_or(0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scans `wayland_dirs` then `xsessions_dirs` (in that order) and returns
|
/// Scans `wayland_dirs` then `xsessions_dirs` (in that order) and returns
|
||||||
|
|
@ -83,72 +21,34 @@ pub fn discover(
|
||||||
xsessions_dirs: &[String],
|
xsessions_dirs: &[String],
|
||||||
default: &str,
|
default: &str,
|
||||||
) -> Option<Session> {
|
) -> Option<Session> {
|
||||||
let all = list(wayland_dirs, xsessions_dirs);
|
let mut all: Vec<(String, DesktopEntry)> = Vec::new();
|
||||||
let idx = default_index(&all, default);
|
for dir in wayland_dirs.iter().chain(xsessions_dirs) {
|
||||||
all.into_iter().nth(idx)
|
all.extend(scan_dir(Path::new(dir)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let chosen = all
|
||||||
|
.iter()
|
||||||
|
.find(|(stem, _)| stem == default)
|
||||||
|
.or_else(|| all.first())?;
|
||||||
|
|
||||||
|
Some(Session {
|
||||||
|
name: chosen.1.name.clone(),
|
||||||
|
exec: split_exec(&chosen.1.exec),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Splits a `.desktop` `Exec=` line into an argv. Double-quoted arguments
|
/// Splits a `.desktop` `Exec=` line into an argv. Only handles plain
|
||||||
/// are one token (Freedesktop Exec quoting). Whole-argument field codes
|
/// whitespace-separated commands (BOS's own `hyprland.desktop` is
|
||||||
/// (`%f`, `%F`, …) are dropped; `%%` is a literal `%`.
|
/// `Exec=Hyprland`) — full field-code (`%f`, `%u`, …) and quoting support
|
||||||
|
/// isn't needed for a greeter that never launches file-manager-style
|
||||||
|
/// entries.
|
||||||
fn split_exec(exec: &str) -> Vec<String> {
|
fn split_exec(exec: &str) -> Vec<String> {
|
||||||
tokenize_exec(exec)
|
exec.split_whitespace()
|
||||||
.into_iter()
|
.filter(|arg| !arg.starts_with('%'))
|
||||||
.filter(|arg| !is_field_code(arg))
|
.map(str::to_string)
|
||||||
.map(|arg| unescape_percent(&arg))
|
|
||||||
.filter(|arg| !arg.is_empty())
|
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tokenize_exec(exec: &str) -> Vec<String> {
|
|
||||||
let mut args = Vec::new();
|
|
||||||
let mut current = String::new();
|
|
||||||
let mut in_quote = false;
|
|
||||||
let mut chars = exec.chars().peekable();
|
|
||||||
|
|
||||||
while let Some(c) = chars.next() {
|
|
||||||
match c {
|
|
||||||
'"' => in_quote = !in_quote,
|
|
||||||
'\\' if in_quote => {
|
|
||||||
if let Some(n) = chars.next() {
|
|
||||||
current.push(n);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c if c.is_whitespace() && !in_quote => {
|
|
||||||
if !current.is_empty() {
|
|
||||||
args.push(std::mem::take(&mut current));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => current.push(c),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !current.is_empty() {
|
|
||||||
args.push(current);
|
|
||||||
}
|
|
||||||
args
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_field_code(arg: &str) -> bool {
|
|
||||||
matches!(
|
|
||||||
arg,
|
|
||||||
"%f" | "%F" | "%u" | "%U" | "%d" | "%D" | "%n" | "%N" | "%i" | "%c" | "%k" | "%v" | "%m"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unescape_percent(arg: &str) -> String {
|
|
||||||
let mut out = String::with_capacity(arg.len());
|
|
||||||
let mut chars = arg.chars().peekable();
|
|
||||||
while let Some(c) = chars.next() {
|
|
||||||
if c == '%' && chars.peek() == Some(&'%') {
|
|
||||||
chars.next();
|
|
||||||
out.push('%');
|
|
||||||
} else {
|
|
||||||
out.push(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -159,20 +59,6 @@ mod tests {
|
||||||
assert_eq!(split_exec("gnome-session %U"), vec!["gnome-session"]);
|
assert_eq!(split_exec("gnome-session %U"), vec!["gnome-session"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn split_exec_quoted_arguments() {
|
|
||||||
assert_eq!(
|
|
||||||
split_exec(r#"wrapper "my session" --flag"#),
|
|
||||||
vec!["wrapper", "my session", "--flag"]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn split_exec_double_percent_is_literal() {
|
|
||||||
assert_eq!(split_exec("echo %%"), vec!["echo", "%"]);
|
|
||||||
assert_eq!(split_exec(r#"echo "100%%""#), vec!["echo", "100%"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn discover_returns_none_when_no_directories_exist() {
|
fn discover_returns_none_when_no_directories_exist() {
|
||||||
assert!(discover(
|
assert!(discover(
|
||||||
|
|
@ -183,92 +69,26 @@ mod tests {
|
||||||
.is_none());
|
.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_fixture(dir: &std::path::Path, stem: &str, name: &str, exec: &str) {
|
|
||||||
std::fs::write(
|
|
||||||
dir.join(format!("{stem}.desktop")),
|
|
||||||
format!("[Desktop Entry]\nName={name}\nExec={exec}\n"),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn discover_prefers_configured_default_over_first_entry() {
|
fn discover_prefers_configured_default_over_first_entry() {
|
||||||
let dir = std::env::temp_dir().join(format!(
|
let dir = std::env::temp_dir().join("breadgreet-test-sessions-discover");
|
||||||
"breadgreet-test-sessions-discover-{}",
|
|
||||||
std::process::id()
|
|
||||||
));
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
write_fixture(&dir, "aaa", "A", "a-cmd");
|
std::fs::write(
|
||||||
write_fixture(&dir, "hyprland", "Hyprland", "Hyprland");
|
dir.join("aaa.desktop"),
|
||||||
|
"[Desktop Entry]\nName=A\nExec=a-cmd\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.join("hyprland.desktop"),
|
||||||
|
"[Desktop Entry]\nName=Hyprland\nExec=Hyprland\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let dir_str = dir.to_str().unwrap().to_string();
|
let dir_str = dir.to_str().unwrap().to_string();
|
||||||
let session = discover(&[dir_str], &[], "hyprland").unwrap();
|
let session = discover(&[dir_str], &[], "hyprland").unwrap();
|
||||||
assert_eq!(session.stem, "hyprland");
|
|
||||||
assert_eq!(session.name, "Hyprland");
|
assert_eq!(session.name, "Hyprland");
|
||||||
assert_eq!(session.exec, vec!["Hyprland"]);
|
assert_eq!(session.exec, vec!["Hyprland"]);
|
||||||
assert_eq!(session.kind, SessionKind::Wayland);
|
|
||||||
|
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn list_returns_all_sessions_wayland_then_x() {
|
|
||||||
let pid = std::process::id();
|
|
||||||
let wayland = std::env::temp_dir().join(format!("breadgreet-test-sessions-list-w-{pid}"));
|
|
||||||
let x11 = std::env::temp_dir().join(format!("breadgreet-test-sessions-list-x-{pid}"));
|
|
||||||
std::fs::create_dir_all(&wayland).unwrap();
|
|
||||||
std::fs::create_dir_all(&x11).unwrap();
|
|
||||||
write_fixture(&wayland, "bos", "BOS", "/usr/local/bin/bos-session");
|
|
||||||
write_fixture(&wayland, "hyprland", "Hyprland", "Hyprland");
|
|
||||||
write_fixture(&x11, "openbox", "Openbox", "openbox-session");
|
|
||||||
|
|
||||||
let listed = list(
|
|
||||||
&[wayland.to_str().unwrap().to_string()],
|
|
||||||
&[x11.to_str().unwrap().to_string()],
|
|
||||||
);
|
|
||||||
let stems: Vec<&str> = listed.iter().map(|s| s.stem.as_str()).collect();
|
|
||||||
assert_eq!(stems, vec!["bos", "hyprland", "openbox"]);
|
|
||||||
assert_eq!(listed[0].exec, vec!["/usr/local/bin/bos-session"]);
|
|
||||||
assert_eq!(listed[0].kind, SessionKind::Wayland);
|
|
||||||
assert_eq!(listed[2].kind, SessionKind::X11);
|
|
||||||
assert!(
|
|
||||||
listed[2]
|
|
||||||
.start_env()
|
|
||||||
.contains(&"XDG_SESSION_TYPE=x11".to_string())
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
listed[0]
|
|
||||||
.start_env()
|
|
||||||
.contains(&"XDG_SESSION_TYPE=wayland".to_string())
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
listed[0]
|
|
||||||
.start_env()
|
|
||||||
.contains(&"XDG_SESSION_DESKTOP=bos".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
std::fs::remove_dir_all(&wayland).ok();
|
|
||||||
std::fs::remove_dir_all(&x11).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn default_index_prefers_bos_then_first() {
|
|
||||||
let sessions = vec![
|
|
||||||
Session {
|
|
||||||
stem: "aaa".into(),
|
|
||||||
name: "A".into(),
|
|
||||||
exec: vec!["a".into()],
|
|
||||||
kind: SessionKind::Wayland,
|
|
||||||
},
|
|
||||||
Session {
|
|
||||||
stem: "bos".into(),
|
|
||||||
name: "BOS".into(),
|
|
||||||
exec: vec!["bos-session".into()],
|
|
||||||
kind: SessionKind::Wayland,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
assert_eq!(default_index(&sessions, "bos"), 1);
|
|
||||||
assert_eq!(default_index(&sessions, "missing"), 0);
|
|
||||||
assert_eq!(default_index(&[], "bos"), 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,45 +6,30 @@ thread_local! {
|
||||||
static USER_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
|
static USER_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
|
||||||
}
|
}
|
||||||
|
|
||||||
fn css_font_family(family: &str) -> String {
|
fn load_css() -> String {
|
||||||
if family.is_empty() {
|
|
||||||
return String::new();
|
|
||||||
}
|
|
||||||
let escaped = family.replace('\\', "\\\\").replace('"', "\\\"");
|
|
||||||
format!("font-family: \"{escaped}\";")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_css(font_family: &str) -> String {
|
|
||||||
let p = load_palette();
|
let p = load_palette();
|
||||||
let font = css_font_family(font_family);
|
|
||||||
format!(
|
format!(
|
||||||
"window.breadgreet {{ background-color: {bg}; color: {on_bg}; {font} }}\
|
"window.breadgreet {{ background-color: {bg}; color: {on_bg}; }}\
|
||||||
.login-card {{ background: {surface}; color: {on_surface}; border-radius: 8px;\
|
.login-card {{ background: {surface}; color: {on_surface}; border-radius: 8px;\
|
||||||
padding: 20px; min-width: 320px; }}\
|
padding: 20px; min-width: 320px; }}\
|
||||||
.login-clock {{ font-size: 48px; font-weight: bold; }}\
|
.login-clock {{ font-size: 48px; font-weight: bold; margin-bottom: 20px; }}\
|
||||||
.login-date {{ font-size: 18px; font-weight: 500; opacity: 0.8; }}\
|
|
||||||
.login-entry {{ font-size: 14px; }}\
|
.login-entry {{ font-size: 14px; }}\
|
||||||
.login-status {{ font-size: 12px; opacity: 0.75; margin-top: 8px; }}\
|
.login-status {{ font-size: 12px; opacity: 0.75; margin-top: 8px; }}\
|
||||||
.login-status.error {{ color: {red}; opacity: 1; }}\
|
.login-status.error {{ color: {red}; opacity: 1; }}\
|
||||||
.login-session {{ font-size: 12px; opacity: 0.85; margin-top: 12px; }}\
|
.login-session {{ font-size: 12px; opacity: 0.6; margin-top: 12px; }}",
|
||||||
dropdown.login-session {{ min-height: 32px; }}\
|
|
||||||
.login-veil {{ background-image: linear-gradient(to bottom, rgba(0,0,0,0.34) 0%, rgba(0,0,0,0.16) 100%); }}",
|
|
||||||
bg = p.background,
|
bg = p.background,
|
||||||
surface = p.color0,
|
surface = p.color0,
|
||||||
red = p.color1,
|
red = p.color1,
|
||||||
on_bg = ink_on(&p.background),
|
on_bg = ink_on(&p.background),
|
||||||
on_surface = ink_on(&p.color0),
|
on_surface = ink_on(&p.color0),
|
||||||
font = font,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply(font_family: &str) {
|
pub fn apply() {
|
||||||
bgtk::apply_shared();
|
bgtk::apply_shared();
|
||||||
let family = font_family.to_string();
|
bgtk::apply_app_css(load_css);
|
||||||
bgtk::apply_app_css(move || load_css(&family));
|
|
||||||
|
|
||||||
let user_path = crate::config::xdg_config_dir()
|
let home = std::env::var("HOME").unwrap_or_default();
|
||||||
.join("breadgreet")
|
let user_path = std::path::PathBuf::from(format!("{home}/.config/breadgreet/style.css"));
|
||||||
.join("style.css");
|
|
||||||
USER_PROVIDER.with(|cell| bgtk::apply_user_css(&user_path, cell));
|
USER_PROVIDER.with(|cell| bgtk::apply_user_css(&user_path, cell));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ toml.workspace = true
|
||||||
# instead, so it builds without pulling these in).
|
# instead, so it builds without pulling these in).
|
||||||
tiny-skia = { version = "0.12", optional = true }
|
tiny-skia = { version = "0.12", optional = true }
|
||||||
cosmic-text = { version = "0.14", optional = true }
|
cosmic-text = { version = "0.14", optional = true }
|
||||||
|
chrono = { version = "0.4", optional = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
paint = ["dep:tiny-skia", "dep:cosmic-text"]
|
paint = ["dep:tiny-skia", "dep:cosmic-text", "dep:chrono"]
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,6 @@ pub struct Background {
|
||||||
/// v2 feature flag — no-op (with a warning) in v1, which only supports a
|
/// v2 feature flag — no-op (with a warning) in v1, which only supports a
|
||||||
/// static color or image background.
|
/// static color or image background.
|
||||||
pub blur: bool,
|
pub blur: bool,
|
||||||
/// Slow Ken Burns pan on image backgrounds (a gentle drift + zoom instead
|
|
||||||
/// of a static image). CPU cost: the background redraws continuously at a
|
|
||||||
/// low frame rate while locked, so this is opt-in.
|
|
||||||
pub ken_burns: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Background {
|
impl Default for Background {
|
||||||
|
|
@ -38,7 +34,6 @@ impl Default for Background {
|
||||||
mode: BackgroundMode::Color,
|
mode: BackgroundMode::Color,
|
||||||
path: String::new(),
|
path: String::new(),
|
||||||
blur: false,
|
blur: false,
|
||||||
ken_burns: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -47,16 +42,12 @@ impl Default for Background {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Clock {
|
pub struct Clock {
|
||||||
pub format: String,
|
pub format: String,
|
||||||
/// strftime format for the date line under the clock. Empty string hides
|
|
||||||
/// the date. `%A` = full weekday, `%b` = abbreviated month, `%d` = day.
|
|
||||||
pub date_format: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Clock {
|
impl Default for Clock {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
format: "%H:%M".to_string(),
|
format: "%H:%M".to_string(),
|
||||||
date_format: "%A · %b %d".to_string(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -80,23 +71,14 @@ impl Default for Font {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads and parses a TOML config file. A missing file is a silent
|
/// Reads and parses a TOML config file, falling back to `T::default()` if the
|
||||||
/// `T::default()`; a present but malformed file prints a warning (with the
|
/// file is missing or malformed — every bread* app runs with sensible
|
||||||
/// path) and also falls back to `T::default()`.
|
/// defaults and no required config.
|
||||||
pub fn load_or_default<T: serde::de::DeserializeOwned + Default>(path: &Path) -> T {
|
pub fn load_or_default<T: serde::de::DeserializeOwned + Default>(path: &Path) -> T {
|
||||||
match std::fs::read_to_string(path) {
|
std::fs::read_to_string(path)
|
||||||
Ok(s) => match toml::from_str(&s) {
|
.ok()
|
||||||
Ok(parsed) => parsed,
|
.and_then(|s| toml::from_str(&s).ok())
|
||||||
Err(err) => {
|
.unwrap_or_default()
|
||||||
eprintln!(
|
|
||||||
"warning: failed to parse {}: {err} — using defaults",
|
|
||||||
path.display()
|
|
||||||
);
|
|
||||||
T::default()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(_) => T::default(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -107,9 +89,7 @@ mod tests {
|
||||||
fn defaults_match_design_system() {
|
fn defaults_match_design_system() {
|
||||||
let a = Appearance::default();
|
let a = Appearance::default();
|
||||||
assert_eq!(a.background.mode, BackgroundMode::Color);
|
assert_eq!(a.background.mode, BackgroundMode::Color);
|
||||||
assert!(!a.background.ken_burns, "Ken Burns must be opt-in (CPU cost)");
|
|
||||||
assert_eq!(a.clock.format, "%H:%M");
|
assert_eq!(a.clock.format, "%H:%M");
|
||||||
assert_eq!(a.clock.date_format, "%A · %b %d");
|
|
||||||
assert_eq!(a.font.family, "Varela Round");
|
assert_eq!(a.font.family, "Varela Round");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,27 +101,11 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_partial_toml_with_defaults_for_rest() {
|
fn parses_partial_toml_with_defaults_for_rest() {
|
||||||
let path = std::env::temp_dir().join(format!(
|
let dir = std::env::temp_dir().join("breadlock-ui-test-partial.toml");
|
||||||
"breadlock-ui-test-partial-{}.toml",
|
std::fs::write(&dir, "[clock]\nformat = \"%I:%M %p\"\n").unwrap();
|
||||||
std::process::id()
|
let a: Appearance = load_or_default(&dir);
|
||||||
));
|
|
||||||
std::fs::write(&path, "[clock]\nformat = \"%I:%M %p\"\n").unwrap();
|
|
||||||
let a: Appearance = load_or_default(&path);
|
|
||||||
assert_eq!(a.clock.format, "%I:%M %p");
|
assert_eq!(a.clock.format, "%I:%M %p");
|
||||||
assert_eq!(a.background.mode, BackgroundMode::Color);
|
assert_eq!(a.background.mode, BackgroundMode::Color);
|
||||||
std::fs::remove_file(&path).ok();
|
std::fs::remove_file(&dir).ok();
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn invalid_toml_falls_back_to_default() {
|
|
||||||
let path = std::env::temp_dir().join(format!(
|
|
||||||
"breadlock-ui-test-invalid-{}.toml",
|
|
||||||
std::process::id()
|
|
||||||
));
|
|
||||||
std::fs::write(&path, "this is not = toml [[[").unwrap();
|
|
||||||
let a: Appearance = load_or_default(&path);
|
|
||||||
assert_eq!(a.clock.format, "%H:%M");
|
|
||||||
assert_eq!(a.font.family, "Varela Round");
|
|
||||||
std::fs::remove_file(&path).ok();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
//! Minimal freedesktop `.desktop` entry parsing — just enough to discover
|
//! Minimal freedesktop `.desktop` entry parsing — just enough to discover
|
||||||
//! session launchers (`Name=`, `Exec=`, `Type=`) under
|
//! session launchers (`Name=`, `Exec=`, `Type=`) under
|
||||||
//! `/usr/share/wayland-sessions` and `/usr/share/xsessions`. Also honours
|
//! `/usr/share/wayland-sessions` and `/usr/share/xsessions`. BOS only ships
|
||||||
//! `Hidden=` / `NoDisplay=` / `TryExec=` so we don't offer sessions that
|
//! one session today, so this deliberately doesn't handle the full spec
|
||||||
//! menus would skip. Localized `Name[xx]=` and `Actions=` are out of scope.
|
//! (localized `Name[xx]=`, `Exec=` quoting/field codes, `Actions=`, etc.) —
|
||||||
|
//! only the three keys a greeter needs to list and launch a session.
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
|
@ -11,21 +12,14 @@ pub struct DesktopEntry {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub exec: String,
|
pub exec: String,
|
||||||
pub entry_type: String,
|
pub entry_type: String,
|
||||||
/// `TryExec=` if present — [`scan_dir`] skips the entry when this
|
|
||||||
/// binary is missing from disk/`PATH`.
|
|
||||||
pub try_exec: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses the `[Desktop Entry]` section of a `.desktop` file's contents.
|
/// Parses the `[Desktop Entry]` section of a `.desktop` file's contents.
|
||||||
/// Returns `None` if `Name=` or `Exec=` is missing, or if `Hidden=true` /
|
/// Returns `None` if `Name=` or `Exec=` is missing.
|
||||||
/// `NoDisplay=true`.
|
|
||||||
pub fn parse(contents: &str) -> Option<DesktopEntry> {
|
pub fn parse(contents: &str) -> Option<DesktopEntry> {
|
||||||
let mut name = None;
|
let mut name = None;
|
||||||
let mut exec = None;
|
let mut exec = None;
|
||||||
let mut entry_type = None;
|
let mut entry_type = None;
|
||||||
let mut try_exec = None;
|
|
||||||
let mut hidden = false;
|
|
||||||
let mut no_display = false;
|
|
||||||
let mut in_desktop_entry = false;
|
let mut in_desktop_entry = false;
|
||||||
|
|
||||||
for line in contents.lines() {
|
for line in contents.lines() {
|
||||||
|
|
@ -45,39 +39,21 @@ pub fn parse(contents: &str) -> Option<DesktopEntry> {
|
||||||
"Name" => name = Some(value.trim().to_string()),
|
"Name" => name = Some(value.trim().to_string()),
|
||||||
"Exec" => exec = Some(value.trim().to_string()),
|
"Exec" => exec = Some(value.trim().to_string()),
|
||||||
"Type" => entry_type = Some(value.trim().to_string()),
|
"Type" => entry_type = Some(value.trim().to_string()),
|
||||||
"TryExec" => {
|
|
||||||
let v = value.trim();
|
|
||||||
if !v.is_empty() {
|
|
||||||
try_exec = Some(v.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"Hidden" => hidden = is_desktop_true(value),
|
|
||||||
"NoDisplay" => no_display = is_desktop_true(value),
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if hidden || no_display {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(DesktopEntry {
|
Some(DesktopEntry {
|
||||||
name: name?,
|
name: name?,
|
||||||
exec: exec?,
|
exec: exec?,
|
||||||
entry_type: entry_type.unwrap_or_else(|| "Application".to_string()),
|
entry_type: entry_type.unwrap_or_else(|| "Application".to_string()),
|
||||||
try_exec,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_desktop_true(value: &str) -> bool {
|
|
||||||
value.trim().eq_ignore_ascii_case("true")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scans a directory for `*.desktop` files, returning `(file stem, entry)`
|
/// Scans a directory for `*.desktop` files, returning `(file stem, entry)`
|
||||||
/// pairs. Unreadable directories and unparsable entries are silently skipped
|
/// pairs. Unreadable directories and unparsable entries are silently skipped
|
||||||
/// — a missing session directory is normal (e.g. no X11 sessions installed).
|
/// — a missing session directory is normal (e.g. no X11 sessions installed).
|
||||||
/// Entries whose `TryExec=` binary is missing are skipped too.
|
|
||||||
pub fn scan_dir(dir: &Path) -> Vec<(String, DesktopEntry)> {
|
pub fn scan_dir(dir: &Path) -> Vec<(String, DesktopEntry)> {
|
||||||
let Ok(read_dir) = std::fs::read_dir(dir) else {
|
let Ok(read_dir) = std::fs::read_dir(dir) else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
|
|
@ -89,13 +65,7 @@ pub fn scan_dir(dir: &Path) -> Vec<(String, DesktopEntry)> {
|
||||||
.filter_map(|e| {
|
.filter_map(|e| {
|
||||||
let stem = e.path().file_stem()?.to_str()?.to_string();
|
let stem = e.path().file_stem()?.to_str()?.to_string();
|
||||||
let contents = std::fs::read_to_string(e.path()).ok()?;
|
let contents = std::fs::read_to_string(e.path()).ok()?;
|
||||||
let entry = parse(&contents)?;
|
Some((stem, parse(&contents)?))
|
||||||
if let Some(ref te) = entry.try_exec {
|
|
||||||
if !command_exists(te) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some((stem, entry))
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|
@ -103,25 +73,6 @@ pub fn scan_dir(dir: &Path) -> Vec<(String, DesktopEntry)> {
|
||||||
entries
|
entries
|
||||||
}
|
}
|
||||||
|
|
||||||
fn command_exists(cmd: &str) -> bool {
|
|
||||||
if cmd.contains('/') {
|
|
||||||
is_runnable(Path::new(cmd))
|
|
||||||
} else {
|
|
||||||
match std::env::var_os("PATH") {
|
|
||||||
Some(paths) => std::env::split_paths(&paths).any(|dir| is_runnable(&dir.join(cmd))),
|
|
||||||
None => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_runnable(path: &Path) -> bool {
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
let Ok(meta) = std::fs::metadata(path) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
meta.is_file() && meta.permissions().mode() & 0o111 != 0
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -132,26 +83,12 @@ mod tests {
|
||||||
Exec=Hyprland\n\
|
Exec=Hyprland\n\
|
||||||
Type=Application\n";
|
Type=Application\n";
|
||||||
|
|
||||||
fn unique_temp_dir(name: &str) -> std::path::PathBuf {
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
static SEQ: AtomicU64 = AtomicU64::new(0);
|
|
||||||
let dir = std::env::temp_dir().join(format!(
|
|
||||||
"breadlock-ui-test-sessions-{name}-{}-{}",
|
|
||||||
std::process::id(),
|
|
||||||
SEQ.fetch_add(1, Ordering::Relaxed)
|
|
||||||
));
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
|
||||||
dir
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_name_exec_type() {
|
fn parses_name_exec_type() {
|
||||||
let e = parse(HYPRLAND_DESKTOP).unwrap();
|
let e = parse(HYPRLAND_DESKTOP).unwrap();
|
||||||
assert_eq!(e.name, "Hyprland");
|
assert_eq!(e.name, "Hyprland");
|
||||||
assert_eq!(e.exec, "Hyprland");
|
assert_eq!(e.exec, "Hyprland");
|
||||||
assert_eq!(e.entry_type, "Application");
|
assert_eq!(e.entry_type, "Application");
|
||||||
assert_eq!(e.try_exec, None);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -174,14 +111,6 @@ mod tests {
|
||||||
assert_eq!(e.entry_type, "Application");
|
assert_eq!(e.entry_type, "Application");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hidden_or_nodisplay_returns_none() {
|
|
||||||
assert!(parse("[Desktop Entry]\nName=X\nExec=x\nHidden=true\n").is_none());
|
|
||||||
assert!(parse("[Desktop Entry]\nName=X\nExec=x\nNoDisplay=true\n").is_none());
|
|
||||||
assert!(parse("[Desktop Entry]\nName=X\nExec=x\nHidden=false\n").is_some());
|
|
||||||
assert!(parse("[Desktop Entry]\nName=X\nExec=x\nNoDisplay=false\n").is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scan_dir_on_missing_directory_returns_empty() {
|
fn scan_dir_on_missing_directory_returns_empty() {
|
||||||
assert!(scan_dir(Path::new("/nonexistent/wayland-sessions")).is_empty());
|
assert!(scan_dir(Path::new("/nonexistent/wayland-sessions")).is_empty());
|
||||||
|
|
@ -189,7 +118,8 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scan_dir_finds_and_sorts_desktop_files() {
|
fn scan_dir_finds_and_sorts_desktop_files() {
|
||||||
let dir = unique_temp_dir("scan");
|
let dir = std::env::temp_dir().join("breadlock-ui-test-sessions");
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
std::fs::write(dir.join("zzz.desktop"), HYPRLAND_DESKTOP).unwrap();
|
std::fs::write(dir.join("zzz.desktop"), HYPRLAND_DESKTOP).unwrap();
|
||||||
std::fs::write(dir.join("aaa.desktop"), "[Desktop Entry]\nName=A\nExec=a\n").unwrap();
|
std::fs::write(dir.join("aaa.desktop"), "[Desktop Entry]\nName=A\nExec=a\n").unwrap();
|
||||||
std::fs::write(dir.join("not-a-session.txt"), "ignored").unwrap();
|
std::fs::write(dir.join("not-a-session.txt"), "ignored").unwrap();
|
||||||
|
|
@ -201,35 +131,4 @@ mod tests {
|
||||||
|
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scan_dir_skips_hidden_nodisplay_and_missing_tryexec() {
|
|
||||||
let dir = unique_temp_dir("skip");
|
|
||||||
std::fs::write(
|
|
||||||
dir.join("hidden.desktop"),
|
|
||||||
"[Desktop Entry]\nName=Hidden\nExec=hidden\nHidden=true\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
std::fs::write(
|
|
||||||
dir.join("nodisp.desktop"),
|
|
||||||
"[Desktop Entry]\nName=NoDisp\nExec=nodisp\nNoDisplay=true\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
std::fs::write(
|
|
||||||
dir.join("gone.desktop"),
|
|
||||||
"[Desktop Entry]\nName=Gone\nExec=gone\nTryExec=/no/such/breadlock-tryexec\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
std::fs::write(
|
|
||||||
dir.join("ok.desktop"),
|
|
||||||
"[Desktop Entry]\nName=Ok\nExec=ok\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let found = scan_dir(&dir);
|
|
||||||
assert_eq!(found.len(), 1);
|
|
||||||
assert_eq!(found[0].0, "ok");
|
|
||||||
|
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,7 @@
|
||||||
//! instead and doesn't need a font-shaping stack.
|
//! instead and doesn't need a font-shaping stack.
|
||||||
|
|
||||||
pub use bread_theme::tokens;
|
pub use bread_theme::tokens;
|
||||||
pub use cosmic_text::Weight;
|
|
||||||
use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping, SwashCache};
|
use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping, SwashCache};
|
||||||
use std::collections::HashMap;
|
|
||||||
use tiny_skia::{Path, PathBuilder, Pixmap, PremultipliedColorU8};
|
use tiny_skia::{Path, PathBuilder, Pixmap, PremultipliedColorU8};
|
||||||
|
|
||||||
/// Builds a rounded-rectangle path. `radius` is clamped so it never exceeds
|
/// Builds a rounded-rectangle path. `radius` is clamped so it never exceeds
|
||||||
|
|
@ -34,14 +32,6 @@ pub fn rounded_rect(x: f32, y: f32, w: f32, h: f32, radius: f32) -> Option<Path>
|
||||||
pub struct TextRenderer {
|
pub struct TextRenderer {
|
||||||
font_system: FontSystem,
|
font_system: FontSystem,
|
||||||
swash_cache: SwashCache,
|
swash_cache: SwashCache,
|
||||||
/// Exact glyph-pixel span `(top, height)` per unique `(text, family,
|
|
||||||
/// size, weight)` — see [`Self::measure_box`]. Keyed by size in
|
|
||||||
/// centipixels so fractional sizes don't thrash the cache.
|
|
||||||
boxes: HashMap<(String, String, u32, u16), (f32, f32)>,
|
|
||||||
/// Whether `Family::Name(family)` resolved to an installed face. Missing
|
|
||||||
/// families fall back to `Family::SansSerif` instead of panicking or
|
|
||||||
/// drawing tofu; the result is cached so we don't scan fontdb every frame.
|
|
||||||
family_ok: HashMap<String, bool>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for TextRenderer {
|
impl Default for TextRenderer {
|
||||||
|
|
@ -55,141 +45,23 @@ impl TextRenderer {
|
||||||
Self {
|
Self {
|
||||||
font_system: FontSystem::new(),
|
font_system: FontSystem::new(),
|
||||||
swash_cache: SwashCache::new(),
|
swash_cache: SwashCache::new(),
|
||||||
boxes: HashMap::new(),
|
|
||||||
family_ok: HashMap::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `Family::Name` if `family` is installed, otherwise the generic
|
fn shape_line(&mut self, text: &str, family: &str, size_px: f32, max_width: f32) -> Buffer {
|
||||||
/// sans-serif. Never panics on a missing configured font.
|
|
||||||
fn resolve_family<'a>(&mut self, family: &'a str) -> Family<'a> {
|
|
||||||
if family.is_empty() || family.eq_ignore_ascii_case("sans-serif") {
|
|
||||||
return Family::SansSerif;
|
|
||||||
}
|
|
||||||
let present = if let Some(&ok) = self.family_ok.get(family) {
|
|
||||||
ok
|
|
||||||
} else {
|
|
||||||
let ok = self.font_system.db().faces().any(|face| {
|
|
||||||
face.families
|
|
||||||
.iter()
|
|
||||||
.any(|(name, _)| name.eq_ignore_ascii_case(family))
|
|
||||||
});
|
|
||||||
self.family_ok.insert(family.to_string(), ok);
|
|
||||||
ok
|
|
||||||
};
|
|
||||||
if present {
|
|
||||||
Family::Name(family)
|
|
||||||
} else {
|
|
||||||
Family::SansSerif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn shape_line(
|
|
||||||
&mut self,
|
|
||||||
text: &str,
|
|
||||||
family: &str,
|
|
||||||
size_px: f32,
|
|
||||||
max_width: f32,
|
|
||||||
weight: Weight,
|
|
||||||
) -> Buffer {
|
|
||||||
// cosmic-text panics if `metrics.font_size` is zero; callers may pass a
|
|
||||||
// scaled-to-zero size during the pill's appear overshoot at t=0.
|
|
||||||
let size_px = size_px.max(0.01);
|
|
||||||
let metrics = Metrics::new(size_px, size_px * 1.25);
|
let metrics = Metrics::new(size_px, size_px * 1.25);
|
||||||
let mut buffer = Buffer::new(&mut self.font_system, metrics);
|
let mut buffer = Buffer::new(&mut self.font_system, metrics);
|
||||||
buffer.set_size(&mut self.font_system, Some(max_width), Some(size_px * 2.0));
|
buffer.set_size(&mut self.font_system, Some(max_width), Some(size_px * 2.0));
|
||||||
let attrs = Attrs::new()
|
let attrs = Attrs::new().family(Family::Name(family));
|
||||||
.family(self.resolve_family(family))
|
|
||||||
.weight(weight);
|
|
||||||
buffer.set_text(&mut self.font_system, text, &attrs, Shaping::Advanced);
|
buffer.set_text(&mut self.font_system, text, &attrs, Shaping::Advanced);
|
||||||
buffer.shape_until_scroll(&mut self.font_system, false);
|
buffer.shape_until_scroll(&mut self.font_system, false);
|
||||||
buffer
|
buffer
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exact vertical span `(top, height)` of the glyph pixels a line drawn
|
|
||||||
/// with [`Self::draw_line`] at `(0, 0)` would occupy: `top` is the
|
|
||||||
/// distance from the draw origin down to the highest glyph pixel.
|
|
||||||
/// `draw_line`'s `origin_y` anchors the *top* of the text (not the
|
|
||||||
/// baseline), so centering a line of height `h` in a box spanning
|
|
||||||
/// `[y0, y1]` needs `origin_y = y0 + (h - height) / 2 - top`.
|
|
||||||
///
|
|
||||||
/// Measured exactly by rendering the line once into a tiny offscreen
|
|
||||||
/// pixmap and scanning it, then cached — lock-screen text changes rarely
|
|
||||||
/// (clock per minute, date per day, static hints once), so the one-off
|
|
||||||
/// cost is negligible and the result is correct for any font.
|
|
||||||
pub fn measure_box(&mut self, text: &str, family: &str, size_px: f32) -> (f32, f32) {
|
|
||||||
self.measure_box_weighted(text, family, size_px, Weight::NORMAL)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Like [`Self::measure_box`] with an explicit font weight (the clock
|
|
||||||
/// uses [`Weight::BOLD`] / 700).
|
|
||||||
pub fn measure_box_weighted(
|
|
||||||
&mut self,
|
|
||||||
text: &str,
|
|
||||||
family: &str,
|
|
||||||
size_px: f32,
|
|
||||||
weight: Weight,
|
|
||||||
) -> (f32, f32) {
|
|
||||||
let key = (
|
|
||||||
text.to_string(),
|
|
||||||
family.to_string(),
|
|
||||||
(size_px * 100.0) as u32,
|
|
||||||
weight.0,
|
|
||||||
);
|
|
||||||
if let Some(b) = self.boxes.get(&key) {
|
|
||||||
return *b;
|
|
||||||
}
|
|
||||||
let w = self
|
|
||||||
.measure_line_weighted(text, family, size_px, weight)
|
|
||||||
.ceil()
|
|
||||||
.max(1.0) as u32;
|
|
||||||
let h = (size_px * 1.5).ceil().max(1.0) as u32;
|
|
||||||
let mut probe = match Pixmap::new(w, h) {
|
|
||||||
Some(p) => p,
|
|
||||||
None => return (0.0, size_px),
|
|
||||||
};
|
|
||||||
self.draw_line_weighted(
|
|
||||||
&mut probe,
|
|
||||||
text,
|
|
||||||
family,
|
|
||||||
size_px,
|
|
||||||
tiny_skia::Color::WHITE,
|
|
||||||
0.0,
|
|
||||||
0.0,
|
|
||||||
weight,
|
|
||||||
);
|
|
||||||
let (mut top, mut bottom) = (h as f32, 0.0f32);
|
|
||||||
for y in 0..h {
|
|
||||||
for x in 0..w {
|
|
||||||
if probe.pixel(x, y).is_some_and(|p| p.alpha() > 0) {
|
|
||||||
top = top.min(y as f32);
|
|
||||||
bottom = bottom.max(y as f32);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let boxed = if bottom >= top {
|
|
||||||
(top, bottom - top + 1.0)
|
|
||||||
} else {
|
|
||||||
(0.0, size_px)
|
|
||||||
};
|
|
||||||
self.boxes.insert(key, boxed);
|
|
||||||
boxed
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Width in pixels `text` would occupy if drawn via [`Self::draw_line`]
|
/// Width in pixels `text` would occupy if drawn via [`Self::draw_line`]
|
||||||
/// with the same `family`/`size_px` — use to center text before drawing.
|
/// with the same `family`/`size_px` — use to center text before drawing.
|
||||||
pub fn measure_line(&mut self, text: &str, family: &str, size_px: f32) -> f32 {
|
pub fn measure_line(&mut self, text: &str, family: &str, size_px: f32) -> f32 {
|
||||||
self.measure_line_weighted(text, family, size_px, Weight::NORMAL)
|
let buffer = self.shape_line(text, family, size_px, f32::INFINITY);
|
||||||
}
|
|
||||||
|
|
||||||
pub fn measure_line_weighted(
|
|
||||||
&mut self,
|
|
||||||
text: &str,
|
|
||||||
family: &str,
|
|
||||||
size_px: f32,
|
|
||||||
weight: Weight,
|
|
||||||
) -> f32 {
|
|
||||||
let buffer = self.shape_line(text, family, size_px, f32::INFINITY, weight);
|
|
||||||
buffer
|
buffer
|
||||||
.layout_runs()
|
.layout_runs()
|
||||||
.map(|run| run.line_w)
|
.map(|run| run.line_w)
|
||||||
|
|
@ -199,8 +71,6 @@ impl TextRenderer {
|
||||||
/// Shapes `text` as a single line in `family` at `size_px` and blits it
|
/// Shapes `text` as a single line in `family` at `size_px` and blits it
|
||||||
/// into `pixmap` with its top-left baseline anchor at `(origin_x,
|
/// into `pixmap` with its top-left baseline anchor at `(origin_x,
|
||||||
/// origin_y)`. Pixels outside `pixmap`'s bounds are silently clipped.
|
/// origin_y)`. Pixels outside `pixmap`'s bounds are silently clipped.
|
||||||
/// Origins stay float: subpixel X goes into cosmic-text's CacheKey bins
|
|
||||||
/// so appear/unlock motion doesn't stair-step against the pill path.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn draw_line(
|
pub fn draw_line(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
|
@ -212,120 +82,53 @@ impl TextRenderer {
|
||||||
origin_x: f32,
|
origin_x: f32,
|
||||||
origin_y: f32,
|
origin_y: f32,
|
||||||
) {
|
) {
|
||||||
self.draw_line_weighted(
|
let buffer = self.shape_line(text, family, size_px, pixmap.width() as f32);
|
||||||
pixmap, text, family, size_px, color, origin_x, origin_y, Weight::NORMAL,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub fn draw_line_weighted(
|
|
||||||
&mut self,
|
|
||||||
pixmap: &mut Pixmap,
|
|
||||||
text: &str,
|
|
||||||
family: &str,
|
|
||||||
size_px: f32,
|
|
||||||
color: tiny_skia::Color,
|
|
||||||
origin_x: f32,
|
|
||||||
origin_y: f32,
|
|
||||||
weight: Weight,
|
|
||||||
) {
|
|
||||||
// Infinite width so this agrees with [`Self::measure_line`] (a finite
|
|
||||||
// width would wrap, and centering from the unwrapped measure then
|
|
||||||
// goes negative). Overflow is clipped at blit time.
|
|
||||||
let buffer = self.shape_line(text, family, size_px, f32::INFINITY, weight);
|
|
||||||
|
|
||||||
let c8 = color.to_color_u8();
|
let c8 = color.to_color_u8();
|
||||||
// cosmic-text's glyph-Mask rendering drops the base color's alpha
|
let text_color = cosmic_text::Color::rgba(c8.red(), c8.green(), c8.blue(), c8.alpha());
|
||||||
// entirely — its swash `with_pixels` uses the glyph coverage as the
|
|
||||||
// output alpha (see the "TODO: blend base alpha?" in its source), so
|
|
||||||
// a translucent text color would render fully opaque. Fold the
|
|
||||||
// requested alpha back in at blend time below; RGB stays straight.
|
|
||||||
let base_alpha = c8.alpha();
|
|
||||||
let text_color = cosmic_text::Color::rgba(c8.red(), c8.green(), c8.blue(), base_alpha);
|
|
||||||
|
|
||||||
let (width, height) = (pixmap.width() as i32, pixmap.height() as i32);
|
let (width, height) = (pixmap.width() as i32, pixmap.height() as i32);
|
||||||
for run in buffer.layout_runs() {
|
let ox = origin_x as i32;
|
||||||
for glyph in run.glyphs.iter() {
|
let oy = origin_y as i32;
|
||||||
// Subpixel origin: X lands in CacheKey's subpixel bins; Y is
|
buffer.draw(
|
||||||
// hinted (cosmic-text truncates the Y offset) and then the
|
&mut self.font_system,
|
||||||
// run's line_y is rounded at blit so we don't trunc origin
|
&mut self.swash_cache,
|
||||||
// independently of glyph placement.
|
text_color,
|
||||||
let physical = glyph.physical((origin_x, origin_y), 1.0);
|
|x, y, _w, _h, glyph_color| {
|
||||||
let glyph_color = glyph.color_opt.unwrap_or(text_color);
|
let (px, py) = (ox + x, oy + y);
|
||||||
self.swash_cache.with_pixels(
|
if px < 0 || py < 0 || px >= width || py >= height {
|
||||||
&mut self.font_system,
|
return;
|
||||||
physical.cache_key,
|
}
|
||||||
glyph_color,
|
let (r, g, b, a) = glyph_color.as_rgba_tuple();
|
||||||
|x, y, color| {
|
if a == 0 {
|
||||||
let px = physical.x + x;
|
return;
|
||||||
let py = run.line_y.round() as i32 + physical.y + y;
|
}
|
||||||
if px < 0 || py < 0 || px >= width || py >= height {
|
blend_over_opaque(pixmap, px as u32, py as u32, r, g, b, a);
|
||||||
return;
|
},
|
||||||
}
|
);
|
||||||
let (r, g, b, a) = color.as_rgba_tuple();
|
|
||||||
if a == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let a = (a as u32 * base_alpha as u32 / 255) as u8;
|
|
||||||
if a == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
blend_over(pixmap, px as u32, py as u32, r, g, b, a);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Alpha-blends a straight-alpha `(r, g, b, a)` source pixel over a
|
/// Alpha-blends a straight-alpha `(r, g, b, a)` source pixel over an
|
||||||
/// destination of *any* alpha. Two paths use this:
|
/// **opaque** destination pixel (always true here — the lock screen
|
||||||
///
|
/// background is painted fully opaque before any text or UI chrome).
|
||||||
/// - **Full compose**: the background is painted fully opaque before any
|
/// Because the destination alpha is always 255, the blended result is also
|
||||||
/// text, so the destination alpha is always 255 and the result is opaque
|
/// opaque, so the `PremultipliedColorU8` invariant (`rgb <= a`) always holds.
|
||||||
/// (the exact formula below, kept byte-identical to the historic one).
|
fn blend_over_opaque(pixmap: &mut Pixmap, x: u32, y: u32, r: u8, g: u8, b: u8, a: u8) {
|
||||||
/// - **GPU chrome** (`compose_chrome`): text is drawn into a *transparent*
|
|
||||||
/// pixmap that is later composited over the GPU background, so glyph
|
|
||||||
/// edges must keep real alpha — a forced-255 blend here would make every
|
|
||||||
/// glyph opaque and, composited over the background, visibly wrong.
|
|
||||||
///
|
|
||||||
/// Premultiplied source-over: `out = src_pm + dst_pm * (1 - src_a)`, which
|
|
||||||
/// preserves the `PremultipliedColorU8` invariant (`rgb <= a`).
|
|
||||||
fn blend_over(pixmap: &mut Pixmap, x: u32, y: u32, r: u8, g: u8, b: u8, a: u8) {
|
|
||||||
let idx = (y * pixmap.width() + x) as usize;
|
let idx = (y * pixmap.width() + x) as usize;
|
||||||
let pixels = pixmap.pixels_mut();
|
let pixels = pixmap.pixels_mut();
|
||||||
let Some(dst) = pixels.get(idx).copied() else {
|
let Some(dst) = pixels.get(idx).copied() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let sa = a as u32;
|
let a32 = a as u32;
|
||||||
if dst.alpha() == 255 {
|
let mix = |s: u8, d: u8| -> u8 { ((s as u32 * a32 + d as u32 * (255 - a32)) / 255) as u8 };
|
||||||
// Opaque destination: the classic exact blend. RGB mixes toward the
|
let blended = PremultipliedColorU8::from_rgba(
|
||||||
// source, alpha stays 255 — identical to the pre-split behavior so
|
mix(r, dst.red()),
|
||||||
// the single-pass software path doesn't move a single pixel.
|
mix(g, dst.green()),
|
||||||
let mix = |s: u8, d: u8| -> u8 { ((s as u32 * sa + d as u32 * (255 - sa)) / 255) as u8 };
|
mix(b, dst.blue()),
|
||||||
if let Some(blended) = PremultipliedColorU8::from_rgba(
|
255,
|
||||||
mix(r, dst.red()),
|
);
|
||||||
mix(g, dst.green()),
|
if let Some(blended) = blended {
|
||||||
mix(b, dst.blue()),
|
|
||||||
255,
|
|
||||||
) {
|
|
||||||
pixels[idx] = blended;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// General (possibly transparent) destination: premultiplied source-over.
|
|
||||||
// out_a = sa + da*(255-sa)/255; out_rgb = src_rgb*sa/255 + dst_rgb*(1-sa).
|
|
||||||
let da = dst.alpha() as u32;
|
|
||||||
let out_a = (sa + da * (255 - sa) / 255) as u8;
|
|
||||||
let out_c = |c: u8, dc: u8| -> u8 {
|
|
||||||
(c as u32 * sa / 255 + dc as u32 * (255 - sa) / 255) as u8
|
|
||||||
};
|
|
||||||
if let Some(blended) = PremultipliedColorU8::from_rgba(
|
|
||||||
out_c(r, dst.red()),
|
|
||||||
out_c(g, dst.green()),
|
|
||||||
out_c(b, dst.blue()),
|
|
||||||
out_a,
|
|
||||||
) {
|
|
||||||
pixels[idx] = blended;
|
pixels[idx] = blended;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -365,95 +168,4 @@ mod tests {
|
||||||
// exact glyph coverage depends on whatever fonts are installed on the CI host.
|
// exact glyph coverage depends on whatever fonts are installed on the CI host.
|
||||||
assert!(pixmap.pixels().iter().all(|p| p.alpha() == 255));
|
assert!(pixmap.pixels().iter().all(|p| p.alpha() == 255));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn draw_line_respects_color_alpha() {
|
|
||||||
// Regression: cosmic-text's glyph-Mask path drops the base color's
|
|
||||||
// alpha (coverage becomes the only alpha), so translucent text used to
|
|
||||||
// render fully opaque — which broke every text fade on the lock screen
|
|
||||||
// (clock/date/hint/status never faded during appear/unlock).
|
|
||||||
let mut renderer = TextRenderer::new();
|
|
||||||
|
|
||||||
let mut full = Pixmap::new(200, 40).unwrap();
|
|
||||||
full.fill(tiny_skia::Color::BLACK);
|
|
||||||
renderer.draw_line(
|
|
||||||
&mut full,
|
|
||||||
"12:34",
|
|
||||||
"sans-serif",
|
|
||||||
24.0,
|
|
||||||
tiny_skia::Color::WHITE,
|
|
||||||
0.0,
|
|
||||||
0.0,
|
|
||||||
);
|
|
||||||
let full_max = full.pixels().iter().map(|p| p.red()).max().unwrap();
|
|
||||||
assert!(full_max > 200, "full-alpha text should render bright, got {full_max}");
|
|
||||||
|
|
||||||
let faint = tiny_skia::Color::from_rgba(1.0, 1.0, 1.0, 0.1).unwrap();
|
|
||||||
let mut low = Pixmap::new(200, 40).unwrap();
|
|
||||||
low.fill(tiny_skia::Color::BLACK);
|
|
||||||
renderer.draw_line(&mut low, "12:34", "sans-serif", 24.0, faint, 0.0, 0.0);
|
|
||||||
let low_max = low.pixels().iter().map(|p| p.red()).max().unwrap();
|
|
||||||
assert!(
|
|
||||||
low_max < 100,
|
|
||||||
"10%-alpha text must not render near-white, got {low_max}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn missing_font_family_falls_back_without_panic() {
|
|
||||||
let mut pixmap = Pixmap::new(64, 16).unwrap();
|
|
||||||
pixmap.fill(tiny_skia::Color::BLACK);
|
|
||||||
let mut renderer = TextRenderer::new();
|
|
||||||
renderer.draw_line(
|
|
||||||
&mut pixmap,
|
|
||||||
"12:34",
|
|
||||||
"DefinitelyNotARealFontFamily_xyzzy",
|
|
||||||
12.0,
|
|
||||||
tiny_skia::Color::WHITE,
|
|
||||||
2.0,
|
|
||||||
2.0,
|
|
||||||
);
|
|
||||||
assert!(pixmap.pixels().iter().any(|p| p.alpha() > 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn draw_line_onto_transparent_keeps_real_alpha() {
|
|
||||||
// Regression: the GPU path (compose_chrome) draws text into a
|
|
||||||
// transparent pixmap that is later composited over the GPU background.
|
|
||||||
// The old blend forced output alpha to 255, so every glyph became
|
|
||||||
// opaque and, once composited, rendered visibly wrong (dark, covering
|
|
||||||
// the background instead of blending). Glyph cores must carry real
|
|
||||||
// alpha here so the final source-over composite is correct.
|
|
||||||
let mut renderer = TextRenderer::new();
|
|
||||||
|
|
||||||
let mut t = Pixmap::new(200, 40).unwrap(); // starts transparent
|
|
||||||
renderer.draw_line(
|
|
||||||
&mut t,
|
|
||||||
"12:34",
|
|
||||||
"sans-serif",
|
|
||||||
24.0,
|
|
||||||
tiny_skia::Color::WHITE,
|
|
||||||
0.0,
|
|
||||||
0.0,
|
|
||||||
);
|
|
||||||
// Full-coverage glyph cores are legitimately opaque, but the AA
|
|
||||||
// edges must carry real intermediate alphas — the old forced-255
|
|
||||||
// blend made *every* drawn pixel (edges included) fully opaque.
|
|
||||||
let has_edge = t
|
|
||||||
.pixels()
|
|
||||||
.iter()
|
|
||||||
.any(|p| p.alpha() > 0 && p.alpha() < 255);
|
|
||||||
assert!(
|
|
||||||
has_edge,
|
|
||||||
"glyph AA edges must keep intermediate alphas onto a transparent pixmap"
|
|
||||||
);
|
|
||||||
// And a 50%-alpha draw must not produce fully-opaque pixels.
|
|
||||||
let mut t2 = Pixmap::new(200, 40).unwrap();
|
|
||||||
let half = tiny_skia::Color::from_rgba(1.0, 1.0, 1.0, 0.5).unwrap();
|
|
||||||
renderer.draw_line(&mut t2, "12:34", "sans-serif", 24.0, half, 0.0, 0.0);
|
|
||||||
assert!(
|
|
||||||
t2.pixels().iter().all(|p| p.alpha() <= 128 + 3),
|
|
||||||
"50%-alpha text onto transparent must stay ~half alpha"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
pub use bread_theme::{ink_on, load_palette, load_palette_for, Palette};
|
pub use bread_theme::{ink_on, load_palette, Palette};
|
||||||
|
|
||||||
/// Parse a `#rrggbb` hex colour. Falls back to opaque black on malformed input
|
/// Parse a `#rrggbb` hex colour. Falls back to opaque black on malformed input
|
||||||
/// (palette slots are always produced by [`bread_theme`], which guarantees
|
/// (palette slots are always produced by [`bread_theme`], which guarantees
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
# Copy to ~/.config/breadlock/breadlock.toml — every field is optional and
|
# Copy to ~/.config/breadlock/breadlock.toml — every field is optional and
|
||||||
# defaults to the value shown here if omitted or the file doesn't exist.
|
# defaults to the value shown here if omitted or the file doesn't exist.
|
||||||
# A malformed file also falls back to defaults (the locker/greeter warn
|
|
||||||
# rather than treating it as missing).
|
|
||||||
|
|
||||||
[background]
|
[background]
|
||||||
# "color" (bread-theme palette background) or "image" (a PNG, cover-fit)
|
# "color" (bread-theme palette background) or "image" (a PNG, cover-fit)
|
||||||
|
|
@ -10,41 +8,15 @@ path = ""
|
||||||
# v2 feature — accepted but currently just logs a warning and shows the
|
# v2 feature — accepted but currently just logs a warning and shows the
|
||||||
# background unblurred (needs a wlr-screencopy capture, not implemented yet).
|
# background unblurred (needs a wlr-screencopy capture, not implemented yet).
|
||||||
blur = false
|
blur = false
|
||||||
# Slow Ken Burns pan on image backgrounds (gentle drift + zoom). Opt-in.
|
|
||||||
# Cheap on the GPU wallpaper path; the software fallback still redraws
|
|
||||||
# the background continuously at a low frame rate while locked.
|
|
||||||
ken_burns = false
|
|
||||||
|
|
||||||
[clock]
|
[clock]
|
||||||
# strftime format
|
# strftime format
|
||||||
format = "%H:%M"
|
format = "%H:%M"
|
||||||
# strftime format for the date line under the clock; empty string hides it
|
|
||||||
# (e.g. %A · %b %d → "Friday · Aug 21")
|
|
||||||
date_format = "%A · %b %d"
|
|
||||||
|
|
||||||
[font]
|
[font]
|
||||||
family = "Varela Round"
|
family = "Varela Round"
|
||||||
|
|
||||||
[input]
|
[input]
|
||||||
# How long the red "wrong password" UI shows, in milliseconds. Typing is
|
# How long the "wrong password" state (red pill) shows before input
|
||||||
# still accepted during this window (it clears the failed state).
|
# re-enables, in milliseconds.
|
||||||
fail_timeout_ms = 800
|
fail_timeout_ms = 800
|
||||||
# Hold Tab to reveal the typed password as plain characters (instead of
|
|
||||||
# dots) while held. Tab can never be part of a password, so it's always
|
|
||||||
# safe as a reveal gesture. Default off.
|
|
||||||
reveal_hold = false
|
|
||||||
|
|
||||||
[animation]
|
|
||||||
# Subtle glow pulse on the password pill every few seconds while idle.
|
|
||||||
breathe = true
|
|
||||||
# Deepen the dim veil after this many seconds of no keystrokes (0 = off).
|
|
||||||
# A gentle extra darkening for OLED/burn-in or late-night comfort.
|
|
||||||
idle_dim_after_secs = 0
|
|
||||||
|
|
||||||
[status]
|
|
||||||
# Now-playing (MPRIS) and battery (upower) shown as a small line under the
|
|
||||||
# clock. Each flag controls both display and whether that D-Bus source is
|
|
||||||
# polled (background thread, every few seconds). Both default on; they
|
|
||||||
# degrade silently (no line) when the service or bus is unavailable.
|
|
||||||
now_playing = true
|
|
||||||
battery = true
|
|
||||||
|
|
|
||||||
|
|
@ -17,28 +17,15 @@ path = "src/main.rs"
|
||||||
name = "breadlock-auth-check"
|
name = "breadlock-auth-check"
|
||||||
path = "src/bin/breadlock-auth-check.rs"
|
path = "src/bin/breadlock-auth-check.rs"
|
||||||
|
|
||||||
# Dev-only harness: renders the lock-screen motion system (render.rs) to a
|
|
||||||
# folder of PNGs with no Wayland involved, for eyeballing animations without
|
|
||||||
# locking a session. Not installed by the package.
|
|
||||||
[[bin]]
|
|
||||||
name = "breadlock-preview"
|
|
||||||
path = "src/bin/breadlock-preview.rs"
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
breadlock-ui = { path = "../breadlock-ui", features = ["paint"] }
|
breadlock-ui = { path = "../breadlock-ui", features = ["paint"] }
|
||||||
bread-utils = { workspace = true, features = ["bread-client"] }
|
|
||||||
smithay-client-toolkit = "0.20"
|
smithay-client-toolkit = "0.20"
|
||||||
wayland-client = { version = "0.31", features = ["system"] }
|
wayland-client = "0.31"
|
||||||
tiny-skia = "0.12"
|
tiny-skia = "0.12"
|
||||||
khronos-egl = { version = "6", features = ["dynamic"] }
|
|
||||||
glow = "0.16"
|
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
zbus = "4"
|
|
||||||
pam-client2 = { version = "0.5", default-features = false }
|
pam-client2 = { version = "0.5", default-features = false }
|
||||||
zeroize = { version = "1", features = ["std"] }
|
zeroize = { version = "1", features = ["std"] }
|
||||||
libc = "0.2"
|
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -8,33 +8,24 @@
|
||||||
|
|
||||||
pub mod pam;
|
pub mod pam;
|
||||||
|
|
||||||
pub use pam::{username_from_process, AuthError};
|
pub use pam::AuthError;
|
||||||
|
|
||||||
use smithay_client_toolkit::reexports::calloop::channel::{self, Sender};
|
use smithay_client_toolkit::reexports::calloop::channel::{self, Sender};
|
||||||
use smithay_client_toolkit::reexports::calloop::LoopHandle;
|
use smithay_client_toolkit::reexports::calloop::LoopHandle;
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
pub type AuthResult = Result<(), AuthError>;
|
pub type AuthResult = Result<(), AuthError>;
|
||||||
|
|
||||||
/// Posted back to the event loop: the attempt's generation so a timed-out
|
|
||||||
/// or Escape-cancelled check cannot apply a late result.
|
|
||||||
pub type AuthOutcome = (u64, AuthResult);
|
|
||||||
|
|
||||||
/// libpam has no cancel; if it hangs we surface Authenticate after this
|
|
||||||
/// and ignore whatever it eventually returns (generation mismatch).
|
|
||||||
const PAM_TIMEOUT: Duration = Duration::from_secs(30);
|
|
||||||
|
|
||||||
/// Registers the receiving half of the auth-result channel on the event
|
/// Registers the receiving half of the auth-result channel on the event
|
||||||
/// loop and returns the `Sender` to hand to [`spawn_check`] on each attempt.
|
/// loop and returns the `Sender` to hand to [`spawn_check`] on each attempt.
|
||||||
pub fn register<Data: 'static>(
|
pub fn register<Data: 'static>(
|
||||||
loop_handle: &LoopHandle<'static, Data>,
|
loop_handle: &LoopHandle<'static, Data>,
|
||||||
mut on_result: impl FnMut(&mut Data, u64, AuthResult) + 'static,
|
mut on_result: impl FnMut(&mut Data, AuthResult) + 'static,
|
||||||
) -> Sender<AuthOutcome> {
|
) -> Sender<AuthResult> {
|
||||||
let (tx, channel) = channel::channel();
|
let (tx, channel) = channel::channel();
|
||||||
loop_handle
|
loop_handle
|
||||||
.insert_source(channel, move |event, _, data| {
|
.insert_source(channel, move |event, _, data| {
|
||||||
if let channel::Event::Msg((generation, result)) = event {
|
if let channel::Event::Msg(result) = event {
|
||||||
on_result(data, generation, result);
|
on_result(data, result);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.expect("failed to register auth-result channel on event loop");
|
.expect("failed to register auth-result channel on event loop");
|
||||||
|
|
@ -47,31 +38,13 @@ pub fn register<Data: 'static>(
|
||||||
/// conversation consumes it — it is never logged. It's a `Zeroizing<String>`
|
/// conversation consumes it — it is never logged. It's a `Zeroizing<String>`
|
||||||
/// so the buffer is wiped the moment it goes out of scope at the end of this
|
/// so the buffer is wiped the moment it goes out of scope at the end of this
|
||||||
/// closure, rather than just deallocated with the bytes intact.
|
/// closure, rather than just deallocated with the bytes intact.
|
||||||
///
|
|
||||||
/// `generation` is echoed back with the result so the event loop can
|
|
||||||
/// drop timed-out or cancelled attempts. libpam itself is not aborted.
|
|
||||||
pub fn spawn_check(
|
pub fn spawn_check(
|
||||||
username: String,
|
username: String,
|
||||||
password: zeroize::Zeroizing<String>,
|
password: zeroize::Zeroizing<String>,
|
||||||
generation: u64,
|
result_tx: Sender<AuthResult>,
|
||||||
result_tx: Sender<AuthOutcome>,
|
|
||||||
) {
|
) {
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let (done_tx, done_rx) = std::sync::mpsc::channel();
|
let result = pam::check(&username, &password);
|
||||||
std::thread::spawn(move || {
|
let _ = result_tx.send(result);
|
||||||
let result = pam::check(&username, &password);
|
|
||||||
let _ = done_tx.send(result);
|
|
||||||
});
|
|
||||||
let result = match done_rx.recv_timeout(PAM_TIMEOUT) {
|
|
||||||
Ok(result) => result,
|
|
||||||
Err(_) => {
|
|
||||||
tracing::warn!(
|
|
||||||
timeout_s = PAM_TIMEOUT.as_secs(),
|
|
||||||
"PAM check timed out; treating as authentication failure"
|
|
||||||
);
|
|
||||||
Err(AuthError::Authenticate)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let _ = result_tx.send((generation, result));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
|
|
||||||
use pam_client2::conv_mock::Conversation;
|
use pam_client2::conv_mock::Conversation;
|
||||||
use pam_client2::{Context, Flag};
|
use pam_client2::{Context, Flag};
|
||||||
use std::ffi::CStr;
|
|
||||||
use zeroize::Zeroize;
|
use zeroize::Zeroize;
|
||||||
|
|
||||||
/// The PAM service name — matches `/etc/pam.d/breadlock`
|
/// The PAM service name — matches `/etc/pam.d/breadlock`
|
||||||
|
|
@ -47,136 +46,3 @@ pub fn check(username: &str, password: &str) -> Result<(), AuthError> {
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copy a NUL-terminated `passwd.pw_name` into an owned `String`.
|
|
||||||
fn cstr_to_username(ptr: *const libc::c_char) -> Option<String> {
|
|
||||||
if ptr.is_null() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
// SAFETY: `ptr` is a non-null C string from getpwuid_r (into our buffer)
|
|
||||||
// or a test fixture.
|
|
||||||
let cstr = unsafe { CStr::from_ptr(ptr) };
|
|
||||||
let name = cstr.to_str().ok()?;
|
|
||||||
if name.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(name.to_owned())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Passwd lookup of `uid` via `getpwuid_r`. Grows the scratch buffer on
|
|
||||||
/// `ERANGE`. Returns `None` if the user is unknown or the name is not UTF-8.
|
|
||||||
pub fn username_from_uid(uid: libc::uid_t) -> Option<String> {
|
|
||||||
let mut pwd = std::mem::MaybeUninit::<libc::passwd>::uninit();
|
|
||||||
let mut buflen = unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) };
|
|
||||||
if buflen <= 0 {
|
|
||||||
buflen = 1024;
|
|
||||||
}
|
|
||||||
let mut buf = vec![0u8; buflen as usize];
|
|
||||||
let mut result: *mut libc::passwd = std::ptr::null_mut();
|
|
||||||
loop {
|
|
||||||
let rc = unsafe {
|
|
||||||
libc::getpwuid_r(
|
|
||||||
uid,
|
|
||||||
pwd.as_mut_ptr(),
|
|
||||||
buf.as_mut_ptr() as *mut libc::c_char,
|
|
||||||
buf.len(),
|
|
||||||
&mut result,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if rc == libc::ERANGE {
|
|
||||||
let next = buf.len().saturating_mul(2).max(buf.len() + 1024);
|
|
||||||
if next == buf.len() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
buf.resize(next, 0);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if rc != 0 || result.is_null() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// SAFETY: getpwuid_r wrote a `passwd` and `result` is non-null; `pw_name`
|
|
||||||
// points into `buf`, which we copy out before `buf` drops.
|
|
||||||
let pwd = unsafe { pwd.assume_init() };
|
|
||||||
cstr_to_username(pwd.pw_name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Prefer the first non-empty of passwd name, `$USER`, `$LOGNAME`.
|
|
||||||
pub(crate) fn pick_username(
|
|
||||||
passwd: Option<&str>,
|
|
||||||
user: Option<&str>,
|
|
||||||
logname: Option<&str>,
|
|
||||||
) -> Option<String> {
|
|
||||||
for candidate in [passwd, user, logname] {
|
|
||||||
if let Some(s) = candidate.filter(|s| !s.is_empty()) {
|
|
||||||
return Some(s.to_owned());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Username for PAM: `getuid` + `getpwuid_r`, then `$USER` / `$LOGNAME`.
|
|
||||||
/// Logs a warning when the passwd lookup fails. `None` if nothing resolved.
|
|
||||||
pub fn username_from_process() -> Option<String> {
|
|
||||||
let uid = unsafe { libc::getuid() };
|
|
||||||
let from_passwd = username_from_uid(uid);
|
|
||||||
if from_passwd.is_none() {
|
|
||||||
tracing::warn!(
|
|
||||||
uid,
|
|
||||||
"passwd lookup for process uid failed; falling back to $USER / $LOGNAME"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
pick_username(
|
|
||||||
from_passwd.as_deref(),
|
|
||||||
std::env::var("USER").ok().as_deref(),
|
|
||||||
std::env::var("LOGNAME").ok().as_deref(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::ffi::CString;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cstr_to_username_copies_nul_terminated_name() {
|
|
||||||
let raw = CString::new("breadway").unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
cstr_to_username(raw.as_ptr()),
|
|
||||||
Some("breadway".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cstr_to_username_rejects_empty_and_null() {
|
|
||||||
let empty = CString::new("").unwrap();
|
|
||||||
assert_eq!(cstr_to_username(empty.as_ptr()), None);
|
|
||||||
assert_eq!(cstr_to_username(std::ptr::null()), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pick_username_prefers_passwd_then_user_then_logname() {
|
|
||||||
assert_eq!(
|
|
||||||
pick_username(Some("from-pw"), Some("from-user"), Some("from-log")),
|
|
||||||
Some("from-pw".into())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
pick_username(None, Some("from-user"), Some("from-log")),
|
|
||||||
Some("from-user".into())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
pick_username(None, None, Some("from-log")),
|
|
||||||
Some("from-log".into())
|
|
||||||
);
|
|
||||||
assert_eq!(pick_username(Some(""), Some(""), Some("")), None);
|
|
||||||
assert_eq!(pick_username(None, None, None), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn username_from_uid_of_self_is_some_or_none_without_panic() {
|
|
||||||
let uid = unsafe { libc::getuid() };
|
|
||||||
let _ = username_from_uid(uid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,214 +1,14 @@
|
||||||
//! Lock-screen background: a solid palette color, or a static image scaled
|
//! Lock-screen background: a solid palette color, or a static image scaled
|
||||||
//! to cover the surface. Live blur-of-desktop (hyprlock-style) is a v2
|
//! to cover the surface. Live blur-of-desktop (hyprlock-style) is a v2
|
||||||
//! follow-up (see README) — `blur = true` is accepted but only logs a
|
//! follow-up (see README) — `blur = true` is accepted but only logs a
|
||||||
//! warning in v1. `ken_burns = true` adds a slow, continuous pan+zoom to
|
//! warning in v1.
|
||||||
//! image backgrounds (opt-in: it keeps the background redrawing at a low
|
|
||||||
//! frame rate while locked).
|
|
||||||
//!
|
|
||||||
//! The renderer is fully software (tiny-skia), so every frame redraws the
|
|
||||||
//! whole surface. Rescaling the *source* wallpaper on every frame is
|
|
||||||
//! prohibitively expensive for large images (a 4K source at output size took
|
|
||||||
//! ~50 ms/frame — choppy at any cadence), so the source is pre-scaled once
|
|
||||||
//! per output size into a cache and each frame is a translate-only blit.
|
|
||||||
|
|
||||||
use breadlock_ui::config::{Background as BackgroundConfig, BackgroundMode};
|
use breadlock_ui::config::{Background as BackgroundConfig, BackgroundMode};
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::f32::consts::TAU;
|
|
||||||
use tiny_skia::{Pixmap, PixmapPaint, Transform};
|
use tiny_skia::{Pixmap, PixmapPaint, Transform};
|
||||||
|
|
||||||
/// One full Ken Burns pan+zoom cycle, in seconds. Deliberately slow so the
|
|
||||||
/// motion reads as a gentle drift rather than a slideshow.
|
|
||||||
const KENBURNS_PERIOD_S: f32 = 90.0;
|
|
||||||
/// Extra zoom beyond plain cover-fit — gives the pan room to travel without
|
|
||||||
/// ever exposing the image edges.
|
|
||||||
const KENBURNS_ZOOM: f32 = 1.06;
|
|
||||||
|
|
||||||
pub enum Background {
|
pub enum Background {
|
||||||
Color(tiny_skia::Color),
|
Color(tiny_skia::Color),
|
||||||
Image(ImageBg),
|
Image(Pixmap),
|
||||||
}
|
|
||||||
|
|
||||||
/// A wallpaper with a lazily-built, output-sized copy. The first `paint` for
|
|
||||||
/// a given output size does one downscale; every frame after that blits the
|
|
||||||
/// cached copy with at most a translation (the Ken Burns pan).
|
|
||||||
/// Cap on cached scaled copies — enough for a typical multi-monitor setup
|
|
||||||
/// without unbounded growth if the compositor sends many sizes.
|
|
||||||
const SCALED_CACHE_SLOTS: usize = 4;
|
|
||||||
|
|
||||||
pub struct ImageBg {
|
|
||||||
/// Original wallpaper. Kept so a different output size (hotplug) simply
|
|
||||||
/// rebuilds the cache rather than needing the source reloaded.
|
|
||||||
source: Pixmap,
|
|
||||||
ken_burns: bool,
|
|
||||||
/// Last scaled copies **per target size**. A single slot thrashed every
|
|
||||||
/// frame under `redraw_all` with two monitors of different sizes.
|
|
||||||
cache: RefCell<Vec<ScaledBg>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ScaledBg {
|
|
||||||
/// `source` pre-scaled to cover-fit (× Ken Burns zoom when enabled) and
|
|
||||||
/// sized to the output — same size or larger, so drawing it needs no
|
|
||||||
/// per-frame scaling.
|
|
||||||
pixmap: Pixmap,
|
|
||||||
/// How many pixels the scaled image overhangs each axis — the pan room.
|
|
||||||
pan_x: f32,
|
|
||||||
pan_y: f32,
|
|
||||||
target_w: u32,
|
|
||||||
target_h: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Copies `src` into `target` shifted by `(dx, dy)` (target pixels). `src` is
|
|
||||||
/// at least as large as `target` in both axes (guaranteed by the cover-fit
|
|
||||||
/// cache build), and `dx, dy` are pan offsets in `[-pan, 0]`, so the visible
|
|
||||||
/// region is `src[-dx..-dx+tw, -dy..-dy+th]`.
|
|
||||||
///
|
|
||||||
/// With `bilinear` the fractional part of the offset is sub-pixel filtered,
|
|
||||||
/// so a slow pan glides instead of stepping one whole pixel at a time (which
|
|
||||||
/// reads as judder); when the offset is (near-)integer, or `bilinear` is off
|
|
||||||
/// (the 60 fps animation frames, where the pan moves < 0.2 px anyway), the
|
|
||||||
/// whole thing collapses to row memcpys. The bilinear path is an integer
|
|
||||||
/// fixed-point (16.16) loop with the edge clamping hoisted out of the hot
|
|
||||||
/// columns/rows — far cheaper than
|
|
||||||
/// [`tiny_skia::Pixmap::draw_pixmap`], which rasterizes every pixel through
|
|
||||||
/// its general pattern pipeline.
|
|
||||||
fn blit_translate(target: &mut Pixmap, src: &Pixmap, dx: f32, dy: f32, bilinear: bool) {
|
|
||||||
let tw = target.width() as usize;
|
|
||||||
let th = target.height() as usize;
|
|
||||||
let sw = src.width() as usize;
|
|
||||||
let sh = src.height() as usize;
|
|
||||||
let sx = (-dx).clamp(0.0, sw.saturating_sub(tw) as f32);
|
|
||||||
let sy = (-dy).clamp(0.0, sh.saturating_sub(th) as f32);
|
|
||||||
|
|
||||||
let fx = (sx.fract() * 65536.0) as u32 & 0xFFFF;
|
|
||||||
let fy = (sy.fract() * 65536.0) as u32 & 0xFFFF;
|
|
||||||
let ix = sx as usize;
|
|
||||||
let iy = sy as usize;
|
|
||||||
|
|
||||||
let sdata = src.data();
|
|
||||||
let dst = target.data_mut();
|
|
||||||
|
|
||||||
if !bilinear || (fx == 0 && fy == 0) {
|
|
||||||
for row in 0..th {
|
|
||||||
let src_row = (iy + row) * sw + ix;
|
|
||||||
let dst_row = row * tw;
|
|
||||||
let (s, d) = (
|
|
||||||
&sdata[src_row * 4..(src_row + tw) * 4],
|
|
||||||
&mut dst[dst_row * 4..(dst_row + tw) * 4],
|
|
||||||
);
|
|
||||||
d.copy_from_slice(s);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let wx = fx;
|
|
||||||
let wx_inv = 65536 - wx;
|
|
||||||
let wy = fy;
|
|
||||||
let wy_inv = 65536 - wy;
|
|
||||||
let swm1 = sw - 1;
|
|
||||||
let shm1 = sh - 1;
|
|
||||||
|
|
||||||
// Per-channel bilinear in packed u32 (one load per pixel instead of four,
|
|
||||||
// one store instead of four — the loop is latency-bound). Each byte's
|
|
||||||
// products stay well under 2^32, so lanes never interfere.
|
|
||||||
#[inline(always)]
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
unsafe fn lerp4(
|
|
||||||
sdata: &[u8],
|
|
||||||
i00: usize,
|
|
||||||
i10: usize,
|
|
||||||
i01: usize,
|
|
||||||
i11: usize,
|
|
||||||
di: usize,
|
|
||||||
wx: u32,
|
|
||||||
wx_inv: u32,
|
|
||||||
wy: u32,
|
|
||||||
wy_inv: u32,
|
|
||||||
dst: &mut [u8],
|
|
||||||
) {
|
|
||||||
let a = u32::from_ne_bytes([
|
|
||||||
*sdata.get_unchecked(i00),
|
|
||||||
*sdata.get_unchecked(i00 + 1),
|
|
||||||
*sdata.get_unchecked(i00 + 2),
|
|
||||||
*sdata.get_unchecked(i00 + 3),
|
|
||||||
]);
|
|
||||||
let b = u32::from_ne_bytes([
|
|
||||||
*sdata.get_unchecked(i01),
|
|
||||||
*sdata.get_unchecked(i01 + 1),
|
|
||||||
*sdata.get_unchecked(i01 + 2),
|
|
||||||
*sdata.get_unchecked(i01 + 3),
|
|
||||||
]);
|
|
||||||
let d = u32::from_ne_bytes([
|
|
||||||
*sdata.get_unchecked(i10),
|
|
||||||
*sdata.get_unchecked(i10 + 1),
|
|
||||||
*sdata.get_unchecked(i10 + 2),
|
|
||||||
*sdata.get_unchecked(i10 + 3),
|
|
||||||
]);
|
|
||||||
let e = u32::from_ne_bytes([
|
|
||||||
*sdata.get_unchecked(i11),
|
|
||||||
*sdata.get_unchecked(i11 + 1),
|
|
||||||
*sdata.get_unchecked(i11 + 2),
|
|
||||||
*sdata.get_unchecked(i11 + 3),
|
|
||||||
]);
|
|
||||||
let mut out = 0u32;
|
|
||||||
for c in 0..4 {
|
|
||||||
let shift = c * 8;
|
|
||||||
let av = (a >> shift) & 0xFF;
|
|
||||||
let bv = (b >> shift) & 0xFF;
|
|
||||||
let dv = (d >> shift) & 0xFF;
|
|
||||||
let ev = (e >> shift) & 0xFF;
|
|
||||||
let top = (av * wx_inv + bv * wx) >> 16;
|
|
||||||
let bot = (dv * wx_inv + ev * wx) >> 16;
|
|
||||||
out |= ((top * wy_inv + bot * wy) >> 16) << shift;
|
|
||||||
}
|
|
||||||
dst[di..di + 4].copy_from_slice(&out.to_ne_bytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interior rows/columns: `ix + tw <= sw` and `iy + th <= sh` (both clamped
|
|
||||||
// above), so `x0 + 1`/`y0 + 1` stay in bounds except on the last
|
|
||||||
// column/row, which are handled after the hot loop. All indices are
|
|
||||||
// verified in-bounds above the `unsafe` calls.
|
|
||||||
for row in 0..th - 1 {
|
|
||||||
let r0 = (iy + row) * sw;
|
|
||||||
let r1 = r0 + sw;
|
|
||||||
let drow = row * tw;
|
|
||||||
for col in 0..tw - 1 {
|
|
||||||
let i00 = (r0 + ix + col) * 4;
|
|
||||||
let i10 = (r1 + ix + col) * 4;
|
|
||||||
let di = (drow + col) * 4;
|
|
||||||
// SAFETY: i01/i11 are the next column (col + 1 < tw, in bounds);
|
|
||||||
// di + 4 < target size; rows in bounds per above.
|
|
||||||
unsafe { lerp4(sdata, i00, i10, i00 + 4, i10 + 4, di, wx, wx_inv, wy, wy_inv, dst) };
|
|
||||||
}
|
|
||||||
// Last column of this row: clamp x1.
|
|
||||||
let i00 = (r0 + ix + tw - 1) * 4;
|
|
||||||
let i10 = (r1 + ix + tw - 1) * 4;
|
|
||||||
let di = (drow + tw - 1) * 4;
|
|
||||||
let x1 = (ix + tw - 1 + 1).min(swm1);
|
|
||||||
let j0 = (r0 + x1) * 4;
|
|
||||||
let j1 = (r1 + x1) * 4;
|
|
||||||
// SAFETY: j0/j1 clamped within source, di within target.
|
|
||||||
unsafe { lerp4(sdata, i00, i10, j0, j1, di, wx, wx_inv, wy, wy_inv, dst) };
|
|
||||||
}
|
|
||||||
// Last row: clamp y1.
|
|
||||||
let r0 = (iy + th - 1) * sw;
|
|
||||||
let r1 = (iy + th - 1 + 1).min(shm1) * sw;
|
|
||||||
let drow = (th - 1) * tw;
|
|
||||||
for col in 0..tw - 1 {
|
|
||||||
let i00 = (r0 + ix + col) * 4;
|
|
||||||
let i10 = (r1 + ix + col) * 4;
|
|
||||||
let di = (drow + col) * 4;
|
|
||||||
// SAFETY: in bounds as in the interior loop.
|
|
||||||
unsafe { lerp4(sdata, i00, i10, i00 + 4, i10 + 4, di, wx, wx_inv, wy, wy_inv, dst) };
|
|
||||||
}
|
|
||||||
// Last column of the last row (both clamps).
|
|
||||||
let i00 = (r0 + ix + tw - 1) * 4;
|
|
||||||
let i10 = (r1 + ix + tw - 1) * 4;
|
|
||||||
let di = (drow + tw - 1) * 4;
|
|
||||||
let x1 = (ix + tw - 1 + 1).min(swm1);
|
|
||||||
let j0 = (r0 + x1) * 4;
|
|
||||||
let j1 = (r1 + x1) * 4;
|
|
||||||
// SAFETY: all clamped in bounds.
|
|
||||||
unsafe { lerp4(sdata, i00, i10, j0, j1, di, wx, wx_inv, wy, wy_inv, dst) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Background {
|
impl Background {
|
||||||
|
|
@ -231,11 +31,7 @@ impl Background {
|
||||||
return fallback();
|
return fallback();
|
||||||
}
|
}
|
||||||
match Pixmap::load_png(&cfg.path) {
|
match Pixmap::load_png(&cfg.path) {
|
||||||
Ok(pixmap) => Background::Image(ImageBg {
|
Ok(pixmap) => Background::Image(pixmap),
|
||||||
source: pixmap,
|
|
||||||
ken_burns: cfg.ken_burns,
|
|
||||||
cache: RefCell::new(Vec::new()),
|
|
||||||
}),
|
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
tracing::warn!(path = %cfg.path, %err, "failed to load background image (PNG only in v1), falling back to palette color");
|
tracing::warn!(path = %cfg.path, %err, "failed to load background image (PNG only in v1), falling back to palette color");
|
||||||
fallback()
|
fallback()
|
||||||
|
|
@ -245,252 +41,28 @@ impl Background {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True when this background needs continuous redraws (Ken Burns pan).
|
|
||||||
pub fn ken_burns(&self) -> bool {
|
|
||||||
matches!(self, Background::Image(bg) if bg.ken_burns)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Paints this background into `target`, cover-fit (scaled uniformly to
|
/// Paints this background into `target`, cover-fit (scaled uniformly to
|
||||||
/// fill the surface, cropping any overflow — never letterboxed). `t_secs`
|
/// fill the surface, cropping any overflow — never letterboxed).
|
||||||
/// is the monotonic clock: with Ken Burns enabled the image slowly pans
|
pub fn paint(&self, target: &mut Pixmap) {
|
||||||
/// and zooms along a smooth Lissajous-ish drift, so consecutive frames
|
|
||||||
/// differ slightly but never jump.
|
|
||||||
///
|
|
||||||
/// The expensive downscale happens at most once per output size (see
|
|
||||||
/// [`ImageBg::cache`]); steady-state frames are a 1:1 blit plus a small
|
|
||||||
/// translation, so the software renderer can hold its frame budget even
|
|
||||||
/// with a multi-megapixel wallpaper.
|
|
||||||
///
|
|
||||||
/// `smooth` asks for sub-pixel bilinear panning. The locker passes `true`
|
|
||||||
/// on its slow idle frames (where the ~1 px/frame drift is visible) and
|
|
||||||
/// `false` on 60 fps animation frames (where the pan moves < 0.2 px and
|
|
||||||
/// the ~20 ms/frame bilinear would blow the frame budget).
|
|
||||||
pub fn paint(&self, target: &mut Pixmap, t_secs: f32, smooth: bool) {
|
|
||||||
match self {
|
match self {
|
||||||
Background::Color(c) => target.fill(*c),
|
Background::Color(c) => target.fill(*c),
|
||||||
Background::Image(bg) => {
|
Background::Image(source) => {
|
||||||
let (tw, th) = (target.width() as f32, target.height() as f32);
|
let (tw, th) = (target.width() as f32, target.height() as f32);
|
||||||
let (sw, sh) = (bg.source.width() as f32, bg.source.height() as f32);
|
let (sw, sh) = (source.width() as f32, source.height() as f32);
|
||||||
if sw <= 0.0 || sh <= 0.0 {
|
if sw <= 0.0 || sh <= 0.0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let mut cache = bg.cache.borrow_mut();
|
let scale = (tw / sw).max(th / sh);
|
||||||
let tw_px = target.width();
|
|
||||||
let th_px = target.height();
|
|
||||||
let hit = cache
|
|
||||||
.iter()
|
|
||||||
.position(|c| c.target_w == tw_px && c.target_h == th_px);
|
|
||||||
if let Some(i) = hit {
|
|
||||||
// LRU: most-recently used at the end.
|
|
||||||
if i + 1 != cache.len() {
|
|
||||||
let entry = cache.remove(i);
|
|
||||||
cache.push(entry);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let cover = (tw / sw).max(th / sh);
|
|
||||||
let scale = cover * if bg.ken_burns { KENBURNS_ZOOM } else { 1.0 };
|
|
||||||
let scaled_w = (sw * scale).round().max(1.0) as u32;
|
|
||||||
let scaled_h = (sh * scale).round().max(1.0) as u32;
|
|
||||||
let Some(mut pixmap) = Pixmap::new(scaled_w, scaled_h) else {
|
|
||||||
tracing::error!(
|
|
||||||
"failed to allocate {scaled_w}x{scaled_h} scaled wallpaper — falling back to a palette-color background"
|
|
||||||
);
|
|
||||||
drop(cache);
|
|
||||||
target.fill(breadlock_ui::theme::tiny_skia_color(
|
|
||||||
&breadlock_ui::theme::Palette::default().background,
|
|
||||||
));
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
pixmap.fill(tiny_skia::Color::BLACK);
|
|
||||||
// The one real downscale in the pipeline: bilinear so the
|
|
||||||
// cached layer is smooth (per-frame draws are pure copies
|
|
||||||
// and don't re-filter).
|
|
||||||
let paint = PixmapPaint {
|
|
||||||
quality: tiny_skia::FilterQuality::Bilinear,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
pixmap.draw_pixmap(
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
bg.source.as_ref(),
|
|
||||||
&paint,
|
|
||||||
Transform::from_scale(scale, scale),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
if cache.len() >= SCALED_CACHE_SLOTS {
|
|
||||||
cache.remove(0);
|
|
||||||
}
|
|
||||||
cache.push(ScaledBg {
|
|
||||||
pixmap,
|
|
||||||
pan_x: scaled_w as f32 - tw,
|
|
||||||
pan_y: scaled_h as f32 - th,
|
|
||||||
target_w: tw_px,
|
|
||||||
target_h: th_px,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let scaled = cache.last().expect("cache populated above");
|
|
||||||
target.fill(tiny_skia::Color::BLACK);
|
target.fill(tiny_skia::Color::BLACK);
|
||||||
let (tx, ty) = if bg.ken_burns {
|
target.draw_pixmap(
|
||||||
let phase = t_secs * TAU / KENBURNS_PERIOD_S;
|
|
||||||
// Sin/cos offset by a quarter cycle: the pan traces a slow
|
|
||||||
// ellipse, starting from a corner.
|
|
||||||
(
|
|
||||||
-scaled.pan_x * (0.5 + 0.5 * phase.sin()),
|
|
||||||
-scaled.pan_y * (0.5 + 0.5 * phase.cos()),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
(0.0, 0.0)
|
|
||||||
};
|
|
||||||
// The cached pixmap is already output-sized, so this per-frame
|
|
||||||
// draw is a 1:1 copy with at most a translation. `draw_pixmap`
|
|
||||||
// runs the full raster pipeline per pixel (~20 ms for a
|
|
||||||
// full-screen layer), which is the dominant software-render
|
|
||||||
// cost — so do the blit directly instead: rows are memcpy'd
|
|
||||||
// (nearest sampling on an already-correct-size image is
|
|
||||||
// pixel-identical, and the pan offsets quantize the same way
|
|
||||||
// tiny-skia's nearest filter does).
|
|
||||||
blit_translate(target, &scaled.pixmap, tx, ty, smooth);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// A 4x4 pixmap whose pixel at (x, y) is `(x * 63, y * 63, 0, 255)` —
|
|
||||||
/// every pixel is distinct, so a shifted copy is easy to assert.
|
|
||||||
fn source_grid() -> Pixmap {
|
|
||||||
let mut p = Pixmap::new(4, 4).unwrap();
|
|
||||||
for y in 0..4 {
|
|
||||||
for x in 0..4 {
|
|
||||||
p.pixels_mut()[y * 4 + x] = tiny_skia::PremultipliedColorU8::from_rgba(
|
|
||||||
(x * 63) as u8,
|
|
||||||
(y * 63) as u8,
|
|
||||||
0,
|
0,
|
||||||
255,
|
0,
|
||||||
)
|
source.as_ref(),
|
||||||
.unwrap();
|
&PixmapPaint::default(),
|
||||||
|
Transform::from_scale(scale, scale),
|
||||||
|
None,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
p
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn blit_translate_copies_shifted_region() {
|
|
||||||
let src = source_grid();
|
|
||||||
let mut dst = Pixmap::new(2, 2).unwrap();
|
|
||||||
// Shift the 4x4 source by (-1, -1): the visible region is src[1..3, 1..3].
|
|
||||||
blit_translate(&mut dst, &src, -1.0, -1.0, false);
|
|
||||||
let px = dst.pixels();
|
|
||||||
assert_eq!(px[0].red(), 63, "(0,0) should be src(1,1) red");
|
|
||||||
assert_eq!(px[0].green(), 63, "(0,0) should be src(1,1) green");
|
|
||||||
assert_eq!(px[1].red(), 126, "(1,0) should be src(2,1) red");
|
|
||||||
assert_eq!(px[1].green(), 63);
|
|
||||||
assert_eq!(px[2].red(), 63, "(0,1) should be src(1,2) red");
|
|
||||||
assert_eq!(px[2].green(), 126);
|
|
||||||
assert_eq!(px[3].red(), 126, "(1,1) should be src(2,2)");
|
|
||||||
assert_eq!(px[3].green(), 126);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn blit_translate_clamps_within_source() {
|
|
||||||
// An offset larger than the overhang must clamp, not read out of
|
|
||||||
// bounds or leave uninitialized rows.
|
|
||||||
let src = source_grid();
|
|
||||||
let mut dst = Pixmap::new(2, 2).unwrap();
|
|
||||||
blit_translate(&mut dst, &src, -99.0, -99.0, false);
|
|
||||||
// Clamped to the bottom-right 2x2 of the source.
|
|
||||||
let px = dst.pixels();
|
|
||||||
assert_eq!(px[0].red(), 126);
|
|
||||||
assert_eq!(px[0].green(), 126);
|
|
||||||
assert_eq!(px[3].red(), 189);
|
|
||||||
assert_eq!(px[3].green(), 189);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ken_burns_pan_never_exposes_edges() {
|
|
||||||
// A small solid-color image panned through a full cycle must cover
|
|
||||||
// the whole target at every phase — no black borders.
|
|
||||||
let mut source = Pixmap::new(80, 40).unwrap();
|
|
||||||
source.fill(tiny_skia::Color::from_rgba8(200, 30, 30, 255));
|
|
||||||
let bg = Background::Image(ImageBg {
|
|
||||||
source,
|
|
||||||
ken_burns: true,
|
|
||||||
cache: RefCell::new(Vec::new()),
|
|
||||||
});
|
|
||||||
let mut target = Pixmap::new(60, 30).unwrap();
|
|
||||||
for i in 0..90 {
|
|
||||||
bg.paint(&mut target, i as f32, true);
|
|
||||||
assert!(
|
|
||||||
target.pixels().iter().all(|p| p.red() == 200 && p.green() == 30),
|
|
||||||
"frame {i} exposed an edge"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bilinear_shift_matches_fractional_position() {
|
|
||||||
// A row of (0..255, 0, 0, 255): a half-pixel right shift should give
|
|
||||||
// the exact average of each adjacent pair.
|
|
||||||
let mut src = Pixmap::new(8, 1).unwrap();
|
|
||||||
for x in 0..8 {
|
|
||||||
src.pixels_mut()[x] =
|
|
||||||
tiny_skia::PremultipliedColorU8::from_rgba((x * 32) as u8, 0, 0, 255).unwrap();
|
|
||||||
}
|
|
||||||
let mut dst = Pixmap::new(6, 1).unwrap();
|
|
||||||
// Shift by (-0.5, 0): visible region starts at src 0.5 → each output
|
|
||||||
// pixel averages src[x] and src[x + 1].
|
|
||||||
blit_translate(&mut dst, &src, -0.5, 0.0, true);
|
|
||||||
let px = dst.pixels();
|
|
||||||
assert_eq!(px[0].red(), 16, "0.5px shift averages neighbors");
|
|
||||||
assert_eq!(px[1].red(), ((32 + 64) / 2) as u8);
|
|
||||||
assert_eq!(px[5].red(), ((160 + 192) / 2) as u8);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn static_image_keeps_cover_fit() {
|
|
||||||
// Without Ken Burns the image is cover-fit exactly: still no edges.
|
|
||||||
let mut source = Pixmap::new(80, 40).unwrap();
|
|
||||||
source.fill(tiny_skia::Color::from_rgba8(200, 30, 30, 255));
|
|
||||||
let bg = Background::Image(ImageBg {
|
|
||||||
source,
|
|
||||||
ken_burns: false,
|
|
||||||
cache: RefCell::new(Vec::new()),
|
|
||||||
});
|
|
||||||
let mut target = Pixmap::new(60, 30).unwrap();
|
|
||||||
bg.paint(&mut target, 0.0, true);
|
|
||||||
assert!(target.pixels().iter().all(|p| p.red() == 200 && p.green() == 30));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scaled_cache_keeps_a_slot_per_target_size() {
|
|
||||||
// Two output sizes (two monitors) must not thrash a single slot.
|
|
||||||
let mut source = Pixmap::new(80, 40).unwrap();
|
|
||||||
source.fill(tiny_skia::Color::from_rgba8(200, 30, 30, 255));
|
|
||||||
let image = ImageBg {
|
|
||||||
source,
|
|
||||||
ken_burns: false,
|
|
||||||
cache: RefCell::new(Vec::new()),
|
|
||||||
};
|
|
||||||
let bg = Background::Image(image);
|
|
||||||
let mut a = Pixmap::new(60, 30).unwrap();
|
|
||||||
let mut b = Pixmap::new(40, 20).unwrap();
|
|
||||||
bg.paint(&mut a, 0.0, false);
|
|
||||||
bg.paint(&mut b, 0.0, false);
|
|
||||||
bg.paint(&mut a, 0.0, false);
|
|
||||||
let Background::Image(image) = &bg else {
|
|
||||||
panic!("expected image background");
|
|
||||||
};
|
|
||||||
let cache = image.cache.borrow();
|
|
||||||
assert_eq!(
|
|
||||||
cache.len(),
|
|
||||||
2,
|
|
||||||
"two target sizes should occupy two slots, got {} slots",
|
|
||||||
cache.len()
|
|
||||||
);
|
|
||||||
assert!(cache.iter().any(|c| c.target_w == 60 && c.target_h == 30));
|
|
||||||
assert!(cache.iter().any(|c| c.target_w == 40 && c.target_h == 20));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,21 +8,17 @@
|
||||||
//! `cargo run --bin breadlock-auth-check`.
|
//! `cargo run --bin breadlock-auth-check`.
|
||||||
|
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use zeroize::{Zeroize, Zeroizing};
|
|
||||||
|
|
||||||
#[path = "../auth/pam.rs"]
|
#[path = "../auth/pam.rs"]
|
||||||
mod pam;
|
mod pam;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let username = pam::username_from_process().unwrap_or_else(|| {
|
let username = std::env::var("USER").unwrap_or_else(|_| {
|
||||||
eprint!("Username: ");
|
eprint!("Username: ");
|
||||||
std::io::stdout().flush().ok();
|
std::io::stdout().flush().ok();
|
||||||
let mut buf = String::new();
|
let mut buf = String::new();
|
||||||
std::io::stdin().read_line(&mut buf).ok();
|
std::io::stdin().read_line(&mut buf).ok();
|
||||||
let name = buf.trim().to_string();
|
buf.trim().to_string()
|
||||||
buf.zeroize();
|
|
||||||
name
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let password = rpassword_prompt();
|
let password = rpassword_prompt();
|
||||||
|
|
@ -36,84 +32,27 @@ fn main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static mut SAVED_TERMIOS: libc::termios = unsafe { std::mem::zeroed() };
|
|
||||||
static ECHO_SAVED: AtomicBool = AtomicBool::new(false);
|
|
||||||
|
|
||||||
extern "C" fn restore_echo_on_signal(sig: libc::c_int) {
|
|
||||||
unsafe {
|
|
||||||
if ECHO_SAVED.load(Ordering::Relaxed) {
|
|
||||||
libc::tcsetattr(
|
|
||||||
libc::STDIN_FILENO,
|
|
||||||
libc::TCSANOW,
|
|
||||||
std::ptr::addr_of!(SAVED_TERMIOS),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
libc::signal(sig, libc::SIG_DFL);
|
|
||||||
libc::raise(sig);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Disable TTY echo; restore on drop (panic, return) and on SIGINT/SIGTERM
|
|
||||||
/// so Ctrl-C cannot leave the terminal silent.
|
|
||||||
struct EchoOff {
|
|
||||||
fd: libc::c_int,
|
|
||||||
orig: libc::termios,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EchoOff {
|
|
||||||
fn new() -> Option<Self> {
|
|
||||||
let fd = libc::STDIN_FILENO;
|
|
||||||
if unsafe { libc::isatty(fd) } == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let mut orig = unsafe { std::mem::zeroed() };
|
|
||||||
if unsafe { libc::tcgetattr(fd, &mut orig) } != 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
unsafe {
|
|
||||||
SAVED_TERMIOS = orig;
|
|
||||||
ECHO_SAVED.store(true, Ordering::Relaxed);
|
|
||||||
libc::signal(
|
|
||||||
libc::SIGINT,
|
|
||||||
restore_echo_on_signal as *const () as libc::sighandler_t,
|
|
||||||
);
|
|
||||||
libc::signal(
|
|
||||||
libc::SIGTERM,
|
|
||||||
restore_echo_on_signal as *const () as libc::sighandler_t,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let mut raw = orig;
|
|
||||||
raw.c_lflag &= !libc::ECHO;
|
|
||||||
if unsafe { libc::tcsetattr(fd, libc::TCSAFLUSH, &raw) } != 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(Self { fd, orig })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for EchoOff {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
unsafe {
|
|
||||||
libc::tcsetattr(self.fd, libc::TCSAFLUSH, &self.orig);
|
|
||||||
ECHO_SAVED.store(false, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
eprintln!();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Minimal no-echo password prompt so this harness doesn't need the `rpassword`
|
/// Minimal no-echo password prompt so this harness doesn't need the `rpassword`
|
||||||
/// crate — good enough for a dev tool, never shipped.
|
/// crate — good enough for a dev tool, never shipped.
|
||||||
fn rpassword_prompt() -> Zeroizing<String> {
|
fn rpassword_prompt() -> String {
|
||||||
use std::io::BufRead;
|
use std::io::BufRead;
|
||||||
eprint!("Password: ");
|
eprint!("Password: ");
|
||||||
std::io::stderr().flush().ok();
|
std::io::stderr().flush().ok();
|
||||||
|
|
||||||
let _echo = EchoOff::new();
|
// Best-effort: disable echo via `stty` if a TTY is attached, restore after.
|
||||||
|
let stty_available = std::process::Command::new("stty")
|
||||||
|
.arg("-echo")
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
let mut line = String::new();
|
let mut line = String::new();
|
||||||
std::io::stdin().lock().read_line(&mut line).ok();
|
std::io::stdin().lock().read_line(&mut line).ok();
|
||||||
let trimmed = line.trim_end_matches(['\n', '\r']);
|
|
||||||
let password = Zeroizing::new(trimmed.to_string());
|
if stty_available {
|
||||||
line.zeroize();
|
let _ = std::process::Command::new("stty").arg("echo").status();
|
||||||
password
|
eprintln!();
|
||||||
|
}
|
||||||
|
|
||||||
|
line.trim_end_matches(['\n', '\r']).to_string()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,311 +0,0 @@
|
||||||
//! Dev-only harness: renders the breadlock lock-screen motion system to a
|
|
||||||
//! folder of PNGs so the new animations can be eyeballed without locking a
|
|
||||||
//! session (or even touching Wayland). Every scene below pins concrete
|
|
||||||
//! progress values into `render::FrameInputs` — the same struct the real
|
|
||||||
//! locker feeds from live timestamps — so what you see here is exactly what
|
|
||||||
//! `state.rs` computes at runtime.
|
|
||||||
//!
|
|
||||||
//! Not installed by the package; run from a build tree with
|
|
||||||
//! `cargo run --bin breadlock-preview [out-dir]` (default `preview/`).
|
|
||||||
//! Scenes are written as `NN-<name>.png` in alphabetical-file order, so a
|
|
||||||
//! file manager or `for f in preview/*.png; do ...` steps through them as a
|
|
||||||
//! flipbook roughly in timeline order.
|
|
||||||
|
|
||||||
use breadlock_ui::painter::TextRenderer;
|
|
||||||
use breadlock_ui::theme;
|
|
||||||
use render::{compose, FrameInputs};
|
|
||||||
|
|
||||||
// Reuse the real renderer + background code via the same `#[path]` include
|
|
||||||
// trick as `breadlock-auth-check` (dev bins are separate crates and can't see
|
|
||||||
// `main.rs`'s modules otherwise). `render.rs` pulls `crate::background::Background`,
|
|
||||||
// which this crate root provides below. Only `compose`/`FrameInputs` are used
|
|
||||||
// here; the compositor-side helpers (blit_to_shm, the timing consts) stay
|
|
||||||
// included so this harness exercises the *real* renderer, so dead-code is
|
|
||||||
// expected and silenced.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[path = "../background.rs"]
|
|
||||||
mod background;
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[path = "../render.rs"]
|
|
||||||
mod render;
|
|
||||||
|
|
||||||
const W: u32 = 960;
|
|
||||||
const H: u32 = 540;
|
|
||||||
const FONT: &str = "Varela Round";
|
|
||||||
|
|
||||||
struct Scene {
|
|
||||||
name: &'static str,
|
|
||||||
clock: &'static str,
|
|
||||||
date: &'static str,
|
|
||||||
clock_old: Option<(&'static str, f32)>,
|
|
||||||
password_len: usize,
|
|
||||||
/// Actual password bytes. Empty except for the reveal scene: production
|
|
||||||
/// `submit()` zeros the secret (and `password_len` follows `password.len()`),
|
|
||||||
/// so checking frames show an empty pill under "Checking…".
|
|
||||||
password: &'static str,
|
|
||||||
failed: bool,
|
|
||||||
failed_t: f32,
|
|
||||||
dot_pop_t: f32,
|
|
||||||
keystroke_age: Option<f32>,
|
|
||||||
/// Idle caret blink phase driver (`t_secs` in FrameInputs). Only matters
|
|
||||||
/// for scenes with no keystroke age: phase = (t × 1.8) % 1.0, caret is
|
|
||||||
/// lit below 0.5.
|
|
||||||
t_secs: f32,
|
|
||||||
status: Option<&'static str>,
|
|
||||||
/// Now-playing / battery line under the clock (empty hides it).
|
|
||||||
info: &'static str,
|
|
||||||
appear_t: f32,
|
|
||||||
unlock_t: f32,
|
|
||||||
breathe_t: f32,
|
|
||||||
status_t: f32,
|
|
||||||
caps_lock: bool,
|
|
||||||
layout_index: u32,
|
|
||||||
reveal: bool,
|
|
||||||
idle_dim: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Scene {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
name: "",
|
|
||||||
clock: "12:34",
|
|
||||||
date: "Friday · Aug 21",
|
|
||||||
clock_old: None,
|
|
||||||
password_len: 0,
|
|
||||||
password: "",
|
|
||||||
failed: false,
|
|
||||||
failed_t: 0.0,
|
|
||||||
dot_pop_t: 1.0,
|
|
||||||
keystroke_age: None,
|
|
||||||
t_secs: 0.2,
|
|
||||||
status: None,
|
|
||||||
info: "",
|
|
||||||
appear_t: 1.0,
|
|
||||||
unlock_t: 0.0,
|
|
||||||
breathe_t: 0.0,
|
|
||||||
status_t: 1.0,
|
|
||||||
caps_lock: false,
|
|
||||||
layout_index: 0,
|
|
||||||
reveal: false,
|
|
||||||
idle_dim: 0.0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `--time [WxH] [frames] [wallpaper.png]` — renders the real compose() path
|
|
||||||
/// (image background + Ken Burns, full chrome) in a loop and prints per-frame
|
|
||||||
/// timings, so the software renderer's cost can be measured without Wayland.
|
|
||||||
fn bench(args: &[String]) {
|
|
||||||
let parse = |s: &str, d: &str| -> String { args.iter().find(|a| a.starts_with(s)).map(|a| a[s.len()..].to_string()).unwrap_or_else(|| d.to_string()) };
|
|
||||||
let size: (u32, u32) = {
|
|
||||||
let v: Vec<u32> = parse("--size=", "1920x1200").split('x').filter_map(|s| s.parse().ok()).collect();
|
|
||||||
(v[0], v[1])
|
|
||||||
};
|
|
||||||
let frames: u32 = parse("--frames=", "120").parse().unwrap_or(120);
|
|
||||||
let path = parse("--wallpaper=", "/home/breadway/.config/breadlock/wallpaper.png");
|
|
||||||
|
|
||||||
let palette = theme::load_palette();
|
|
||||||
let bg_cfg = breadlock_ui::config::Background {
|
|
||||||
mode: breadlock_ui::config::BackgroundMode::Image,
|
|
||||||
path,
|
|
||||||
blur: false,
|
|
||||||
ken_burns: true,
|
|
||||||
};
|
|
||||||
let background = background::Background::load(&bg_cfg, &palette);
|
|
||||||
|
|
||||||
let mut text = TextRenderer::new();
|
|
||||||
// Warm up once: the first frame builds the scaled-wallpaper cache and
|
|
||||||
// shapes the glyphs. Steady-state frames are what the timer loop sees.
|
|
||||||
let warm = FrameInputs {
|
|
||||||
width: size.0,
|
|
||||||
height: size.1,
|
|
||||||
background: &background,
|
|
||||||
palette: &palette,
|
|
||||||
font_family: FONT,
|
|
||||||
clock_text: "12:34",
|
|
||||||
date_text: "Friday · Aug 21",
|
|
||||||
clock_old: None, password_len: 6,
|
|
||||||
password: "hunter2",
|
|
||||||
reveal: false,
|
|
||||||
caps_lock: false,
|
|
||||||
layout_index: 0,
|
|
||||||
idle_dim: 0.0,
|
|
||||||
failed: false,
|
|
||||||
failed_t: 0.0,
|
|
||||||
dot_pop_t: 1.0,
|
|
||||||
keystroke_age: None,
|
|
||||||
t_secs: 0.0,
|
|
||||||
breathe_t: 0.0,
|
|
||||||
status_t: 1.0,
|
|
||||||
status_text: None,
|
|
||||||
info_text: "",
|
|
||||||
appear_t: 1.0,
|
|
||||||
unlock_t: 0.0,
|
|
||||||
smooth_pan: true,
|
|
||||||
};
|
|
||||||
compose(&mut text, &warm).expect("warm-up compose failed");
|
|
||||||
|
|
||||||
// Isolate the background pass cost (wallpaper blit + fills) alone.
|
|
||||||
let mut bg_times = Vec::new();
|
|
||||||
{
|
|
||||||
let mut dummy = tiny_skia::Pixmap::new(size.0, size.1).expect("pixmap");
|
|
||||||
for i in 0..60 {
|
|
||||||
let t = std::time::Instant::now();
|
|
||||||
background.paint(&mut dummy, (i as f32 / 60.0) * 90.0, true);
|
|
||||||
bg_times.push(t.elapsed().as_secs_f64() * 1000.0);
|
|
||||||
}
|
|
||||||
bg_times.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
|
||||||
let avg: f64 = bg_times.iter().sum::<f64>() / bg_times.len() as f64;
|
|
||||||
println!("background.paint only: avg {avg:.2} ms max {:.2} ms", bg_times[bg_times.len() - 1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut times = Vec::with_capacity(frames as usize);
|
|
||||||
let start = std::time::Instant::now();
|
|
||||||
for i in 0..frames {
|
|
||||||
let t = std::time::Instant::now();
|
|
||||||
let inputs = FrameInputs {
|
|
||||||
width: size.0,
|
|
||||||
height: size.1,
|
|
||||||
background: &background,
|
|
||||||
palette: &palette,
|
|
||||||
font_family: FONT,
|
|
||||||
clock_text: "12:34",
|
|
||||||
date_text: "Friday · Aug 21",
|
|
||||||
clock_old: None,
|
|
||||||
password_len: 6,
|
|
||||||
password: "hunter2",
|
|
||||||
reveal: false,
|
|
||||||
caps_lock: false,
|
|
||||||
layout_index: 0,
|
|
||||||
idle_dim: 0.0,
|
|
||||||
failed: false,
|
|
||||||
failed_t: 0.0,
|
|
||||||
dot_pop_t: 1.0,
|
|
||||||
keystroke_age: None,
|
|
||||||
// Walk t_secs through a Ken Burns cycle so every frame differs.
|
|
||||||
t_secs: (i as f32 / frames as f32) * 90.0,
|
|
||||||
breathe_t: (i % 10) as f32 / 10.0,
|
|
||||||
status_t: 1.0,
|
|
||||||
status_text: None,
|
|
||||||
info_text: "",
|
|
||||||
appear_t: 1.0,
|
|
||||||
unlock_t: 0.0,
|
|
||||||
smooth_pan: true,
|
|
||||||
};
|
|
||||||
if compose(&mut text, &inputs).is_none() {
|
|
||||||
eprintln!("compose returned None at frame {i}");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
times.push(t.elapsed().as_secs_f64() * 1000.0);
|
|
||||||
}
|
|
||||||
let total = start.elapsed().as_secs_f64() * 1000.0;
|
|
||||||
times.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
|
||||||
let avg: f64 = times.iter().sum::<f64>() / times.len() as f64;
|
|
||||||
let p95 = times[(times.len() as f64 * 0.95) as usize];
|
|
||||||
println!(
|
|
||||||
"{frames} frames @ {}x{}: avg {avg:.2} ms p95 {p95:.2} ms max {:.2} ms total {total:.0} ms (first frame excluded from avg? no)",
|
|
||||||
size.0, size.1, times[times.len() - 1]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
|
||||||
if args.iter().any(|a| a == "--time") {
|
|
||||||
bench(&args);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let out_dir = args
|
|
||||||
.first()
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| "preview".to_string());
|
|
||||||
std::fs::create_dir_all(&out_dir).expect("failed to create preview output dir");
|
|
||||||
|
|
||||||
let palette = theme::load_palette();
|
|
||||||
let background = background::Background::load(
|
|
||||||
&breadlock_ui::config::Background::default(),
|
|
||||||
&palette,
|
|
||||||
);
|
|
||||||
|
|
||||||
let scenes = [
|
|
||||||
// ---- Staggered entrance: clock leads, pill pops in last (overshoot).
|
|
||||||
Scene { name: "01-appear-start", appear_t: 0.0, ..Scene::default() },
|
|
||||||
Scene { name: "02-appear-clock", password_len: 4, appear_t: 0.25, ..Scene::default() },
|
|
||||||
Scene { name: "03-appear-pill", password_len: 4, appear_t: 0.55, ..Scene::default() },
|
|
||||||
// ---- Rest pose: empty pill showing the "Enter password" hint.
|
|
||||||
Scene { name: "04-rest-pose", t_secs: 0.5, ..Scene::default() },
|
|
||||||
// ---- Idle breath: glow peak on the pill (accent ring + deeper shadow).
|
|
||||||
Scene { name: "05-breathe-peak", breathe_t: 1.0, ..Scene::default() },
|
|
||||||
// ---- Typing: newest dot mid-pop, caret solid.
|
|
||||||
Scene { name: "06-typing-pop", password_len: 6, dot_pop_t: 0.4, keystroke_age: Some(0.2), ..Scene::default() },
|
|
||||||
// ---- Idle blink: two dots, caret lit (phase 0.36 → visible half-cycle).
|
|
||||||
Scene { name: "07-idle-blink", password_len: 2, ..Scene::default() },
|
|
||||||
// ---- Checking: status mid slide-in. Live submit() zeros the secret
|
|
||||||
// so password_len is 0 — don't fake a filled pill here.
|
|
||||||
Scene { name: "08-checking", status: Some("Checking…"), status_t: 0.5, password_len: 0, password: "", ..Scene::default() },
|
|
||||||
// ---- Wrong password: mid-shake, red pill, red status (settled).
|
|
||||||
Scene { name: "09-failed-shake", password_len: 6, failed: true, failed_t: 0.35, status: Some("Wrong password"), ..Scene::default() },
|
|
||||||
// ---- Success: green flash ring, dots cascading accent → white.
|
|
||||||
Scene { name: "10-success-flash", password_len: 6, unlock_t: 0.12, ..Scene::default() },
|
|
||||||
// ---- Unlock fade-out: chrome faded, parallax drift (clock furthest).
|
|
||||||
Scene { name: "11-unlock-fade", password_len: 6, unlock_t: 0.8, ..Scene::default() },
|
|
||||||
// ---- Minute rollover: old clock fading out above, new fading in below.
|
|
||||||
Scene { name: "12-clock-crossfade", clock: "12:35", clock_old: Some(("12:34", 0.5)), password_len: 4, ..Scene::default() },
|
|
||||||
// ---- Caps Lock on: chip above the pill.
|
|
||||||
Scene { name: "13-caps-lock", password_len: 4, caps_lock: true, ..Scene::default() },
|
|
||||||
// ---- Non-default layout: layout chip instead of caps.
|
|
||||||
Scene { name: "14-layout-2", password_len: 4, layout_index: 1, ..Scene::default() },
|
|
||||||
// ---- Hold-to-reveal: plain password characters instead of dots.
|
|
||||||
Scene { name: "15-reveal", password_len: 7, password: "hunter2", reveal: true, ..Scene::default() },
|
|
||||||
// ---- Idle auto-dim: deepened veil (rest pose + full idle dim).
|
|
||||||
Scene { name: "16-idle-dim", idle_dim: 1.0, ..Scene::default() },
|
|
||||||
// ---- Repeat failure: attempt counter in the status line.
|
|
||||||
Scene { name: "17-failed-3x", password_len: 6, failed: true, failed_t: 0.8, status: Some("Wrong password — 3 failed attempts"), ..Scene::default() },
|
|
||||||
// ---- D-Bus status: now-playing + battery under the clock.
|
|
||||||
Scene { name: "18-status-info", info: "The War on Drugs — Red Eyes · 87% · charging", ..Scene::default() },
|
|
||||||
];
|
|
||||||
|
|
||||||
let mut text = TextRenderer::new();
|
|
||||||
let mut count = 0;
|
|
||||||
for scene in &scenes {
|
|
||||||
let inputs = FrameInputs {
|
|
||||||
width: W,
|
|
||||||
height: H,
|
|
||||||
background: &background,
|
|
||||||
palette: &palette,
|
|
||||||
font_family: FONT,
|
|
||||||
clock_text: scene.clock,
|
|
||||||
date_text: scene.date,
|
|
||||||
clock_old: scene.clock_old,
|
|
||||||
password_len: scene.password_len,
|
|
||||||
password: scene.password,
|
|
||||||
reveal: scene.reveal,
|
|
||||||
caps_lock: scene.caps_lock,
|
|
||||||
layout_index: scene.layout_index,
|
|
||||||
idle_dim: scene.idle_dim,
|
|
||||||
failed: scene.failed,
|
|
||||||
failed_t: scene.failed_t,
|
|
||||||
dot_pop_t: scene.dot_pop_t,
|
|
||||||
keystroke_age: scene.keystroke_age,
|
|
||||||
t_secs: scene.t_secs,
|
|
||||||
breathe_t: scene.breathe_t,
|
|
||||||
status_t: scene.status_t,
|
|
||||||
status_text: scene.status,
|
|
||||||
info_text: scene.info,
|
|
||||||
appear_t: scene.appear_t,
|
|
||||||
unlock_t: scene.unlock_t,
|
|
||||||
smooth_pan: false,
|
|
||||||
};
|
|
||||||
let Some(pixmap) = compose(&mut text, &inputs) else {
|
|
||||||
eprintln!("compose returned None for scene {}", scene.name);
|
|
||||||
std::process::exit(1);
|
|
||||||
};
|
|
||||||
let path = format!("{}/{}.png", out_dir, scene.name);
|
|
||||||
pixmap
|
|
||||||
.save_png(&path)
|
|
||||||
.unwrap_or_else(|err| panic!("failed to write {path}: {err}"));
|
|
||||||
count += 1;
|
|
||||||
println!("wrote {path}");
|
|
||||||
}
|
|
||||||
println!("{count} frames → {out_dir}/");
|
|
||||||
}
|
|
||||||
|
|
@ -1,344 +0,0 @@
|
||||||
//! `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 `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` and `bread.command.lock.unlock` are the
|
|
||||||
//! verbs 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, and treats unlock as already
|
|
||||||
//! unlocked (`bread.lock.unlock.done`). If the locker is running, unlock
|
|
||||||
//! is `bread.lock.unlock.failed` — only PAM at the lock screen may
|
|
||||||
//! unlock. Super+L / hypridle remain `loginctl lock-session`. Bus unlock
|
|
||||||
//! never calls compositor `unlock()` or `loginctl unlock-session`.
|
|
||||||
|
|
||||||
use std::process::{Command, Stdio};
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
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.*`,
|
|
||||||
/// 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";
|
|
||||||
|
|
||||||
/// Set for the life of `run_lock` so [`locker_is_running`] is true without
|
|
||||||
/// a second `try_acquire("lock")` from the locker process (flock is
|
|
||||||
/// per-process, so that check would miss ourselves).
|
|
||||||
static LOCKER_RUNNING: AtomicBool = AtomicBool::new(false);
|
|
||||||
|
|
||||||
/// RAII flag: [`locker_is_running`] is true until this drops.
|
|
||||||
pub struct LockerRunningGuard;
|
|
||||||
|
|
||||||
impl Drop for LockerRunningGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
LOCKER_RUNNING.store(false, Ordering::SeqCst);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mark this process as the locker for the life of the returned guard.
|
|
||||||
pub fn enter_lock_process() -> LockerRunningGuard {
|
|
||||||
LOCKER_RUNNING.store(true, Ordering::SeqCst);
|
|
||||||
LockerRunningGuard
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn emit_locked() {
|
|
||||||
BreadClient::connect(APP_ID).emit("bread.lock.locked", serde_json::json!({}));
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 this process is the locker, or another process holds the
|
|
||||||
/// locker singleton — i.e. breadlock is already locking this session.
|
|
||||||
pub fn locker_is_running() -> bool {
|
|
||||||
LOCKER_RUNNING.load(Ordering::SeqCst) || 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())
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(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(locker_is_running(), start_locker);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Payload on `bread.lock.unlock.failed` while the locker is running.
|
|
||||||
/// Bus clients cannot unlock; only PAM at the lock screen can.
|
|
||||||
const UNLOCK_REFUSED_WHILE_LOCKED: &str =
|
|
||||||
"bus unlock cannot bypass PAM; authenticate at the lock screen";
|
|
||||||
|
|
||||||
/// Honor `bread.command.lock.unlock`. Fail-secure: never compositor
|
|
||||||
/// `unlock()`, never `loginctl unlock-session`. Already unlocked is
|
|
||||||
/// `.done`; a running locker is `.failed`.
|
|
||||||
pub fn honor_unlock_command() {
|
|
||||||
honor_unlock_command_with(locker_is_running(), emit_unlock_done, emit_unlock_failed);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn honor_lock_command_with(locked: bool, start: impl FnOnce() -> Result<(), String>) {
|
|
||||||
if locked {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn honor_unlock_command_with(
|
|
||||||
locked: bool,
|
|
||||||
emit_done: impl FnOnce(),
|
|
||||||
emit_failed: impl FnOnce(&str),
|
|
||||||
) {
|
|
||||||
if !locked {
|
|
||||||
tracing::info!("bread.command.lock.unlock: already unlocked");
|
|
||||||
emit_done();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tracing::error!(
|
|
||||||
error = UNLOCK_REFUSED_WHILE_LOCKED,
|
|
||||||
"bread.command.lock.unlock: refused while locked"
|
|
||||||
);
|
|
||||||
emit_failed(UNLOCK_REFUSED_WHILE_LOCKED);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reacts to `bread.command.lock.*`. Unknown verbs are ignored, not stubbed.
|
|
||||||
pub fn handle_command(event: &BreadEvent) {
|
|
||||||
handle_command_with(event, honor_lock_command, honor_unlock_command);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_command_with(event: &BreadEvent, on_lock: impl FnOnce(), on_unlock: impl FnOnce()) {
|
|
||||||
let Some(verb) = event.event.strip_prefix("bread.command.lock.") else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
match verb {
|
|
||||||
"lock" => on_lock(),
|
|
||||||
"unlock" => on_unlock(),
|
|
||||||
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::*;
|
|
||||||
use std::cell::Cell;
|
|
||||||
|
|
||||||
fn event(name: &str) -> BreadEvent {
|
|
||||||
BreadEvent {
|
|
||||||
event: name.to_string(),
|
|
||||||
timestamp: 0,
|
|
||||||
data: serde_json::json!({}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn handle_command_ignores_unrecognized_verb() {
|
|
||||||
let lock = Cell::new(false);
|
|
||||||
let unlock = Cell::new(false);
|
|
||||||
handle_command_with(
|
|
||||||
&event("bread.command.lock.pin"),
|
|
||||||
|| lock.set(true),
|
|
||||||
|| unlock.set(true),
|
|
||||||
);
|
|
||||||
handle_command_with(
|
|
||||||
&event("bread.command.clip.clear"),
|
|
||||||
|| lock.set(true),
|
|
||||||
|| unlock.set(true),
|
|
||||||
);
|
|
||||||
handle_command_with(
|
|
||||||
&event("bread.lock.locked"),
|
|
||||||
|| lock.set(true),
|
|
||||||
|| unlock.set(true),
|
|
||||||
);
|
|
||||||
assert!(!lock.get());
|
|
||||||
assert!(!unlock.get());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn handle_command_dispatches_only_lock_and_unlock() {
|
|
||||||
let lock = Cell::new(0u32);
|
|
||||||
let unlock = Cell::new(0u32);
|
|
||||||
handle_command_with(
|
|
||||||
&event("bread.command.lock.lock"),
|
|
||||||
|| lock.set(lock.get() + 1),
|
|
||||||
|| unlock.set(unlock.get() + 1),
|
|
||||||
);
|
|
||||||
handle_command_with(
|
|
||||||
&event("bread.command.lock.unlock"),
|
|
||||||
|| lock.set(lock.get() + 1),
|
|
||||||
|| unlock.set(unlock.get() + 1),
|
|
||||||
);
|
|
||||||
handle_command_with(
|
|
||||||
&event("bread.command.lock.pin"),
|
|
||||||
|| lock.set(lock.get() + 1),
|
|
||||||
|| unlock.set(unlock.get() + 1),
|
|
||||||
);
|
|
||||||
assert_eq!(lock.get(), 1);
|
|
||||||
assert_eq!(unlock.get(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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_runs_start() {
|
|
||||||
let started = Cell::new(false);
|
|
||||||
honor_lock_command_with(false, || {
|
|
||||||
started.set(true);
|
|
||||||
Err("boom".into())
|
|
||||||
});
|
|
||||||
assert!(started.get());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn honor_lock_command_with_successful_start_runs_start() {
|
|
||||||
let started = Cell::new(false);
|
|
||||||
honor_lock_command_with(false, || {
|
|
||||||
started.set(true);
|
|
||||||
Ok(())
|
|
||||||
});
|
|
||||||
assert!(started.get());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn honor_lock_command_already_locked_does_not_start() {
|
|
||||||
let started = Cell::new(false);
|
|
||||||
honor_lock_command_with(true, || {
|
|
||||||
started.set(true);
|
|
||||||
Ok(())
|
|
||||||
});
|
|
||||||
assert!(!started.get());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn honor_unlock_command_already_unlocked_emits_done() {
|
|
||||||
let done = Cell::new(false);
|
|
||||||
let failed = Cell::new(false);
|
|
||||||
honor_unlock_command_with(false, || done.set(true), |_| failed.set(true));
|
|
||||||
assert!(done.get());
|
|
||||||
assert!(!failed.get());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn honor_unlock_command_while_locked_emits_failed_not_done() {
|
|
||||||
let done = Cell::new(false);
|
|
||||||
let failed = Cell::new(false);
|
|
||||||
honor_unlock_command_with(
|
|
||||||
true,
|
|
||||||
|| done.set(true),
|
|
||||||
|e| {
|
|
||||||
assert_eq!(e, UNLOCK_REFUSED_WHILE_LOCKED);
|
|
||||||
failed.set(true);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
assert!(!done.get());
|
|
||||||
assert!(failed.get());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn honor_unlock_command_while_locked_error_mentions_pam() {
|
|
||||||
assert!(
|
|
||||||
UNLOCK_REFUSED_WHILE_LOCKED.contains("PAM"),
|
|
||||||
"bus unlock refusal must say it cannot bypass PAM, got {UNLOCK_REFUSED_WHILE_LOCKED:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn enter_lock_process_makes_locker_is_running_true_without_singleton() {
|
|
||||||
let app = format!("breadlock-test-running-flag-{}", std::process::id());
|
|
||||||
assert!(!singleton_held(&app));
|
|
||||||
{
|
|
||||||
let _g = enter_lock_process();
|
|
||||||
assert!(LOCKER_RUNNING.load(Ordering::SeqCst));
|
|
||||||
}
|
|
||||||
assert!(!LOCKER_RUNNING.load(Ordering::SeqCst));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -8,73 +8,19 @@ pub struct Config {
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub appearance: Appearance,
|
pub appearance: Appearance,
|
||||||
pub input: Input,
|
pub input: Input,
|
||||||
pub animation: Animation,
|
|
||||||
pub status: Status,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// System-status line under the clock (D-Bus). Both default on; they are
|
|
||||||
/// polled on a background thread and degrade silently when D-Bus or the
|
|
||||||
/// relevant service is unavailable.
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
#[serde(default)]
|
|
||||||
pub struct Status {
|
|
||||||
/// Show the currently-playing MPRIS track under the clock.
|
|
||||||
pub now_playing: bool,
|
|
||||||
/// Show the upower battery percentage under the clock.
|
|
||||||
pub battery: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Status {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
now_playing: true,
|
|
||||||
battery: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Input {
|
pub struct Input {
|
||||||
/// How long the red "wrong password" UI stays up. Input is not blocked
|
/// How long the "wrong password" shake shows before input re-enables.
|
||||||
/// during this window — typing or Escape clears it immediately.
|
|
||||||
pub fail_timeout_ms: u64,
|
pub fail_timeout_ms: u64,
|
||||||
/// Hold `Tab` to reveal the typed password as plain characters instead
|
|
||||||
/// of dots. Off by default: plaintext would sit in compositor buffers
|
|
||||||
/// while held. Tab can never be part of a password (it produces no
|
|
||||||
/// utf8), so holding it is always safe to use as a reveal gesture.
|
|
||||||
pub reveal_hold: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Input {
|
impl Default for Input {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
fail_timeout_ms: 800,
|
fail_timeout_ms: 800,
|
||||||
reveal_hold: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Idle animation toggles. Everything here runs on a low-duty-cycle timer so
|
|
||||||
/// the software-rendered lock screen doesn't burn CPU while idle.
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
#[serde(default)]
|
|
||||||
pub struct Animation {
|
|
||||||
/// Subtle glow pulse on the password pill every few seconds — proves the
|
|
||||||
/// screen is live, not frozen. Runs only during a short active window of
|
|
||||||
/// each cycle (see `BREATHE_*` in render.rs).
|
|
||||||
pub breathe: bool,
|
|
||||||
/// Deepen the dim veil after this many seconds of no keystrokes (0 =
|
|
||||||
/// off). A gentle extra darkening for OLED/burn-in and late-night
|
|
||||||
/// comfort; ramps in over a few seconds once the idle threshold hits.
|
|
||||||
pub idle_dim_after_secs: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Animation {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
breathe: true,
|
|
||||||
idle_dim_after_secs: 0,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -100,31 +46,6 @@ mod tests {
|
||||||
assert_eq!(Config::default().input.fail_timeout_ms, 800);
|
assert_eq!(Config::default().input.fail_timeout_ms, 800);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn default_reveal_hold_is_off() {
|
|
||||||
assert!(!Config::default().input.reveal_hold);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn default_animation_breathe_is_on() {
|
|
||||||
assert!(Config::default().animation.breathe);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn status_defaults_on() {
|
|
||||||
let cfg = Config::default();
|
|
||||||
assert!(cfg.status.now_playing);
|
|
||||||
assert!(cfg.status.battery);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn status_can_be_turned_off() {
|
|
||||||
let toml = "[status]\nnow_playing = false\nbattery = false\n";
|
|
||||||
let cfg: Config = toml::from_str(toml).unwrap();
|
|
||||||
assert!(!cfg.status.now_playing);
|
|
||||||
assert!(!cfg.status.battery);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn flattened_appearance_parses_alongside_input() {
|
fn flattened_appearance_parses_alongside_input() {
|
||||||
let toml = "[clock]\nformat = \"%H:%M:%S\"\n[input]\nfail_timeout_ms = 1200\n";
|
let toml = "[clock]\nformat = \"%H:%M:%S\"\n[input]\nfail_timeout_ms = 1200\n";
|
||||||
|
|
|
||||||
1025
breadlock/src/gpu.rs
1025
breadlock/src/gpu.rs
File diff suppressed because it is too large
Load diff
|
|
@ -2,13 +2,12 @@ use smithay_client_toolkit::seat::keyboard::{
|
||||||
KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers,
|
KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers,
|
||||||
};
|
};
|
||||||
use smithay_client_toolkit::seat::{Capability, SeatHandler, SeatState};
|
use smithay_client_toolkit::seat::{Capability, SeatHandler, SeatState};
|
||||||
use std::time::Instant;
|
|
||||||
use wayland_client::protocol::{wl_keyboard, wl_seat, wl_surface};
|
use wayland_client::protocol::{wl_keyboard, wl_seat, wl_surface};
|
||||||
use wayland_client::{Connection, QueueHandle};
|
use wayland_client::{Connection, QueueHandle};
|
||||||
use zeroize::Zeroize;
|
use zeroize::Zeroize;
|
||||||
|
|
||||||
use crate::auth;
|
use crate::auth;
|
||||||
use crate::state::{AppState, AuthState, PASSWORD_CAP};
|
use crate::state::{AppState, AuthState};
|
||||||
|
|
||||||
impl SeatHandler for AppState {
|
impl SeatHandler for AppState {
|
||||||
fn seat_state(&mut self) -> &mut SeatState {
|
fn seat_state(&mut self) -> &mut SeatState {
|
||||||
|
|
@ -25,40 +24,48 @@ impl SeatHandler for AppState {
|
||||||
capability: Capability,
|
capability: Capability,
|
||||||
) {
|
) {
|
||||||
if capability == Capability::Keyboard && self.keyboard.is_none() {
|
if capability == Capability::Keyboard && self.keyboard.is_none() {
|
||||||
self.try_bind_keyboard(qh, &seat);
|
// Plain `get_keyboard` never populates SCTK's internal repeat
|
||||||
|
// timer, so `KeyboardHandler::repeat_key` below only ever fires
|
||||||
|
// for compositors that implement server-side key repeat
|
||||||
|
// (wl_keyboard >= v10's "repeated" pseudo key-state) themselves —
|
||||||
|
// Hyprland does not reliably do this. `get_keyboard_with_repeat`
|
||||||
|
// registers SCTK's own client-side repeat timer driven by the
|
||||||
|
// compositor's `repeat_info` (delay/rate); if a compositor *does*
|
||||||
|
// do server-side repeat it advertises `rate = 0`, which this
|
||||||
|
// timer already treats as disabled, so the two mechanisms can't
|
||||||
|
// double-fire.
|
||||||
|
let repeat_qh = qh.clone();
|
||||||
|
let loop_handle = self.loop_handle.clone();
|
||||||
|
match self.seat_state.get_keyboard_with_repeat(
|
||||||
|
qh,
|
||||||
|
&seat,
|
||||||
|
None,
|
||||||
|
loop_handle,
|
||||||
|
Box::new(move |state: &mut AppState, _keyboard, event| {
|
||||||
|
state.handle_key(&repeat_qh, event);
|
||||||
|
}),
|
||||||
|
) {
|
||||||
|
Ok(keyboard) => self.keyboard = Some(keyboard),
|
||||||
|
Err(err) => tracing::error!(%err, "failed to bind keyboard"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_capability(
|
fn remove_capability(
|
||||||
&mut self,
|
&mut self,
|
||||||
_conn: &Connection,
|
_conn: &Connection,
|
||||||
qh: &QueueHandle<Self>,
|
_qh: &QueueHandle<Self>,
|
||||||
seat: wl_seat::WlSeat,
|
_seat: wl_seat::WlSeat,
|
||||||
capability: Capability,
|
capability: Capability,
|
||||||
) {
|
) {
|
||||||
if capability != Capability::Keyboard {
|
if capability == Capability::Keyboard {
|
||||||
return;
|
if let Some(keyboard) = self.keyboard.take() {
|
||||||
|
keyboard.release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Only release if THIS seat owns the bound keyboard.
|
|
||||||
if self.keyboard_seat.as_ref() != Some(&seat) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Some(keyboard) = self.keyboard.take() {
|
|
||||||
keyboard.release();
|
|
||||||
}
|
|
||||||
self.keyboard_seat = None;
|
|
||||||
self.bind_keyboard_from_available_seats(qh);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_seat(&mut self, _conn: &Connection, qh: &QueueHandle<Self>, seat: wl_seat::WlSeat) {
|
fn remove_seat(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _seat: wl_seat::WlSeat) {
|
||||||
if self.keyboard_seat.as_ref() != Some(&seat) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Some(keyboard) = self.keyboard.take() {
|
|
||||||
keyboard.release();
|
|
||||||
}
|
|
||||||
self.keyboard_seat = None;
|
|
||||||
self.bind_keyboard_from_available_seats(qh);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,16 +85,11 @@ impl KeyboardHandler for AppState {
|
||||||
fn leave(
|
fn leave(
|
||||||
&mut self,
|
&mut self,
|
||||||
_conn: &Connection,
|
_conn: &Connection,
|
||||||
qh: &QueueHandle<Self>,
|
_qh: &QueueHandle<Self>,
|
||||||
_keyboard: &wl_keyboard::WlKeyboard,
|
_keyboard: &wl_keyboard::WlKeyboard,
|
||||||
_surface: &wl_surface::WlSurface,
|
_surface: &wl_surface::WlSurface,
|
||||||
_serial: u32,
|
_serial: u32,
|
||||||
) {
|
) {
|
||||||
// Tab-held then focus leave would otherwise leave plaintext on screen.
|
|
||||||
if self.reveal_held {
|
|
||||||
self.reveal_held = false;
|
|
||||||
self.redraw_all(qh);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn press_key(
|
fn press_key(
|
||||||
|
|
@ -115,124 +117,31 @@ impl KeyboardHandler for AppState {
|
||||||
fn release_key(
|
fn release_key(
|
||||||
&mut self,
|
&mut self,
|
||||||
_conn: &Connection,
|
_conn: &Connection,
|
||||||
qh: &QueueHandle<Self>,
|
_qh: &QueueHandle<Self>,
|
||||||
_keyboard: &wl_keyboard::WlKeyboard,
|
_keyboard: &wl_keyboard::WlKeyboard,
|
||||||
_serial: u32,
|
_serial: u32,
|
||||||
event: KeyEvent,
|
_event: KeyEvent,
|
||||||
) {
|
) {
|
||||||
// Letting go of the reveal key (Tab) drops the plain-text view back
|
|
||||||
// to dots. Any other release doesn't change state.
|
|
||||||
if event.keysym == Keysym::Tab && self.reveal_held {
|
|
||||||
self.reveal_held = false;
|
|
||||||
self.redraw_all(qh);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_modifiers(
|
fn update_modifiers(
|
||||||
&mut self,
|
&mut self,
|
||||||
_conn: &Connection,
|
_conn: &Connection,
|
||||||
qh: &QueueHandle<Self>,
|
_qh: &QueueHandle<Self>,
|
||||||
_keyboard: &wl_keyboard::WlKeyboard,
|
_keyboard: &wl_keyboard::WlKeyboard,
|
||||||
_serial: u32,
|
_serial: u32,
|
||||||
modifiers: Modifiers,
|
_modifiers: Modifiers,
|
||||||
_raw_modifiers: RawModifiers,
|
_raw_modifiers: RawModifiers,
|
||||||
layout: u32,
|
_layout: u32,
|
||||||
) {
|
) {
|
||||||
let changed = self.caps_lock != modifiers.caps_lock || self.layout_index != layout;
|
|
||||||
self.caps_lock = modifiers.caps_lock;
|
|
||||||
self.layout_index = layout;
|
|
||||||
// A modifier update is still "activity" — it follows a key press, so
|
|
||||||
// don't let the idle auto-dim start counting while typing.
|
|
||||||
self.last_activity = Instant::now();
|
|
||||||
if changed {
|
|
||||||
self.redraw_all(qh);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
fn try_bind_keyboard(&mut self, qh: &QueueHandle<Self>, seat: &wl_seat::WlSeat) {
|
|
||||||
if self.keyboard.is_some() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Plain `get_keyboard` never populates SCTK's internal repeat
|
|
||||||
// timer, so `KeyboardHandler::repeat_key` below only ever fires
|
|
||||||
// for compositors that implement server-side key repeat
|
|
||||||
// (wl_keyboard >= v10's "repeated" pseudo key-state) themselves —
|
|
||||||
// Hyprland does not reliably do this. `get_keyboard_with_repeat`
|
|
||||||
// registers SCTK's own client-side repeat timer driven by the
|
|
||||||
// compositor's `repeat_info` (delay/rate); if a compositor *does*
|
|
||||||
// do server-side repeat it advertises `rate = 0`, which this
|
|
||||||
// timer already treats as disabled, so the two mechanisms can't
|
|
||||||
// double-fire.
|
|
||||||
let repeat_qh = qh.clone();
|
|
||||||
let loop_handle = self.loop_handle.clone();
|
|
||||||
match self.seat_state.get_keyboard_with_repeat(
|
|
||||||
qh,
|
|
||||||
seat,
|
|
||||||
None,
|
|
||||||
loop_handle,
|
|
||||||
Box::new(move |state: &mut AppState, _keyboard, event| {
|
|
||||||
state.handle_key(&repeat_qh, event);
|
|
||||||
}),
|
|
||||||
) {
|
|
||||||
Ok(keyboard) => {
|
|
||||||
self.keyboard = Some(keyboard);
|
|
||||||
self.keyboard_seat = Some(seat.clone());
|
|
||||||
}
|
|
||||||
Err(err) => tracing::error!(%err, "failed to bind keyboard"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bind_keyboard_from_available_seats(&mut self, qh: &QueueHandle<Self>) {
|
|
||||||
if self.keyboard.is_some() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let seats: Vec<wl_seat::WlSeat> = self.seat_state.seats().collect();
|
|
||||||
for seat in seats {
|
|
||||||
if self.keyboard.is_some() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if self
|
|
||||||
.seat_state
|
|
||||||
.info(&seat)
|
|
||||||
.is_some_and(|info| info.has_keyboard)
|
|
||||||
{
|
|
||||||
self.try_bind_keyboard(qh, &seat);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_key(&mut self, qh: &QueueHandle<Self>, event: KeyEvent) {
|
fn handle_key(&mut self, qh: &QueueHandle<Self>, event: KeyEvent) {
|
||||||
// Unlock fade: auth already succeeded; surfaces stay up until it ends.
|
// Ignore all input while a PAM check is in flight so a fast second
|
||||||
if self.unlocking.is_some() {
|
// Enter can't race the first attempt.
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escape during Checking cancels the wait (generation bump so a
|
|
||||||
// late PAM result cannot unlock). libpam itself is not aborted.
|
|
||||||
if self.auth_state == AuthState::Checking {
|
if self.auth_state == AuthState::Checking {
|
||||||
if event.keysym == Keysym::Escape {
|
|
||||||
self.auth_generation = self.auth_generation.wrapping_add(1);
|
|
||||||
self.auth_state = AuthState::Idle;
|
|
||||||
self.checking_started = None;
|
|
||||||
self.password_display_len = 0;
|
|
||||||
self.last_activity = Instant::now();
|
|
||||||
self.redraw_all(qh);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Any key counts as activity — it resets the idle auto-dim ramp even
|
|
||||||
// when it doesn't change the password (e.g. pressing Enter on an
|
|
||||||
// empty field).
|
|
||||||
self.last_activity = Instant::now();
|
|
||||||
|
|
||||||
// Hold-to-reveal (Tab): show the plain characters while held. Tab
|
|
||||||
// itself produces no utf8, so it can't corrupt the password.
|
|
||||||
if event.keysym == Keysym::Tab && self.config.input.reveal_hold {
|
|
||||||
self.reveal_held = true;
|
|
||||||
self.redraw_all(qh);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -259,7 +168,6 @@ impl AppState {
|
||||||
}
|
}
|
||||||
Keysym::Escape => {
|
Keysym::Escape => {
|
||||||
self.password.zeroize();
|
self.password.zeroize();
|
||||||
self.password_display_len = 0;
|
|
||||||
self.clear_failed_state();
|
self.clear_failed_state();
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
|
|
@ -267,21 +175,10 @@ impl AppState {
|
||||||
// Return/BackSpace/Escape are handled above by keysym;
|
// Return/BackSpace/Escape are handled above by keysym;
|
||||||
// this guards against a compositor also sending utf8 for
|
// this guards against a compositor also sending utf8 for
|
||||||
// those (defensive — filters any stray control chars).
|
// those (defensive — filters any stray control chars).
|
||||||
let mut grew = false;
|
|
||||||
for ch in text.chars().filter(|c| !c.is_control()) {
|
for ch in text.chars().filter(|c| !c.is_control()) {
|
||||||
if try_push_password(&mut self.password, ch) {
|
self.password.push(ch);
|
||||||
grew = true;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if grew {
|
|
||||||
// Only keystrokes that *grew* the password re-prime the
|
|
||||||
// newest-dot pop-in and the caret's solid phase (see the
|
|
||||||
// `last_keystroke` field doc in state.rs).
|
|
||||||
self.last_keystroke = Some(Instant::now());
|
|
||||||
self.clear_failed_state();
|
|
||||||
}
|
}
|
||||||
|
self.clear_failed_state();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -290,16 +187,8 @@ impl AppState {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clear_failed_state(&mut self) {
|
fn clear_failed_state(&mut self) {
|
||||||
if matches!(
|
if matches!(self.auth_state, AuthState::Failed | AuthState::ConfigError) {
|
||||||
self.auth_state,
|
|
||||||
AuthState::Failed | AuthState::AccountInvalid | AuthState::ConfigError
|
|
||||||
) {
|
|
||||||
self.auth_state = AuthState::Idle;
|
self.auth_state = AuthState::Idle;
|
||||||
// Drop the red-pill tint and shake offsets; `failed_at` is also
|
|
||||||
// cleared so `schedule_clear_failed`'s timer is a no-op unless
|
|
||||||
// its generation still matches a later fail.
|
|
||||||
self.failed_at = None;
|
|
||||||
self.password_display_len = 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -307,14 +196,7 @@ impl AppState {
|
||||||
if self.password.is_empty() {
|
if self.password.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if self.username.is_empty() {
|
|
||||||
self.enter_fail(AuthState::ConfigError);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.password_display_len = password_char_count(&self.password);
|
|
||||||
self.auth_state = AuthState::Checking;
|
self.auth_state = AuthState::Checking;
|
||||||
self.checking_started = Some(Instant::now());
|
|
||||||
self.auth_generation = self.auth_generation.wrapping_add(1);
|
|
||||||
// Hand ownership of the buffer to the auth thread; re-reserve
|
// Hand ownership of the buffer to the auth thread; re-reserve
|
||||||
// capacity up front so the next password typed doesn't reallocate
|
// capacity up front so the next password typed doesn't reallocate
|
||||||
// (see the `password` field doc in state.rs). The taken buffer is
|
// (see the `password` field doc in state.rs). The taken buffer is
|
||||||
|
|
@ -322,61 +204,8 @@ impl AppState {
|
||||||
// check (`auth::spawn_check`/`pam::check`).
|
// check (`auth::spawn_check`/`pam::check`).
|
||||||
let password = std::mem::replace(
|
let password = std::mem::replace(
|
||||||
&mut self.password,
|
&mut self.password,
|
||||||
zeroize::Zeroizing::new(String::with_capacity(PASSWORD_CAP)),
|
zeroize::Zeroizing::new(String::with_capacity(128)),
|
||||||
);
|
|
||||||
auth::spawn_check(
|
|
||||||
self.username.clone(),
|
|
||||||
password,
|
|
||||||
self.auth_generation,
|
|
||||||
self.auth_tx.clone(),
|
|
||||||
);
|
);
|
||||||
}
|
auth::spawn_check(self.username.clone(), password, self.auth_tx.clone());
|
||||||
}
|
|
||||||
|
|
||||||
/// Push `ch` only if it fits in the already-reserved capacity (no realloc,
|
|
||||||
/// so an old unzeroized heap buffer is never leaked).
|
|
||||||
pub(crate) fn try_push_password(password: &mut String, ch: char) -> bool {
|
|
||||||
let extra = ch.len_utf8();
|
|
||||||
if password.len().saturating_add(extra) > password.capacity() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
password.push(ch);
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Character count for the password pill — never `String::len()` (UTF-8).
|
|
||||||
pub(crate) fn password_char_count(password: &str) -> usize {
|
|
||||||
password.chars().count()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn password_cap_ignores_push_that_would_realloc() {
|
|
||||||
let mut s = String::with_capacity(8);
|
|
||||||
assert!(try_push_password(&mut s, 'a'));
|
|
||||||
while try_push_password(&mut s, 'x') {}
|
|
||||||
let cap = s.capacity();
|
|
||||||
let len = s.len();
|
|
||||||
assert!(!try_push_password(&mut s, 'y'));
|
|
||||||
assert_eq!(s.len(), len);
|
|
||||||
assert_eq!(s.capacity(), cap);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn password_char_count_is_not_byte_len() {
|
|
||||||
let mut s = String::with_capacity(16);
|
|
||||||
assert!(try_push_password(&mut s, 'é'));
|
|
||||||
assert_eq!(s.len(), 2);
|
|
||||||
assert_eq!(password_char_count(&s), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reserved_capacity_is_256() {
|
|
||||||
assert_eq!(PASSWORD_CAP, 256);
|
|
||||||
let s = String::with_capacity(PASSWORD_CAP);
|
|
||||||
assert!(s.capacity() >= PASSWORD_CAP);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
pub(crate) mod keyboard;
|
mod keyboard;
|
||||||
|
|
|
||||||
|
|
@ -9,34 +9,20 @@ impl SessionLockHandler for AppState {
|
||||||
fn locked(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, session_lock: SessionLock) {
|
fn locked(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, session_lock: SessionLock) {
|
||||||
tracing::info!("session locked");
|
tracing::info!("session locked");
|
||||||
self.session_lock = Some(session_lock);
|
self.session_lock = Some(session_lock);
|
||||||
crate::bread_events::emit_locked();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The compositor denied the lock request, or ended an active lock out
|
/// The compositor denied the lock request, or ended an active lock out
|
||||||
/// from under us (e.g. protocol error). Either way there's no lock left
|
/// from under us (e.g. protocol error). Either way there's no lock left
|
||||||
/// to protect, so the only sane move is to exit — staying resident
|
/// to protect, so the only sane move is to exit — staying resident
|
||||||
/// unlocked would be worse than not running at all.
|
/// unlocked would be worse than not running at all.
|
||||||
///
|
|
||||||
/// If `locked` already arrived, dropping the object sends `destroy()`
|
|
||||||
/// which is a protocol error; send `unlock_and_destroy` first.
|
|
||||||
fn finished(
|
fn finished(
|
||||||
&mut self,
|
&mut self,
|
||||||
_conn: &Connection,
|
_conn: &Connection,
|
||||||
_qh: &QueueHandle<Self>,
|
_qh: &QueueHandle<Self>,
|
||||||
_session_lock: SessionLock,
|
_session_lock: SessionLock,
|
||||||
) {
|
) {
|
||||||
// PAM unlock already took the stored lock; don't unlock/emit again.
|
tracing::warn!("compositor ended the session lock; exiting");
|
||||||
let Some(lock) = self.session_lock.take() else {
|
self.session_lock = None;
|
||||||
self.exit = true;
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if lock.is_locked() {
|
|
||||||
tracing::warn!("compositor ended an active session lock; unlocking then exiting");
|
|
||||||
lock.unlock();
|
|
||||||
crate::bread_events::emit_unlocked();
|
|
||||||
} else {
|
|
||||||
tracing::warn!("compositor ended the session lock before it was acquired; exiting");
|
|
||||||
}
|
|
||||||
self.exit = true;
|
self.exit = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,29 +35,14 @@ impl SessionLockHandler for AppState {
|
||||||
_serial: u32,
|
_serial: u32,
|
||||||
) {
|
) {
|
||||||
let (width, height) = configure.new_size;
|
let (width, height) = configure.new_size;
|
||||||
let (buf_w, buf_h) = if let Some(s) = self
|
if let Some(s) = self
|
||||||
.surfaces
|
.surfaces
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
.find(|s| s.surface.wl_surface() == surface.wl_surface())
|
.find(|s| s.surface.wl_surface() == surface.wl_surface())
|
||||||
{
|
{
|
||||||
s.width = width;
|
s.width = width;
|
||||||
s.height = height;
|
s.height = height;
|
||||||
let scale = s.scale.max(1);
|
}
|
||||||
surface.wl_surface().set_buffer_scale(scale);
|
self.redraw_surface(qh, &surface, width, height);
|
||||||
let buf_w = width.saturating_mul(scale as u32);
|
|
||||||
let buf_h = height.saturating_mul(scale as u32);
|
|
||||||
// Lazily wrap the surface in EGL on its first (sized) configure;
|
|
||||||
// resize the EGL window on subsequent ones. Size is buffer pixels.
|
|
||||||
if let Some(renderer) = &self.gpu {
|
|
||||||
match &mut s.gpu {
|
|
||||||
None => s.gpu = renderer.create_surface(surface.wl_surface(), buf_w, buf_h),
|
|
||||||
Some(gs) => gs.resize(buf_w, buf_h),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(buf_w, buf_h)
|
|
||||||
} else {
|
|
||||||
(width, height)
|
|
||||||
};
|
|
||||||
self.redraw_surface(qh, &surface, buf_w, buf_h);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,30 +9,10 @@ impl CompositorHandler for AppState {
|
||||||
fn scale_factor_changed(
|
fn scale_factor_changed(
|
||||||
&mut self,
|
&mut self,
|
||||||
_conn: &Connection,
|
_conn: &Connection,
|
||||||
qh: &QueueHandle<Self>,
|
_qh: &QueueHandle<Self>,
|
||||||
surface: &wl_surface::WlSurface,
|
_surface: &wl_surface::WlSurface,
|
||||||
new_factor: i32,
|
_new_factor: i32,
|
||||||
) {
|
) {
|
||||||
// Protocol: buffer scale must be > 0. Treat 0 (or negative) as 1.
|
|
||||||
let scale = new_factor.max(1);
|
|
||||||
let (lock_surface, width, height) = {
|
|
||||||
let Some(s) = self
|
|
||||||
.surfaces
|
|
||||||
.iter_mut()
|
|
||||||
.find(|s| s.surface.wl_surface() == surface)
|
|
||||||
else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
s.scale = scale;
|
|
||||||
surface.set_buffer_scale(scale);
|
|
||||||
let width = s.width.saturating_mul(scale as u32);
|
|
||||||
let height = s.height.saturating_mul(scale as u32);
|
|
||||||
if let Some(gs) = s.gpu.as_mut() {
|
|
||||||
gs.resize(width, height);
|
|
||||||
}
|
|
||||||
(s.surface.clone(), width, height)
|
|
||||||
};
|
|
||||||
self.redraw_surface(qh, &lock_surface, width, height);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn transform_changed(
|
fn transform_changed(
|
||||||
|
|
@ -86,12 +66,6 @@ impl OutputHandler for AppState {
|
||||||
qh: &QueueHandle<Self>,
|
qh: &QueueHandle<Self>,
|
||||||
output: wl_output::WlOutput,
|
output: wl_output::WlOutput,
|
||||||
) {
|
) {
|
||||||
// SCTK also fires `new_output` for outputs already bound at
|
|
||||||
// registry-init; `main` already created a lock surface for those.
|
|
||||||
// One lock surface per output is a protocol requirement.
|
|
||||||
if self.surfaces.iter().any(|s| s.output == output) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let Some(session_lock) = self.session_lock.clone() else {
|
let Some(session_lock) = self.session_lock.clone() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
@ -102,10 +76,6 @@ impl OutputHandler for AppState {
|
||||||
output,
|
output,
|
||||||
width: 0,
|
width: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
scale: 1,
|
|
||||||
gpu: None,
|
|
||||||
shm_pool: None,
|
|
||||||
shm_buffer: None,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
mod auth;
|
mod auth;
|
||||||
mod background;
|
mod background;
|
||||||
mod bread_events;
|
|
||||||
mod config;
|
mod config;
|
||||||
mod gpu;
|
|
||||||
mod input;
|
mod input;
|
||||||
mod lock;
|
mod lock;
|
||||||
mod render;
|
mod render;
|
||||||
mod state;
|
mod state;
|
||||||
mod status;
|
|
||||||
|
|
||||||
use smithay_client_toolkit::compositor::CompositorState;
|
use smithay_client_toolkit::compositor::CompositorState;
|
||||||
use smithay_client_toolkit::output::OutputState;
|
use smithay_client_toolkit::output::OutputState;
|
||||||
|
|
@ -23,145 +20,25 @@ 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 / unlock so both work while unlocked\n\
|
|
||||||
\n\
|
|
||||||
Session-level lock: loginctl lock-session (hypridle then runs breadlock).\n\
|
|
||||||
Bus unlock does not replace PAM — type the password at the lock screen.\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)) {
|
let username = std::env::var("USER")
|
||||||
Ok(Mode::Lock) => run_lock(),
|
.or_else(|_| std::env::var("LOGNAME"))
|
||||||
Ok(Mode::Listen) => run_listen(),
|
.unwrap_or_else(|_| {
|
||||||
Ok(Mode::Help) => print_usage(),
|
tracing::error!("neither $USER nor $LOGNAME is set — refusing to start without a username to authenticate");
|
||||||
Err(err) => {
|
|
||||||
eprintln!("breadlock: {err}");
|
|
||||||
print_usage();
|
|
||||||
std::process::exit(2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Long-running subscriber so `bread.command.lock.lock` / `.unlock` work
|
|
||||||
/// while the session is unlocked. The locker process also subscribes;
|
|
||||||
/// this path is what actually starts breadlock (the same no-args
|
|
||||||
/// invocation hypridle uses). Unlock while a locker is running is
|
|
||||||
/// refused (`.failed`); only PAM may unlock. 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);
|
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 / unlock");
|
|
||||||
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
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let _running = bread_events::enter_lock_process();
|
|
||||||
|
|
||||||
// Honor bread.command.lock.lock / unlock while this locker is up
|
|
||||||
// (already-locked is bread.lock.lock.done; unlock is .failed —
|
|
||||||
// never compositor unlock() or loginctl). Unlocked-path commands
|
|
||||||
// need `breadlock listen`.
|
|
||||||
let _commands = bread_events::subscribe_commands();
|
|
||||||
|
|
||||||
let username = auth::username_from_process().unwrap_or_else(|| {
|
|
||||||
tracing::error!(
|
|
||||||
"could not resolve a username (passwd lookup and $USER/$LOGNAME all failed) — \
|
|
||||||
taking the session lock anyway and refusing PAM"
|
|
||||||
);
|
|
||||||
String::new()
|
|
||||||
});
|
|
||||||
let username_missing = username.is_empty();
|
|
||||||
|
|
||||||
let config = config::load();
|
let config = config::load();
|
||||||
let palette = breadlock_ui::theme::load_palette();
|
let palette = breadlock_ui::theme::load_palette();
|
||||||
let background = Background::load(&config.appearance.background, &palette);
|
let background = Background::load(&config.appearance.background, &palette);
|
||||||
|
|
||||||
let conn = Connection::connect_to_env().expect("failed to connect to the Wayland display — breadlock must run inside an active Wayland session");
|
let conn = Connection::connect_to_env().expect("failed to connect to the Wayland display — breadlock must run inside an active Wayland session");
|
||||||
// GPU background rendering (EGL/GLES2). Any failure is non-fatal: the
|
|
||||||
// software renderer takes over. `run_lock` is only ever entered in Lock
|
|
||||||
// mode (the listen subscriber never renders), so no mode check here.
|
|
||||||
let gpu = gpu::GpuRenderer::new(&conn, &config.appearance.background, &palette);
|
|
||||||
if gpu.is_some() {
|
|
||||||
tracing::info!("GPU background rendering enabled (EGL/GLES2)");
|
|
||||||
} else {
|
|
||||||
tracing::warn!("GPU background rendering unavailable — using the software renderer");
|
|
||||||
}
|
|
||||||
let (globals, event_queue) =
|
let (globals, event_queue) =
|
||||||
registry_queue_init::<AppState>(&conn).expect("failed to initialize Wayland registry");
|
registry_queue_init::<AppState>(&conn).expect("failed to initialize Wayland registry");
|
||||||
let qh: QueueHandle<AppState> = event_queue.handle();
|
let qh: QueueHandle<AppState> = event_queue.handle();
|
||||||
|
|
@ -170,22 +47,14 @@ fn run_lock() {
|
||||||
let loop_handle = event_loop.handle();
|
let loop_handle = event_loop.handle();
|
||||||
|
|
||||||
let auth_result_qh = qh.clone();
|
let auth_result_qh = qh.clone();
|
||||||
let auth_tx = auth::register(&loop_handle, move |state: &mut AppState, generation, result| {
|
let auth_tx = auth::register(&loop_handle, move |state: &mut AppState, result| {
|
||||||
if generation != state.auth_generation {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
// Keep the lock surfaces up and fade the overlay out.
|
tracing::info!("authenticated, unlocking");
|
||||||
// Compositor unlock() runs only after UNLOCK_MS — dying
|
if let Some(lock) = state.session_lock.take() {
|
||||||
// mid-fade is fail-secure (session stays locked).
|
lock.unlock();
|
||||||
tracing::info!("authenticated, fading out");
|
|
||||||
state.failed_attempts = 0;
|
|
||||||
state.auth_state = AuthState::Idle;
|
|
||||||
state.checking_started = None;
|
|
||||||
if state.unlocking.is_none() {
|
|
||||||
state.unlocking = Some(std::time::Instant::now());
|
|
||||||
}
|
}
|
||||||
|
state.exit = true;
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
match err {
|
match err {
|
||||||
|
|
@ -199,16 +68,11 @@ fn run_lock() {
|
||||||
%err,
|
%err,
|
||||||
"PAM context initialization failed — check /etc/pam.d/breadlock exists and is valid; authentication cannot succeed until this is fixed"
|
"PAM context initialization failed — check /etc/pam.d/breadlock exists and is valid; authentication cannot succeed until this is fixed"
|
||||||
);
|
);
|
||||||
state.enter_fail(AuthState::ConfigError);
|
state.auth_state = AuthState::ConfigError;
|
||||||
}
|
}
|
||||||
auth::AuthError::Authenticate => {
|
auth::AuthError::Authenticate | auth::AuthError::AccountInvalid => {
|
||||||
tracing::warn!(%err, "authentication failed");
|
tracing::warn!(%err, "authentication failed");
|
||||||
state.failed_attempts = state.failed_attempts.saturating_add(1);
|
state.auth_state = AuthState::Failed;
|
||||||
state.enter_fail(AuthState::Failed);
|
|
||||||
}
|
|
||||||
auth::AuthError::AccountInvalid => {
|
|
||||||
tracing::warn!(%err, "account locked or expired");
|
|
||||||
state.enter_fail(AuthState::AccountInvalid);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
state.schedule_clear_failed(auth_result_qh.clone());
|
state.schedule_clear_failed(auth_result_qh.clone());
|
||||||
|
|
@ -217,17 +81,6 @@ fn run_lock() {
|
||||||
state.redraw_all(&auth_result_qh);
|
state.redraw_all(&auth_result_qh);
|
||||||
});
|
});
|
||||||
|
|
||||||
// D-Bus status (now-playing / battery): the poller posts snapshots here
|
|
||||||
// and each one triggers a redraw so the line under the clock stays live.
|
|
||||||
let status_qh = qh.clone();
|
|
||||||
let status_tx = status::register(&loop_handle, move |state: &mut AppState, info| {
|
|
||||||
if state.status_info != info {
|
|
||||||
state.status_info = info;
|
|
||||||
state.redraw_all(&status_qh);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
status::spawn_poller(status_tx, config.status.now_playing, config.status.battery);
|
|
||||||
|
|
||||||
let compositor_state =
|
let compositor_state =
|
||||||
CompositorState::bind(&globals, &qh).expect("compositor global not advertised");
|
CompositorState::bind(&globals, &qh).expect("compositor global not advertised");
|
||||||
let output_state = OutputState::new(&globals, &qh);
|
let output_state = OutputState::new(&globals, &qh);
|
||||||
|
|
@ -246,44 +99,17 @@ fn run_lock() {
|
||||||
session_lock: None,
|
session_lock: None,
|
||||||
surfaces: Vec::new(),
|
surfaces: Vec::new(),
|
||||||
keyboard: None,
|
keyboard: None,
|
||||||
keyboard_seat: None,
|
|
||||||
config,
|
config,
|
||||||
palette,
|
palette,
|
||||||
background,
|
background,
|
||||||
gpu,
|
|
||||||
text_renderer: breadlock_ui::painter::TextRenderer::new(),
|
text_renderer: breadlock_ui::painter::TextRenderer::new(),
|
||||||
username,
|
username,
|
||||||
// Pre-reserve capacity so ordinary typing doesn't reallocate — a
|
// Pre-reserve capacity so ordinary typing doesn't reallocate — a
|
||||||
// reallocation leaves the old (unzeroized) backing buffer, with the
|
// reallocation leaves the old (unzeroized) backing buffer, with the
|
||||||
// password bytes still in it, on the heap.
|
// password bytes still in it, on the heap.
|
||||||
password: zeroize::Zeroizing::new(String::with_capacity(state::PASSWORD_CAP)),
|
password: zeroize::Zeroizing::new(String::with_capacity(128)),
|
||||||
password_display_len: 0,
|
|
||||||
auth_state: AuthState::Idle,
|
auth_state: AuthState::Idle,
|
||||||
auth_tx,
|
auth_tx,
|
||||||
auth_generation: 0,
|
|
||||||
failed_generation: 0,
|
|
||||||
checking_started: None,
|
|
||||||
started: std::time::Instant::now(),
|
|
||||||
appear_started: None,
|
|
||||||
unlocking: None,
|
|
||||||
last_keystroke: None,
|
|
||||||
failed_at: None,
|
|
||||||
last_clock_text: String::new(),
|
|
||||||
clock_from: None,
|
|
||||||
status_anim_started: None,
|
|
||||||
last_auth_state: AuthState::Idle,
|
|
||||||
breathe_started: None,
|
|
||||||
breathe_next_at: Some(
|
|
||||||
std::time::Instant::now()
|
|
||||||
+ std::time::Duration::from_millis(render::BREATHE_INITIAL_DELAY_MS),
|
|
||||||
),
|
|
||||||
anim_timer_armed: false,
|
|
||||||
caps_lock: false,
|
|
||||||
layout_index: 0,
|
|
||||||
reveal_held: false,
|
|
||||||
last_activity: std::time::Instant::now(),
|
|
||||||
failed_attempts: 0,
|
|
||||||
status_info: status::StatusInfo::default(),
|
|
||||||
exit: false,
|
exit: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -303,18 +129,10 @@ fn run_lock() {
|
||||||
output,
|
output,
|
||||||
width: 0,
|
width: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
scale: 1,
|
|
||||||
gpu: None,
|
|
||||||
shm_pool: None,
|
|
||||||
shm_buffer: None,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
app_state.session_lock = Some(session_lock);
|
app_state.session_lock = Some(session_lock);
|
||||||
|
|
||||||
if username_missing {
|
|
||||||
app_state.enter_fail(AuthState::ConfigError);
|
|
||||||
}
|
|
||||||
|
|
||||||
WaylandSource::new(conn, event_queue)
|
WaylandSource::new(conn, event_queue)
|
||||||
.insert(loop_handle.clone())
|
.insert(loop_handle.clone())
|
||||||
.expect("failed to register the Wayland source on the event loop");
|
.expect("failed to register the Wayland source on the event loop");
|
||||||
|
|
@ -347,13 +165,7 @@ fn run_lock() {
|
||||||
let mut consecutive_errors = 0u32;
|
let mut consecutive_errors = 0u32;
|
||||||
while !app_state.exit {
|
while !app_state.exit {
|
||||||
match event_loop.dispatch(Duration::from_millis(250), &mut app_state) {
|
match event_loop.dispatch(Duration::from_millis(250), &mut app_state) {
|
||||||
Ok(()) => {
|
Ok(()) => consecutive_errors = 0,
|
||||||
consecutive_errors = 0;
|
|
||||||
// Backup if the 16ms anim timer failed to register: the
|
|
||||||
// 250ms dispatch timeout (or the 1s clock tick) still
|
|
||||||
// completes a finished unlock fade.
|
|
||||||
app_state.complete_unlock_if_ready();
|
|
||||||
}
|
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
consecutive_errors += 1;
|
consecutive_errors += 1;
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
|
|
@ -390,32 +202,3 @@ 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -7,21 +7,16 @@ use smithay_client_toolkit::registry::{ProvidesRegistryState, RegistryState};
|
||||||
use smithay_client_toolkit::registry_handlers;
|
use smithay_client_toolkit::registry_handlers;
|
||||||
use smithay_client_toolkit::seat::SeatState;
|
use smithay_client_toolkit::seat::SeatState;
|
||||||
use smithay_client_toolkit::session_lock::{SessionLock, SessionLockState, SessionLockSurface};
|
use smithay_client_toolkit::session_lock::{SessionLock, SessionLockState, SessionLockSurface};
|
||||||
use smithay_client_toolkit::shm::slot::{Buffer, SlotPool};
|
|
||||||
use smithay_client_toolkit::shm::{Shm, ShmHandler};
|
use smithay_client_toolkit::shm::{Shm, ShmHandler};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::Duration;
|
||||||
use wayland_client::protocol::{wl_keyboard, wl_output, wl_seat, wl_shm};
|
use wayland_client::protocol::{wl_keyboard, wl_output, wl_shm};
|
||||||
use wayland_client::{Connection, QueueHandle};
|
use wayland_client::{Connection, QueueHandle};
|
||||||
|
|
||||||
use crate::auth::AuthOutcome;
|
use crate::auth::AuthResult;
|
||||||
use crate::background::Background;
|
use crate::background::Background;
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::render;
|
use crate::render;
|
||||||
|
|
||||||
/// Reserved password buffer size. Typing past this is ignored so `String`
|
|
||||||
/// never reallocates (an old unzeroized heap buffer would leak).
|
|
||||||
pub(crate) const PASSWORD_CAP: usize = 256;
|
|
||||||
|
|
||||||
/// Per-output lock surface plus the size the compositor last `configure`d it
|
/// Per-output lock surface plus the size the compositor last `configure`d it
|
||||||
/// to (0x0 until the first configure arrives). `output` is kept so
|
/// to (0x0 until the first configure arrives). `output` is kept so
|
||||||
/// `output_destroyed` can find and drop the surface belonging to an unplugged
|
/// `output_destroyed` can find and drop the surface belonging to an unplugged
|
||||||
|
|
@ -31,36 +26,23 @@ pub struct LockSurface {
|
||||||
pub output: wl_output::WlOutput,
|
pub output: wl_output::WlOutput,
|
||||||
pub width: u32,
|
pub width: u32,
|
||||||
pub height: u32,
|
pub height: u32,
|
||||||
/// `wl_surface` buffer scale. 1 until `scale_factor_changed`. Always >= 1.
|
|
||||||
pub scale: i32,
|
|
||||||
/// EGL-backed renderer for this surface (created on first `configure`);
|
|
||||||
/// `None` when the GPU path is unavailable, in which case the software
|
|
||||||
/// wl_shm path is used.
|
|
||||||
pub gpu: Option<crate::gpu::GpuSurface>,
|
|
||||||
/// Reused shm pool + current buffer (software path). Not recreated every
|
|
||||||
/// frame; SlotPool waits for compositor release before reuse.
|
|
||||||
pub shm_pool: Option<SlotPool>,
|
|
||||||
pub shm_buffer: Option<Buffer>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum AuthState {
|
pub enum AuthState {
|
||||||
Idle,
|
Idle,
|
||||||
/// A PAM check is running on its own thread; input other than Escape is
|
/// A PAM check is running on its own thread; input is ignored until it
|
||||||
/// ignored until it resolves so a second Enter can't race the first
|
/// resolves so a second Enter can't race the first attempt.
|
||||||
/// attempt. Escape cancels the wait (the in-flight libpam call is not
|
|
||||||
/// aborted; its result is ignored).
|
|
||||||
Checking,
|
Checking,
|
||||||
/// The password was rejected by PAM — an ordinary wrong-password
|
/// The password (or account state) was rejected by PAM — an ordinary
|
||||||
/// outcome the user can retry. Input is not blocked.
|
/// wrong-password/locked-account outcome the user can retry.
|
||||||
Failed,
|
Failed,
|
||||||
/// PAM `acct_mgmt` rejected the account (locked, expired, etc.).
|
|
||||||
AccountInvalid,
|
|
||||||
/// PAM itself failed to initialize (e.g. `/etc/pam.d/breadlock` is
|
/// PAM itself failed to initialize (e.g. `/etc/pam.d/breadlock` is
|
||||||
/// missing or unreadable), or the process username could not be
|
/// missing or unreadable) — this is a config/deployment problem, not
|
||||||
/// resolved — a config/deployment problem, not something the user's
|
/// something the user's password can fix. Rendered with a distinct
|
||||||
/// password can fix. Rendered with a distinct message so a broken
|
/// message so a broken install doesn't look like an endless string of
|
||||||
/// install doesn't look like an endless string of typos.
|
/// typos with no way to discover the real cause. See `main.rs`'s
|
||||||
|
/// auth-result callback, which is the only place this is set.
|
||||||
ConfigError,
|
ConfigError,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,16 +58,10 @@ pub struct AppState {
|
||||||
pub session_lock: Option<SessionLock>,
|
pub session_lock: Option<SessionLock>,
|
||||||
pub surfaces: Vec<LockSurface>,
|
pub surfaces: Vec<LockSurface>,
|
||||||
pub keyboard: Option<wl_keyboard::WlKeyboard>,
|
pub keyboard: Option<wl_keyboard::WlKeyboard>,
|
||||||
/// Seat that owns [`Self::keyboard`]. `remove_capability` only releases
|
|
||||||
/// the keyboard if that seat lost Keyboard.
|
|
||||||
pub keyboard_seat: Option<wl_seat::WlSeat>,
|
|
||||||
|
|
||||||
pub config: Config,
|
pub config: Config,
|
||||||
pub palette: breadlock_ui::theme::Palette,
|
pub palette: breadlock_ui::theme::Palette,
|
||||||
pub background: Background,
|
pub background: Background,
|
||||||
/// GPU background renderer (EGL/GLES2). `None` falls back to the
|
|
||||||
/// fully-software path.
|
|
||||||
pub gpu: Option<crate::gpu::GpuRenderer>,
|
|
||||||
pub text_renderer: breadlock_ui::painter::TextRenderer,
|
pub text_renderer: breadlock_ui::painter::TextRenderer,
|
||||||
|
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
|
@ -96,77 +72,8 @@ pub struct AppState {
|
||||||
/// explicit zeroing — see `input/keyboard.rs` — since `Zeroizing` only
|
/// explicit zeroing — see `input/keyboard.rs` — since `Zeroizing` only
|
||||||
/// hooks `Drop`, not in-place mutation.
|
/// hooks `Drop`, not in-place mutation.
|
||||||
pub password: zeroize::Zeroizing<String>,
|
pub password: zeroize::Zeroizing<String>,
|
||||||
/// Character count shown in the pill after submit (secret already
|
|
||||||
/// moved to the auth thread). Used until Idle or the user types again.
|
|
||||||
pub password_display_len: usize,
|
|
||||||
pub auth_state: AuthState,
|
pub auth_state: AuthState,
|
||||||
pub auth_tx: Sender<AuthOutcome>,
|
pub auth_tx: Sender<AuthResult>,
|
||||||
/// Bumped on each submit / Escape-cancel. Late PAM results whose
|
|
||||||
/// generation does not match are ignored.
|
|
||||||
pub auth_generation: u64,
|
|
||||||
/// Bumped each time Failed / AccountInvalid / ConfigError is set.
|
|
||||||
/// The fail-clear timer captures this and only clears if it still matches.
|
|
||||||
pub failed_generation: u64,
|
|
||||||
/// When the current PAM check entered Checking — drives `checking_dots`.
|
|
||||||
pub checking_started: Option<Instant>,
|
|
||||||
|
|
||||||
/// Monotonic clock reference — drives the idle caret blink cadence.
|
|
||||||
pub started: Instant,
|
|
||||||
/// First-frame timestamp for the lock-appear animation. `None` until
|
|
||||||
/// the first non-degenerate redraw so the fade starts when the surface
|
|
||||||
/// is actually visible, not when the process starts.
|
|
||||||
pub appear_started: Option<Instant>,
|
|
||||||
/// Set on PAM success. While `Some`, lock surfaces stay up and the
|
|
||||||
/// overlay fades out; compositor `unlock()` happens only after the
|
|
||||||
/// fade completes. Dying mid-fade leaves the session locked (fail-secure).
|
|
||||||
pub unlocking: Option<Instant>,
|
|
||||||
/// Timestamp of the most recent keystroke that grew the password — drives
|
|
||||||
/// the newest-dot pop-in and the caret's solid-then-blink behavior.
|
|
||||||
pub last_keystroke: Option<Instant>,
|
|
||||||
/// When the failed state was entered — drives the wrong-password shake.
|
|
||||||
/// Cleared (with the failed state) by typing or `fail_timeout_ms`.
|
|
||||||
pub failed_at: Option<Instant>,
|
|
||||||
/// Clock text drawn last frame; a change starts a minute-rollover
|
|
||||||
/// crossfade instead of a hard text swap.
|
|
||||||
pub last_clock_text: String,
|
|
||||||
/// Outgoing clock string + when its crossfade started. Kept until the
|
|
||||||
/// fade completes so later frames still pass the previous string.
|
|
||||||
pub clock_from: Option<(String, Instant)>,
|
|
||||||
/// When the current status line appeared ("Checking…" / "Wrong password") —
|
|
||||||
/// drives its slide-in. Reset whenever `auth_state` changes (see
|
|
||||||
/// `last_auth_state`).
|
|
||||||
pub status_anim_started: Option<Instant>,
|
|
||||||
/// The `auth_state` from the last frame — a change resets the status
|
|
||||||
/// slide-in so a freshly appearing status rises in instead of popping.
|
|
||||||
pub last_auth_state: AuthState,
|
|
||||||
/// When the current idle-breath window started (glow pulse). `None`
|
|
||||||
/// between breaths.
|
|
||||||
pub breathe_started: Option<Instant>,
|
|
||||||
/// When the next idle-breath window is due — the 1s clock tick arms the
|
|
||||||
/// animation timer once it's due, so idle CPU stays near zero.
|
|
||||||
pub breathe_next_at: Option<Instant>,
|
|
||||||
/// True while a ~16ms animation timer is registered on the event loop.
|
|
||||||
pub anim_timer_armed: bool,
|
|
||||||
|
|
||||||
/// Caps Lock is on (from the last keyboard modifier update) — drives the
|
|
||||||
/// small "Caps Lock" chip so the user isn't mystified by uppercase-only
|
|
||||||
/// input. Stale until the first modifier update arrives.
|
|
||||||
pub caps_lock: bool,
|
|
||||||
/// Active keyboard layout index (0-based) — shown next to the caps chip
|
|
||||||
/// when a non-default layout is selected.
|
|
||||||
pub layout_index: u32,
|
|
||||||
/// True while the user holds the reveal key (Tab) — dots render as the
|
|
||||||
/// plain characters while held.
|
|
||||||
pub reveal_held: bool,
|
|
||||||
/// Last keystroke/activity timestamp — drives the idle auto-dim ramp
|
|
||||||
/// (`animation.idle_dim_after_secs`). Any key press resets it.
|
|
||||||
pub last_activity: Instant,
|
|
||||||
/// Consecutive failed password attempts this session — drives the
|
|
||||||
/// "N failed attempts" status line. Reset on a successful auth.
|
|
||||||
pub failed_attempts: u32,
|
|
||||||
/// Latest D-Bus snapshot (now-playing / battery) from the status poller.
|
|
||||||
/// Empty fields render nothing; replaced wholesale on each poll.
|
|
||||||
pub status_info: crate::status::StatusInfo,
|
|
||||||
|
|
||||||
pub exit: bool,
|
pub exit: bool,
|
||||||
}
|
}
|
||||||
|
|
@ -186,318 +93,62 @@ impl AppState {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.appear_started.is_none() {
|
|
||||||
self.appear_started = Some(Instant::now());
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = Instant::now();
|
|
||||||
let clock_text = chrono::Local::now()
|
let clock_text = chrono::Local::now()
|
||||||
.format(&self.config.appearance.clock.format)
|
.format(&self.config.appearance.clock.format)
|
||||||
.to_string();
|
.to_string();
|
||||||
let date_text = chrono::Local::now()
|
|
||||||
.format(&self.config.appearance.clock.date_format)
|
|
||||||
.to_string();
|
|
||||||
// A status line appearing (or changing) resets its slide-in.
|
|
||||||
if self.auth_state != self.last_auth_state {
|
|
||||||
self.status_anim_started = Some(now);
|
|
||||||
self.last_auth_state = self.auth_state;
|
|
||||||
}
|
|
||||||
// Idle breath: one sine hump over the active window. When the window
|
|
||||||
// ends, schedule the next one a full period out (the 1s clock tick
|
|
||||||
// re-arms the animation timer once it's due).
|
|
||||||
let breathe_t = if let Some(started) = self.breathe_started {
|
|
||||||
let p = render::unit_progress(started, render::BREATHE_ACTIVE_MS);
|
|
||||||
if p >= 1.0 {
|
|
||||||
self.breathe_started = None;
|
|
||||||
self.breathe_next_at =
|
|
||||||
Some(started + Duration::from_millis(render::BREATHE_PERIOD_MS));
|
|
||||||
0.0
|
|
||||||
} else {
|
|
||||||
render::breathe_envelope(p)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
// While a PAM check runs, the status dots tick to signal progress.
|
|
||||||
let status_text = match self.auth_state {
|
let status_text = match self.auth_state {
|
||||||
AuthState::Checking => {
|
AuthState::Checking => Some("Checking…".to_string()),
|
||||||
let started = self.checking_started.unwrap_or(now);
|
AuthState::Failed => Some("Wrong password".to_string()),
|
||||||
Some(format!("Checking{}", checking_dots(started)))
|
AuthState::ConfigError => {
|
||||||
|
Some("PAM config error — check logs (breadlock service not set up correctly)".to_string())
|
||||||
}
|
}
|
||||||
AuthState::Failed => {
|
|
||||||
// Repeat failures get a counter so the user can tell the
|
|
||||||
// locker apart from a stuck/corrupt one ("Wrong password"
|
|
||||||
// alone reads identically every time).
|
|
||||||
let n = self.failed_attempts.max(1);
|
|
||||||
Some(if n > 1 {
|
|
||||||
format!("Wrong password — {n} failed attempts")
|
|
||||||
} else {
|
|
||||||
"Wrong password".to_string()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
AuthState::AccountInvalid => Some("Account locked or expired".to_string()),
|
|
||||||
AuthState::ConfigError => Some(
|
|
||||||
"PAM config error — check logs (breadlock service not set up correctly)"
|
|
||||||
.to_string(),
|
|
||||||
),
|
|
||||||
AuthState::Idle => None,
|
AuthState::Idle => None,
|
||||||
};
|
};
|
||||||
// D-Bus status line under the clock: now-playing and/or battery,
|
|
||||||
// joined with a dot separator. Fades in with the appear animation
|
|
||||||
// (render.rs keys `info_text` off `appear_t`, so no per-frame state
|
|
||||||
// is needed here).
|
|
||||||
let mut info_parts: Vec<&str> = Vec::new();
|
|
||||||
if self.config.status.now_playing && !self.status_info.now_playing.is_empty() {
|
|
||||||
info_parts.push(&self.status_info.now_playing);
|
|
||||||
}
|
|
||||||
if self.config.status.battery && !self.status_info.battery.is_empty() {
|
|
||||||
info_parts.push(&self.status_info.battery);
|
|
||||||
}
|
|
||||||
let info_text = info_parts.join(" · ");
|
|
||||||
|
|
||||||
// Idle auto-dim: ramp 0..1 over IDLE_DIM_RAMP_MS once the configured
|
|
||||||
// idle threshold elapses with no keystrokes. 0 when disabled.
|
|
||||||
let idle_dim = if self.config.animation.idle_dim_after_secs > 0 {
|
|
||||||
let idle_s = self.last_activity.elapsed().as_secs_f64()
|
|
||||||
- self.config.animation.idle_dim_after_secs as f64;
|
|
||||||
if idle_s <= 0.0 {
|
|
||||||
0.0
|
|
||||||
} else {
|
|
||||||
(idle_s / (render::IDLE_DIM_RAMP_MS as f64 / 1000.0)).min(1.0) as f32
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
let status_t = self
|
|
||||||
.status_anim_started
|
|
||||||
.map(|t| render::unit_progress(t, render::STATUS_SLIDE_MS))
|
|
||||||
.unwrap_or(1.0);
|
|
||||||
|
|
||||||
// Minute rollover: keep the previous clock text in `clock_from`
|
|
||||||
// until the crossfade completes. Do not overwrite the outgoing string.
|
|
||||||
if let Some((_, started)) = self.clock_from {
|
|
||||||
if render::unit_progress(started, render::CLOCK_CROSSFADE_MS) >= 1.0 {
|
|
||||||
self.clock_from = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if self.clock_from.is_none()
|
|
||||||
&& !self.last_clock_text.is_empty()
|
|
||||||
&& clock_text != self.last_clock_text
|
|
||||||
{
|
|
||||||
self.clock_from = Some((self.last_clock_text.clone(), now));
|
|
||||||
}
|
|
||||||
self.last_clock_text = clock_text.clone();
|
|
||||||
let clock_old = self.clock_from.as_ref().map(|(from, started)| {
|
|
||||||
(
|
|
||||||
from.as_str(),
|
|
||||||
render::unit_progress(*started, render::CLOCK_CROSSFADE_MS),
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
let appear_t = self
|
|
||||||
.appear_started
|
|
||||||
.map(|t| render::unit_progress(t, render::APPEAR_MS))
|
|
||||||
.unwrap_or(0.0);
|
|
||||||
let unlock_t = self
|
|
||||||
.unlocking
|
|
||||||
.map(|t| render::unit_progress(t, render::UNLOCK_MS))
|
|
||||||
.unwrap_or(0.0);
|
|
||||||
let failed_t = self
|
|
||||||
.failed_at
|
|
||||||
.map(|t| render::unit_progress(t, render::SHAKE_MS))
|
|
||||||
.unwrap_or(0.0);
|
|
||||||
let dot_pop_t = self
|
|
||||||
.last_keystroke
|
|
||||||
.map(|t| render::unit_progress(t, render::DOT_POP_MS))
|
|
||||||
.unwrap_or(1.0);
|
|
||||||
|
|
||||||
let password_len = if self.password.is_empty() {
|
|
||||||
self.password_display_len
|
|
||||||
} else {
|
|
||||||
self.password.chars().count()
|
|
||||||
};
|
|
||||||
|
|
||||||
let output_palette = self.palette_for_surface(surface);
|
|
||||||
let inputs = render::FrameInputs {
|
let inputs = render::FrameInputs {
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
background: &self.background,
|
background: &self.background,
|
||||||
palette: &output_palette,
|
palette: &self.palette,
|
||||||
font_family: &self.config.appearance.font.family,
|
font_family: &self.config.appearance.font.family,
|
||||||
clock_text: &clock_text,
|
clock_text: &clock_text,
|
||||||
date_text: &date_text,
|
password_len: self.password.len(),
|
||||||
clock_old,
|
failed: matches!(self.auth_state, AuthState::Failed | AuthState::ConfigError),
|
||||||
password_len,
|
|
||||||
password: &self.password,
|
|
||||||
reveal: self.reveal_held,
|
|
||||||
caps_lock: self.caps_lock,
|
|
||||||
layout_index: self.layout_index,
|
|
||||||
idle_dim,
|
|
||||||
failed: matches!(
|
|
||||||
self.auth_state,
|
|
||||||
AuthState::Failed | AuthState::AccountInvalid | AuthState::ConfigError
|
|
||||||
),
|
|
||||||
failed_t,
|
|
||||||
dot_pop_t,
|
|
||||||
keystroke_age: self.last_keystroke.map(|t| t.elapsed().as_secs_f32()),
|
|
||||||
t_secs: self.started.elapsed().as_secs_f32(),
|
|
||||||
breathe_t,
|
|
||||||
status_t,
|
|
||||||
status_text: status_text.as_deref(),
|
status_text: status_text.as_deref(),
|
||||||
info_text: &info_text,
|
|
||||||
appear_t,
|
|
||||||
unlock_t,
|
|
||||||
smooth_pan: !self.fast_anim_in_progress(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// GPU path: the EGL surface renders the wallpaper (pan/veil in the
|
|
||||||
// shader) and the software-composed chrome on top. Disjoint-field
|
|
||||||
// borrows of `self` make `gpu` + `surfaces` + `text_renderer`
|
|
||||||
// simultaneously mutable.
|
|
||||||
let wants_gpu = self.gpu.is_some()
|
|
||||||
&& self
|
|
||||||
.surfaces
|
|
||||||
.iter()
|
|
||||||
.any(|s| s.surface.wl_surface() == surface.wl_surface() && s.gpu.is_some());
|
|
||||||
if wants_gpu {
|
|
||||||
let Some(renderer) = self.gpu.as_mut() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(lock_surface) = self
|
|
||||||
.surfaces
|
|
||||||
.iter_mut()
|
|
||||||
.find(|s| s.surface.wl_surface() == surface.wl_surface())
|
|
||||||
else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(gpu_surface) = lock_surface.gpu.as_mut() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if renderer.render_frame(gpu_surface, &inputs, &mut self.text_renderer) {
|
|
||||||
self.arm_anim_if_needed(qh);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tracing::warn!("GPU frame failed — dropping EGL window and falling back to software");
|
|
||||||
}
|
|
||||||
|
|
||||||
// An EGL window on this wl_surface makes a later shm attach illegal;
|
|
||||||
// Drop of GpuSurface destroys the native window first.
|
|
||||||
if wants_gpu {
|
|
||||||
if let Some(s) = self
|
|
||||||
.surfaces
|
|
||||||
.iter_mut()
|
|
||||||
.find(|s| s.surface.wl_surface() == surface.wl_surface())
|
|
||||||
{
|
|
||||||
s.gpu = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(pixmap) = render::compose(&mut self.text_renderer, &inputs) else {
|
let Some(pixmap) = render::compose(&mut self.text_renderer, &inputs) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
self.present_shm(surface, width, height, &pixmap);
|
let stride = width as usize * 4;
|
||||||
self.arm_anim_if_needed(qh);
|
let pool =
|
||||||
}
|
smithay_client_toolkit::shm::raw::RawPool::new(stride * height as usize, &self.shm);
|
||||||
|
let mut pool = match pool {
|
||||||
fn present_shm(
|
Ok(pool) => pool,
|
||||||
&mut self,
|
Err(err) => {
|
||||||
surface: &SessionLockSurface,
|
tracing::error!(%err, "failed to allocate shm pool for lock surface redraw");
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
pixmap: &tiny_skia::Pixmap,
|
|
||||||
) {
|
|
||||||
let Some(px) = (width as usize).checked_mul(height as usize) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(len) = px.checked_mul(4) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if len == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if width > i32::MAX as u32 || height > i32::MAX as u32 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let stride = match (width as usize).checked_mul(4) {
|
|
||||||
Some(s) if s <= i32::MAX as usize => s as i32,
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
let idx = self
|
|
||||||
.surfaces
|
|
||||||
.iter()
|
|
||||||
.position(|s| s.surface.wl_surface() == surface.wl_surface());
|
|
||||||
let Some(idx) = idx else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
if self.surfaces[idx].shm_pool.is_none() {
|
|
||||||
match SlotPool::new(len, &self.shm) {
|
|
||||||
Ok(pool) => self.surfaces[idx].shm_pool = Some(pool),
|
|
||||||
Err(err) => {
|
|
||||||
tracing::error!(%err, "failed to allocate shm pool for lock surface redraw");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let lock = &mut self.surfaces[idx];
|
|
||||||
if let Some(buf) = &lock.shm_buffer {
|
|
||||||
if buf.height() != height as i32 || buf.stride() != stride {
|
|
||||||
lock.shm_buffer = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut reused = false;
|
|
||||||
if let Some(pool) = lock.shm_pool.as_mut() {
|
|
||||||
if let Some(buf) = lock.shm_buffer.as_ref() {
|
|
||||||
if let Some(canvas) = pool.canvas(buf) {
|
|
||||||
render::blit_to_shm(pixmap, canvas);
|
|
||||||
reused = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !reused {
|
|
||||||
let Some(pool) = lock.shm_pool.as_mut() else {
|
|
||||||
return;
|
return;
|
||||||
};
|
}
|
||||||
let (new_buf, canvas) = match pool.create_buffer(
|
|
||||||
width as i32,
|
|
||||||
height as i32,
|
|
||||||
stride,
|
|
||||||
wl_shm::Format::Argb8888,
|
|
||||||
) {
|
|
||||||
Ok(pair) => pair,
|
|
||||||
Err(err) => {
|
|
||||||
tracing::error!(%err, "failed to create shm buffer for lock surface redraw");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
render::blit_to_shm(pixmap, canvas);
|
|
||||||
lock.shm_buffer = Some(new_buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(buf) = lock.shm_buffer.as_ref() else {
|
|
||||||
return;
|
|
||||||
};
|
};
|
||||||
if buf.attach_to(surface.wl_surface()).is_err() {
|
render::blit_to_shm(&pixmap, pool.mmap());
|
||||||
return;
|
|
||||||
}
|
let buffer = pool.create_buffer(
|
||||||
|
0,
|
||||||
|
width as i32,
|
||||||
|
height as i32,
|
||||||
|
stride as i32,
|
||||||
|
wl_shm::Format::Argb8888,
|
||||||
|
(),
|
||||||
|
qh,
|
||||||
|
);
|
||||||
|
|
||||||
|
surface.wl_surface().attach(Some(&buffer), 0, 0);
|
||||||
surface
|
surface
|
||||||
.wl_surface()
|
.wl_surface()
|
||||||
.damage_buffer(0, 0, width as i32, height as i32);
|
.damage_buffer(0, 0, width as i32, height as i32);
|
||||||
surface.wl_surface().commit();
|
surface.wl_surface().commit();
|
||||||
}
|
buffer.destroy();
|
||||||
|
|
||||||
fn palette_for_surface(&self, surface: &SessionLockSurface) -> breadlock_ui::theme::Palette {
|
|
||||||
self.surfaces
|
|
||||||
.iter()
|
|
||||||
.find(|s| s.surface.wl_surface() == surface.wl_surface())
|
|
||||||
.and_then(|s| self.output_state.info(&s.output))
|
|
||||||
.and_then(|info| info.name)
|
|
||||||
.map(|name| breadlock_ui::theme::load_palette_for(&name))
|
|
||||||
.unwrap_or_else(|| self.palette.clone())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Redraws every currently-configured surface — used for the clock tick
|
/// Redraws every currently-configured surface — used for the clock tick
|
||||||
|
|
@ -506,242 +157,28 @@ impl AppState {
|
||||||
let surfaces: Vec<(SessionLockSurface, u32, u32)> = self
|
let surfaces: Vec<(SessionLockSurface, u32, u32)> = self
|
||||||
.surfaces
|
.surfaces
|
||||||
.iter()
|
.iter()
|
||||||
.map(|s| {
|
.map(|s| (s.surface.clone(), s.width, s.height))
|
||||||
let scale = s.scale.max(1) as u32;
|
|
||||||
(
|
|
||||||
s.surface.clone(),
|
|
||||||
s.width.saturating_mul(scale),
|
|
||||||
s.height.saturating_mul(scale),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
for (surface, width, height) in surfaces {
|
for (surface, width, height) in surfaces {
|
||||||
self.redraw_surface(qh, &surface, width, height);
|
self.redraw_surface(qh, &surface, width, height);
|
||||||
}
|
}
|
||||||
self.complete_unlock_if_ready();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn appear_in_progress(&self) -> bool {
|
/// After a failed attempt, clears the "wrong password" state (and
|
||||||
self.appear_started
|
/// re-enables the red pill) once `input.fail_timeout_ms` has elapsed —
|
||||||
.map(|t| t.elapsed() < Duration::from_millis(render::APPEAR_MS))
|
/// unless the user already cleared it themselves by typing again.
|
||||||
.unwrap_or(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unlock_in_progress(&self) -> bool {
|
|
||||||
self.unlocking
|
|
||||||
.map(|t| t.elapsed() < Duration::from_millis(render::UNLOCK_MS))
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn failed_shake_in_progress(&self) -> bool {
|
|
||||||
self.failed_at
|
|
||||||
.map(|t| t.elapsed() < Duration::from_millis(render::SHAKE_MS))
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dot_pop_in_progress(&self) -> bool {
|
|
||||||
self.last_keystroke
|
|
||||||
.map(|t| t.elapsed() < Duration::from_millis(render::DOT_POP_MS))
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clock_fade_in_progress(&self) -> bool {
|
|
||||||
self.clock_from
|
|
||||||
.as_ref()
|
|
||||||
.map(|(_, t)| t.elapsed() < Duration::from_millis(render::CLOCK_CROSSFADE_MS))
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn status_slide_in_progress(&self) -> bool {
|
|
||||||
self.status_anim_started
|
|
||||||
.map(|t| t.elapsed() < Duration::from_millis(render::STATUS_SLIDE_MS))
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn breathe_in_progress(&self) -> bool {
|
|
||||||
self.breathe_started.is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An idle breath is due when the cycle timer says so (and no breath is
|
|
||||||
/// already running). The 1s clock tick calls `redraw_all`, which arms the
|
|
||||||
/// animation timer through here — so the screen stays asleep between
|
|
||||||
/// breaths.
|
|
||||||
fn breathe_due(&self) -> bool {
|
|
||||||
if !self.config.animation.breathe || self.breathe_started.is_some() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
self.breathe_next_at
|
|
||||||
.map(|t| Instant::now() >= t)
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn idle_dim_in_progress(&self) -> bool {
|
|
||||||
if self.config.animation.idle_dim_after_secs == 0 {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let idle_s = self.last_activity.elapsed().as_secs_f64();
|
|
||||||
let threshold = self.config.animation.idle_dim_after_secs as f64;
|
|
||||||
let ramp_s = render::IDLE_DIM_RAMP_MS as f64 / 1000.0;
|
|
||||||
idle_s > threshold && idle_s < threshold + ramp_s
|
|
||||||
}
|
|
||||||
|
|
||||||
fn caret_blink_in_progress(&self) -> bool {
|
|
||||||
if self.unlocking.is_some() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let len = if self.password.is_empty() {
|
|
||||||
self.password_display_len
|
|
||||||
} else {
|
|
||||||
self.password.chars().count()
|
|
||||||
};
|
|
||||||
len > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Any effect still running that needs the animation timer: the fast ones
|
|
||||||
/// (entrance, unlock flash+fade, shake, dot pop, clock rollover, status
|
|
||||||
/// slide, a live PAM check) plus the slow ones (idle breath, Ken Burns
|
|
||||||
/// pan, idle dim ramp, caret blink) which run at a reduced cadence — see
|
|
||||||
/// `tick_animation`.
|
|
||||||
fn anim_in_progress(&self) -> bool {
|
|
||||||
self.unlocking.is_some()
|
|
||||||
|| self.appear_in_progress()
|
|
||||||
|| self.failed_shake_in_progress()
|
|
||||||
|| self.dot_pop_in_progress()
|
|
||||||
|| self.clock_fade_in_progress()
|
|
||||||
|| self.status_slide_in_progress()
|
|
||||||
|| self.breathe_in_progress()
|
|
||||||
|| self.breathe_due()
|
|
||||||
|| self.auth_state == AuthState::Checking
|
|
||||||
|| self.background.ken_burns()
|
|
||||||
|| self.idle_dim_in_progress()
|
|
||||||
|| self.caret_blink_in_progress()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Keep requesting frames while any effect is running.
|
|
||||||
fn arm_anim_if_needed(&mut self, qh: &QueueHandle<Self>) {
|
|
||||||
if self.anim_timer_armed || !self.anim_in_progress() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// A breath that's due starts its window now, so the first ticked
|
|
||||||
// frame already shows the start of the hump.
|
|
||||||
if self.breathe_due() {
|
|
||||||
self.breathe_started = Some(Instant::now());
|
|
||||||
}
|
|
||||||
self.anim_timer_armed = true;
|
|
||||||
let qh = qh.clone();
|
|
||||||
if self
|
|
||||||
.loop_handle
|
|
||||||
.insert_source(
|
|
||||||
Timer::from_duration(Duration::from_millis(render::ANIM_FRAME_MS)),
|
|
||||||
move |_, _, state| state.tick_animation(&qh),
|
|
||||||
)
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
tracing::error!("failed to arm lock animation timer");
|
|
||||||
self.anim_timer_armed = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A 60 fps animation is in flight (everything except the slow idle
|
|
||||||
/// effects: idle breath, Ken Burns pan, idle dim, caret blink). Drives
|
|
||||||
/// both the timer cadence and whether background frames get sub-pixel
|
|
||||||
/// panning.
|
|
||||||
fn fast_anim_in_progress(&self) -> bool {
|
|
||||||
self.appear_in_progress()
|
|
||||||
|| self.unlock_in_progress()
|
|
||||||
|| self.failed_shake_in_progress()
|
|
||||||
|| self.dot_pop_in_progress()
|
|
||||||
|| self.clock_fade_in_progress()
|
|
||||||
|| self.status_slide_in_progress()
|
|
||||||
|| self.auth_state == AuthState::Checking
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tick_animation(&mut self, qh: &QueueHandle<Self>) -> TimeoutAction {
|
|
||||||
self.redraw_all(qh);
|
|
||||||
if self.unlocking.is_some() && !self.unlock_in_progress() {
|
|
||||||
self.anim_timer_armed = false;
|
|
||||||
TimeoutAction::Drop
|
|
||||||
} else if self.anim_in_progress() {
|
|
||||||
// Slow effects (idle breath, Ken Burns, dim, caret) don't need
|
|
||||||
// 60fps — halve the redraw cost for them. Everything else stays
|
|
||||||
// at ~60Hz.
|
|
||||||
let fast = self.fast_anim_in_progress();
|
|
||||||
TimeoutAction::ToDuration(Duration::from_millis(if fast {
|
|
||||||
render::ANIM_FRAME_MS
|
|
||||||
} else {
|
|
||||||
render::SLOW_FRAME_MS
|
|
||||||
}))
|
|
||||||
} else {
|
|
||||||
self.anim_timer_armed = false;
|
|
||||||
TimeoutAction::Drop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// After the unlock fade reaches t==1, send compositor `unlock` and
|
|
||||||
/// exit. Not called until then — dying mid-fade stays locked.
|
|
||||||
pub fn complete_unlock_if_ready(&mut self) {
|
|
||||||
let Some(started) = self.unlocking else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if started.elapsed() < Duration::from_millis(render::UNLOCK_MS) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Some(lock) = self.session_lock.take() {
|
|
||||||
tracing::info!("unlock fade complete");
|
|
||||||
lock.unlock();
|
|
||||||
crate::bread_events::emit_unlocked();
|
|
||||||
}
|
|
||||||
self.exit = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// After a failed attempt, clears the red UI once `input.fail_timeout_ms`
|
|
||||||
/// has elapsed — unless a newer fail (or the user typing) has moved the
|
|
||||||
/// generation. Input is not blocked during Failed.
|
|
||||||
pub fn schedule_clear_failed(&self, qh: QueueHandle<Self>) {
|
pub fn schedule_clear_failed(&self, qh: QueueHandle<Self>) {
|
||||||
let timeout = Duration::from_millis(self.config.input.fail_timeout_ms);
|
let timeout = Duration::from_millis(self.config.input.fail_timeout_ms);
|
||||||
let gen = self.failed_generation;
|
|
||||||
let _ =
|
let _ =
|
||||||
self.loop_handle
|
self.loop_handle
|
||||||
.insert_source(Timer::from_duration(timeout), move |_, _, state| {
|
.insert_source(Timer::from_duration(timeout), move |_, _, state| {
|
||||||
if fail_timer_applies(gen, state.failed_generation, state.auth_state) {
|
if matches!(state.auth_state, AuthState::Failed | AuthState::ConfigError) {
|
||||||
state.auth_state = AuthState::Idle;
|
state.auth_state = AuthState::Idle;
|
||||||
state.failed_at = None;
|
|
||||||
state.password_display_len = 0;
|
|
||||||
state.redraw_all(&qh);
|
state.redraw_all(&qh);
|
||||||
}
|
}
|
||||||
TimeoutAction::Drop
|
TimeoutAction::Drop
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record a Failed / AccountInvalid / ConfigError and bump the
|
|
||||||
/// generation so an older fail-clear timer cannot wipe this one.
|
|
||||||
pub fn enter_fail(&mut self, next: AuthState) {
|
|
||||||
self.auth_state = next;
|
|
||||||
self.failed_at = Some(Instant::now());
|
|
||||||
self.checking_started = None;
|
|
||||||
self.failed_generation = self.failed_generation.wrapping_add(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The animated ellipsis for the "Checking" status while a PAM check runs:
|
|
||||||
/// cycles "", ".", "..", "…" every ~500ms (driven by time since `started`).
|
|
||||||
fn checking_dots(started: Instant) -> &'static str {
|
|
||||||
match (started.elapsed().as_secs_f32() * 2.0) as usize % 4 {
|
|
||||||
0 => "",
|
|
||||||
1 => ".",
|
|
||||||
2 => "..",
|
|
||||||
_ => "…",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A fail-clear timer only fires if its captured generation is still current
|
|
||||||
/// and the UI is still in a fail-style state.
|
|
||||||
fn fail_timer_applies(timer_gen: u64, current_gen: u64, auth: AuthState) -> bool {
|
|
||||||
timer_gen == current_gen
|
|
||||||
&& matches!(
|
|
||||||
auth,
|
|
||||||
AuthState::Failed | AuthState::AccountInvalid | AuthState::ConfigError
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ShmHandler for AppState {
|
impl ShmHandler for AppState {
|
||||||
|
|
@ -756,30 +193,3 @@ impl ProvidesRegistryState for AppState {
|
||||||
}
|
}
|
||||||
registry_handlers![OutputState, SeatState];
|
registry_handlers![OutputState, SeatState];
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn checking_dots_with_stale_instant_is_not_empty() {
|
|
||||||
let started = Instant::now() - Duration::from_millis(750);
|
|
||||||
assert_ne!(checking_dots(started), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn checking_dots_at_now_is_empty_or_dot() {
|
|
||||||
// Fresh Instant: elapsed ≈ 0 → "".
|
|
||||||
assert_eq!(checking_dots(Instant::now()), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fail_timer_ignores_stale_generation() {
|
|
||||||
assert!(!fail_timer_applies(1, 2, AuthState::Failed));
|
|
||||||
assert!(fail_timer_applies(3, 3, AuthState::Failed));
|
|
||||||
assert!(fail_timer_applies(1, 1, AuthState::AccountInvalid));
|
|
||||||
assert!(fail_timer_applies(1, 1, AuthState::ConfigError));
|
|
||||||
assert!(!fail_timer_applies(1, 1, AuthState::Idle));
|
|
||||||
assert!(!fail_timer_applies(1, 1, AuthState::Checking));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,389 +0,0 @@
|
||||||
//! D-Bus status integration — now-playing (MPRIS) and battery (upower).
|
|
||||||
//!
|
|
||||||
//! Both are polled on a single background thread (zbus's blocking API has no
|
|
||||||
//! place on the render loop) and the result is posted back through a
|
|
||||||
//! `calloop::channel`, mirroring how [`crate::auth`] bridges PAM. Session and
|
|
||||||
//! system bus connections are opened once in that thread and reused; a failed
|
|
||||||
//! call drops the connection so the next tick reconnects. Missing or broken
|
|
||||||
//! D-Bus (headless CI, a session without upower, etc.) just yields empty
|
|
||||||
//! status — this module never blocks or fails the locker.
|
|
||||||
|
|
||||||
use smithay_client_toolkit::reexports::calloop::channel::{self, Sender};
|
|
||||||
use smithay_client_toolkit::reexports::calloop::LoopHandle;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use zbus::zvariant::{Dict, OwnedValue, Value};
|
|
||||||
|
|
||||||
/// One snapshot of the system status, rendered as a small line under the
|
|
||||||
/// clock when either field is present.
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq)]
|
|
||||||
pub struct StatusInfo {
|
|
||||||
/// `"{title} — {artist}"` for the currently-playing MPRIS player (the
|
|
||||||
/// first one advertising `PlaybackStatus == "Playing"`, else the first
|
|
||||||
/// paused one). Playing players with no title fall back to artist, the
|
|
||||||
/// player name, or `"Playing"`. Empty when nothing is playing or MPRIS
|
|
||||||
/// is unreachable.
|
|
||||||
pub now_playing: String,
|
|
||||||
/// `"87% · charging"`-style summary from upower's display device.
|
|
||||||
/// Empty when there is no battery or upower is unreachable.
|
|
||||||
pub battery: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Registers the receiving half of the status channel on the event loop and
|
|
||||||
/// returns the `Sender` the background poller hands snapshots to.
|
|
||||||
pub fn register<Data: 'static>(
|
|
||||||
loop_handle: &LoopHandle<'static, Data>,
|
|
||||||
mut on_update: impl FnMut(&mut Data, StatusInfo) + 'static,
|
|
||||||
) -> Sender<StatusInfo> {
|
|
||||||
let (tx, channel) = channel::channel();
|
|
||||||
loop_handle
|
|
||||||
.insert_source(channel, move |event, _, data| {
|
|
||||||
if let channel::Event::Msg(info) = event {
|
|
||||||
on_update(data, info);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.expect("failed to register status channel on event loop");
|
|
||||||
tx
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How often the background thread re-queries D-Bus.
|
|
||||||
const POLL_SECS: u64 = 3;
|
|
||||||
|
|
||||||
const MPRIS_FIELD_MAX: usize = 80;
|
|
||||||
const MPRIS_LINE_MAX: usize = 120;
|
|
||||||
|
|
||||||
/// UPower Device Type for a battery. DisplayDevice on a desktop is often
|
|
||||||
/// some other kind (line power) with `Percentage == 0`.
|
|
||||||
const UPOWER_TYPE_BATTERY: u32 = 2;
|
|
||||||
|
|
||||||
/// Spawns the poller thread. It runs for the life of the process (the locker
|
|
||||||
/// exits on unlock), re-querying every [`POLL_SECS`] seconds and forwarding
|
|
||||||
/// each snapshot. When both `now_playing` and `battery` are false, returns
|
|
||||||
/// immediately without touching D-Bus.
|
|
||||||
pub fn spawn_poller(tx: Sender<StatusInfo>, now_playing: bool, battery: bool) {
|
|
||||||
if !now_playing && !battery {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let mut session: Option<zbus::blocking::Connection> = None;
|
|
||||||
let mut system: Option<zbus::blocking::Connection> = None;
|
|
||||||
loop {
|
|
||||||
let info = poll_once(&mut session, &mut system, now_playing, battery);
|
|
||||||
if tx.send(info).is_err() {
|
|
||||||
// Event loop gone (unlocked) — nothing left to report.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
std::thread::sleep(std::time::Duration::from_secs(POLL_SECS));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn poll_once(
|
|
||||||
session: &mut Option<zbus::blocking::Connection>,
|
|
||||||
system: &mut Option<zbus::blocking::Connection>,
|
|
||||||
now_playing: bool,
|
|
||||||
battery: bool,
|
|
||||||
) -> StatusInfo {
|
|
||||||
StatusInfo {
|
|
||||||
now_playing: if now_playing {
|
|
||||||
poll_now_playing(session)
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
},
|
|
||||||
battery: if battery {
|
|
||||||
poll_battery(system)
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn poll_now_playing(session: &mut Option<zbus::blocking::Connection>) -> String {
|
|
||||||
if session.is_none() {
|
|
||||||
*session = zbus::blocking::Connection::session().ok();
|
|
||||||
}
|
|
||||||
match session.as_ref().map(poll_now_playing_on) {
|
|
||||||
Some(Ok(line)) => line,
|
|
||||||
Some(Err(())) => {
|
|
||||||
*session = None;
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
None => String::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn poll_now_playing_on(conn: &zbus::blocking::Connection) -> Result<String, ()> {
|
|
||||||
let names = conn
|
|
||||||
.call_method(
|
|
||||||
Some("org.freedesktop.DBus"),
|
|
||||||
"/org/freedesktop/DBus",
|
|
||||||
Some("org.freedesktop.DBus"),
|
|
||||||
"ListNames",
|
|
||||||
&(),
|
|
||||||
)
|
|
||||||
.and_then(|reply| reply.body().deserialize::<Vec<String>>())
|
|
||||||
.map_err(|_| ())?;
|
|
||||||
|
|
||||||
let mut paused: Option<String> = None;
|
|
||||||
for name in names.iter().filter(|n| n.starts_with("org.mpris.MediaPlayer2.")) {
|
|
||||||
let Some((status, title, artist)) = read_player(conn, name) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let line = format_now_playing(title.as_deref(), artist.as_deref(), name);
|
|
||||||
match status.as_str() {
|
|
||||||
"Playing" => return Ok(line),
|
|
||||||
"Paused" if paused.is_none() => paused = Some(line),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(paused.unwrap_or_default())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_player(
|
|
||||||
conn: &zbus::blocking::Connection,
|
|
||||||
name: &str,
|
|
||||||
) -> Option<(String, Option<String>, Option<String>)> {
|
|
||||||
let props = conn
|
|
||||||
.call_method(
|
|
||||||
Some(name),
|
|
||||||
"/org/mpris/MediaPlayer2",
|
|
||||||
Some("org.freedesktop.DBus.Properties"),
|
|
||||||
"GetAll",
|
|
||||||
&("org.mpris.MediaPlayer2.Player",),
|
|
||||||
)
|
|
||||||
.ok()?;
|
|
||||||
let dict: HashMap<String, OwnedValue> = props.body().deserialize().ok()?;
|
|
||||||
|
|
||||||
let status = dict
|
|
||||||
.get("PlaybackStatus")
|
|
||||||
.and_then(|v| v.downcast_ref::<&str>().ok())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
let mut title = None;
|
|
||||||
let mut artist = None;
|
|
||||||
if let Some(metadata) = dict.get("Metadata").and_then(|v| v.downcast_ref::<Dict>().ok()) {
|
|
||||||
title = metadata
|
|
||||||
.get::<&str, &str>(&"xesam:title")
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.map(str::to_string);
|
|
||||||
artist = metadata
|
|
||||||
.get::<&str, Value>(&"xesam:artist")
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.and_then(|v| match v {
|
|
||||||
Value::Array(arr) => {
|
|
||||||
let joined = arr
|
|
||||||
.iter()
|
|
||||||
.filter_map(|e| e.downcast_ref::<&str>().ok())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(", ");
|
|
||||||
if joined.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(joined)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Some((status, title, artist))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds the now-playing line. Title and artist are newline-stripped and
|
|
||||||
/// capped; a Playing player with neither still yields the player name (or
|
|
||||||
/// `"Playing"`) so it is not outranked by a later titled Paused player.
|
|
||||||
fn format_now_playing(title: Option<&str>, artist: Option<&str>, player: &str) -> String {
|
|
||||||
let title = title
|
|
||||||
.map(sanitize_mpris_field)
|
|
||||||
.filter(|s| !s.is_empty());
|
|
||||||
let artist = artist
|
|
||||||
.map(sanitize_mpris_field)
|
|
||||||
.filter(|s| !s.is_empty());
|
|
||||||
let line = match (title, artist) {
|
|
||||||
(Some(t), Some(a)) => format!("{t} — {a}"),
|
|
||||||
(Some(t), None) => t,
|
|
||||||
(None, Some(a)) => a,
|
|
||||||
(None, None) => mpris_player_fallback(player),
|
|
||||||
};
|
|
||||||
truncate_chars(&line, MPRIS_LINE_MAX)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sanitize_mpris_field(s: &str) -> String {
|
|
||||||
let collapsed = s.split_whitespace().collect::<Vec<_>>().join(" ");
|
|
||||||
truncate_chars(&collapsed, MPRIS_FIELD_MAX)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mpris_player_fallback(bus_name: &str) -> String {
|
|
||||||
bus_name
|
|
||||||
.strip_prefix("org.mpris.MediaPlayer2.")
|
|
||||||
.and_then(|rest| rest.split('.').next())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.unwrap_or("Playing")
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn truncate_chars(s: &str, max: usize) -> String {
|
|
||||||
match s.char_indices().nth(max) {
|
|
||||||
None => s.to_string(),
|
|
||||||
Some((idx, _)) => s[..idx].to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn poll_battery(system: &mut Option<zbus::blocking::Connection>) -> String {
|
|
||||||
if system.is_none() {
|
|
||||||
*system = zbus::blocking::Connection::system().ok();
|
|
||||||
}
|
|
||||||
match system.as_ref().map(poll_battery_on) {
|
|
||||||
Some(Ok(line)) => line,
|
|
||||||
Some(Err(())) => {
|
|
||||||
*system = None;
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
None => String::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn poll_battery_on(conn: &zbus::blocking::Connection) -> Result<String, ()> {
|
|
||||||
let path = conn
|
|
||||||
.call_method(
|
|
||||||
Some("org.freedesktop.UPower"),
|
|
||||||
"/org/freedesktop/UPower",
|
|
||||||
Some("org.freedesktop.UPower"),
|
|
||||||
"GetDisplayDevice",
|
|
||||||
&(),
|
|
||||||
)
|
|
||||||
.and_then(|reply| reply.body().deserialize::<zbus::zvariant::OwnedObjectPath>())
|
|
||||||
.map_err(|_| ())?;
|
|
||||||
let props = conn
|
|
||||||
.call_method(
|
|
||||||
Some("org.freedesktop.UPower"),
|
|
||||||
path.as_str(),
|
|
||||||
Some("org.freedesktop.DBus.Properties"),
|
|
||||||
"GetAll",
|
|
||||||
&("org.freedesktop.UPower.Device",),
|
|
||||||
)
|
|
||||||
.and_then(|reply| {
|
|
||||||
reply
|
|
||||||
.body()
|
|
||||||
.deserialize::<HashMap<String, OwnedValue>>()
|
|
||||||
})
|
|
||||||
.map_err(|_| ())?;
|
|
||||||
// DisplayDevice always exists; without a battery IsPresent is false
|
|
||||||
// and Percentage is often 0. Missing IsPresent is treated as absent.
|
|
||||||
let present = props
|
|
||||||
.get("IsPresent")
|
|
||||||
.and_then(|v| v.downcast_ref::<bool>().ok())
|
|
||||||
.unwrap_or(false);
|
|
||||||
if let Some(kind) = props.get("Type").and_then(|v| v.downcast_ref::<u32>().ok()) {
|
|
||||||
if kind != UPOWER_TYPE_BATTERY {
|
|
||||||
return Ok(String::new());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let Some(pct) = props
|
|
||||||
.get("Percentage")
|
|
||||||
.and_then(|v| v.downcast_ref::<f64>().ok())
|
|
||||||
else {
|
|
||||||
return Ok(String::new());
|
|
||||||
};
|
|
||||||
let state = props
|
|
||||||
.get("State")
|
|
||||||
.and_then(|v| v.downcast_ref::<u32>().ok())
|
|
||||||
.unwrap_or(0);
|
|
||||||
Ok(format_battery(present, pct, state))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_battery(present: bool, pct: f64, state: u32) -> String {
|
|
||||||
if !present {
|
|
||||||
return String::new();
|
|
||||||
}
|
|
||||||
// UPower Device state: 1 charging, 2 discharging, 3 empty, 4 full.
|
|
||||||
let suffix = match state {
|
|
||||||
1 => " · charging",
|
|
||||||
2 => "",
|
|
||||||
4 => " · full",
|
|
||||||
_ => "",
|
|
||||||
};
|
|
||||||
format!("{pct:.0}%{suffix}")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_battery_absent_is_empty() {
|
|
||||||
assert_eq!(format_battery(false, 0.0, 0), "");
|
|
||||||
assert_eq!(format_battery(false, 87.4, 1), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_battery_present_covers_common_states() {
|
|
||||||
assert_eq!(format_battery(true, 87.4, 1), "87% · charging");
|
|
||||||
assert_eq!(format_battery(true, 43.0, 2), "43%");
|
|
||||||
assert_eq!(format_battery(true, 100.0, 4), "100% · full");
|
|
||||||
assert_eq!(format_battery(true, 2.0, 3), "2%");
|
|
||||||
// Laptop at 0% still has a battery; desktops are filtered via IsPresent.
|
|
||||||
assert_eq!(format_battery(true, 0.0, 2), "0%");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_now_playing_joins_title_and_artist() {
|
|
||||||
assert_eq!(
|
|
||||||
format_now_playing(
|
|
||||||
Some("Paranoid Android"),
|
|
||||||
Some("Radiohead"),
|
|
||||||
"org.mpris.MediaPlayer2.spotify"
|
|
||||||
),
|
|
||||||
"Paranoid Android — Radiohead"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
format_now_playing(Some("Untitled"), None, "org.mpris.MediaPlayer2.mpv"),
|
|
||||||
"Untitled"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_now_playing_playing_without_title_uses_fallback() {
|
|
||||||
assert_eq!(
|
|
||||||
format_now_playing(None, Some("Radiohead"), "org.mpris.MediaPlayer2.spotify"),
|
|
||||||
"Radiohead"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
format_now_playing(None, None, "org.mpris.MediaPlayer2.spotify"),
|
|
||||||
"spotify"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
format_now_playing(None, None, "org.mpris.MediaPlayer2.firefox.instance1"),
|
|
||||||
"firefox"
|
|
||||||
);
|
|
||||||
assert_eq!(format_now_playing(None, None, ""), "Playing");
|
|
||||||
assert_eq!(
|
|
||||||
format_now_playing(Some("\n\n"), None, "org.mpris.MediaPlayer2.mpv"),
|
|
||||||
"mpv"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_now_playing_strips_newlines_and_truncates() {
|
|
||||||
assert_eq!(
|
|
||||||
format_now_playing(Some("foo\nbar"), Some("a\r\nb"), "org.mpris.MediaPlayer2.x"),
|
|
||||||
"foo bar — a b"
|
|
||||||
);
|
|
||||||
let title = "T".repeat(100);
|
|
||||||
let titled = format_now_playing(Some(&title), None, "org.mpris.MediaPlayer2.x");
|
|
||||||
assert_eq!(titled.chars().count(), MPRIS_FIELD_MAX);
|
|
||||||
assert!(!titled.contains('\n'));
|
|
||||||
let artist = "A".repeat(100);
|
|
||||||
let combined = format_now_playing(Some(&title), Some(&artist), "org.mpris.MediaPlayer2.x");
|
|
||||||
assert_eq!(combined.chars().count(), MPRIS_LINE_MAX);
|
|
||||||
assert!(combined.starts_with('T'));
|
|
||||||
assert!(!combined.contains('\n'));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn spawn_poller_both_false_returns() {
|
|
||||||
let (tx, _rx) = channel::channel();
|
|
||||||
spawn_poller(tx, false, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,661 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>breadlock × breadgreet — style & motion sketch</title>
|
|
||||||
<style>
|
|
||||||
/* ============================================================
|
|
||||||
Design tokens — mirrors bread-theme (BREAD_DESIGN_SYSTEM.md)
|
|
||||||
bg/surface/overlay/foreground are the fixed BOS dark base;
|
|
||||||
color1-6 are pywal-derived accents. Two palettes shown:
|
|
||||||
"bread" = curated bread-toned defaults (fresh install)
|
|
||||||
"tokyo" = an example pywal palette (Tokyo Night)
|
|
||||||
============================================================ */
|
|
||||||
:root, [data-palette="bread"] {
|
|
||||||
--bg: #0c0c0c;
|
|
||||||
--surface: #1a1a1a;
|
|
||||||
--surface2: #232323;
|
|
||||||
--overlay: #d8d8d8;
|
|
||||||
--fg: #e8e8e8;
|
|
||||||
--red: #b98749; /* color1 */
|
|
||||||
--green: #cd9450; /* color2 */
|
|
||||||
--yellow: #e3a85c; /* color3 */
|
|
||||||
--accent: #eab672; /* color4 */
|
|
||||||
--pink: #f6c477; /* color5 */
|
|
||||||
--teal: #eabe82; /* color6 */
|
|
||||||
--radius: 8px; /* primary */
|
|
||||||
--radius-sm: 6px; /* secondary (inputs) */
|
|
||||||
--font: "Varela Round", "Segoe UI", system-ui, -apple-system, sans-serif;
|
|
||||||
--line: #2b2b2b;
|
|
||||||
}
|
|
||||||
[data-palette="tokyo"] {
|
|
||||||
--red: #f7768e;
|
|
||||||
--green: #9ece6a;
|
|
||||||
--yellow: #e0af68;
|
|
||||||
--accent: #7aa2f7;
|
|
||||||
--pink: #bb9af7;
|
|
||||||
--teal: #7dcfff;
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
html, body { margin: 0; padding: 0; }
|
|
||||||
body {
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--fg);
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.5;
|
|
||||||
padding: 32px clamp(16px, 4vw, 48px) 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
header.maxw, main { max-width: 1240px; margin: 0 auto; }
|
|
||||||
|
|
||||||
header h1 { font-size: 26px; margin: 0 0 6px; letter-spacing: .2px; }
|
|
||||||
header p.sub { color: var(--overlay); opacity: .72; margin: 0 0 18px; max-width: 72ch; }
|
|
||||||
header .meta {
|
|
||||||
display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.palette-toggle {
|
|
||||||
display: inline-flex; border: 1px solid var(--line); border-radius: var(--radius);
|
|
||||||
overflow: hidden; margin-left: auto;
|
|
||||||
}
|
|
||||||
.palette-toggle button {
|
|
||||||
background: transparent; color: var(--overlay); border: 0; padding: 6px 14px;
|
|
||||||
font: inherit; font-size: 12px; cursor: pointer;
|
|
||||||
}
|
|
||||||
.palette-toggle button.active { background: var(--surface2); color: var(--fg); }
|
|
||||||
.swatches { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
|
||||||
.swatch { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--overlay); opacity: .85; }
|
|
||||||
.swatch i { width: 14px; height: 14px; border-radius: 4px; border: 1px solid rgba(255,255,255,.15); display: inline-block; }
|
|
||||||
|
|
||||||
h2 { font-size: 16px; margin: 34px 0 4px; }
|
|
||||||
h2 small { color: var(--overlay); opacity: .6; font-weight: 400; margin-left: 8px; }
|
|
||||||
.hint { color: var(--overlay); opacity: .7; font-size: 12px; margin: 0 0 14px; max-width: 90ch; }
|
|
||||||
|
|
||||||
/* ---------- stages ---------- */
|
|
||||||
.stages { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
|
||||||
@media (max-width: 960px) { .stages { grid-template-columns: 1fr; } }
|
|
||||||
|
|
||||||
.stage {
|
|
||||||
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
|
|
||||||
padding: 14px; display: flex; flex-direction: column; gap: 12px;
|
|
||||||
}
|
|
||||||
.stage > .stage-head { display: flex; align-items: baseline; gap: 8px; }
|
|
||||||
.stage > .stage-head h3 { margin: 0; font-size: 14px; }
|
|
||||||
.stage > .stage-head span { font-size: 11px; color: var(--overlay); opacity: .6; }
|
|
||||||
.stage .states { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
||||||
.stage .states button {
|
|
||||||
background: var(--surface2); color: var(--overlay); border: 1px solid var(--line);
|
|
||||||
border-radius: 999px; padding: 4px 12px; font: inherit; font-size: 11px; cursor: pointer;
|
|
||||||
}
|
|
||||||
.stage .states button.active { background: var(--accent); color: #0c0c0c; border-color: transparent; font-weight: 700; }
|
|
||||||
|
|
||||||
/* the "monitor" */
|
|
||||||
.screen {
|
|
||||||
position: relative; width: 100%; aspect-ratio: 16 / 9; border-radius: var(--radius-sm);
|
|
||||||
overflow: hidden; border: 1px solid var(--line); background: var(--bg);
|
|
||||||
}
|
|
||||||
.wallpaper, .veil { position: absolute; inset: 0; }
|
|
||||||
[data-palette="bread"] .wallpaper {
|
|
||||||
background:
|
|
||||||
radial-gradient(120% 90% at 18% 8%, #3b2f1e 0%, transparent 55%),
|
|
||||||
radial-gradient(100% 100% at 88% 88%, #2c2313 0%, transparent 60%),
|
|
||||||
linear-gradient(160deg, #17130c 0%, #0c0c0c 72%);
|
|
||||||
}
|
|
||||||
[data-palette="tokyo"] .wallpaper {
|
|
||||||
background:
|
|
||||||
radial-gradient(120% 90% at 18% 8%, #2b3152 0%, transparent 55%),
|
|
||||||
radial-gradient(100% 100% at 88% 88%, #1c2338 0%, transparent 60%),
|
|
||||||
linear-gradient(160deg, #10121c 0%, #0c0c0c 72%);
|
|
||||||
}
|
|
||||||
.veil { background: rgba(0, 0, 0, .28); } /* DIM_ALPHA = 0.28, fades with chrome */
|
|
||||||
|
|
||||||
/* ---------- breadlock overlay ---------- */
|
|
||||||
.lockoverlay {
|
|
||||||
position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center;
|
|
||||||
padding-top: 12%; transition: opacity 400ms ease, transform 400ms ease;
|
|
||||||
}
|
|
||||||
.screen[data-state="unlock"] .lockoverlay { opacity: 0; transform: translateY(-20px); }
|
|
||||||
|
|
||||||
.lclock {
|
|
||||||
font-size: clamp(34px, 7.5vw, 60px); font-weight: 700; color: #fff; text-align: center;
|
|
||||||
animation: riseIn 450ms ease-out both;
|
|
||||||
}
|
|
||||||
.ldate {
|
|
||||||
font-size: clamp(11px, 1.6vw, 14px); color: rgba(255,255,255,.82); margin-top: 6px;
|
|
||||||
animation: riseIn 450ms ease-out 80ms both;
|
|
||||||
}
|
|
||||||
.badge-new {
|
|
||||||
display: inline-block; vertical-align: middle; margin-left: 6px; padding: 1px 6px;
|
|
||||||
font-size: 9px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase;
|
|
||||||
border-radius: 4px; background: var(--accent); color: #0c0c0c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill {
|
|
||||||
position: relative; margin-top: clamp(20px, 5vh, 44px);
|
|
||||||
width: clamp(190px, 36vw, 280px); height: clamp(34px, 6vw, 48px);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid rgba(255,255,255,.08);
|
|
||||||
display: flex; align-items: center; justify-content: center; gap: clamp(8px, 1.6vw, 18px);
|
|
||||||
animation: popIn 380ms cubic-bezier(.34, 1.56, .64, 1) 100ms both;
|
|
||||||
transition: background-color 150ms ease, border-color 150ms ease;
|
|
||||||
box-shadow: 0 4px 18px rgba(0,0,0,.45);
|
|
||||||
}
|
|
||||||
.dot {
|
|
||||||
width: clamp(6px, 1.1vw, 9px); height: clamp(6px, 1.1vw, 9px); border-radius: 50%;
|
|
||||||
background: var(--accent); animation: dotPop 200ms ease-out both;
|
|
||||||
}
|
|
||||||
.pill .dot:nth-child(1) { animation-delay: 150ms; }
|
|
||||||
.pill .dot:nth-child(2) { animation-delay: 200ms; }
|
|
||||||
.pill .dot:nth-child(3) { animation-delay: 250ms; }
|
|
||||||
.caret {
|
|
||||||
width: 2px; height: 1.2em; background: var(--accent); border-radius: 1px;
|
|
||||||
animation: caretBlink 1.1s steps(1) infinite; opacity: .9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lstatus { margin-top: 12px; font-size: 12px; color: var(--overlay); min-height: 1em; text-align: center; }
|
|
||||||
|
|
||||||
/* wrong password: shake + red, then fade back */
|
|
||||||
.screen[data-state="wrong"] .pill {
|
|
||||||
background: var(--red); border-color: transparent;
|
|
||||||
animation: shake 380ms cubic-bezier(.36,.07,.19,.97);
|
|
||||||
}
|
|
||||||
.screen[data-state="wrong"] .dot, .screen[data-state="wrong"] .caret { background: #fff; }
|
|
||||||
.screen[data-state="wrong"] .lstatus { color: var(--red); font-weight: 700; }
|
|
||||||
|
|
||||||
/* success: green flash, then the unlock fade is handled by data-state="unlock" */
|
|
||||||
.screen[data-state="success"] .pill { background: var(--green); border-color: transparent; animation: successFlash 300ms ease-out; }
|
|
||||||
.screen[data-state="success"] .dot, .screen[data-state="success"] .caret { background: #fff; }
|
|
||||||
.screen[data-state="success"] .lstatus { color: var(--green); font-weight: 700; }
|
|
||||||
|
|
||||||
.replay {
|
|
||||||
align-self: flex-start; background: transparent; color: var(--accent); border: 1px solid var(--line);
|
|
||||||
border-radius: var(--radius-sm); padding: 4px 12px; font: inherit; font-size: 11px; cursor: pointer;
|
|
||||||
}
|
|
||||||
.replay:hover { border-color: var(--accent); }
|
|
||||||
|
|
||||||
/* ---------- breadgreet ---------- */
|
|
||||||
.greetoverlay {
|
|
||||||
position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center;
|
|
||||||
justify-content: center; gap: 18px;
|
|
||||||
}
|
|
||||||
.gclock { font-size: clamp(28px, 5vw, 44px); font-weight: 700; color: #fff; animation: riseIn 450ms ease-out both; }
|
|
||||||
|
|
||||||
.card {
|
|
||||||
width: clamp(240px, 44vw, 340px); background: var(--surface);
|
|
||||||
border: 1px solid rgba(255,255,255,.08); border-radius: var(--radius);
|
|
||||||
padding: 20px; display: flex; flex-direction: column; gap: 10px;
|
|
||||||
animation: riseIn 450ms ease-out 120ms both;
|
|
||||||
box-shadow: 0 6px 24px rgba(0,0,0,.5);
|
|
||||||
}
|
|
||||||
.gentry {
|
|
||||||
width: 100%; background: var(--surface2); color: var(--fg);
|
|
||||||
border: 1px solid var(--line); border-radius: var(--radius-sm);
|
|
||||||
padding: 10px 14px; font: inherit; font-size: 14px;
|
|
||||||
transition: border-color 200ms ease, box-shadow 200ms ease;
|
|
||||||
}
|
|
||||||
.gentry:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px rgba(234,182,114,.25); }
|
|
||||||
[data-palette="tokyo"] .gentry:focus { box-shadow: 0 0 0 2px rgba(122,162,247,.28); }
|
|
||||||
|
|
||||||
.gstatus { font-size: 12px; color: var(--overlay); opacity: .75; min-height: 1em; text-align: center; transition: opacity 200ms; }
|
|
||||||
.screen[data-state="error"] .gstatus { color: var(--red); opacity: 1; font-weight: 700; }
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
margin: 0 auto; width: 18px; height: 18px; border-radius: 50%;
|
|
||||||
border: 2px solid rgba(255,255,255,.15); border-top-color: var(--accent);
|
|
||||||
animation: spin .8s linear infinite; display: none;
|
|
||||||
}
|
|
||||||
.screen[data-state="checking"] .spinner { display: block; }
|
|
||||||
|
|
||||||
.srow {
|
|
||||||
display: flex; align-items: center; gap: 10px; width: 100%;
|
|
||||||
background: var(--surface2); border: 1px solid var(--line); border-radius: var(--radius-sm);
|
|
||||||
padding: 8px 12px; font-size: 12px; color: var(--overlay);
|
|
||||||
}
|
|
||||||
.srow .icon {
|
|
||||||
width: 20px; height: 20px; border-radius: 5px; flex: none;
|
|
||||||
background: linear-gradient(135deg, var(--accent), var(--teal));
|
|
||||||
}
|
|
||||||
.srow .label { flex: 1; text-align: left; }
|
|
||||||
.srow .chev { color: var(--overlay); opacity: .6; }
|
|
||||||
.screen[data-state="error"] .card { animation: shake 380ms cubic-bezier(.36,.07,.19,.97); }
|
|
||||||
|
|
||||||
/* ---------- keyframes ---------- */
|
|
||||||
@keyframes riseIn { from { opacity: 0; transform: translateY(18px); } to { opacity: 1; transform: none; } }
|
|
||||||
@keyframes popIn { from { opacity: 0; transform: scale(.94); } 70% { transform: scale(1.02); } to { opacity: 1; transform: scale(1); } }
|
|
||||||
@keyframes dotPop { from { transform: scale(0); } 70% { transform: scale(1.35); } to { transform: scale(1); } }
|
|
||||||
@keyframes caretBlink { 0%, 55% { opacity: .9; } 56%, 100% { opacity: 0; } }
|
|
||||||
@keyframes shake {
|
|
||||||
10%, 90% { transform: translateX(-2px); } 20%, 80% { transform: translateX(5px); }
|
|
||||||
30%, 50%, 70% { transform: translateX(-8px); } 40%, 60% { transform: translateX(8px); }
|
|
||||||
}
|
|
||||||
@keyframes successFlash { from { box-shadow: 0 0 0 0 rgba(205,148,80,.55); } to { box-shadow: 0 0 0 22px rgba(205,148,80,0); } }
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
@keyframes breathe { 0%, 100% { box-shadow: 0 4px 18px rgba(0,0,0,.45); } 50% { box-shadow: 0 4px 26px rgba(0,0,0,.6), 0 0 0 1px rgba(234,182,114,.12); } }
|
|
||||||
@keyframes clockFlip { 0% { opacity: 1; } 45% { opacity: 0; transform: translateY(6px); } 55% { opacity: 0; transform: translateY(-6px); } 100% { opacity: 1; transform: none; } }
|
|
||||||
@keyframes kbdPan { 0% { transform: translateX(-2.5%) scale(1.06); } 100% { transform: translateX(2.5%) scale(1.06); } }
|
|
||||||
|
|
||||||
/* ---------- motion library ---------- */
|
|
||||||
.tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }
|
|
||||||
.tile {
|
|
||||||
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
|
|
||||||
padding: 12px; display: flex; flex-direction: column; gap: 8px;
|
|
||||||
}
|
|
||||||
.tile .tname { font-size: 13px; font-weight: 700; display: flex; align-items: center; gap: 6px; }
|
|
||||||
.tile .tag { font-size: 9px; font-weight: 700; letter-spacing: .5px; text-transform: uppercase; padding: 1px 6px; border-radius: 4px; background: var(--surface2); color: var(--overlay); }
|
|
||||||
.tile .tag.S { color: var(--green); } .tile .tag.M { color: var(--yellow); } .tile .tag.L { color: var(--red); }
|
|
||||||
.tile .tnote { font-size: 11px; color: var(--overlay); opacity: .75; min-height: 3em; }
|
|
||||||
.tile .tscreen {
|
|
||||||
position: relative; width: 100%; aspect-ratio: 16 / 7; border-radius: var(--radius-sm);
|
|
||||||
overflow: hidden; background: var(--bg); border: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* tile demos */
|
|
||||||
.tile .tscreen .tclock { position: absolute; top: 22%; left: 0; right: 0; text-align: center; color: #fff; font-weight: 700; font-size: 22px; }
|
|
||||||
.tile .tscreen .tpill {
|
|
||||||
position: absolute; top: 52%; left: 50%; transform: translateX(-50%);
|
|
||||||
width: 120px; height: 26px; border-radius: var(--radius-sm); background: var(--surface);
|
|
||||||
border: 1px solid rgba(255,255,255,.08); display: flex; align-items: center; justify-content: center; gap: 9px;
|
|
||||||
}
|
|
||||||
.tile .tscreen .tpill i { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); }
|
|
||||||
.tile .tscreen .tpill .tc { width: 2px; height: 12px; border-radius: 1px; background: var(--accent); animation: caretBlink 1.1s steps(1) infinite; }
|
|
||||||
|
|
||||||
.tile[data-tile="stagger"] .tclock, .tile[data-tile="stagger"] .tpill { animation: riseIn 450ms ease-out both; }
|
|
||||||
.tile[data-tile="stagger"] .tpill { animation-name: popIn; animation-delay: 140ms; }
|
|
||||||
|
|
||||||
.tile[data-tile="dotpop"] .tpill i:nth-child(1) { animation: dotPop 200ms ease-out 120ms both; }
|
|
||||||
.tile[data-tile="dotpop"] .tpill i:nth-child(2) { animation: dotPop 200ms ease-out 180ms both; }
|
|
||||||
.tile[data-tile="dotpop"] .tpill i:nth-child(3) { animation: dotPop 200ms ease-out 240ms both; }
|
|
||||||
|
|
||||||
.tile[data-tile="shake"] .tpill { background: var(--red); animation: shake 380ms cubic-bezier(.36,.07,.19,.97) 200ms both; }
|
|
||||||
.tile[data-tile="shake"] .tpill i { background: #fff; }
|
|
||||||
.tile[data-tile="shake"] .tstatus { position: absolute; top: 66%; width: 100%; text-align: center; font-size: 10px; color: var(--red); opacity: 0; animation: fadeIn 200ms ease 380ms both; }
|
|
||||||
|
|
||||||
.tile[data-tile="flash"] .tpill { background: var(--green); animation: successFlash 300ms ease-out 200ms both; }
|
|
||||||
.tile[data-tile="flash"] .tpill i { background: #fff; }
|
|
||||||
|
|
||||||
.tile[data-tile="crossfade"] .tclock .old, .tile[data-tile="crossfade"] .tclock .new {
|
|
||||||
position: absolute; inset: 0; transition: opacity 300ms ease, transform 300ms ease;
|
|
||||||
}
|
|
||||||
.tile[data-tile="crossfade"] .tclock .new { opacity: 0; transform: translateY(6px); }
|
|
||||||
.tile[data-tile="crossfade"].ticked .tclock .old { opacity: 0; transform: translateY(-6px); }
|
|
||||||
.tile[data-tile="crossfade"].ticked .tclock .new { opacity: 1; transform: none; }
|
|
||||||
|
|
||||||
.tile[data-tile="breathe"] .tpill { animation: breathe 3.2s ease-in-out infinite; }
|
|
||||||
|
|
||||||
.tile[data-tile="gcard"] .gcard-mini {
|
|
||||||
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
|
||||||
width: 130px; background: var(--surface); border: 1px solid rgba(255,255,255,.08);
|
|
||||||
border-radius: var(--radius); padding: 10px; animation: riseIn 450ms ease-out both;
|
|
||||||
}
|
|
||||||
.tile[data-tile="gcard"] .gcard-mini b { display: block; height: 12px; border-radius: 4px; background: var(--surface2); }
|
|
||||||
.tile[data-tile="gcard"] .gcard-mini b + b { margin-top: 6px; height: 8px; opacity: .6; }
|
|
||||||
|
|
||||||
.tile[data-tile="focus"] .gcard-mini b:first-child { transition: border-color 200ms, box-shadow 200ms; border: 1px solid var(--line); }
|
|
||||||
.tile[data-tile="focus"].focused .gcard-mini b:first-child { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(234,182,114,.25); }
|
|
||||||
|
|
||||||
.tile[data-tile="spinner"] .spin-mini {
|
|
||||||
position: absolute; top: 50%; left: 50%; width: 20px; height: 20px; margin: -10px 0 0 -10px;
|
|
||||||
border-radius: 50%; border: 2px solid rgba(255,255,255,.15); border-top-color: var(--accent);
|
|
||||||
animation: spin .8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile[data-tile="session"] .rows { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 140px; display: flex; flex-direction: column; gap: 5px; }
|
|
||||||
.tile[data-tile="session"] .rows div {
|
|
||||||
display: flex; align-items: center; gap: 7px; background: var(--surface2);
|
|
||||||
border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 5px 8px; font-size: 10px; color: var(--overlay);
|
|
||||||
}
|
|
||||||
.tile[data-tile="session"] .rows div i { width: 12px; height: 12px; border-radius: 3px; background: linear-gradient(135deg, var(--accent), var(--teal)); }
|
|
||||||
.tile[data-tile="session"] .rows div.sel { border-color: var(--accent); color: var(--fg); }
|
|
||||||
|
|
||||||
.tile[data-tile="kenburns"] .tpill { opacity: 0; }
|
|
||||||
.tile[data-tile="kenburns"] .tscreen.noanim::after { animation: none; }
|
|
||||||
.tile[data-tile="kenburns"] .tscreen::after {
|
|
||||||
content: ""; position: absolute; inset: -8%;
|
|
||||||
background: radial-gradient(120% 90% at 18% 8%, #3b2f1e 0%, transparent 55%),
|
|
||||||
radial-gradient(100% 100% at 88% 88%, #2c2313 0%, transparent 60%),
|
|
||||||
linear-gradient(160deg, #17130c 0%, #0c0c0c 72%);
|
|
||||||
animation: kbdPan 9s ease-in-out infinite alternate;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
|
||||||
|
|
||||||
/* ---------- implementation notes ---------- */
|
|
||||||
.notes { margin-top: 34px; display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
|
||||||
@media (max-width: 960px) { .notes { grid-template-columns: 1fr; } }
|
|
||||||
.notes .col { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px 18px; }
|
|
||||||
.notes h4 { margin: 0 0 10px; font-size: 13px; }
|
|
||||||
.notes ul { margin: 0; padding-left: 18px; font-size: 12px; color: var(--overlay); opacity: .9; }
|
|
||||||
.notes li { margin-bottom: 8px; }
|
|
||||||
.notes code { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11px; color: var(--accent); opacity: .9; }
|
|
||||||
footer { margin-top: 40px; color: var(--overlay); opacity: .55; font-size: 11px; max-width: 100ch; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body data-palette="bread">
|
|
||||||
|
|
||||||
<header class="maxw">
|
|
||||||
<div class="meta">
|
|
||||||
<h1>breadlock × breadgreet — style & motion sketch</h1>
|
|
||||||
<div class="palette-toggle" id="paletteToggle">
|
|
||||||
<button data-palette="bread" class="active">Bread default</button>
|
|
||||||
<button data-palette="tokyo">Pywal (Tokyo Night)</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="sub">
|
|
||||||
Live CSS prototype of the lock screen and greeter, grounded in the real
|
|
||||||
<code>bread-theme</code> tokens (fixed BOS dark base + pywal accents). The locker's
|
|
||||||
software renderer (tiny-skia) can reproduce every motion here; the greeter uses the
|
|
||||||
same CSS engine directly (GTK4). <b>Badges</b> mark what already ships vs what's proposed.
|
|
||||||
</p>
|
|
||||||
<div class="swatches">
|
|
||||||
<span class="swatch"><i style="background:#0c0c0c"></i>bg</span>
|
|
||||||
<span class="swatch"><i style="background:#1a1a1a"></i>surface</span>
|
|
||||||
<span class="swatch"><i id="swRed" style="background:var(--red)"></i>red</span>
|
|
||||||
<span class="swatch"><i id="swGreen" style="background:var(--green)"></i>green</span>
|
|
||||||
<span class="swatch"><i id="swAccent" style="background:var(--accent)"></i>accent</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<h2>Live stages <small>click a state chip to replay it</small></h2>
|
|
||||||
<p class="hint">The two apps should feel like one family: same palette, same radius/spacing tokens, same motion language (ease-out, 300–450ms).</p>
|
|
||||||
|
|
||||||
<div class="stages">
|
|
||||||
<!-- ================= breadlock ================= -->
|
|
||||||
<section class="stage">
|
|
||||||
<div class="stage-head"><h3>breadlock</h3><span>tiny-skia · 16ms timer loop · <code>render.rs</code></span></div>
|
|
||||||
<div class="screen" id="lockScreen" data-state="idle">
|
|
||||||
<div class="wallpaper"></div>
|
|
||||||
<div class="veil"></div>
|
|
||||||
<div class="lockoverlay">
|
|
||||||
<div class="lclock">21:47<span class="badge-new">date</span><div class="ldate">Friday · Aug 21</div></div>
|
|
||||||
<div class="pill">
|
|
||||||
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
|
||||||
<span class="caret" title="proposed"></span>
|
|
||||||
</div>
|
|
||||||
<div class="lstatus" id="lockStatus"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="states" data-screen="lockScreen">
|
|
||||||
<button data-state="idle" class="active">Idle</button>
|
|
||||||
<button data-state="typing">Typing</button>
|
|
||||||
<button data-state="wrong">Wrong pw</button>
|
|
||||||
<button data-state="success">Success</button>
|
|
||||||
<button data-state="unlock">Unlock</button>
|
|
||||||
</div>
|
|
||||||
<button class="replay" data-replay="lockScreen">Replay entrance</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ================= breadgreet ================= -->
|
|
||||||
<section class="stage">
|
|
||||||
<div class="stage-head"><h3>breadgreet</h3><span>GTK4 · relm4 · CSS in <code>theme.rs</code></span></div>
|
|
||||||
<div class="screen" id="greetScreen" data-state="username">
|
|
||||||
<div class="wallpaper"></div>
|
|
||||||
<div class="veil"></div>
|
|
||||||
<div class="greetoverlay">
|
|
||||||
<div class="gclock">21:47</div>
|
|
||||||
<div class="card">
|
|
||||||
<input class="gentry" id="greetEntry" type="text" placeholder="Username" />
|
|
||||||
<div class="gstatus" id="greetStatus"></div>
|
|
||||||
<div class="spinner"></div>
|
|
||||||
<div class="srow">
|
|
||||||
<span class="icon"></span>
|
|
||||||
<span class="label">bos — Hyprland <span class="badge-new">icon</span></span>
|
|
||||||
<span class="chev">▾</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="states" data-screen="greetScreen">
|
|
||||||
<button data-state="username" class="active">Username</button>
|
|
||||||
<button data-state="prompt">Password prompt</button>
|
|
||||||
<button data-state="checking">Checking…</button>
|
|
||||||
<button data-state="error">Wrong pw</button>
|
|
||||||
</div>
|
|
||||||
<button class="replay" data-replay="greetScreen">Replay entrance</button>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>Motion library <small>proposed animations, mapped to where each lands</small></h2>
|
|
||||||
<p class="hint">Every idea below is prototypeable in CSS first, then ported. Effort: <b style="color:var(--green)">S</b> small ·
|
|
||||||
<b style="color:var(--yellow)">M</b> medium · <b style="color:var(--red)">L</b> large (protocol/CPU work).</p>
|
|
||||||
|
|
||||||
<div class="tiles">
|
|
||||||
<div class="tile" data-tile="stagger">
|
|
||||||
<div class="tname">Entrance stagger <span class="tag S">S</span></div>
|
|
||||||
<div class="tscreen"><div class="tclock">21:47</div><div class="tpill"><i></i><i></i><i></i></div></div>
|
|
||||||
<div class="tnote">Clock → pill → status cascade instead of one uniform fade. Pill overshoots ~2% (ease-out-back). Replaces the single overlay motion in <code>render.rs</code>.</div>
|
|
||||||
<button class="replay" data-replay="tile-stagger">Replay</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tile" data-tile="dotpop">
|
|
||||||
<div class="tname">Dot pop + caret <span class="tag S">S</span></div>
|
|
||||||
<div class="tscreen"><div class="tpill"><i></i><i></i><i></i><span class="tc"></span></div></div>
|
|
||||||
<div class="tnote">Newest password dot scales in with overshoot; a blinking caret marks where you're typing. Today dots just appear — <code>render.rs</code> dot loop.</div>
|
|
||||||
<button class="replay" data-replay="tile-dotpop">Replay</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tile" data-tile="shake">
|
|
||||||
<div class="tname">Wrong-password shake <span class="tag S">S</span></div>
|
|
||||||
<div class="tscreen"><div class="tpill"><i></i><i></i><i></i></div><div class="tstatus">Wrong password</div></div>
|
|
||||||
<div class="tnote">The classic, currently reserved for v2 — failure is just a red pill today. Damped 8px shake, red fill, auto-clear after <code>fail_timeout_ms</code>.</div>
|
|
||||||
<button class="replay" data-replay="tile-shake">Replay</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tile" data-tile="flash">
|
|
||||||
<div class="tname">Success flash → unlock drift <span class="tag S">S</span></div>
|
|
||||||
<div class="tscreen"><div class="tpill"><i></i><i></i><i></i></div></div>
|
|
||||||
<div class="tnote">Correct password: green (<code>color2</code>) flash + glow ring, then the existing 400ms fade-and-drift-up unlock in <code>state.rs</code>.</div>
|
|
||||||
<button class="replay" data-replay="tile-flash">Replay</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tile" data-tile="crossfade">
|
|
||||||
<div class="tname">Clock minute crossfade <span class="tag S">S</span></div>
|
|
||||||
<div class="tscreen"><div class="tclock"><span class="old">21:47</span><span class="new">21:48</span></div></div>
|
|
||||||
<div class="tnote">300ms dip-and-swap on the minute tick instead of a hard blink. Locker: crossfade layer in <code>render.rs</code>. Greeter: GTK CSS transition.</div>
|
|
||||||
<button class="replay" data-replay="tile-crossfade">Tick</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tile" data-tile="breathe">
|
|
||||||
<div class="tname">Idle breathing <span class="tag S">S</span></div>
|
|
||||||
<div class="tscreen"><div class="tpill"><i></i><i></i><i></i></div></div>
|
|
||||||
<div class="tnote">Very subtle 3–4s sine on the pill's glow — proof the screen is live, not frozen. Cheap in <code>render.rs</code>; keep amplitude tiny (CPU is software-rendered).</div>
|
|
||||||
<button class="replay" data-replay="tile-breathe">Replay</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tile" data-tile="gcard">
|
|
||||||
<div class="tname">Greeter card entrance <span class="tag S">S</span></div>
|
|
||||||
<div class="tscreen"><div class="gcard-mini"><b></b><b></b></div></div>
|
|
||||||
<div class="tnote">Greeter currently has zero animation. Fade + 18px rise, staggered after the clock — GTK4 CSS <code>@keyframes</code> in <code>breadgreet/theme.rs</code>.</div>
|
|
||||||
<button class="replay" data-replay="tile-gcard">Replay</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tile" data-tile="focus">
|
|
||||||
<div class="tname">Entry focus ring <span class="tag S">S</span></div>
|
|
||||||
<div class="tscreen"><div class="gcard-mini"><b></b><b></b></div></div>
|
|
||||||
<div class="tnote">Accent border + soft glow on focus, 200ms transition. Standard GTK CSS <code>:focus</code> — matches the shared stylesheet's "blue on focus" input rule.</div>
|
|
||||||
<button class="replay" data-replay="tile-focus">Focus</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tile" data-tile="spinner">
|
|
||||||
<div class="tname">Auth spinner <span class="tag S">S</span></div>
|
|
||||||
<div class="tscreen"><div class="spin-mini"></div></div>
|
|
||||||
<div class="tnote">Real <code>gtk::Spinner</code> during <code>Stage::Working</code> instead of static "Checking…" text. One widget swap in <code>breadgreet/main.rs</code>.</div>
|
|
||||||
<button class="replay" data-replay="tile-spinner">Replay</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tile" data-tile="session">
|
|
||||||
<div class="tname">Session icon rows <span class="tag M">M</span></div>
|
|
||||||
<div class="tscreen"><div class="rows"><div class="sel"><i></i>bos — Hyprland</div><div><i></i>Hyprland</div></div></div>
|
|
||||||
<div class="tnote"><code>sessions.rs</code> doesn't parse <code>Icon=</code> today. Custom dropdown rows with per-session icons; falls back to a letter tile.</div>
|
|
||||||
<button class="replay" data-replay="tile-session">Replay</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tile" data-tile="kenburns">
|
|
||||||
<div class="tname">Wallpaper Ken Burns <span class="tag M">M</span></div>
|
|
||||||
<div class="tscreen"><div class="tpill"><i></i></div></div>
|
|
||||||
<div class="tnote">Slow pan on image wallpapers — just a drifting <code>Transform</code> in <code>background.rs::paint</code>, no new protocol. Gate behind config (CPU cost).</div>
|
|
||||||
<button class="replay" data-replay="tile-kenburns">Replay</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="notes">
|
|
||||||
<div class="col">
|
|
||||||
<h4>breadlock — where things land</h4>
|
|
||||||
<ul>
|
|
||||||
<li><b>Motion</b>: extend the existing <code>anim_timer</code>/<code>tick_animation</code> loop in <code>state.rs</code>; add per-element progress fields (appear started per element, fail-shake start, dot-pop start).</li>
|
|
||||||
<li><b>Frame math</b>: all easing lives in <code>render.rs</code> (<code>ease_out_cubic</code>, <code>overlay_motion</code>). Add <code>ease_out_back</code> for the pill overshoot and a damped sinusoid for the shake.</li>
|
|
||||||
<li><b>Success flash</b>: reuse <code>unlocking: Option<Instant></code> — flash phase 0–250ms, fade 250–650ms, then <code>unlock()</code>.</li>
|
|
||||||
<li><b>Bigger</b>: live blur-of-desktop needs a <code>wlr-screencopy</code> capture (already flagged v2 in README) — software downscale → blur → upscale to keep CPU sane. New <code>[animation]</code> config section (enabled / speed / per-effect toggles).</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<h4>breadgreet — where things land</h4>
|
|
||||||
<ul>
|
|
||||||
<li><b>Motion</b>: GTK4 CSS supports <code>@keyframes</code>/<code>animation</code> and transitions — everything goes in <code>breadgreet/theme.rs::load_css</code>, no Rust logic needed for entrance/focus/status.</li>
|
|
||||||
<li><b>Spinner</b>: swap the status label for a <code>gtk::Spinner</code> during <code>Stage::Working</code> in <code>main.rs</code>.</li>
|
|
||||||
<li><b>Session icons</b>: parse <code>Icon=</code> in <code>sessions.rs</code> and switch <code>DropDown</code> to custom rows.</li>
|
|
||||||
<li><b>Unify with the locker</b>: same clock sizing/weight, same radius + spacing tokens; card <code>backdrop-filter: blur()</code> if GTK ≥ 4.12 supports it (README already requires 4.12).</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer>
|
|
||||||
Sketch mirrors <code>breadlock/src/render.rs</code> + <code>state.rs</code>, <code>breadgreet/src/theme.rs</code> + <code>main.rs</code>, and the tokens in
|
|
||||||
<code>bread-ecosystem/BREAD_DESIGN_SYSTEM.md</code> / <code>bread-theme/src/palette.rs</code>. Palette: fixed BOS dark base
|
|
||||||
(bg <code>#0c0c0c</code>, surface <code>#1a1a1a</code>, overlay <code>#d8d8d8</code>) with pywal-driven accents (color1–6).
|
|
||||||
The "bread" palette's red/green/accent are the curated bread-toned defaults, which is why "wrong password" is brownish until pywal is active.
|
|
||||||
</footer>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
/* Palette toggle */
|
|
||||||
const toggle = document.getElementById("paletteToggle");
|
|
||||||
toggle.addEventListener("click", (e) => {
|
|
||||||
const btn = e.target.closest("button");
|
|
||||||
if (!btn) return;
|
|
||||||
document.body.dataset.palette = btn.dataset.palette;
|
|
||||||
toggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b === btn));
|
|
||||||
});
|
|
||||||
|
|
||||||
/* Restart a stylesheet-driven CSS animation: drop the animation via an
|
|
||||||
inline override, force a reflow, then remove the override so the rule
|
|
||||||
applies again from its first frame. */
|
|
||||||
function replayAnim(el) {
|
|
||||||
el.style.animation = "none";
|
|
||||||
void el.offsetWidth;
|
|
||||||
el.style.animation = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- breadlock stage ---- */
|
|
||||||
const lockScreen = document.getElementById("lockScreen");
|
|
||||||
const lockStatus = document.getElementById("lockStatus");
|
|
||||||
|
|
||||||
function setLockState(state) {
|
|
||||||
// The attribute change is what starts each CSS animation; bounce through
|
|
||||||
// idle so repeat clicks on the same chip replay it.
|
|
||||||
if (state === "wrong" || state === "success" || state === "unlock") {
|
|
||||||
lockScreen.dataset.state = "idle";
|
|
||||||
void lockScreen.offsetWidth;
|
|
||||||
}
|
|
||||||
lockScreen.dataset.state = state;
|
|
||||||
document.querySelectorAll('[data-screen="lockScreen"] button').forEach((b) =>
|
|
||||||
b.classList.toggle("active", b.dataset.state === state));
|
|
||||||
|
|
||||||
switch (state) {
|
|
||||||
case "typing":
|
|
||||||
lockScreen.querySelectorAll(".dot").forEach(replayAnim);
|
|
||||||
lockStatus.textContent = "";
|
|
||||||
break;
|
|
||||||
case "wrong":
|
|
||||||
lockStatus.textContent = "Wrong password";
|
|
||||||
setTimeout(() => { if (lockScreen.dataset.state === "wrong") setLockState("idle"); }, 1200);
|
|
||||||
break;
|
|
||||||
case "success":
|
|
||||||
lockStatus.textContent = "✓ Unlocked";
|
|
||||||
setTimeout(() => setLockState("unlock"), 600);
|
|
||||||
break;
|
|
||||||
case "unlock":
|
|
||||||
setTimeout(() => { setLockState("idle"); replayLockEntrance(); }, 900);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
lockStatus.textContent = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function replayLockEntrance() {
|
|
||||||
replayAnim(lockScreen.querySelector(".lclock"));
|
|
||||||
replayAnim(lockScreen.querySelector(".ldate"));
|
|
||||||
replayAnim(lockScreen.querySelector(".pill"));
|
|
||||||
}
|
|
||||||
document.querySelectorAll('[data-screen="lockScreen"] button').forEach((b) =>
|
|
||||||
b.addEventListener("click", () => setLockState(b.dataset.state)));
|
|
||||||
document.querySelector('[data-replay="lockScreen"]').addEventListener("click", replayLockEntrance);
|
|
||||||
|
|
||||||
/* ---- breadgreet stage ---- */
|
|
||||||
const greetScreen = document.getElementById("greetScreen");
|
|
||||||
const greetEntry = document.getElementById("greetEntry");
|
|
||||||
const greetStatus = document.getElementById("greetStatus");
|
|
||||||
const greetStates = {
|
|
||||||
username: { placeholder: "Username", status: "" },
|
|
||||||
prompt: { placeholder: "Password", status: "Password for breadway" },
|
|
||||||
checking: { placeholder: "Password", status: "Checking…" },
|
|
||||||
error: { placeholder: "Password", status: "Wrong password" },
|
|
||||||
};
|
|
||||||
|
|
||||||
function setGreetState(state) {
|
|
||||||
if (state === "error") {
|
|
||||||
greetScreen.dataset.state = "username";
|
|
||||||
void greetScreen.offsetWidth;
|
|
||||||
}
|
|
||||||
greetScreen.dataset.state = state;
|
|
||||||
document.querySelectorAll('[data-screen="greetScreen"] button').forEach((b) =>
|
|
||||||
b.classList.toggle("active", b.dataset.state === state));
|
|
||||||
const s = greetStates[state];
|
|
||||||
greetEntry.placeholder = s.placeholder;
|
|
||||||
greetStatus.textContent = s.status;
|
|
||||||
if (state === "error") {
|
|
||||||
setTimeout(() => { if (greetScreen.dataset.state === "error") setGreetState("prompt"); }, 1200);
|
|
||||||
} else if (state === "checking") {
|
|
||||||
setTimeout(() => { if (greetScreen.dataset.state === "checking") setGreetState("username"); }, 1600);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.querySelectorAll('[data-screen="greetScreen"] button').forEach((b) =>
|
|
||||||
b.addEventListener("click", () => setGreetState(b.dataset.state)));
|
|
||||||
document.querySelector('[data-replay="greetScreen"]').addEventListener("click", () => {
|
|
||||||
replayAnim(greetScreen.querySelector(".gclock"));
|
|
||||||
replayAnim(greetScreen.querySelector(".card"));
|
|
||||||
});
|
|
||||||
|
|
||||||
/* ---- motion library ---- */
|
|
||||||
const tileActions = {
|
|
||||||
"tile-stagger": (t) => { replayAnim(t.querySelector(".tclock")); replayAnim(t.querySelector(".tpill")); },
|
|
||||||
"tile-dotpop": (t) => t.querySelectorAll(".tpill i").forEach(replayAnim),
|
|
||||||
"tile-shake": (t) => { replayAnim(t.querySelector(".tpill")); replayAnim(t.querySelector(".tstatus")); },
|
|
||||||
"tile-flash": (t) => replayAnim(t.querySelector(".tpill")),
|
|
||||||
"tile-crossfade": (t) => t.classList.toggle("ticked"),
|
|
||||||
"tile-breathe": (t) => replayAnim(t.querySelector(".tpill")),
|
|
||||||
"tile-gcard": (t) => replayAnim(t.querySelector(".gcard-mini")),
|
|
||||||
"tile-focus": (t) => t.classList.toggle("focused"),
|
|
||||||
"tile-spinner": () => {},
|
|
||||||
"tile-session": (t) => replayAnim(t.querySelector(".rows")),
|
|
||||||
"tile-kenburns": (t) => {
|
|
||||||
const sc = t.querySelector(".tscreen");
|
|
||||||
sc.classList.add("noanim");
|
|
||||||
void sc.offsetWidth;
|
|
||||||
sc.classList.remove("noanim");
|
|
||||||
},
|
|
||||||
};
|
|
||||||
document.querySelectorAll(".tile").forEach((tile) => {
|
|
||||||
const btn = tile.querySelector(".replay");
|
|
||||||
if (!btn) return;
|
|
||||||
btn.addEventListener("click", () => tileActions["tile-" + tile.dataset.tile]?.(tile));
|
|
||||||
});
|
|
||||||
|
|
||||||
/* Boot the lock stage with the entrance visible. */
|
|
||||||
replayLockEntrance();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Maintainer: Breadway <plasticbread849@gmail.com>
|
# Maintainer: Breadway <plasticbread849@gmail.com>
|
||||||
|
|
||||||
pkgname=breadlock
|
pkgname=breadlock
|
||||||
pkgver=0.2.0
|
pkgver=0.1.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Session locker and greetd greeter for Hyprland / Wayland"
|
pkgdesc="Session locker and greetd greeter for Hyprland / Wayland"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|
@ -15,18 +15,15 @@ depends=('pam' 'wayland' 'libxkbcommon' 'gtk4')
|
||||||
optdepends=(
|
optdepends=(
|
||||||
'cage: minimal Wayland compositor to host breadgreet under greetd'
|
'cage: minimal Wayland compositor to host breadgreet under greetd'
|
||||||
'hyprland: the session breadlock protects and breadgreet launches'
|
'hyprland: the session breadlock protects and breadgreet launches'
|
||||||
'upower: battery line on the lock screen'
|
|
||||||
)
|
)
|
||||||
makedepends=('rust' 'cargo')
|
makedepends=('rust' 'cargo')
|
||||||
backup=('etc/pam.d/breadlock')
|
|
||||||
source=("${pkgname}-${pkgver}.tar.gz")
|
source=("${pkgname}-${pkgver}.tar.gz")
|
||||||
sha256sums=('SKIP')
|
sha256sums=('SKIP')
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
cd "${srcdir}/${pkgname}-${pkgver}"
|
||||||
# --bin (not -p breadlock) deliberately excludes the breadlock-auth-check
|
# --bin (not -p breadlock) deliberately excludes the breadlock-auth-check
|
||||||
# and breadlock-preview dev harnesses, which share the breadlock package
|
# dev harness, which shares the breadlock package but isn't installed.
|
||||||
# but aren't installed.
|
|
||||||
cargo build --release --locked --bin breadlock --bin breadgreet
|
cargo build --release --locked --bin breadlock --bin breadgreet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue