Add lock-screen status line and harden the locker
Show MPRIS now-playing and upower battery under the clock, polled on a background thread (zbus). Flags gate both drawing and D-Bus; desktops without a battery stay blank; titles are truncated. Also the locker audit pass that landed in the same files: - PAM authenticates getuid()/getpwuid_r, not $USER, and missing username still takes the lock - one lock surface per output, generation-gated fail timers, PAM timeout, Tab-reveal off by default and cleared on keyboard leave - GPU chrome is a dirty-rect quad with EGL cleanup; shm reuses a SlotPool; appear/shake/flash/veil/clock-crossfade bugs - hold-to-reveal, checking ellipsis, and password display length Live-checked on hermes (GPU path, PAM unlock, Tab-hold).
This commit is contained in:
parent
5475e4da05
commit
6925e132fd
22 changed files with 2839 additions and 432 deletions
637
Cargo.lock
generated
637
Cargo.lock
generated
|
|
@ -38,6 +38,137 @@ version = "0.7.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-broadcast"
|
||||||
|
version = "0.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||||
|
dependencies = [
|
||||||
|
"event-listener",
|
||||||
|
"event-listener-strategy",
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-channel"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
|
||||||
|
dependencies = [
|
||||||
|
"concurrent-queue",
|
||||||
|
"event-listener-strategy",
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-executor"
|
||||||
|
version = "1.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
|
||||||
|
dependencies = [
|
||||||
|
"async-task",
|
||||||
|
"concurrent-queue",
|
||||||
|
"fastrand",
|
||||||
|
"futures-lite",
|
||||||
|
"pin-project-lite",
|
||||||
|
"slab",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-fs"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
|
||||||
|
dependencies = [
|
||||||
|
"async-lock",
|
||||||
|
"blocking",
|
||||||
|
"futures-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-io"
|
||||||
|
version = "2.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
"cfg-if",
|
||||||
|
"concurrent-queue",
|
||||||
|
"futures-io",
|
||||||
|
"futures-lite",
|
||||||
|
"parking",
|
||||||
|
"polling",
|
||||||
|
"rustix",
|
||||||
|
"slab",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-lock"
|
||||||
|
version = "3.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
||||||
|
dependencies = [
|
||||||
|
"event-listener",
|
||||||
|
"event-listener-strategy",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-process"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
|
||||||
|
dependencies = [
|
||||||
|
"async-channel",
|
||||||
|
"async-io",
|
||||||
|
"async-lock",
|
||||||
|
"async-signal",
|
||||||
|
"async-task",
|
||||||
|
"blocking",
|
||||||
|
"cfg-if",
|
||||||
|
"event-listener",
|
||||||
|
"futures-lite",
|
||||||
|
"rustix",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-recursion"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-signal"
|
||||||
|
version = "0.2.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
|
||||||
|
dependencies = [
|
||||||
|
"async-io",
|
||||||
|
"async-lock",
|
||||||
|
"atomic-waker",
|
||||||
|
"cfg-if",
|
||||||
|
"futures-core",
|
||||||
|
"futures-io",
|
||||||
|
"rustix",
|
||||||
|
"signal-hook-registry",
|
||||||
|
"slab",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-task"
|
||||||
|
version = "4.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.92"
|
version = "0.1.92"
|
||||||
|
|
@ -49,6 +180,12 @@ dependencies = [
|
||||||
"syn 3.0.3",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "atomic-waker"
|
||||||
|
version = "1.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
|
|
@ -64,6 +201,28 @@ dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blocking"
|
||||||
|
version = "1.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
|
||||||
|
dependencies = [
|
||||||
|
"async-channel",
|
||||||
|
"async-task",
|
||||||
|
"futures-io",
|
||||||
|
"futures-lite",
|
||||||
|
"piper",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bread-shared"
|
name = "bread-shared"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
|
|
@ -124,6 +283,7 @@ dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"glow",
|
"glow",
|
||||||
"khronos-egl",
|
"khronos-egl",
|
||||||
|
"libc",
|
||||||
"pam-client2",
|
"pam-client2",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
@ -134,6 +294,7 @@ dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"wayland-client",
|
"wayland-client",
|
||||||
|
"zbus",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -142,7 +303,6 @@ name = "breadlock-ui"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bread-theme",
|
"bread-theme",
|
||||||
"chrono",
|
|
||||||
"cosmic-text",
|
"cosmic-text",
|
||||||
"serde",
|
"serde",
|
||||||
"tiny-skia",
|
"tiny-skia",
|
||||||
|
|
@ -255,6 +415,12 @@ version = "1.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.45"
|
version = "0.4.45"
|
||||||
|
|
@ -306,6 +472,15 @@ dependencies = [
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
|
|
@ -321,12 +496,32 @@ version = "0.8.22"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cursor-icon"
|
name = "cursor-icon"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"crypto-common",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dirs"
|
name = "dirs"
|
||||||
version = "5.0.1"
|
version = "5.0.1"
|
||||||
|
|
@ -363,6 +558,33 @@ version = "1.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "endi"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "enumflags2"
|
||||||
|
version = "0.7.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
|
||||||
|
dependencies = [
|
||||||
|
"enumflags2_derive",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "enumflags2_derive"
|
||||||
|
version = "0.7.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
|
|
@ -379,6 +601,26 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "event-listener"
|
||||||
|
version = "5.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
|
||||||
|
dependencies = [
|
||||||
|
"parking",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "event-listener-strategy"
|
||||||
|
version = "0.5.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||||
|
dependencies = [
|
||||||
|
"event-listener",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
|
|
@ -524,6 +766,19 @@ version = "0.3.34"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
|
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-lite"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"futures-core",
|
||||||
|
"futures-io",
|
||||||
|
"parking",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-macro"
|
name = "futures-macro"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
|
|
@ -621,6 +876,16 @@ dependencies = [
|
||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generic-array"
|
||||||
|
version = "0.14.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
|
|
@ -876,6 +1141,12 @@ version = "0.5.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hex"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iana-time-zone"
|
name = "iana-time-zone"
|
||||||
version = "0.1.65"
|
version = "0.1.65"
|
||||||
|
|
@ -1049,6 +1320,19 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cfg-if",
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
"memoffset",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
|
|
@ -1079,6 +1363,16 @@ version = "0.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ordered-stream"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pam-client2"
|
name = "pam-client2"
|
||||||
version = "0.5.5"
|
version = "0.5.5"
|
||||||
|
|
@ -1123,12 +1417,29 @@ dependencies = [
|
||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parking"
|
||||||
|
version = "2.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "piper"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"fastrand",
|
||||||
|
"futures-io",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pkg-config"
|
name = "pkg-config"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
|
|
@ -1162,6 +1473,15 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ppv-lite86"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro-crate"
|
name = "proc-macro-crate"
|
||||||
version = "3.5.0"
|
version = "3.5.0"
|
||||||
|
|
@ -1204,6 +1524,36 @@ version = "6.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.8.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rand_chacha",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rangemap"
|
name = "rangemap"
|
||||||
version = "1.8.0"
|
version = "1.8.0"
|
||||||
|
|
@ -1407,6 +1757,17 @@ dependencies = [
|
||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_repr"
|
||||||
|
version = "0.1.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_spanned"
|
name = "serde_spanned"
|
||||||
version = "0.6.9"
|
version = "0.6.9"
|
||||||
|
|
@ -1425,6 +1786,17 @@ dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sharded-slab"
|
name = "sharded-slab"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
|
|
@ -1542,6 +1914,12 @@ dependencies = [
|
||||||
"lock_api",
|
"lock_api",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "static_assertions"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strict-num"
|
name = "strict-num"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
|
|
@ -1609,6 +1987,19 @@ version = "0.13.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.27.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.69"
|
version = "1.0.69"
|
||||||
|
|
@ -1892,6 +2283,23 @@ version = "0.21.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8"
|
checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uds_windows"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||||
|
dependencies = [
|
||||||
|
"memoffset",
|
||||||
|
"tempfile",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-bidi"
|
name = "unicode-bidi"
|
||||||
version = "0.3.18"
|
version = "0.3.18"
|
||||||
|
|
@ -2205,7 +2613,25 @@ version = "0.48.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-targets",
|
"windows-targets 0.48.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.59.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2223,13 +2649,29 @@ version = "0.48.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows_aarch64_gnullvm",
|
"windows_aarch64_gnullvm 0.48.5",
|
||||||
"windows_aarch64_msvc",
|
"windows_aarch64_msvc 0.48.5",
|
||||||
"windows_i686_gnu",
|
"windows_i686_gnu 0.48.5",
|
||||||
"windows_i686_msvc",
|
"windows_i686_msvc 0.48.5",
|
||||||
"windows_x86_64_gnu",
|
"windows_x86_64_gnu 0.48.5",
|
||||||
"windows_x86_64_gnullvm",
|
"windows_x86_64_gnullvm 0.48.5",
|
||||||
"windows_x86_64_msvc",
|
"windows_x86_64_msvc 0.48.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm 0.52.6",
|
||||||
|
"windows_aarch64_msvc 0.52.6",
|
||||||
|
"windows_i686_gnu 0.52.6",
|
||||||
|
"windows_i686_gnullvm",
|
||||||
|
"windows_i686_msvc 0.52.6",
|
||||||
|
"windows_x86_64_gnu 0.52.6",
|
||||||
|
"windows_x86_64_gnullvm 0.52.6",
|
||||||
|
"windows_x86_64_msvc 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2238,42 +2680,90 @@ version = "0.48.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "0.7.15"
|
version = "0.7.15"
|
||||||
|
|
@ -2298,6 +2788,16 @@ version = "0.3.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23"
|
checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xdg-home"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xkbcommon"
|
name = "xkbcommon"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
|
|
@ -2324,12 +2824,94 @@ version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5"
|
checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zbus"
|
||||||
|
version = "4.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725"
|
||||||
|
dependencies = [
|
||||||
|
"async-broadcast",
|
||||||
|
"async-executor",
|
||||||
|
"async-fs",
|
||||||
|
"async-io",
|
||||||
|
"async-lock",
|
||||||
|
"async-process",
|
||||||
|
"async-recursion",
|
||||||
|
"async-task",
|
||||||
|
"async-trait",
|
||||||
|
"blocking",
|
||||||
|
"enumflags2",
|
||||||
|
"event-listener",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"futures-util",
|
||||||
|
"hex",
|
||||||
|
"nix",
|
||||||
|
"ordered-stream",
|
||||||
|
"rand",
|
||||||
|
"serde",
|
||||||
|
"serde_repr",
|
||||||
|
"sha1",
|
||||||
|
"static_assertions",
|
||||||
|
"tracing",
|
||||||
|
"uds_windows",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
"xdg-home",
|
||||||
|
"zbus_macros",
|
||||||
|
"zbus_names",
|
||||||
|
"zvariant",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zbus_macros"
|
||||||
|
version = "4.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro-crate",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
"zvariant_utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zbus_names"
|
||||||
|
version = "3.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"static_assertions",
|
||||||
|
"zvariant",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zeno"
|
name = "zeno"
|
||||||
version = "0.3.3"
|
version = "0.3.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524"
|
checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy"
|
||||||
|
version = "0.8.56"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy-derive"
|
||||||
|
version = "0.8.56"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zeroize"
|
name = "zeroize"
|
||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
|
|
@ -2341,3 +2923,40 @@ name = "zmij"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zvariant"
|
||||||
|
version = "4.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe"
|
||||||
|
dependencies = [
|
||||||
|
"endi",
|
||||||
|
"enumflags2",
|
||||||
|
"serde",
|
||||||
|
"static_assertions",
|
||||||
|
"zvariant_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zvariant_derive"
|
||||||
|
version = "4.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro-crate",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
"zvariant_utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zvariant_utils"
|
||||||
|
version = "2.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ from session lock/unlock.
|
||||||
| Event | Data | When |
|
| 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.locked` | `{}` | The compositor accepted the `ext-session-lock-v1` request (`SessionLockHandler::locked`). Not emitted merely because breadlock started or asked to lock. |
|
||||||
| `bread.lock.unlocked` | `{}` | PAM authenticated successfully and breadlock sent `unlock` to the compositor. Not emitted on a compositor-ended lock (`finished`), a dispatch-error exit (fail-secure: the session stays locked), or a failed/typo password. |
|
| `bread.lock.unlocked` | `{}` | PAM authenticated successfully and breadlock sent `unlock` to the compositor, **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.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.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: no locker was running (already unlocked), or `loginctl unlock-session` was invoked for this session. This is the command confirmation, not compositor proof — wait on `bread.lock.unlocked` if you need PAM + `ext-session-lock-v1` unlock. |
|
| `bread.lock.unlock.done` | `{}` | `bread.command.lock.unlock` was honored: no locker was running (already unlocked), or `loginctl unlock-session` was invoked for this session. This is the command confirmation, not compositor proof — wait on `bread.lock.unlocked` if you need PAM + `ext-session-lock-v1` unlock. |
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ 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", "dep:chrono"]
|
paint = ["dep:tiny-skia", "dep:cosmic-text"]
|
||||||
|
|
|
||||||
|
|
@ -5,6 +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 std::collections::HashMap;
|
||||||
use tiny_skia::{Path, PathBuilder, Pixmap, PremultipliedColorU8};
|
use tiny_skia::{Path, PathBuilder, Pixmap, PremultipliedColorU8};
|
||||||
|
|
@ -34,9 +35,13 @@ 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,
|
/// Exact glyph-pixel span `(top, height)` per unique `(text, family,
|
||||||
/// size)` — see [`Self::measure_box`]. Keyed by size in centipixels so
|
/// size, weight)` — see [`Self::measure_box`]. Keyed by size in
|
||||||
/// fractional sizes don't thrash the cache.
|
/// centipixels so fractional sizes don't thrash the cache.
|
||||||
boxes: HashMap<(String, String, u32), (f32, f32)>,
|
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 {
|
||||||
|
|
@ -51,14 +56,51 @@ impl TextRenderer {
|
||||||
font_system: FontSystem::new(),
|
font_system: FontSystem::new(),
|
||||||
swash_cache: SwashCache::new(),
|
swash_cache: SwashCache::new(),
|
||||||
boxes: HashMap::new(),
|
boxes: HashMap::new(),
|
||||||
|
family_ok: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn shape_line(&mut self, text: &str, family: &str, size_px: f32, max_width: f32) -> Buffer {
|
/// `Family::Name` if `family` is installed, otherwise the generic
|
||||||
|
/// 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().family(Family::Name(family));
|
let attrs = Attrs::new()
|
||||||
|
.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
|
||||||
|
|
@ -76,17 +118,46 @@ impl TextRenderer {
|
||||||
/// (clock per minute, date per day, static hints once), so the one-off
|
/// (clock per minute, date per day, static hints once), so the one-off
|
||||||
/// cost is negligible and the result is correct for any font.
|
/// 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) {
|
pub fn measure_box(&mut self, text: &str, family: &str, size_px: f32) -> (f32, f32) {
|
||||||
let key = (text.to_string(), family.to_string(), (size_px * 100.0) as u32);
|
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) {
|
if let Some(b) = self.boxes.get(&key) {
|
||||||
return *b;
|
return *b;
|
||||||
}
|
}
|
||||||
let w = self.measure_line(text, family, size_px).ceil().max(1.0) as u32;
|
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 h = (size_px * 1.5).ceil().max(1.0) as u32;
|
||||||
let mut probe = match Pixmap::new(w, h) {
|
let mut probe = match Pixmap::new(w, h) {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => return (0.0, size_px),
|
None => return (0.0, size_px),
|
||||||
};
|
};
|
||||||
self.draw_line(&mut probe, text, family, size_px, tiny_skia::Color::WHITE, 0.0, 0.0);
|
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);
|
let (mut top, mut bottom) = (h as f32, 0.0f32);
|
||||||
for y in 0..h {
|
for y in 0..h {
|
||||||
for x in 0..w {
|
for x in 0..w {
|
||||||
|
|
@ -108,7 +179,17 @@ impl TextRenderer {
|
||||||
/// 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 {
|
||||||
let buffer = self.shape_line(text, family, size_px, f32::INFINITY);
|
self.measure_line_weighted(text, family, size_px, Weight::NORMAL)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
@ -118,6 +199,8 @@ 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,
|
||||||
|
|
@ -129,7 +212,27 @@ impl TextRenderer {
|
||||||
origin_x: f32,
|
origin_x: f32,
|
||||||
origin_y: f32,
|
origin_y: f32,
|
||||||
) {
|
) {
|
||||||
let buffer = self.shape_line(text, family, size_px, pixmap.width() as f32);
|
self.draw_line_weighted(
|
||||||
|
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
|
// cosmic-text's glyph-Mask rendering drops the base color's alpha
|
||||||
|
|
@ -141,30 +244,37 @@ impl TextRenderer {
|
||||||
let text_color = cosmic_text::Color::rgba(c8.red(), c8.green(), c8.blue(), base_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);
|
||||||
let ox = origin_x as i32;
|
for run in buffer.layout_runs() {
|
||||||
let oy = origin_y as i32;
|
for glyph in run.glyphs.iter() {
|
||||||
buffer.draw(
|
// Subpixel origin: X lands in CacheKey's subpixel bins; Y is
|
||||||
&mut self.font_system,
|
// hinted (cosmic-text truncates the Y offset) and then the
|
||||||
&mut self.swash_cache,
|
// run's line_y is rounded at blit so we don't trunc origin
|
||||||
text_color,
|
// independently of glyph placement.
|
||||||
|x, y, _w, _h, glyph_color| {
|
let physical = glyph.physical((origin_x, origin_y), 1.0);
|
||||||
let (px, py) = (ox + x, oy + y);
|
let glyph_color = glyph.color_opt.unwrap_or(text_color);
|
||||||
if px < 0 || py < 0 || px >= width || py >= height {
|
self.swash_cache.with_pixels(
|
||||||
return;
|
&mut self.font_system,
|
||||||
}
|
physical.cache_key,
|
||||||
let (r, g, b, a) = glyph_color.as_rgba_tuple();
|
glyph_color,
|
||||||
if a == 0 {
|
|x, y, color| {
|
||||||
return;
|
let px = physical.x + x;
|
||||||
}
|
let py = run.line_y.round() as i32 + physical.y + y;
|
||||||
// `a` is the glyph coverage; combine it with the requested
|
if px < 0 || py < 0 || px >= width || py >= height {
|
||||||
// color alpha for the true source alpha.
|
return;
|
||||||
let a = (a as u32 * base_alpha as u32 / 255) as u8;
|
}
|
||||||
if a == 0 {
|
let (r, g, b, a) = color.as_rgba_tuple();
|
||||||
return;
|
if a == 0 {
|
||||||
}
|
return;
|
||||||
blend_over(pixmap, px as u32, py as u32, r, g, b, a);
|
}
|
||||||
},
|
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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -289,6 +399,23 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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]
|
#[test]
|
||||||
fn draw_line_onto_transparent_keeps_real_alpha() {
|
fn draw_line_onto_transparent_keeps_real_alpha() {
|
||||||
// Regression: the GPU path (compose_chrome) draws text into a
|
// Regression: the GPU path (compose_chrome) draws text into a
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
# 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)
|
||||||
|
|
@ -8,8 +10,9 @@ 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: the
|
# Slow Ken Burns pan on image backgrounds (gentle drift + zoom). Opt-in.
|
||||||
# background redraws continuously at a low frame rate while locked.
|
# Cheap on the GPU wallpaper path; the software fallback still redraws
|
||||||
|
# the background continuously at a low frame rate while locked.
|
||||||
ken_burns = false
|
ken_burns = false
|
||||||
|
|
||||||
[clock]
|
[clock]
|
||||||
|
|
@ -23,13 +26,13 @@ date_format = "%A · %b %d"
|
||||||
family = "Varela Round"
|
family = "Varela Round"
|
||||||
|
|
||||||
[input]
|
[input]
|
||||||
# How long the "wrong password" state (red pill) shows before input
|
# How long the red "wrong password" UI shows, in milliseconds. Typing is
|
||||||
# re-enables, in milliseconds.
|
# still accepted during this window (it clears the failed state).
|
||||||
fail_timeout_ms = 800
|
fail_timeout_ms = 800
|
||||||
# Hold Tab to reveal the typed password as plain characters (instead of
|
# 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
|
# dots) while held. Tab can never be part of a password, so it's always
|
||||||
# safe as a reveal gesture.
|
# safe as a reveal gesture. Default off.
|
||||||
reveal_hold = true
|
reveal_hold = false
|
||||||
|
|
||||||
[animation]
|
[animation]
|
||||||
# Subtle glow pulse on the password pill every few seconds while idle.
|
# Subtle glow pulse on the password pill every few seconds while idle.
|
||||||
|
|
@ -37,3 +40,11 @@ breathe = true
|
||||||
# Deepen the dim veil after this many seconds of no keystrokes (0 = off).
|
# 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.
|
# A gentle extra darkening for OLED/burn-in or late-night comfort.
|
||||||
idle_dim_after_secs = 0
|
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
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,10 @@ tiny-skia = "0.12"
|
||||||
khronos-egl = { version = "6", features = ["dynamic"] }
|
khronos-egl = { version = "6", features = ["dynamic"] }
|
||||||
glow = "0.16"
|
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
|
serde_json.workspace = true
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -8,24 +8,33 @@
|
||||||
|
|
||||||
pub mod pam;
|
pub mod pam;
|
||||||
|
|
||||||
pub use pam::AuthError;
|
pub use pam::{username_from_process, 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, AuthResult) + 'static,
|
mut on_result: impl FnMut(&mut Data, u64, AuthResult) + 'static,
|
||||||
) -> Sender<AuthResult> {
|
) -> Sender<AuthOutcome> {
|
||||||
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(result) = event {
|
if let channel::Event::Msg((generation, result)) = event {
|
||||||
on_result(data, result);
|
on_result(data, generation, result);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.expect("failed to register auth-result channel on event loop");
|
.expect("failed to register auth-result channel on event loop");
|
||||||
|
|
@ -38,13 +47,31 @@ 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>,
|
||||||
result_tx: Sender<AuthResult>,
|
generation: u64,
|
||||||
|
result_tx: Sender<AuthOutcome>,
|
||||||
) {
|
) {
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let result = pam::check(&username, &password);
|
let (done_tx, done_rx) = std::sync::mpsc::channel();
|
||||||
let _ = result_tx.send(result);
|
std::thread::spawn(move || {
|
||||||
|
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,6 +4,7 @@
|
||||||
|
|
||||||
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`
|
||||||
|
|
@ -46,3 +47,136 @@ 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,18 @@ pub enum Background {
|
||||||
/// A wallpaper with a lazily-built, output-sized copy. The first `paint` for
|
/// 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
|
/// a given output size does one downscale; every frame after that blits the
|
||||||
/// cached copy with at most a translation (the Ken Burns pan).
|
/// 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 {
|
pub struct ImageBg {
|
||||||
/// Original wallpaper. Kept so a different output size (hotplug) simply
|
/// Original wallpaper. Kept so a different output size (hotplug) simply
|
||||||
/// rebuilds the cache rather than needing the source reloaded.
|
/// rebuilds the cache rather than needing the source reloaded.
|
||||||
source: Pixmap,
|
source: Pixmap,
|
||||||
ken_burns: bool,
|
ken_burns: bool,
|
||||||
cache: RefCell<Option<ScaledBg>>,
|
/// 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 {
|
struct ScaledBg {
|
||||||
|
|
@ -70,8 +76,8 @@ fn blit_translate(target: &mut Pixmap, src: &Pixmap, dx: f32, dy: f32, bilinear:
|
||||||
let th = target.height() as usize;
|
let th = target.height() as usize;
|
||||||
let sw = src.width() as usize;
|
let sw = src.width() as usize;
|
||||||
let sh = src.height() as usize;
|
let sh = src.height() as usize;
|
||||||
let sx = (-dx).clamp(0.0, (sw - tw).max(0) as f32);
|
let sx = (-dx).clamp(0.0, sw.saturating_sub(tw) as f32);
|
||||||
let sy = (-dy).clamp(0.0, (sh - th).max(0) as f32);
|
let sy = (-dy).clamp(0.0, sh.saturating_sub(th) as f32);
|
||||||
|
|
||||||
let fx = (sx.fract() * 65536.0) as u32 & 0xFFFF;
|
let fx = (sx.fract() * 65536.0) as u32 & 0xFFFF;
|
||||||
let fy = (sy.fract() * 65536.0) as u32 & 0xFFFF;
|
let fy = (sy.fract() * 65536.0) as u32 & 0xFFFF;
|
||||||
|
|
@ -105,6 +111,7 @@ fn blit_translate(target: &mut Pixmap, src: &Pixmap, dx: f32, dy: f32, bilinear:
|
||||||
// one store instead of four — the loop is latency-bound). Each byte's
|
// one store instead of four — the loop is latency-bound). Each byte's
|
||||||
// products stay well under 2^32, so lanes never interfere.
|
// products stay well under 2^32, so lanes never interfere.
|
||||||
#[inline(always)]
|
#[inline(always)]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
unsafe fn lerp4(
|
unsafe fn lerp4(
|
||||||
sdata: &[u8],
|
sdata: &[u8],
|
||||||
i00: usize,
|
i00: usize,
|
||||||
|
|
@ -145,10 +152,10 @@ fn blit_translate(target: &mut Pixmap, src: &Pixmap, dx: f32, dy: f32, bilinear:
|
||||||
let mut out = 0u32;
|
let mut out = 0u32;
|
||||||
for c in 0..4 {
|
for c in 0..4 {
|
||||||
let shift = c * 8;
|
let shift = c * 8;
|
||||||
let av = ((a >> shift) & 0xFF) as u32;
|
let av = (a >> shift) & 0xFF;
|
||||||
let bv = ((b >> shift) & 0xFF) as u32;
|
let bv = (b >> shift) & 0xFF;
|
||||||
let dv = ((d >> shift) & 0xFF) as u32;
|
let dv = (d >> shift) & 0xFF;
|
||||||
let ev = ((e >> shift) & 0xFF) as u32;
|
let ev = (e >> shift) & 0xFF;
|
||||||
let top = (av * wx_inv + bv * wx) >> 16;
|
let top = (av * wx_inv + bv * wx) >> 16;
|
||||||
let bot = (dv * wx_inv + ev * wx) >> 16;
|
let bot = (dv * wx_inv + ev * wx) >> 16;
|
||||||
out |= ((top * wy_inv + bot * wy) >> 16) << shift;
|
out |= ((top * wy_inv + bot * wy) >> 16) << shift;
|
||||||
|
|
@ -227,7 +234,7 @@ impl Background {
|
||||||
Ok(pixmap) => Background::Image(ImageBg {
|
Ok(pixmap) => Background::Image(ImageBg {
|
||||||
source: pixmap,
|
source: pixmap,
|
||||||
ken_burns: cfg.ken_burns,
|
ken_burns: cfg.ken_burns,
|
||||||
cache: RefCell::new(None),
|
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");
|
||||||
|
|
@ -268,11 +275,18 @@ impl Background {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let mut cache = bg.cache.borrow_mut();
|
let mut cache = bg.cache.borrow_mut();
|
||||||
let stale = cache
|
let tw_px = target.width();
|
||||||
.as_ref()
|
let th_px = target.height();
|
||||||
.map(|c| c.target_w != target.width() || c.target_h != target.height())
|
let hit = cache
|
||||||
.unwrap_or(true);
|
.iter()
|
||||||
if stale {
|
.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 cover = (tw / sw).max(th / sh);
|
||||||
let scale = cover * if bg.ken_burns { KENBURNS_ZOOM } else { 1.0 };
|
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_w = (sw * scale).round().max(1.0) as u32;
|
||||||
|
|
@ -281,7 +295,6 @@ impl Background {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
"failed to allocate {scaled_w}x{scaled_h} scaled wallpaper — falling back to a palette-color background"
|
"failed to allocate {scaled_w}x{scaled_h} scaled wallpaper — falling back to a palette-color background"
|
||||||
);
|
);
|
||||||
*cache = None;
|
|
||||||
drop(cache);
|
drop(cache);
|
||||||
target.fill(breadlock_ui::theme::tiny_skia_color(
|
target.fill(breadlock_ui::theme::tiny_skia_color(
|
||||||
&breadlock_ui::theme::Palette::default().background,
|
&breadlock_ui::theme::Palette::default().background,
|
||||||
|
|
@ -292,8 +305,10 @@ impl Background {
|
||||||
// The one real downscale in the pipeline: bilinear so the
|
// The one real downscale in the pipeline: bilinear so the
|
||||||
// cached layer is smooth (per-frame draws are pure copies
|
// cached layer is smooth (per-frame draws are pure copies
|
||||||
// and don't re-filter).
|
// and don't re-filter).
|
||||||
let mut paint = PixmapPaint::default();
|
let paint = PixmapPaint {
|
||||||
paint.quality = tiny_skia::FilterQuality::Bilinear;
|
quality: tiny_skia::FilterQuality::Bilinear,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
pixmap.draw_pixmap(
|
pixmap.draw_pixmap(
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
|
|
@ -302,15 +317,18 @@ impl Background {
|
||||||
Transform::from_scale(scale, scale),
|
Transform::from_scale(scale, scale),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
*cache = Some(ScaledBg {
|
if cache.len() >= SCALED_CACHE_SLOTS {
|
||||||
|
cache.remove(0);
|
||||||
|
}
|
||||||
|
cache.push(ScaledBg {
|
||||||
pixmap,
|
pixmap,
|
||||||
pan_x: scaled_w as f32 - tw,
|
pan_x: scaled_w as f32 - tw,
|
||||||
pan_y: scaled_h as f32 - th,
|
pan_y: scaled_h as f32 - th,
|
||||||
target_w: target.width(),
|
target_w: tw_px,
|
||||||
target_h: target.height(),
|
target_h: th_px,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let scaled = cache.as_ref().expect("cache populated above");
|
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 {
|
let (tx, ty) = if bg.ken_burns {
|
||||||
let phase = t_secs * TAU / KENBURNS_PERIOD_S;
|
let phase = t_secs * TAU / KENBURNS_PERIOD_S;
|
||||||
|
|
@ -400,7 +418,7 @@ mod tests {
|
||||||
let bg = Background::Image(ImageBg {
|
let bg = Background::Image(ImageBg {
|
||||||
source,
|
source,
|
||||||
ken_burns: true,
|
ken_burns: true,
|
||||||
cache: RefCell::new(None),
|
cache: RefCell::new(Vec::new()),
|
||||||
});
|
});
|
||||||
let mut target = Pixmap::new(60, 30).unwrap();
|
let mut target = Pixmap::new(60, 30).unwrap();
|
||||||
for i in 0..90 {
|
for i in 0..90 {
|
||||||
|
|
@ -426,7 +444,7 @@ mod tests {
|
||||||
// pixel averages src[x] and src[x + 1].
|
// pixel averages src[x] and src[x + 1].
|
||||||
blit_translate(&mut dst, &src, -0.5, 0.0, true);
|
blit_translate(&mut dst, &src, -0.5, 0.0, true);
|
||||||
let px = dst.pixels();
|
let px = dst.pixels();
|
||||||
assert_eq!(px[0].red(), ((0 + 32) / 2) as u8, "0.5px shift averages neighbors");
|
assert_eq!(px[0].red(), 16, "0.5px shift averages neighbors");
|
||||||
assert_eq!(px[1].red(), ((32 + 64) / 2) as u8);
|
assert_eq!(px[1].red(), ((32 + 64) / 2) as u8);
|
||||||
assert_eq!(px[5].red(), ((160 + 192) / 2) as u8);
|
assert_eq!(px[5].red(), ((160 + 192) / 2) as u8);
|
||||||
}
|
}
|
||||||
|
|
@ -439,10 +457,40 @@ mod tests {
|
||||||
let bg = Background::Image(ImageBg {
|
let bg = Background::Image(ImageBg {
|
||||||
source,
|
source,
|
||||||
ken_burns: false,
|
ken_burns: false,
|
||||||
cache: RefCell::new(None),
|
cache: RefCell::new(Vec::new()),
|
||||||
});
|
});
|
||||||
let mut target = Pixmap::new(60, 30).unwrap();
|
let mut target = Pixmap::new(60, 30).unwrap();
|
||||||
bg.paint(&mut target, 0.0, true);
|
bg.paint(&mut target, 0.0, true);
|
||||||
assert!(target.pixels().iter().all(|p| p.red() == 200 && p.green() == 30));
|
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,17 +8,21 @@
|
||||||
//! `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 = std::env::var("USER").unwrap_or_else(|_| {
|
let username = pam::username_from_process().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();
|
||||||
buf.trim().to_string()
|
let name = buf.trim().to_string();
|
||||||
|
buf.zeroize();
|
||||||
|
name
|
||||||
});
|
});
|
||||||
|
|
||||||
let password = rpassword_prompt();
|
let password = rpassword_prompt();
|
||||||
|
|
@ -32,27 +36,84 @@ 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() -> String {
|
fn rpassword_prompt() -> Zeroizing<String> {
|
||||||
use std::io::BufRead;
|
use std::io::BufRead;
|
||||||
eprint!("Password: ");
|
eprint!("Password: ");
|
||||||
std::io::stderr().flush().ok();
|
std::io::stderr().flush().ok();
|
||||||
|
|
||||||
// Best-effort: disable echo via `stty` if a TTY is attached, restore after.
|
let _echo = EchoOff::new();
|
||||||
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']);
|
||||||
if stty_available {
|
let password = Zeroizing::new(trimmed.to_string());
|
||||||
let _ = std::process::Command::new("stty").arg("echo").status();
|
line.zeroize();
|
||||||
eprintln!();
|
password
|
||||||
}
|
|
||||||
|
|
||||||
line.trim_end_matches(['\n', '\r']).to_string()
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,10 @@ struct Scene {
|
||||||
date: &'static str,
|
date: &'static str,
|
||||||
clock_old: Option<(&'static str, f32)>,
|
clock_old: Option<(&'static str, f32)>,
|
||||||
password_len: usize,
|
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: bool,
|
||||||
failed_t: f32,
|
failed_t: f32,
|
||||||
dot_pop_t: f32,
|
dot_pop_t: f32,
|
||||||
|
|
@ -49,6 +53,8 @@ struct Scene {
|
||||||
/// lit below 0.5.
|
/// lit below 0.5.
|
||||||
t_secs: f32,
|
t_secs: f32,
|
||||||
status: Option<&'static str>,
|
status: Option<&'static str>,
|
||||||
|
/// Now-playing / battery line under the clock (empty hides it).
|
||||||
|
info: &'static str,
|
||||||
appear_t: f32,
|
appear_t: f32,
|
||||||
unlock_t: f32,
|
unlock_t: f32,
|
||||||
breathe_t: f32,
|
breathe_t: f32,
|
||||||
|
|
@ -67,12 +73,14 @@ impl Default for Scene {
|
||||||
date: "Friday · Aug 21",
|
date: "Friday · Aug 21",
|
||||||
clock_old: None,
|
clock_old: None,
|
||||||
password_len: 0,
|
password_len: 0,
|
||||||
|
password: "",
|
||||||
failed: false,
|
failed: false,
|
||||||
failed_t: 0.0,
|
failed_t: 0.0,
|
||||||
dot_pop_t: 1.0,
|
dot_pop_t: 1.0,
|
||||||
keystroke_age: None,
|
keystroke_age: None,
|
||||||
t_secs: 0.2,
|
t_secs: 0.2,
|
||||||
status: None,
|
status: None,
|
||||||
|
info: "",
|
||||||
appear_t: 1.0,
|
appear_t: 1.0,
|
||||||
unlock_t: 0.0,
|
unlock_t: 0.0,
|
||||||
breathe_t: 0.0,
|
breathe_t: 0.0,
|
||||||
|
|
@ -131,6 +139,7 @@ fn bench(args: &[String]) {
|
||||||
breathe_t: 0.0,
|
breathe_t: 0.0,
|
||||||
status_t: 1.0,
|
status_t: 1.0,
|
||||||
status_text: None,
|
status_text: None,
|
||||||
|
info_text: "",
|
||||||
appear_t: 1.0,
|
appear_t: 1.0,
|
||||||
unlock_t: 0.0,
|
unlock_t: 0.0,
|
||||||
smooth_pan: true,
|
smooth_pan: true,
|
||||||
|
|
@ -179,6 +188,7 @@ fn bench(args: &[String]) {
|
||||||
breathe_t: (i % 10) as f32 / 10.0,
|
breathe_t: (i % 10) as f32 / 10.0,
|
||||||
status_t: 1.0,
|
status_t: 1.0,
|
||||||
status_text: None,
|
status_text: None,
|
||||||
|
info_text: "",
|
||||||
appear_t: 1.0,
|
appear_t: 1.0,
|
||||||
unlock_t: 0.0,
|
unlock_t: 0.0,
|
||||||
smooth_pan: true,
|
smooth_pan: true,
|
||||||
|
|
@ -230,8 +240,9 @@ fn main() {
|
||||||
Scene { name: "06-typing-pop", password_len: 6, dot_pop_t: 0.4, keystroke_age: Some(0.2), ..Scene::default() },
|
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).
|
// ---- Idle blink: two dots, caret lit (phase 0.36 → visible half-cycle).
|
||||||
Scene { name: "07-idle-blink", password_len: 2, ..Scene::default() },
|
Scene { name: "07-idle-blink", password_len: 2, ..Scene::default() },
|
||||||
// ---- Checking: status mid slide-in with the animated ellipsis.
|
// ---- Checking: status mid slide-in. Live submit() zeros the secret
|
||||||
Scene { name: "08-checking", status: Some("Checking…"), status_t: 0.5, ..Scene::default() },
|
// 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).
|
// ---- 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() },
|
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.
|
// ---- Success: green flash ring, dots cascading accent → white.
|
||||||
|
|
@ -245,11 +256,13 @@ fn main() {
|
||||||
// ---- Non-default layout: layout chip instead of caps.
|
// ---- Non-default layout: layout chip instead of caps.
|
||||||
Scene { name: "14-layout-2", password_len: 4, layout_index: 1, ..Scene::default() },
|
Scene { name: "14-layout-2", password_len: 4, layout_index: 1, ..Scene::default() },
|
||||||
// ---- Hold-to-reveal: plain password characters instead of dots.
|
// ---- Hold-to-reveal: plain password characters instead of dots.
|
||||||
Scene { name: "15-reveal", password_len: 8, reveal: true, ..Scene::default() },
|
Scene { name: "15-reveal", password_len: 7, password: "hunter2", reveal: true, ..Scene::default() },
|
||||||
// ---- Idle auto-dim: deepened veil (rest pose + full idle dim).
|
// ---- Idle auto-dim: deepened veil (rest pose + full idle dim).
|
||||||
Scene { name: "16-idle-dim", idle_dim: 1.0, ..Scene::default() },
|
Scene { name: "16-idle-dim", idle_dim: 1.0, ..Scene::default() },
|
||||||
// ---- Repeat failure: attempt counter in the status line.
|
// ---- 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() },
|
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 text = TextRenderer::new();
|
||||||
|
|
@ -265,7 +278,7 @@ fn main() {
|
||||||
date_text: scene.date,
|
date_text: scene.date,
|
||||||
clock_old: scene.clock_old,
|
clock_old: scene.clock_old,
|
||||||
password_len: scene.password_len,
|
password_len: scene.password_len,
|
||||||
password: "hunter2",
|
password: scene.password,
|
||||||
reveal: scene.reveal,
|
reveal: scene.reveal,
|
||||||
caps_lock: scene.caps_lock,
|
caps_lock: scene.caps_lock,
|
||||||
layout_index: scene.layout_index,
|
layout_index: scene.layout_index,
|
||||||
|
|
@ -278,6 +291,7 @@ fn main() {
|
||||||
breathe_t: scene.breathe_t,
|
breathe_t: scene.breathe_t,
|
||||||
status_t: scene.status_t,
|
status_t: scene.status_t,
|
||||||
status_text: scene.status,
|
status_text: scene.status,
|
||||||
|
info_text: scene.info,
|
||||||
appear_t: scene.appear_t,
|
appear_t: scene.appear_t,
|
||||||
unlock_t: scene.unlock_t,
|
unlock_t: scene.unlock_t,
|
||||||
smooth_pan: false,
|
smooth_pan: false,
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
//! calls compositor `unlock()` — that stays on the PAM path.
|
//! calls compositor `unlock()` — that stays on the PAM path.
|
||||||
|
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
|
|
||||||
use bread_utils::bread_client::{BreadClient, BreadEvent, Subscription};
|
use bread_utils::bread_client::{BreadClient, BreadEvent, Subscription};
|
||||||
|
|
@ -29,6 +30,26 @@ pub const APP_ID: &str = "lock";
|
||||||
/// locker process can coexist. The locker itself uses [`APP_ID`].
|
/// locker process can coexist. The locker itself uses [`APP_ID`].
|
||||||
pub const LISTEN_APP: &str = "lock-listen";
|
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() {
|
pub fn emit_locked() {
|
||||||
BreadClient::connect(APP_ID).emit("bread.lock.locked", serde_json::json!({}));
|
BreadClient::connect(APP_ID).emit("bread.lock.locked", serde_json::json!({}));
|
||||||
}
|
}
|
||||||
|
|
@ -59,11 +80,10 @@ pub fn emit_unlock_failed(error: &str) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True when another process holds the locker singleton — i.e. breadlock
|
/// True when this process is the locker, or another process holds the
|
||||||
/// is already locking this session. A `try_acquire` that succeeds is
|
/// locker singleton — i.e. breadlock is already locking this session.
|
||||||
/// released immediately; this is a check, not a claim.
|
|
||||||
pub fn locker_is_running() -> bool {
|
pub fn locker_is_running() -> bool {
|
||||||
singleton_held(APP_ID)
|
LOCKER_RUNNING.load(Ordering::SeqCst) || singleton_held(APP_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn singleton_held(app: &str) -> bool {
|
fn singleton_held(app: &str) -> bool {
|
||||||
|
|
@ -81,6 +101,8 @@ pub fn start_locker() -> Result<(), String> {
|
||||||
let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("breadlock"));
|
let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("breadlock"));
|
||||||
let mut child = Command::new(exe)
|
let mut child = Command::new(exe)
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|e| format!("failed to start breadlock: {e}"))?;
|
.map_err(|e| format!("failed to start breadlock: {e}"))?;
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
|
|
@ -94,7 +116,7 @@ pub fn start_locker() -> Result<(), String> {
|
||||||
/// `ext-session-lock-v1` has been accepted — wait on `bread.lock.locked`
|
/// `ext-session-lock-v1` has been accepted — wait on `bread.lock.locked`
|
||||||
/// for the compositor confirmation.
|
/// for the compositor confirmation.
|
||||||
pub fn honor_lock_command() {
|
pub fn honor_lock_command() {
|
||||||
honor_lock_command_with(start_locker);
|
honor_lock_command_with(locker_is_running(), start_locker);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Session-level unlock (`loginctl unlock-session` on the caller's
|
/// Session-level unlock (`loginctl unlock-session` on the caller's
|
||||||
|
|
@ -122,8 +144,8 @@ pub fn honor_unlock_command() {
|
||||||
honor_unlock_command_with(locker_is_running(), unlock_session);
|
honor_unlock_command_with(locker_is_running(), unlock_session);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn honor_lock_command_with(start: impl FnOnce() -> Result<(), String>) {
|
fn honor_lock_command_with(locked: bool, start: impl FnOnce() -> Result<(), String>) {
|
||||||
if locker_is_running() {
|
if locked {
|
||||||
tracing::info!("bread.command.lock.lock: already locked");
|
tracing::info!("bread.command.lock.lock: already locked");
|
||||||
emit_lock_done();
|
emit_lock_done();
|
||||||
return;
|
return;
|
||||||
|
|
@ -160,12 +182,16 @@ fn honor_unlock_command_with(locked: bool, unlock: impl FnOnce() -> Result<(), S
|
||||||
|
|
||||||
/// Reacts to `bread.command.lock.*`. Unknown verbs are ignored, not stubbed.
|
/// Reacts to `bread.command.lock.*`. Unknown verbs are ignored, not stubbed.
|
||||||
pub fn handle_command(event: &BreadEvent) {
|
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 {
|
let Some(verb) = event.event.strip_prefix("bread.command.lock.") else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
match verb {
|
match verb {
|
||||||
"lock" => honor_lock_command(),
|
"lock" => on_lock(),
|
||||||
"unlock" => honor_unlock_command(),
|
"unlock" => on_unlock(),
|
||||||
other => tracing::info!(verb = other, "ignoring unknown bread.command.lock verb"),
|
other => tracing::info!(verb = other, "ignoring unknown bread.command.lock verb"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -181,6 +207,7 @@ pub fn subscribe_commands() -> Subscription {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::cell::Cell;
|
||||||
|
|
||||||
fn event(name: &str) -> BreadEvent {
|
fn event(name: &str) -> BreadEvent {
|
||||||
BreadEvent {
|
BreadEvent {
|
||||||
|
|
@ -192,9 +219,48 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn handle_command_ignores_unrecognized_verb() {
|
fn handle_command_ignores_unrecognized_verb() {
|
||||||
handle_command(&event("bread.command.lock.pin"));
|
let lock = Cell::new(false);
|
||||||
handle_command(&event("bread.command.clip.clear"));
|
let unlock = Cell::new(false);
|
||||||
handle_command(&event("bread.lock.locked"));
|
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]
|
#[test]
|
||||||
|
|
@ -216,13 +282,33 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn honor_lock_command_with_failed_start_does_not_panic() {
|
fn honor_lock_command_with_failed_start_runs_start() {
|
||||||
honor_lock_command_with(|| Err("boom".into()));
|
let started = Cell::new(false);
|
||||||
|
honor_lock_command_with(false, || {
|
||||||
|
started.set(true);
|
||||||
|
Err("boom".into())
|
||||||
|
});
|
||||||
|
assert!(started.get());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn honor_lock_command_with_successful_start_does_not_panic() {
|
fn honor_lock_command_with_successful_start_runs_start() {
|
||||||
honor_lock_command_with(|| Ok(()));
|
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]
|
#[test]
|
||||||
|
|
@ -236,12 +322,33 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn honor_unlock_command_with_failed_loginctl_does_not_panic() {
|
fn honor_unlock_command_with_failed_loginctl_runs_unlock() {
|
||||||
honor_unlock_command_with(true, || Err("boom".into()));
|
let called = Cell::new(false);
|
||||||
|
honor_unlock_command_with(true, || {
|
||||||
|
called.set(true);
|
||||||
|
Err("boom".into())
|
||||||
|
});
|
||||||
|
assert!(called.get());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn honor_unlock_command_with_successful_loginctl_does_not_panic() {
|
fn honor_unlock_command_with_successful_loginctl_runs_unlock() {
|
||||||
honor_unlock_command_with(true, || Ok(()));
|
let called = Cell::new(false);
|
||||||
|
honor_unlock_command_with(true, || {
|
||||||
|
called.set(true);
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
assert!(called.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,40 @@ pub struct Config {
|
||||||
pub appearance: Appearance,
|
pub appearance: Appearance,
|
||||||
pub input: Input,
|
pub input: Input,
|
||||||
pub animation: Animation,
|
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 "wrong password" shake shows before input re-enables.
|
/// How long the red "wrong password" UI stays up. Input is not blocked
|
||||||
|
/// 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
|
/// Hold `Tab` to reveal the typed password as plain characters instead
|
||||||
/// of dots. Tab can never be part of a password (it produces no utf8),
|
/// of dots. Off by default: plaintext would sit in compositor buffers
|
||||||
/// so holding it is always safe to use as a reveal gesture.
|
/// 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,
|
pub reveal_hold: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,7 +50,7 @@ impl Default for Input {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
fail_timeout_ms: 800,
|
fail_timeout_ms: 800,
|
||||||
reveal_hold: true,
|
reveal_hold: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -76,11 +100,31 @@ 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]
|
#[test]
|
||||||
fn default_animation_breathe_is_on() {
|
fn default_animation_breathe_is_on() {
|
||||||
assert!(Config::default().animation.breathe);
|
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";
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,9 @@
|
||||||
//!
|
//!
|
||||||
//! If EGL initialization fails for any reason (headless, no GPU, compositor
|
//! If EGL initialization fails for any reason (headless, no GPU, compositor
|
||||||
//! without EGL), [`GpuRenderer::new`] returns `None` and the locker falls
|
//! without EGL), [`GpuRenderer::new`] returns `None` and the locker falls
|
||||||
//! back to the fully-software path unchanged.
|
//! back to the fully-software path unchanged. `GpuSurface` destroys its
|
||||||
|
//! native window and EGL surface on drop (output unplug); `render_frame`
|
||||||
|
//! returns `false` on make_current/swap failure so the caller can fall back.
|
||||||
|
|
||||||
use crate::render::{self, FrameInputs};
|
use crate::render::{self, FrameInputs};
|
||||||
use breadlock_ui::config::{Background as BackgroundConfig, BackgroundMode};
|
use breadlock_ui::config::{Background as BackgroundConfig, BackgroundMode};
|
||||||
|
|
@ -29,9 +31,11 @@ use wayland_client::{Connection, Proxy};
|
||||||
const KENBURNS_PERIOD_S: f32 = 90.0;
|
const KENBURNS_PERIOD_S: f32 = 90.0;
|
||||||
const KENBURNS_ZOOM: f32 = 1.06;
|
const KENBURNS_ZOOM: f32 = 1.06;
|
||||||
|
|
||||||
const EGL_ATTRIBS: [egl::Int; 11] = [
|
const EGL_ATTRIBS: [egl::Int; 13] = [
|
||||||
egl::SURFACE_TYPE,
|
egl::SURFACE_TYPE,
|
||||||
(egl::WINDOW_BIT | egl::PBUFFER_BIT) as egl::Int,
|
egl::WINDOW_BIT as egl::Int,
|
||||||
|
egl::RENDERABLE_TYPE,
|
||||||
|
egl::OPENGL_ES2_BIT as egl::Int,
|
||||||
egl::RED_SIZE,
|
egl::RED_SIZE,
|
||||||
8,
|
8,
|
||||||
egl::GREEN_SIZE,
|
egl::GREEN_SIZE,
|
||||||
|
|
@ -101,22 +105,41 @@ pub struct wl_egl_window {
|
||||||
extern "C" {
|
extern "C" {
|
||||||
fn wl_egl_window_create(surface: *mut wl_surface, width: i32, height: i32) -> *mut wl_egl_window;
|
fn wl_egl_window_create(surface: *mut wl_surface, width: i32, height: i32) -> *mut wl_egl_window;
|
||||||
fn wl_egl_window_resize(window: *mut wl_egl_window, width: i32, height: i32, dx: i32, dy: i32);
|
fn wl_egl_window_resize(window: *mut wl_egl_window, width: i32, height: i32, dx: i32, dy: i32);
|
||||||
|
fn wl_egl_window_destroy(window: *mut wl_egl_window);
|
||||||
}
|
}
|
||||||
|
|
||||||
// EGL objects are intentionally not destroyed on the way out: the process
|
/// EGL entry points captured at surface creation so `Drop` can destroy the
|
||||||
// exits immediately after unlock, and dropping the pbuffer/context while it
|
/// window/surface without holding a pointer into `GpuRenderer` (which would
|
||||||
// might still be current would be UB — leaving them for the OS is cleaner.
|
/// dangle if AppState is moved).
|
||||||
const _: () = ();
|
struct EglSurfaceFns {
|
||||||
|
destroy_surface: unsafe extern "system" fn(egl::EGLDisplay, egl::EGLSurface) -> egl::Boolean,
|
||||||
|
make_current: unsafe extern "system" fn(
|
||||||
|
egl::EGLDisplay,
|
||||||
|
egl::EGLSurface,
|
||||||
|
egl::EGLSurface,
|
||||||
|
egl::EGLContext,
|
||||||
|
) -> egl::Boolean,
|
||||||
|
get_current_surface: unsafe extern "system" fn(egl::Int) -> egl::EGLSurface,
|
||||||
|
}
|
||||||
|
|
||||||
/// One EGL-backed lock surface. Created lazily on the first `configure` (the
|
/// One EGL-backed lock surface. Created lazily on the first `configure` (the
|
||||||
/// size is unknown before that) and resized on subsequent ones. The process
|
/// size is unknown before that) and resized on subsequent ones. Dropped on
|
||||||
/// exits right after unlock, so EGL objects are deliberately not destroyed
|
/// output unplug (`output_destroyed` retains the `LockSurface` out of the
|
||||||
/// individually.
|
/// vec), so the native window and EGL surface must be destroyed here —
|
||||||
|
/// process-exit-only was wrong for hotplug.
|
||||||
pub struct GpuSurface {
|
pub struct GpuSurface {
|
||||||
egl_window: *mut wl_egl_window,
|
egl_window: *mut wl_egl_window,
|
||||||
egl_surface: egl::Surface,
|
egl_surface: egl::Surface,
|
||||||
|
display: egl::Display,
|
||||||
|
egl_fns: Option<EglSurfaceFns>,
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
|
/// Per-surface chrome texture/pixmap so two outputs of different sizes
|
||||||
|
/// don't thrash one shared texture (which left undefined texels in the
|
||||||
|
/// leftover region).
|
||||||
|
chrome_tex: Option<glow::Texture>,
|
||||||
|
chrome_tex_size: (u32, u32),
|
||||||
|
chrome_pixmap: Option<Pixmap>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GpuSurface {
|
impl GpuSurface {
|
||||||
|
|
@ -131,6 +154,37 @@ impl GpuSurface {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Drop for GpuSurface {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Unbind this surface if it's current, then destroy the EGL surface
|
||||||
|
// and the native window. Making-current with NO_SURFACE first avoids
|
||||||
|
// the UB of destroying a current surface.
|
||||||
|
unsafe {
|
||||||
|
if let Some(fns) = self.egl_fns.take() {
|
||||||
|
let surf = self.egl_surface.as_ptr();
|
||||||
|
let current = (fns.get_current_surface)(egl::DRAW) == surf
|
||||||
|
|| (fns.get_current_surface)(egl::READ) == surf;
|
||||||
|
if current {
|
||||||
|
let _ = (fns.make_current)(
|
||||||
|
self.display.as_ptr(),
|
||||||
|
egl::NO_SURFACE,
|
||||||
|
egl::NO_SURFACE,
|
||||||
|
egl::NO_CONTEXT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let _ = (fns.destroy_surface)(self.display.as_ptr(), surf);
|
||||||
|
}
|
||||||
|
if !self.egl_window.is_null() {
|
||||||
|
wl_egl_window_destroy(self.egl_window);
|
||||||
|
self.egl_window = std::ptr::null_mut();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// GL chrome texture: deleting it needs a current context we may not
|
||||||
|
// have (the other output could be current). One leaked texture per
|
||||||
|
// unplugged output is acceptable; the process still owns the context.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct Wallpaper {
|
struct Wallpaper {
|
||||||
tex: glow::Texture,
|
tex: glow::Texture,
|
||||||
size: (u32, u32),
|
size: (u32, u32),
|
||||||
|
|
@ -143,11 +197,11 @@ pub struct GpuRenderer {
|
||||||
config: egl::Config,
|
config: egl::Config,
|
||||||
context: egl::Context,
|
context: egl::Context,
|
||||||
/// 1x1 pbuffer used to make the context current during setup (before any
|
/// 1x1 pbuffer used to make the context current during setup (before any
|
||||||
/// real lock surface exists). Kept alive for the renderer's lifetime —
|
/// real lock surface exists). `None` when we fell back to a surfaceless
|
||||||
/// the read is deliberate: dropping it while the context might still be
|
/// context. Kept alive for the renderer's lifetime — dropping it while
|
||||||
/// current on it is undefined behavior.
|
/// the context might still be current on it is undefined behavior.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
setup_surface: egl::Surface,
|
setup_surface: Option<egl::Surface>,
|
||||||
gl: glow::Context,
|
gl: glow::Context,
|
||||||
bg_program: glow::Program,
|
bg_program: glow::Program,
|
||||||
chrome_program: glow::Program,
|
chrome_program: glow::Program,
|
||||||
|
|
@ -158,10 +212,6 @@ pub struct GpuRenderer {
|
||||||
/// the palette color).
|
/// the palette color).
|
||||||
white_tex: glow::Texture,
|
white_tex: glow::Texture,
|
||||||
bg_color: [f32; 4],
|
bg_color: [f32; 4],
|
||||||
chrome_tex: glow::Texture,
|
|
||||||
chrome_tex_size: (u32, u32),
|
|
||||||
/// Reused scratch for the chrome compose.
|
|
||||||
chrome_pixmap: Option<Pixmap>,
|
|
||||||
u_screen: [Option<glow::UniformLocation>; 2],
|
u_screen: [Option<glow::UniformLocation>; 2],
|
||||||
u_uv_scale: [Option<glow::UniformLocation>; 2],
|
u_uv_scale: [Option<glow::UniformLocation>; 2],
|
||||||
u_uv_offset: [Option<glow::UniformLocation>; 2],
|
u_uv_offset: [Option<glow::UniformLocation>; 2],
|
||||||
|
|
@ -184,6 +234,7 @@ impl GpuRenderer {
|
||||||
// SAFETY: the display pointer comes from our live wayland connection.
|
// SAFETY: the display pointer comes from our live wayland connection.
|
||||||
let display = unsafe { egl.get_display(conn.display().id().as_ptr() as *mut c_void) }?;
|
let display = unsafe { egl.get_display(conn.display().id().as_ptr() as *mut c_void) }?;
|
||||||
egl.initialize(display).ok()?;
|
egl.initialize(display).ok()?;
|
||||||
|
egl.bind_api(egl::OPENGL_ES_API).ok()?;
|
||||||
let mut configs = Vec::with_capacity(1);
|
let mut configs = Vec::with_capacity(1);
|
||||||
egl.choose_config(display, &EGL_ATTRIBS, &mut configs).ok()?;
|
egl.choose_config(display, &EGL_ATTRIBS, &mut configs).ok()?;
|
||||||
let config = *configs.first()?;
|
let config = *configs.first()?;
|
||||||
|
|
@ -191,14 +242,23 @@ impl GpuRenderer {
|
||||||
.create_context(display, config, None, &[egl::CONTEXT_CLIENT_VERSION, 2, egl::NONE])
|
.create_context(display, config, None, &[egl::CONTEXT_CLIENT_VERSION, 2, egl::NONE])
|
||||||
.ok()?;
|
.ok()?;
|
||||||
// A 1x1 pbuffer is enough to make the context current for setup
|
// A 1x1 pbuffer is enough to make the context current for setup
|
||||||
// before any real lock surface exists (pbuffers size via
|
// before any real lock surface exists. The chosen config is
|
||||||
// EGL_WIDTH/EGL_HEIGHT).
|
// WINDOW_BIT-only (more portable than also requiring PBUFFER_BIT),
|
||||||
|
// so pbuffer creation may fail — fall back to a surfaceless
|
||||||
|
// context (EGL_KHR_surfaceless_context) in that case.
|
||||||
let setup_surface = egl
|
let setup_surface = egl
|
||||||
.create_pbuffer_surface(display, config, &[egl::WIDTH, 1, egl::HEIGHT, 1, egl::NONE])
|
.create_pbuffer_surface(display, config, &[egl::WIDTH, 1, egl::HEIGHT, 1, egl::NONE])
|
||||||
.ok()?;
|
.ok();
|
||||||
if egl
|
let made = match setup_surface {
|
||||||
.make_current(display, Some(setup_surface), Some(setup_surface), Some(context))
|
Some(s) => egl
|
||||||
.is_err()
|
.make_current(display, Some(s), Some(s), Some(context))
|
||||||
|
.is_ok(),
|
||||||
|
None => false,
|
||||||
|
};
|
||||||
|
if !made
|
||||||
|
&& egl
|
||||||
|
.make_current(display, None, None, Some(context))
|
||||||
|
.is_err()
|
||||||
{
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
@ -300,16 +360,6 @@ impl GpuRenderer {
|
||||||
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32);
|
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full-size chrome texture (sub-image uploaded per frame).
|
|
||||||
let chrome_tex = unsafe { gl.create_texture() }.ok()?;
|
|
||||||
unsafe {
|
|
||||||
gl.bind_texture(glow::TEXTURE_2D, Some(chrome_tex));
|
|
||||||
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32);
|
|
||||||
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32);
|
|
||||||
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32);
|
|
||||||
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32);
|
|
||||||
}
|
|
||||||
|
|
||||||
let bg = breadlock_ui::theme::tiny_skia_color(&palette.background);
|
let bg = breadlock_ui::theme::tiny_skia_color(&palette.background);
|
||||||
let bg_color = [bg.red(), bg.green(), bg.blue(), 1.0];
|
let bg_color = [bg.red(), bg.green(), bg.blue(), 1.0];
|
||||||
|
|
||||||
|
|
@ -344,9 +394,6 @@ impl GpuRenderer {
|
||||||
wallpaper,
|
wallpaper,
|
||||||
white_tex,
|
white_tex,
|
||||||
bg_color,
|
bg_color,
|
||||||
chrome_tex,
|
|
||||||
chrome_tex_size: (0, 0),
|
|
||||||
chrome_pixmap: None,
|
|
||||||
u_screen,
|
u_screen,
|
||||||
u_uv_scale,
|
u_uv_scale,
|
||||||
u_uv_offset,
|
u_uv_offset,
|
||||||
|
|
@ -379,40 +426,62 @@ impl GpuRenderer {
|
||||||
let egl_surface = unsafe {
|
let egl_surface = unsafe {
|
||||||
self.egl
|
self.egl
|
||||||
.create_window_surface(self.display, self.config, egl_window as *mut c_void, None)
|
.create_window_surface(self.display, self.config, egl_window as *mut c_void, None)
|
||||||
}
|
};
|
||||||
.ok()?;
|
let egl_surface = match egl_surface {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(%err, "eglCreateWindowSurface failed");
|
||||||
|
// SAFETY: we still own the native window created above.
|
||||||
|
unsafe { wl_egl_window_destroy(egl_window) };
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
Some(GpuSurface {
|
Some(GpuSurface {
|
||||||
egl_window,
|
egl_window,
|
||||||
egl_surface,
|
egl_surface,
|
||||||
|
display: self.display,
|
||||||
|
egl_fns: load_egl_surface_fns(&self.egl),
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
|
chrome_tex: None,
|
||||||
|
chrome_tex_size: (0, 0),
|
||||||
|
chrome_pixmap: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renders one frame for `surface`: wallpaper quad (pan + veil in the
|
/// Renders one frame for `surface`: wallpaper quad (pan + veil in the
|
||||||
/// shader), then the software-composed chrome blitted over it.
|
/// shader), then the software-composed chrome blitted over it.
|
||||||
|
///
|
||||||
|
/// Returns `false` if `make_current` or `swap_buffers` failed (caller
|
||||||
|
/// could fall back to the software path; `state.rs` currently ignores
|
||||||
|
/// the result and we just skip the frame).
|
||||||
pub fn render_frame(
|
pub fn render_frame(
|
||||||
&mut self,
|
&mut self,
|
||||||
surface: &mut GpuSurface,
|
surface: &mut GpuSurface,
|
||||||
inputs: &FrameInputs,
|
inputs: &FrameInputs,
|
||||||
text: &mut TextRenderer,
|
text: &mut TextRenderer,
|
||||||
) {
|
) -> bool {
|
||||||
let (w, h) = (surface.width, surface.height);
|
let (w, h) = (surface.width, surface.height);
|
||||||
if w == 0 || h == 0 {
|
if w == 0 || h == 0 {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
if self
|
if self
|
||||||
.egl
|
.egl
|
||||||
.make_current(self.display, Some(surface.egl_surface), Some(surface.egl_surface), Some(self.context))
|
.make_current(self.display, Some(surface.egl_surface), Some(surface.egl_surface), Some(self.context))
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
return;
|
tracing::warn!("eglMakeCurrent failed; skipping GPU frame");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
let gl = &self.gl;
|
let gl = &self.gl;
|
||||||
unsafe { gl.viewport(0, 0, w as i32, h as i32) };
|
unsafe { gl.viewport(0, 0, w as i32, h as i32) };
|
||||||
self.draw_background(w, h, inputs);
|
self.draw_background(w, h, inputs);
|
||||||
self.draw_chrome(w, h, inputs, text);
|
self.draw_chrome(surface, inputs, text);
|
||||||
let _ = self.egl.swap_buffers(self.display, surface.egl_surface);
|
if self.egl.swap_buffers(self.display, surface.egl_surface).is_err() {
|
||||||
|
tracing::warn!("eglSwapBuffers failed; skipping GPU frame");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_background(&mut self, w: u32, h: u32, inputs: &FrameInputs) {
|
fn draw_background(&mut self, w: u32, h: u32, inputs: &FrameInputs) {
|
||||||
|
|
@ -498,19 +567,21 @@ impl GpuRenderer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_chrome(&mut self, w: u32, h: u32, inputs: &FrameInputs, text: &mut TextRenderer) {
|
fn draw_chrome(&mut self, surface: &mut GpuSurface, inputs: &FrameInputs, text: &mut TextRenderer) {
|
||||||
let dirty = self
|
let (w, h) = (surface.width, surface.height);
|
||||||
|
let dirty = surface
|
||||||
.chrome_pixmap
|
.chrome_pixmap
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|p| (p.width(), p.height()) != (w, h))
|
.map(|p| (p.width(), p.height()) != (w, h))
|
||||||
.unwrap_or(true);
|
.unwrap_or(true);
|
||||||
if dirty {
|
if dirty {
|
||||||
self.chrome_pixmap = Pixmap::new(w, h);
|
surface.chrome_pixmap = Pixmap::new(w, h);
|
||||||
}
|
}
|
||||||
let Some(pixmap) = self.chrome_pixmap.as_mut() else {
|
let Some(pixmap) = surface.chrome_pixmap.as_mut() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let rect = render::compose_chrome(pixmap, text, inputs);
|
let rect = render::compose_chrome(pixmap, text, inputs);
|
||||||
|
// Empty ChromeRect is (+∞, +∞, −∞, −∞); after clamping, x1 <= x0.
|
||||||
let x0 = rect.x0.max(0.0).floor() as i32;
|
let x0 = rect.x0.max(0.0).floor() as i32;
|
||||||
let y0 = rect.y0.max(0.0).floor() as i32;
|
let y0 = rect.y0.max(0.0).floor() as i32;
|
||||||
let x1 = (rect.x1.min(w as f32)).ceil() as i32;
|
let x1 = (rect.x1.min(w as f32)).ceil() as i32;
|
||||||
|
|
@ -518,10 +589,47 @@ impl GpuRenderer {
|
||||||
if x1 <= x0 || y1 <= y0 {
|
if x1 <= x0 || y1 <= y0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if self.chrome_tex_size != (w, h) {
|
|
||||||
let gl = &self.gl;
|
let gl = &self.gl;
|
||||||
|
if surface.chrome_tex.is_none() {
|
||||||
|
let tex = match unsafe { gl.create_texture() } {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
unsafe {
|
unsafe {
|
||||||
gl.bind_texture(glow::TEXTURE_2D, Some(self.chrome_tex));
|
gl.bind_texture(glow::TEXTURE_2D, Some(tex));
|
||||||
|
gl.tex_parameter_i32(
|
||||||
|
glow::TEXTURE_2D,
|
||||||
|
glow::TEXTURE_MIN_FILTER,
|
||||||
|
glow::NEAREST as i32,
|
||||||
|
);
|
||||||
|
gl.tex_parameter_i32(
|
||||||
|
glow::TEXTURE_2D,
|
||||||
|
glow::TEXTURE_MAG_FILTER,
|
||||||
|
glow::NEAREST as i32,
|
||||||
|
);
|
||||||
|
gl.tex_parameter_i32(
|
||||||
|
glow::TEXTURE_2D,
|
||||||
|
glow::TEXTURE_WRAP_S,
|
||||||
|
glow::CLAMP_TO_EDGE as i32,
|
||||||
|
);
|
||||||
|
gl.tex_parameter_i32(
|
||||||
|
glow::TEXTURE_2D,
|
||||||
|
glow::TEXTURE_WRAP_T,
|
||||||
|
glow::CLAMP_TO_EDGE as i32,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
surface.chrome_tex = Some(tex);
|
||||||
|
surface.chrome_tex_size = (0, 0);
|
||||||
|
}
|
||||||
|
let chrome_tex = surface.chrome_tex.expect("set above");
|
||||||
|
if surface.chrome_tex_size != (w, h) {
|
||||||
|
// Allocate with zeros — `tex_image_2d(..., None)` leaves undefined
|
||||||
|
// texels, which ghosted when we later drew a fullscreen chrome quad
|
||||||
|
// after only uploading the dirty AABB.
|
||||||
|
let zeros = vec![0u8; w as usize * h as usize * 4];
|
||||||
|
unsafe {
|
||||||
|
gl.bind_texture(glow::TEXTURE_2D, Some(chrome_tex));
|
||||||
gl.tex_image_2d(
|
gl.tex_image_2d(
|
||||||
glow::TEXTURE_2D,
|
glow::TEXTURE_2D,
|
||||||
0,
|
0,
|
||||||
|
|
@ -531,18 +639,17 @@ impl GpuRenderer {
|
||||||
0,
|
0,
|
||||||
glow::RGBA,
|
glow::RGBA,
|
||||||
glow::UNSIGNED_BYTE,
|
glow::UNSIGNED_BYTE,
|
||||||
glow::PixelUnpackData::Slice(None),
|
glow::PixelUnpackData::Slice(Some(&zeros)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
self.chrome_tex_size = (w, h);
|
surface.chrome_tex_size = (w, h);
|
||||||
}
|
}
|
||||||
|
|
||||||
let rw = (x1 - x0) as usize;
|
let rw = (x1 - x0) as usize;
|
||||||
let rh = (y1 - y0) as usize;
|
let rh = (y1 - y0) as usize;
|
||||||
let data = pixmap.data();
|
let data = pixmap.data();
|
||||||
let gl = &self.gl;
|
|
||||||
unsafe {
|
unsafe {
|
||||||
gl.bind_texture(glow::TEXTURE_2D, Some(self.chrome_tex));
|
gl.bind_texture(glow::TEXTURE_2D, Some(chrome_tex));
|
||||||
// glTexSubImage2D reads `rw` pixels contiguously per row with no
|
// glTexSubImage2D reads `rw` pixels contiguously per row with no
|
||||||
// knowledge of the source's row stride. We're on GLES2 (where
|
// knowledge of the source's row stride. We're on GLES2 (where
|
||||||
// GL_UNPACK_ROW_LENGTH doesn't exist), so pack the sub-rect rows
|
// GL_UNPACK_ROW_LENGTH doesn't exist), so pack the sub-rect rows
|
||||||
|
|
@ -554,8 +661,8 @@ impl GpuRenderer {
|
||||||
gl.tex_sub_image_2d(
|
gl.tex_sub_image_2d(
|
||||||
glow::TEXTURE_2D,
|
glow::TEXTURE_2D,
|
||||||
0,
|
0,
|
||||||
x0 as i32,
|
x0,
|
||||||
y0 as i32,
|
y0,
|
||||||
rw as i32,
|
rw as i32,
|
||||||
rh as i32,
|
rh as i32,
|
||||||
glow::RGBA,
|
glow::RGBA,
|
||||||
|
|
@ -567,9 +674,17 @@ impl GpuRenderer {
|
||||||
gl.bind_buffer(glow::ARRAY_BUFFER, Some(self.quad_vbo));
|
gl.bind_buffer(glow::ARRAY_BUFFER, Some(self.quad_vbo));
|
||||||
let wf = w as f32;
|
let wf = w as f32;
|
||||||
let hf = h as f32;
|
let hf = h as f32;
|
||||||
|
let xf0 = x0 as f32;
|
||||||
|
let yf0 = y0 as f32;
|
||||||
|
let xf1 = x1 as f32;
|
||||||
|
let yf1 = y1 as f32;
|
||||||
|
// Quad covering *only* the current ChromeRect. A shrinking status
|
||||||
|
// line would otherwise ghost from leftover texels on a fullscreen
|
||||||
|
// chrome draw. UV still maps pixel coords → [0,1] over the full
|
||||||
|
// texture, so this sub-rect samples the matching texels.
|
||||||
let verts: [f32; 12] = [
|
let verts: [f32; 12] = [
|
||||||
0.0, 0.0, wf, 0.0, 0.0, hf, //
|
xf0, yf0, xf1, yf0, xf0, yf1, //
|
||||||
wf, 0.0, wf, hf, 0.0, hf,
|
xf1, yf0, xf1, yf1, xf0, yf1,
|
||||||
];
|
];
|
||||||
gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, f32s_as_bytes(&verts), glow::DYNAMIC_DRAW);
|
gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, f32s_as_bytes(&verts), glow::DYNAMIC_DRAW);
|
||||||
if let Some(loc) = self.u_screen[1].as_ref() {
|
if let Some(loc) = self.u_screen[1].as_ref() {
|
||||||
|
|
@ -585,7 +700,7 @@ impl GpuRenderer {
|
||||||
gl.uniform_1_i32(Some(loc), 0);
|
gl.uniform_1_i32(Some(loc), 0);
|
||||||
}
|
}
|
||||||
gl.active_texture(glow::TEXTURE0);
|
gl.active_texture(glow::TEXTURE0);
|
||||||
gl.bind_texture(glow::TEXTURE_2D, Some(self.chrome_tex));
|
gl.bind_texture(glow::TEXTURE_2D, Some(chrome_tex));
|
||||||
gl.enable(glow::BLEND);
|
gl.enable(glow::BLEND);
|
||||||
gl.blend_func(glow::ONE, glow::ONE_MINUS_SRC_ALPHA);
|
gl.blend_func(glow::ONE, glow::ONE_MINUS_SRC_ALPHA);
|
||||||
gl.draw_arrays(glow::TRIANGLES, 0, 6);
|
gl.draw_arrays(glow::TRIANGLES, 0, 6);
|
||||||
|
|
@ -617,6 +732,22 @@ fn pan_region(wp: (u32, u32), target: (u32, u32), ken_burns: bool, t_secs: f32)
|
||||||
(-tx, -ty, scaled_w, scaled_h)
|
(-tx, -ty, scaled_w, scaled_h)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolves the EGL calls `GpuSurface::drop` needs. Function pointers, not a
|
||||||
|
/// pointer into `GpuRenderer`, so a later move of the renderer is fine.
|
||||||
|
fn load_egl_surface_fns(egl: &egl::DynamicInstance<egl::EGL1_4>) -> Option<EglSurfaceFns> {
|
||||||
|
unsafe fn load<T>(egl: &egl::DynamicInstance<egl::EGL1_4>, name: &str) -> Option<T> {
|
||||||
|
let p = egl.get_proc_address(name)?;
|
||||||
|
// SAFETY: `name` is an EGL 1.0 core entry point; the signature of `T`
|
||||||
|
// matches the Khronos spec. Both types are function pointers.
|
||||||
|
Some(std::mem::transmute_copy::<_, T>(&p))
|
||||||
|
}
|
||||||
|
Some(EglSurfaceFns {
|
||||||
|
destroy_surface: unsafe { load(egl, "eglDestroySurface") }?,
|
||||||
|
make_current: unsafe { load(egl, "eglMakeCurrent") }?,
|
||||||
|
get_current_surface: unsafe { load(egl, "eglGetCurrentSurface") }?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Packs the `(x0, y0, rw, rh)` sub-rect of a `w`-wide RGBA row-major buffer
|
/// Packs the `(x0, y0, rw, rh)` sub-rect of a `w`-wide RGBA row-major buffer
|
||||||
/// into a tightly-strided `rw`-per-row buffer for `glTexSubImage2D`, which
|
/// into a tightly-strided `rw`-per-row buffer for `glTexSubImage2D`, which
|
||||||
/// reads rows contiguously and has no stride concept on GLES2.
|
/// reads rows contiguously and has no stride concept on GLES2.
|
||||||
|
|
@ -822,7 +953,7 @@ mod tests {
|
||||||
let h = 720.0;
|
let h = 720.0;
|
||||||
// Software dim_rows: alpha = lerp(TOP, BOTTOM, y/h) with y=0 at top,
|
// Software dim_rows: alpha = lerp(TOP, BOTTOM, y/h) with y=0 at top,
|
||||||
// so the top of the screen gets the DEEPER dim (DIM_ALPHA_TOP).
|
// so the top of the screen gets the DEEPER dim (DIM_ALPHA_TOP).
|
||||||
assert!(DIM_ALPHA_TOP > DIM_ALPHA_BOTTOM);
|
const { assert!(DIM_ALPHA_TOP > DIM_ALPHA_BOTTOM) };
|
||||||
// Shader at the very top (gl_FragCoord.y = h): row = 0 -> dim = TOP.
|
// Shader at the very top (gl_FragCoord.y = h): row = 0 -> dim = TOP.
|
||||||
assert!((shader_dim_at(h, h) - DIM_ALPHA_TOP).abs() < 1e-6);
|
assert!((shader_dim_at(h, h) - DIM_ALPHA_TOP).abs() < 1e-6);
|
||||||
// Shader at the very bottom (gl_FragCoord.y = 0): row = 1 -> dim = BOTTOM.
|
// Shader at the very bottom (gl_FragCoord.y = 0): row = 1 -> dim = BOTTOM.
|
||||||
|
|
@ -844,6 +975,21 @@ mod tests {
|
||||||
fn egl_attribs_are_none_terminated_pairs() {
|
fn egl_attribs_are_none_terminated_pairs() {
|
||||||
assert_eq!(EGL_ATTRIBS.len() % 2, 1, "attribs must be key/value pairs + NONE");
|
assert_eq!(EGL_ATTRIBS.len() % 2, 1, "attribs must be key/value pairs + NONE");
|
||||||
assert_eq!(*EGL_ATTRIBS.last().unwrap(), egl::NONE, "attrib list must be NONE-terminated");
|
assert_eq!(*EGL_ATTRIBS.last().unwrap(), egl::NONE, "attrib list must be NONE-terminated");
|
||||||
|
let mut saw_window = false;
|
||||||
|
let mut saw_es2 = false;
|
||||||
|
let mut saw_pbuffer = false;
|
||||||
|
for pair in EGL_ATTRIBS.chunks(2).filter(|c| c.len() == 2) {
|
||||||
|
if pair[0] == egl::SURFACE_TYPE {
|
||||||
|
saw_window = pair[1] & egl::WINDOW_BIT as egl::Int != 0;
|
||||||
|
saw_pbuffer = pair[1] & egl::PBUFFER_BIT as egl::Int != 0;
|
||||||
|
}
|
||||||
|
if pair[0] == egl::RENDERABLE_TYPE {
|
||||||
|
saw_es2 = pair[1] & egl::OPENGL_ES2_BIT as egl::Int != 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(saw_window, "config must request WINDOW_BIT");
|
||||||
|
assert!(!saw_pbuffer, "do not require PBUFFER_BIT (not portable)");
|
||||||
|
assert!(saw_es2, "config must request EGL_OPENGL_ES2_BIT");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -867,7 +1013,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
// Take the 2x1 rect at (1, 0).
|
// Take the 2x1 rect at (1, 0).
|
||||||
let packed = pack_rows(&data, 4, 1, 0, 2, 1);
|
let packed = pack_rows(&data, 4, 1, 0, 2, 1);
|
||||||
assert_eq!(packed.len(), 2 * 1 * 4);
|
assert_eq!(packed.len(), 8);
|
||||||
assert_eq!(packed[0], 1);
|
assert_eq!(packed[0], 1);
|
||||||
assert_eq!(packed[4], 2);
|
assert_eq!(packed[4], 2);
|
||||||
// Two rows: the second row must NOT be shifted by (w - rw) — the bug.
|
// Two rows: the second row must NOT be shifted by (w - rw) — the bug.
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use wayland_client::{Connection, QueueHandle};
|
||||||
use zeroize::Zeroize;
|
use zeroize::Zeroize;
|
||||||
|
|
||||||
use crate::auth;
|
use crate::auth;
|
||||||
use crate::state::{AppState, AuthState};
|
use crate::state::{AppState, AuthState, PASSWORD_CAP};
|
||||||
|
|
||||||
impl SeatHandler for AppState {
|
impl SeatHandler for AppState {
|
||||||
fn seat_state(&mut self) -> &mut SeatState {
|
fn seat_state(&mut self) -> &mut SeatState {
|
||||||
|
|
@ -25,48 +25,40 @@ impl SeatHandler for AppState {
|
||||||
capability: Capability,
|
capability: Capability,
|
||||||
) {
|
) {
|
||||||
if capability == Capability::Keyboard && self.keyboard.is_none() {
|
if capability == Capability::Keyboard && self.keyboard.is_none() {
|
||||||
// Plain `get_keyboard` never populates SCTK's internal repeat
|
self.try_bind_keyboard(qh, &seat);
|
||||||
// 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 {
|
||||||
if let Some(keyboard) = self.keyboard.take() {
|
return;
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,11 +78,16 @@ 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(
|
||||||
|
|
@ -154,11 +151,75 @@ impl KeyboardHandler for AppState {
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
// Ignore all input while a PAM check is in flight so a fast second
|
// Unlock fade: auth already succeeded; surfaces stay up until it ends.
|
||||||
// Enter can't race the first attempt, and while the unlock fade is
|
if self.unlocking.is_some() {
|
||||||
// playing (auth already succeeded; surfaces stay up until it ends).
|
return;
|
||||||
if self.auth_state == AuthState::Checking || self.unlocking.is_some() {
|
}
|
||||||
|
|
||||||
|
// 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 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -198,6 +259,7 @@ 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();
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
|
|
@ -205,14 +267,21 @@ 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()) {
|
||||||
self.password.push(ch);
|
if try_push_password(&mut self.password, 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();
|
||||||
}
|
}
|
||||||
// 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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -221,11 +290,16 @@ impl AppState {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clear_failed_state(&mut self) {
|
fn clear_failed_state(&mut self) {
|
||||||
if matches!(self.auth_state, AuthState::Failed | AuthState::ConfigError) {
|
if matches!(
|
||||||
|
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
|
// Drop the red-pill tint and shake offsets; `failed_at` is also
|
||||||
// cleared so `schedule_clear_failed`'s timer is a no-op.
|
// cleared so `schedule_clear_failed`'s timer is a no-op unless
|
||||||
|
// its generation still matches a later fail.
|
||||||
self.failed_at = None;
|
self.failed_at = None;
|
||||||
|
self.password_display_len = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -233,7 +307,14 @@ 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
|
||||||
|
|
@ -241,8 +322,61 @@ 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(128)),
|
zeroize::Zeroizing::new(String::with_capacity(PASSWORD_CAP)),
|
||||||
|
);
|
||||||
|
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 @@
|
||||||
mod keyboard;
|
pub(crate) mod keyboard;
|
||||||
|
|
|
||||||
|
|
@ -16,14 +16,27 @@ impl SessionLockHandler for AppState {
|
||||||
/// 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,
|
||||||
) {
|
) {
|
||||||
tracing::warn!("compositor ended the session lock; exiting");
|
// PAM unlock already took the stored lock; don't unlock/emit again.
|
||||||
self.session_lock = None;
|
let Some(lock) = self.session_lock.take() else {
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,22 +49,29 @@ impl SessionLockHandler for AppState {
|
||||||
_serial: u32,
|
_serial: u32,
|
||||||
) {
|
) {
|
||||||
let (width, height) = configure.new_size;
|
let (width, height) = configure.new_size;
|
||||||
if let Some(s) = self
|
let (buf_w, buf_h) = 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);
|
||||||
|
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;
|
// Lazily wrap the surface in EGL on its first (sized) configure;
|
||||||
// resize the EGL window on subsequent ones.
|
// resize the EGL window on subsequent ones. Size is buffer pixels.
|
||||||
if let Some(renderer) = &self.gpu {
|
if let Some(renderer) = &self.gpu {
|
||||||
match &mut s.gpu {
|
match &mut s.gpu {
|
||||||
None => s.gpu = renderer.create_surface(surface.wl_surface(), width, height),
|
None => s.gpu = renderer.create_surface(surface.wl_surface(), buf_w, buf_h),
|
||||||
Some(gs) => gs.resize(width, height),
|
Some(gs) => gs.resize(buf_w, buf_h),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
(buf_w, buf_h)
|
||||||
self.redraw_surface(qh, &surface, width, height);
|
} else {
|
||||||
|
(width, height)
|
||||||
|
};
|
||||||
|
self.redraw_surface(qh, &surface, buf_w, buf_h);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,30 @@ 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(
|
||||||
|
|
@ -66,6 +86,12 @@ 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;
|
||||||
};
|
};
|
||||||
|
|
@ -76,7 +102,10 @@ impl OutputHandler for AppState {
|
||||||
output,
|
output,
|
||||||
width: 0,
|
width: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
|
scale: 1,
|
||||||
gpu: None,
|
gpu: None,
|
||||||
|
shm_pool: None,
|
||||||
|
shm_buffer: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ 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;
|
||||||
|
|
@ -128,18 +129,21 @@ fn run_lock() {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let _running = bread_events::enter_lock_process();
|
||||||
|
|
||||||
// Honor bread.command.lock.lock / unlock while this locker is up
|
// Honor bread.command.lock.lock / unlock while this locker is up
|
||||||
// (already-locked is bread.lock.lock.done; unlock is loginctl, not
|
// (already-locked is bread.lock.lock.done; unlock is loginctl, not
|
||||||
// compositor unlock()). Unlocked-path commands need `breadlock listen`.
|
// compositor unlock()). Unlocked-path commands need `breadlock listen`.
|
||||||
let _commands = bread_events::subscribe_commands();
|
let _commands = bread_events::subscribe_commands();
|
||||||
|
|
||||||
let username = std::env::var("USER")
|
let username = auth::username_from_process().unwrap_or_else(|| {
|
||||||
.or_else(|_| std::env::var("LOGNAME"))
|
tracing::error!(
|
||||||
.unwrap_or_else(|_| {
|
"could not resolve a username (passwd lookup and $USER/$LOGNAME all failed) — \
|
||||||
tracing::error!("neither $USER nor $LOGNAME is set — refusing to start without a username to authenticate");
|
taking the session lock anyway and refusing PAM"
|
||||||
std::process::exit(1);
|
);
|
||||||
});
|
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();
|
||||||
|
|
@ -163,7 +167,10 @@ 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, result| {
|
let auth_tx = auth::register(&loop_handle, move |state: &mut AppState, generation, result| {
|
||||||
|
if generation != state.auth_generation {
|
||||||
|
return;
|
||||||
|
}
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
// Keep the lock surfaces up and fade the overlay out.
|
// Keep the lock surfaces up and fade the overlay out.
|
||||||
|
|
@ -171,6 +178,8 @@ fn run_lock() {
|
||||||
// mid-fade is fail-secure (session stays locked).
|
// mid-fade is fail-secure (session stays locked).
|
||||||
tracing::info!("authenticated, fading out");
|
tracing::info!("authenticated, fading out");
|
||||||
state.failed_attempts = 0;
|
state.failed_attempts = 0;
|
||||||
|
state.auth_state = AuthState::Idle;
|
||||||
|
state.checking_started = None;
|
||||||
if state.unlocking.is_none() {
|
if state.unlocking.is_none() {
|
||||||
state.unlocking = Some(std::time::Instant::now());
|
state.unlocking = Some(std::time::Instant::now());
|
||||||
}
|
}
|
||||||
|
|
@ -187,14 +196,16 @@ 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.auth_state = AuthState::ConfigError;
|
state.enter_fail(AuthState::ConfigError);
|
||||||
state.failed_at = Some(std::time::Instant::now());
|
|
||||||
}
|
}
|
||||||
auth::AuthError::Authenticate | auth::AuthError::AccountInvalid => {
|
auth::AuthError::Authenticate => {
|
||||||
tracing::warn!(%err, "authentication failed");
|
tracing::warn!(%err, "authentication failed");
|
||||||
state.failed_attempts = state.failed_attempts.saturating_add(1);
|
state.failed_attempts = state.failed_attempts.saturating_add(1);
|
||||||
state.auth_state = AuthState::Failed;
|
state.enter_fail(AuthState::Failed);
|
||||||
state.failed_at = Some(std::time::Instant::now());
|
}
|
||||||
|
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());
|
||||||
|
|
@ -203,6 +214,17 @@ 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);
|
||||||
|
|
@ -221,6 +243,7 @@ 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,
|
||||||
|
|
@ -230,16 +253,20 @@ fn run_lock() {
|
||||||
// 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(128)),
|
password: zeroize::Zeroizing::new(String::with_capacity(state::PASSWORD_CAP)),
|
||||||
|
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(),
|
started: std::time::Instant::now(),
|
||||||
appear_started: None,
|
appear_started: None,
|
||||||
unlocking: None,
|
unlocking: None,
|
||||||
last_keystroke: None,
|
last_keystroke: None,
|
||||||
failed_at: None,
|
failed_at: None,
|
||||||
last_clock_text: String::new(),
|
last_clock_text: String::new(),
|
||||||
clock_anim_started: None,
|
clock_from: None,
|
||||||
status_anim_started: None,
|
status_anim_started: None,
|
||||||
last_auth_state: AuthState::Idle,
|
last_auth_state: AuthState::Idle,
|
||||||
breathe_started: None,
|
breathe_started: None,
|
||||||
|
|
@ -253,6 +280,7 @@ fn run_lock() {
|
||||||
reveal_held: false,
|
reveal_held: false,
|
||||||
last_activity: std::time::Instant::now(),
|
last_activity: std::time::Instant::now(),
|
||||||
failed_attempts: 0,
|
failed_attempts: 0,
|
||||||
|
status_info: status::StatusInfo::default(),
|
||||||
exit: false,
|
exit: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -272,11 +300,18 @@ fn run_lock() {
|
||||||
output,
|
output,
|
||||||
width: 0,
|
width: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
|
scale: 1,
|
||||||
gpu: None,
|
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");
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
//! little-endian machines. [`blit_to_shm`] does the swizzle.
|
//! little-endian machines. [`blit_to_shm`] does the swizzle.
|
||||||
|
|
||||||
use crate::background::Background;
|
use crate::background::Background;
|
||||||
use breadlock_ui::painter::{rounded_rect, tokens, TextRenderer};
|
use breadlock_ui::painter::{rounded_rect, tokens, TextRenderer, Weight};
|
||||||
use breadlock_ui::theme::tiny_skia_color;
|
use breadlock_ui::theme::tiny_skia_color;
|
||||||
use std::f32::consts::PI;
|
use std::f32::consts::PI;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
@ -86,7 +86,9 @@ fn dim_rows(pixmap: &mut Pixmap, veil_alpha: f32) {
|
||||||
data[i] = (data[i] as f32 * k) as u8;
|
data[i] = (data[i] as f32 * k) as u8;
|
||||||
data[i + 1] = (data[i + 1] as f32 * k) as u8;
|
data[i + 1] = (data[i + 1] as f32 * k) as u8;
|
||||||
data[i + 2] = (data[i + 2] as f32 * k) as u8;
|
data[i + 2] = (data[i + 2] as f32 * k) as u8;
|
||||||
data[i + 3] = (data[i + 3] as f32 * k) as u8;
|
// Keep alpha: the GPU veil shader writes A=1, and scaling A here
|
||||||
|
// made the software path translucent against a compositor that
|
||||||
|
// expected an opaque lock surface.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -164,6 +166,9 @@ pub struct FrameInputs<'a> {
|
||||||
/// Status-line slide-in progress (0..1, 1 settled).
|
/// Status-line slide-in progress (0..1, 1 settled).
|
||||||
pub status_t: f32,
|
pub status_t: f32,
|
||||||
pub status_text: Option<&'a str>,
|
pub status_text: Option<&'a str>,
|
||||||
|
/// Now-playing / battery line under the clock (D-Bus status). Empty
|
||||||
|
/// string hides it.
|
||||||
|
pub info_text: &'a str,
|
||||||
/// Raw 0..1 lock-appear progress (pre-ease). 1 is rest pose.
|
/// Raw 0..1 lock-appear progress (pre-ease). 1 is rest pose.
|
||||||
pub appear_t: f32,
|
pub appear_t: f32,
|
||||||
/// Raw 0..1 unlock-fade progress (pre-ease). 0 when not unlocking.
|
/// Raw 0..1 unlock-fade progress (pre-ease). 0 when not unlocking.
|
||||||
|
|
@ -225,11 +230,23 @@ fn staggered_t(appear_t: f32, delay_ms: u64) -> f32 {
|
||||||
((appear_t * APPEAR_MS as f32 - delay_ms as f32) / window).clamp(0.0, 1.0)
|
((appear_t * APPEAR_MS as f32 - delay_ms as f32) / window).clamp(0.0, 1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Maps raw unlock progress so the first [`FLASH_FRAC`] stays at rest pose
|
||||||
|
/// (green flash on top of a fully-opaque chrome) and only the remainder
|
||||||
|
/// eases the fade/drift.
|
||||||
|
fn unlock_fade_t(unlock_t: f32) -> f32 {
|
||||||
|
if unlock_t <= FLASH_FRAC {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
((unlock_t - FLASH_FRAC) / (1.0 - FLASH_FRAC)).clamp(0.0, 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Overlay alpha and y-offset (positive is down) from raw 0..1 progress —
|
/// Overlay alpha and y-offset (positive is down) from raw 0..1 progress —
|
||||||
/// used for the full-screen dim veil, which fades with the whole chrome.
|
/// used for the full-screen dim veil, which fades with the whole chrome.
|
||||||
|
/// Unlock fade starts *after* the success flash (see [`unlock_fade_t`]).
|
||||||
pub fn overlay_motion(appear_t: f32, unlock_t: f32) -> (f32, f32) {
|
pub fn overlay_motion(appear_t: f32, unlock_t: f32) -> (f32, f32) {
|
||||||
let appear = ease_out_cubic(appear_t);
|
let appear = ease_out_cubic(appear_t);
|
||||||
let unlock = ease_out_cubic(unlock_t);
|
let unlock = ease_out_cubic(unlock_fade_t(unlock_t));
|
||||||
let alpha = (appear * (1.0 - unlock)).clamp(0.0, 1.0);
|
let alpha = (appear * (1.0 - unlock)).clamp(0.0, 1.0);
|
||||||
let y = APPEAR_SLIDE_PX * (1.0 - appear) - UNLOCK_DRIFT_PX * unlock;
|
let y = APPEAR_SLIDE_PX * (1.0 - appear) - UNLOCK_DRIFT_PX * unlock;
|
||||||
(alpha, y)
|
(alpha, y)
|
||||||
|
|
@ -275,7 +292,11 @@ fn lerp_color(a: Color, b: Color, t: f32) -> Color {
|
||||||
/// Bounding rect of the lock-screen chrome (clock, date, pill, status) in
|
/// Bounding rect of the lock-screen chrome (clock, date, pill, status) in
|
||||||
/// surface pixels — the GPU path uses it to know which region of the chrome
|
/// surface pixels — the GPU path uses it to know which region of the chrome
|
||||||
/// texture was drawn (and therefore needs uploading each frame).
|
/// texture was drawn (and therefore needs uploading each frame).
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
///
|
||||||
|
/// Empty is `(+∞, +∞, −∞, −∞)` so [`ChromeRect::expand`] can seed from the
|
||||||
|
/// first real box. `(0,0,0,0)` as a start made `0.min(clock_x)` stick at the
|
||||||
|
/// origin and the GPU uploaded a huge/wrong dirty rect.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct ChromeRect {
|
pub struct ChromeRect {
|
||||||
pub x0: f32,
|
pub x0: f32,
|
||||||
pub y0: f32,
|
pub y0: f32,
|
||||||
|
|
@ -283,6 +304,17 @@ pub struct ChromeRect {
|
||||||
pub y1: f32,
|
pub y1: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for ChromeRect {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
x0: f32::INFINITY,
|
||||||
|
y0: f32::INFINITY,
|
||||||
|
x1: f32::NEG_INFINITY,
|
||||||
|
y1: f32::NEG_INFINITY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ChromeRect {
|
impl ChromeRect {
|
||||||
fn expand(&mut self, x0: f32, y0: f32, x1: f32, y1: f32) {
|
fn expand(&mut self, x0: f32, y0: f32, x1: f32, y1: f32) {
|
||||||
self.x0 = self.x0.min(x0);
|
self.x0 = self.x0.min(x0);
|
||||||
|
|
@ -290,6 +322,11 @@ impl ChromeRect {
|
||||||
self.x1 = self.x1.max(x1);
|
self.x1 = self.x1.max(x1);
|
||||||
self.y1 = self.y1.max(y1);
|
self.y1 = self.y1.max(y1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_empty(&self) -> bool {
|
||||||
|
self.x1 <= self.x0 || self.y1 <= self.y0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Composes one frame. Returns `None` only if `width`/`height` are degenerate
|
/// Composes one frame. Returns `None` only if `width`/`height` are degenerate
|
||||||
|
|
@ -302,8 +339,9 @@ pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option<Pixmap>
|
||||||
|
|
||||||
/// Composes only the chrome (clock/date/pill/status) into a transparent
|
/// Composes only the chrome (clock/date/pill/status) into a transparent
|
||||||
/// `pixmap`, returning the bounding rect of everything drawn. The background
|
/// `pixmap`, returning the bounding rect of everything drawn. The background
|
||||||
/// and veil are the GPU's job in the accelerated path; colors are still
|
/// and veil are the GPU's job in the accelerated path; each element applies
|
||||||
/// pre-faded by the veil alpha so the software and GPU paths match.
|
/// its appear/unlock alpha once (the veil lives on the wallpaper, not baked
|
||||||
|
/// into chrome colors).
|
||||||
pub fn compose_chrome(
|
pub fn compose_chrome(
|
||||||
pixmap: &mut Pixmap,
|
pixmap: &mut Pixmap,
|
||||||
text: &mut TextRenderer,
|
text: &mut TextRenderer,
|
||||||
|
|
@ -319,7 +357,7 @@ pub fn compose_chrome(
|
||||||
/// background/veil are skipped (chrome-only) and each drawn element's box is
|
/// background/veil are skipped (chrome-only) and each drawn element's box is
|
||||||
/// recorded.
|
/// recorded.
|
||||||
fn compose_impl(
|
fn compose_impl(
|
||||||
mut pixmap: &mut Pixmap,
|
pixmap: &mut Pixmap,
|
||||||
text: &mut TextRenderer,
|
text: &mut TextRenderer,
|
||||||
inputs: &FrameInputs,
|
inputs: &FrameInputs,
|
||||||
mut rects: Option<&mut ChromeRect>,
|
mut rects: Option<&mut ChromeRect>,
|
||||||
|
|
@ -330,27 +368,28 @@ fn compose_impl(
|
||||||
.paint(pixmap, inputs.t_secs, inputs.smooth_pan);
|
.paint(pixmap, inputs.t_secs, inputs.smooth_pan);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overall chrome fade: appear eased in, unlock eased out. The unlock
|
// Overall chrome fade: appear eased in, unlock eased out *after* the
|
||||||
// `fade` multiplies every element below. The dim veil deepens further
|
// success flash. Skipping when `base_veil == 0` at appear t=0 flashed
|
||||||
// once the idle auto-dim kicks in — chrome alpha stays at the base veil
|
// undimmed wallpaper (and left the GPU chrome rect empty); only skip
|
||||||
// (0..1) while only the background dim scales past it.
|
// once the unlock has fully finished.
|
||||||
let unlock = ease_out_cubic(inputs.unlock_t);
|
if inputs.unlock_t >= 1.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let unlock = ease_out_cubic(unlock_fade_t(inputs.unlock_t));
|
||||||
let fade = 1.0 - unlock;
|
let fade = 1.0 - unlock;
|
||||||
let (base_veil, _) = overlay_motion(inputs.appear_t, inputs.unlock_t);
|
let (base_veil, _) = overlay_motion(inputs.appear_t, inputs.unlock_t);
|
||||||
let bg_veil = veil_alpha(inputs.appear_t, inputs.unlock_t, inputs.idle_dim);
|
let bg_veil = veil_alpha(inputs.appear_t, inputs.unlock_t, inputs.idle_dim);
|
||||||
if base_veil <= 0.0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let (w, h) = (inputs.width as f32, inputs.height as f32);
|
let (w, h) = (inputs.width as f32, inputs.height as f32);
|
||||||
let surface_color = faded(tiny_skia_color(&inputs.palette.color0), base_veil);
|
// Palette colors are *not* pre-multiplied by the veil: each element
|
||||||
let accent_color = faded(tiny_skia_color(&inputs.palette.color4), base_veil);
|
// applies its appear/unlock alpha once via `faded(..., elem_alpha)`.
|
||||||
let green_color = faded(tiny_skia_color(&inputs.palette.color2), base_veil);
|
// Pre-fading here and again with `pill_alpha` made the pill/status
|
||||||
let on_surface = faded(
|
// fainter than the clock (which only faded once).
|
||||||
tiny_skia_color(breadlock_ui::theme::ink_on(&inputs.palette.color0)),
|
let surface_color = tiny_skia_color(&inputs.palette.color0);
|
||||||
base_veil,
|
let accent_color = tiny_skia_color(&inputs.palette.color4);
|
||||||
);
|
let green_color = tiny_skia_color(&inputs.palette.color2);
|
||||||
let red_color = faded(tiny_skia_color(&inputs.palette.color1), base_veil);
|
let on_surface = tiny_skia_color(breadlock_ui::theme::ink_on(&inputs.palette.color0));
|
||||||
|
let red_color = tiny_skia_color(&inputs.palette.color1);
|
||||||
|
|
||||||
// Translucent veil over the (static) wallpaper — a vertical gradient
|
// Translucent veil over the (static) wallpaper — a vertical gradient
|
||||||
// (deeper at the top) that fades with the whole chrome. Applied in place
|
// (deeper at the top) that fades with the whole chrome. Applied in place
|
||||||
|
|
@ -389,65 +428,84 @@ fn compose_impl(
|
||||||
if let Some(r) = rects.as_deref_mut() {
|
if let Some(r) = rects.as_deref_mut() {
|
||||||
let old_w = inputs
|
let old_w = inputs
|
||||||
.clock_old
|
.clock_old
|
||||||
.map(|(t, _)| text.measure_line(t, inputs.font_family, clock_size))
|
.map(|(t, _)| text.measure_line_weighted(t, inputs.font_family, clock_size, Weight::BOLD))
|
||||||
.unwrap_or(0.0);
|
.unwrap_or(0.0);
|
||||||
let new_w = text.measure_line(inputs.clock_text, inputs.font_family, clock_size);
|
let new_w = text.measure_line_weighted(
|
||||||
|
inputs.clock_text,
|
||||||
|
inputs.font_family,
|
||||||
|
clock_size,
|
||||||
|
Weight::BOLD,
|
||||||
|
);
|
||||||
let cw = old_w.max(new_w);
|
let cw = old_w.max(new_w);
|
||||||
r.expand((w - cw) / 2.0, clock_y, (w + cw) / 2.0, clock_y + clock_size);
|
r.expand((w - cw) / 2.0, clock_y, (w + cw) / 2.0, clock_y + clock_size);
|
||||||
}
|
}
|
||||||
match inputs.clock_old {
|
match inputs.clock_old {
|
||||||
Some((old, t)) => {
|
Some((old, t)) => {
|
||||||
let t = t.clamp(0.0, 1.0);
|
let t = t.clamp(0.0, 1.0);
|
||||||
let old_w = text.measure_line(old, inputs.font_family, clock_size);
|
let old_w = text.measure_line_weighted(old, inputs.font_family, clock_size, Weight::BOLD);
|
||||||
text.draw_line(
|
text.draw_line_weighted(
|
||||||
&mut pixmap,
|
pixmap,
|
||||||
old,
|
old,
|
||||||
inputs.font_family,
|
inputs.font_family,
|
||||||
clock_size,
|
clock_size,
|
||||||
faded(Color::WHITE, clock_alpha * (1.0 - t)),
|
faded(Color::WHITE, clock_alpha * (1.0 - t)),
|
||||||
(w - old_w) / 2.0,
|
(w - old_w) / 2.0,
|
||||||
clock_y - 6.0 * t,
|
clock_y - 6.0 * t,
|
||||||
|
Weight::BOLD,
|
||||||
);
|
);
|
||||||
let new_w = text.measure_line(inputs.clock_text, inputs.font_family, clock_size);
|
let new_w = text.measure_line_weighted(
|
||||||
text.draw_line(
|
inputs.clock_text,
|
||||||
&mut pixmap,
|
inputs.font_family,
|
||||||
|
clock_size,
|
||||||
|
Weight::BOLD,
|
||||||
|
);
|
||||||
|
text.draw_line_weighted(
|
||||||
|
pixmap,
|
||||||
inputs.clock_text,
|
inputs.clock_text,
|
||||||
inputs.font_family,
|
inputs.font_family,
|
||||||
clock_size,
|
clock_size,
|
||||||
faded(Color::WHITE, clock_alpha * t),
|
faded(Color::WHITE, clock_alpha * t),
|
||||||
(w - new_w) / 2.0,
|
(w - new_w) / 2.0,
|
||||||
clock_y + 6.0 * (1.0 - t),
|
clock_y + 6.0 * (1.0 - t),
|
||||||
|
Weight::BOLD,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
let clock_w = text.measure_line(inputs.clock_text, inputs.font_family, clock_size);
|
let clock_w = text.measure_line_weighted(
|
||||||
text.draw_line(
|
inputs.clock_text,
|
||||||
&mut pixmap,
|
inputs.font_family,
|
||||||
|
clock_size,
|
||||||
|
Weight::BOLD,
|
||||||
|
);
|
||||||
|
text.draw_line_weighted(
|
||||||
|
pixmap,
|
||||||
inputs.clock_text,
|
inputs.clock_text,
|
||||||
inputs.font_family,
|
inputs.font_family,
|
||||||
clock_size,
|
clock_size,
|
||||||
faded(Color::WHITE, clock_alpha),
|
faded(Color::WHITE, clock_alpha),
|
||||||
(w - clock_w) / 2.0,
|
(w - clock_w) / 2.0,
|
||||||
clock_y,
|
clock_y,
|
||||||
|
Weight::BOLD,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Date line under the clock (hidden when date_text is empty). The
|
// ---- Date line under the clock (hidden when date_text is empty) and
|
||||||
// clock's `origin_y` anchors its *top*, so the date is placed below the
|
// now-playing / battery under that. Info is *not* nested under the date
|
||||||
// clock's actual glyph box (exact per font, cached) with a small gap.
|
// — an empty `date_format` used to hide the status line too.
|
||||||
|
let date_size = (w * 0.016).clamp(DATE_SIZE_MIN, DATE_SIZE_MAX);
|
||||||
|
let (clock_top, clock_height) =
|
||||||
|
text.measure_box_weighted(inputs.clock_text, inputs.font_family, clock_size, Weight::BOLD);
|
||||||
|
let mut below_y = clock_y_rest + elem_y(date_e, DRIFT_DATE) + clock_top + clock_height
|
||||||
|
+ tokens::SPACE_SM as f32;
|
||||||
if !inputs.date_text.is_empty() {
|
if !inputs.date_text.is_empty() {
|
||||||
let date_size = (w * 0.016).clamp(DATE_SIZE_MIN, DATE_SIZE_MAX);
|
let date_y = below_y;
|
||||||
let (clock_top, clock_height) =
|
|
||||||
text.measure_box(inputs.clock_text, inputs.font_family, clock_size);
|
|
||||||
let date_y = clock_y_rest + elem_y(date_e, DRIFT_DATE) + clock_top + clock_height
|
|
||||||
+ tokens::SPACE_SM as f32;
|
|
||||||
let date_w = text.measure_line(inputs.date_text, inputs.font_family, date_size);
|
let date_w = text.measure_line(inputs.date_text, inputs.font_family, date_size);
|
||||||
if let Some(r) = rects.as_deref_mut() {
|
if let Some(r) = rects.as_mut() {
|
||||||
r.expand((w - date_w) / 2.0, date_y, (w + date_w) / 2.0, date_y + date_size);
|
r.expand((w - date_w) / 2.0, date_y, (w + date_w) / 2.0, date_y + date_size);
|
||||||
}
|
}
|
||||||
text.draw_line(
|
text.draw_line(
|
||||||
&mut pixmap,
|
pixmap,
|
||||||
inputs.date_text,
|
inputs.date_text,
|
||||||
inputs.font_family,
|
inputs.font_family,
|
||||||
date_size,
|
date_size,
|
||||||
|
|
@ -455,6 +513,37 @@ fn compose_impl(
|
||||||
(w - date_w) / 2.0,
|
(w - date_w) / 2.0,
|
||||||
date_y,
|
date_y,
|
||||||
);
|
);
|
||||||
|
below_y = date_y + date_size + tokens::SPACE_XS as f32;
|
||||||
|
}
|
||||||
|
if !inputs.info_text.is_empty() {
|
||||||
|
let info_size = date_size * 0.85;
|
||||||
|
let info_y = below_y;
|
||||||
|
let info_anim = ease_out_cubic(staggered_t(inputs.appear_t, DATE_DELAY_MS + 120));
|
||||||
|
let info_shown = ellipsize(
|
||||||
|
text,
|
||||||
|
inputs.info_text,
|
||||||
|
inputs.font_family,
|
||||||
|
info_size,
|
||||||
|
w * 0.70,
|
||||||
|
);
|
||||||
|
let info_w = text.measure_line(&info_shown, inputs.font_family, info_size);
|
||||||
|
if let Some(r) = rects.as_mut() {
|
||||||
|
r.expand(
|
||||||
|
(w - info_w) / 2.0,
|
||||||
|
info_y,
|
||||||
|
(w + info_w) / 2.0,
|
||||||
|
info_y + info_size,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
text.draw_line(
|
||||||
|
pixmap,
|
||||||
|
&info_shown,
|
||||||
|
inputs.font_family,
|
||||||
|
info_size,
|
||||||
|
faded(Color::WHITE, info_anim * fade * 0.6),
|
||||||
|
(w - info_w) / 2.0,
|
||||||
|
info_y,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Password pill, centered. Red while failed (tinting in smoothly over
|
// ---- Password pill, centered. Red while failed (tinting in smoothly over
|
||||||
|
|
@ -493,14 +582,23 @@ fn compose_impl(
|
||||||
cx * (1.0 - scale) + shake_x,
|
cx * (1.0 - scale) + shake_x,
|
||||||
cy * (1.0 - scale),
|
cy * (1.0 - scale),
|
||||||
);
|
);
|
||||||
|
// Map a point through the same scale-about-center + shake as the pill
|
||||||
|
// path, so dots/caret/hint/reveal travel with it.
|
||||||
|
let map_pill = |x: f32, y: f32| {
|
||||||
|
(
|
||||||
|
x * scale + cx * (1.0 - scale) + shake_x,
|
||||||
|
y * scale + cy * (1.0 - scale),
|
||||||
|
)
|
||||||
|
};
|
||||||
// Chrome rect: pad for the shadow layers, breath/success rings, the
|
// Chrome rect: pad for the shadow layers, breath/success rings, the
|
||||||
// shake offset and the scale overshoot.
|
// shake offset and the scale overshoot. Include shake so a failed
|
||||||
|
// frame's dirty AABB actually moves with the pill.
|
||||||
if let Some(r) = rects.as_deref_mut() {
|
if let Some(r) = rects.as_deref_mut() {
|
||||||
const PILL_PAD: f32 = 26.0;
|
const PILL_PAD: f32 = 26.0;
|
||||||
r.expand(
|
r.expand(
|
||||||
pill_x - PILL_PAD,
|
pill_x + shake_x - PILL_PAD,
|
||||||
pill_y - PILL_PAD,
|
pill_y - PILL_PAD,
|
||||||
pill_x + pill_w + PILL_PAD,
|
pill_x + shake_x + pill_w + PILL_PAD,
|
||||||
pill_y + pill_h + PILL_PAD,
|
pill_y + pill_h + PILL_PAD,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -545,8 +643,10 @@ fn compose_impl(
|
||||||
// Hairline border for depth — dropped on the wrong/success states
|
// Hairline border for depth — dropped on the wrong/success states
|
||||||
// (the sketch sets `border-color: transparent` there).
|
// (the sketch sets `border-color: transparent` there).
|
||||||
if !inputs.failed && inputs.unlock_t == 0.0 {
|
if !inputs.failed && inputs.unlock_t == 0.0 {
|
||||||
let mut stroke = tiny_skia::Stroke::default();
|
let stroke = tiny_skia::Stroke {
|
||||||
stroke.width = 1.0;
|
width: 1.0,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
let mut paint = Paint::default();
|
let mut paint = Paint::default();
|
||||||
paint.set_color(faded(
|
paint.set_color(faded(
|
||||||
Color::WHITE,
|
Color::WHITE,
|
||||||
|
|
@ -558,8 +658,10 @@ fn compose_impl(
|
||||||
// Idle breath: a faint accent ring blooms around the pill at the
|
// Idle breath: a faint accent ring blooms around the pill at the
|
||||||
// breath peak (matches the sketch's `breathe` keyframes).
|
// breath peak (matches the sketch's `breathe` keyframes).
|
||||||
if inputs.breathe_t > 0.0 && !inputs.failed && inputs.unlock_t == 0.0 {
|
if inputs.breathe_t > 0.0 && !inputs.failed && inputs.unlock_t == 0.0 {
|
||||||
let mut stroke = tiny_skia::Stroke::default();
|
let stroke = tiny_skia::Stroke {
|
||||||
stroke.width = 1.5;
|
width: 1.5,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
let mut paint = Paint::default();
|
let mut paint = Paint::default();
|
||||||
paint.set_color(faded(accent_color, BREATHE_RING_ALPHA * inputs.breathe_t * pill_alpha));
|
paint.set_color(faded(accent_color, BREATHE_RING_ALPHA * inputs.breathe_t * pill_alpha));
|
||||||
pixmap.stroke_path(&path, &paint, &stroke, pill_xf, None);
|
pixmap.stroke_path(&path, &paint, &stroke, pill_xf, None);
|
||||||
|
|
@ -569,8 +671,10 @@ fn compose_impl(
|
||||||
// `FLASH_MS` of the unlock.
|
// `FLASH_MS` of the unlock.
|
||||||
if inputs.unlock_t > 0.0 && inputs.unlock_t < FLASH_FRAC {
|
if inputs.unlock_t > 0.0 && inputs.unlock_t < FLASH_FRAC {
|
||||||
let flash_t = inputs.unlock_t / FLASH_FRAC;
|
let flash_t = inputs.unlock_t / FLASH_FRAC;
|
||||||
let mut stroke = tiny_skia::Stroke::default();
|
let stroke = tiny_skia::Stroke {
|
||||||
stroke.width = 2.0 + 16.0 * flash_t;
|
width: 2.0 + 16.0 * flash_t,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
let mut paint = Paint::default();
|
let mut paint = Paint::default();
|
||||||
paint.set_color(faded(green_color, 0.55 * (1.0 - flash_t) * pill_alpha));
|
paint.set_color(faded(green_color, 0.55 * (1.0 - flash_t) * pill_alpha));
|
||||||
pixmap.stroke_path(&path, &paint, &stroke, pill_xf, None);
|
pixmap.stroke_path(&path, &paint, &stroke, pill_xf, None);
|
||||||
|
|
@ -599,7 +703,7 @@ fn compose_impl(
|
||||||
// top, so the two never touch even with the pill's glow/shadow.
|
// top, so the two never touch even with the pill's glow/shadow.
|
||||||
let chip_y = pill_y - chip_h - tokens::SPACE_LG as f32;
|
let chip_y = pill_y - chip_h - tokens::SPACE_LG as f32;
|
||||||
let chip_alpha = pill_e * fade;
|
let chip_alpha = pill_e * fade;
|
||||||
if let Some(r) = rects.as_deref_mut() {
|
if let Some(r) = rects.as_mut() {
|
||||||
r.expand(chip_x, chip_y, chip_x + chip_w, chip_y + chip_h);
|
r.expand(chip_x, chip_y, chip_x + chip_w, chip_y + chip_h);
|
||||||
}
|
}
|
||||||
if let Some(path) = rounded_rect(chip_x, chip_y, chip_w, chip_h, chip_h / 2.0) {
|
if let Some(path) = rounded_rect(chip_x, chip_y, chip_w, chip_h, chip_h / 2.0) {
|
||||||
|
|
@ -608,8 +712,10 @@ fn compose_impl(
|
||||||
paint.set_color(faded(surface_color, chip_alpha));
|
paint.set_color(faded(surface_color, chip_alpha));
|
||||||
paint.anti_alias = true;
|
paint.anti_alias = true;
|
||||||
pixmap.fill_path(&path, &paint, tiny_skia::FillRule::Winding, Transform::identity(), None);
|
pixmap.fill_path(&path, &paint, tiny_skia::FillRule::Winding, Transform::identity(), None);
|
||||||
let mut stroke = tiny_skia::Stroke::default();
|
let stroke = tiny_skia::Stroke {
|
||||||
stroke.width = 1.0;
|
width: 1.0,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
let mut paint = Paint::default();
|
let mut paint = Paint::default();
|
||||||
paint.set_color(faded(Color::WHITE, PILL_BORDER_ALPHA * chip_alpha));
|
paint.set_color(faded(Color::WHITE, PILL_BORDER_ALPHA * chip_alpha));
|
||||||
pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
|
pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
|
||||||
|
|
@ -618,7 +724,7 @@ fn compose_impl(
|
||||||
let label_y = chip_y + (chip_h - chip_height) / 2.0 - chip_top;
|
let label_y = chip_y + (chip_h - chip_height) / 2.0 - chip_top;
|
||||||
let label_w = text.measure_line(&label, inputs.font_family, chip_size);
|
let label_w = text.measure_line(&label, inputs.font_family, chip_size);
|
||||||
text.draw_line(
|
text.draw_line(
|
||||||
&mut pixmap,
|
pixmap,
|
||||||
&label,
|
&label,
|
||||||
inputs.font_family,
|
inputs.font_family,
|
||||||
chip_size,
|
chip_size,
|
||||||
|
|
@ -637,13 +743,16 @@ fn compose_impl(
|
||||||
.max(1.0) as usize;
|
.max(1.0) as usize;
|
||||||
let shown_dots = inputs.password_len.min(max_dots);
|
let shown_dots = inputs.password_len.min(max_dots);
|
||||||
let dot_y = pill_y + pill_h / 2.0;
|
let dot_y = pill_y + pill_h / 2.0;
|
||||||
if inputs.reveal && shown_dots > 0 {
|
// Skip in-pill text while the appear scale is ~0 (cosmic-text panics on
|
||||||
|
// a zero font size). Paths still go through `pill_xf` and collapse.
|
||||||
|
let contents_live = scale >= 0.05;
|
||||||
|
if inputs.reveal && shown_dots > 0 && contents_live {
|
||||||
// Hold-to-reveal: render the actual password, centered, capped to
|
// Hold-to-reveal: render the actual password, centered, capped to
|
||||||
// the pill width (truncate with a trailing ellipsis on overflow).
|
// the pill width (truncate with a trailing ellipsis on overflow).
|
||||||
// Measured into locals first — `text` is borrowed mutably by
|
// Measured into locals first — `text` is borrowed mutably by
|
||||||
// `draw_line`, so all `measure_*` calls must happen up front.
|
// `draw_line`, so all `measure_*` calls must happen up front.
|
||||||
let reveal_size = tokens::FONT_SIZE_BASE as f32;
|
let reveal_size = tokens::FONT_SIZE_BASE as f32;
|
||||||
let reveal_rendered = reveal_fit(
|
let reveal_rendered = ellipsize(
|
||||||
text,
|
text,
|
||||||
inputs.password,
|
inputs.password,
|
||||||
inputs.font_family,
|
inputs.font_family,
|
||||||
|
|
@ -654,22 +763,23 @@ fn compose_impl(
|
||||||
text.measure_box(&reveal_rendered, inputs.font_family, reveal_size);
|
text.measure_box(&reveal_rendered, inputs.font_family, reveal_size);
|
||||||
let reveal_y = pill_y + (pill_h - reveal_height) / 2.0 - reveal_top;
|
let reveal_y = pill_y + (pill_h - reveal_height) / 2.0 - reveal_top;
|
||||||
let reveal_w = text.measure_line(&reveal_rendered, inputs.font_family, reveal_size);
|
let reveal_w = text.measure_line(&reveal_rendered, inputs.font_family, reveal_size);
|
||||||
if let Some(r) = rects.as_deref_mut() {
|
let (rx, ry) = map_pill((w - reveal_w) / 2.0, reveal_y);
|
||||||
|
if let Some(r) = rects.as_mut() {
|
||||||
r.expand(
|
r.expand(
|
||||||
pill_x + tokens::SPACE_LG as f32,
|
pill_x + shake_x + tokens::SPACE_LG as f32,
|
||||||
pill_y,
|
pill_y,
|
||||||
pill_x + pill_w - tokens::SPACE_LG as f32,
|
pill_x + shake_x + pill_w - tokens::SPACE_LG as f32,
|
||||||
pill_y + pill_h,
|
pill_y + pill_h,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
text.draw_line(
|
text.draw_line(
|
||||||
&mut pixmap,
|
pixmap,
|
||||||
&reveal_rendered,
|
&reveal_rendered,
|
||||||
inputs.font_family,
|
inputs.font_family,
|
||||||
reveal_size,
|
reveal_size * scale,
|
||||||
faded(on_surface, pill_alpha),
|
faded(on_surface, pill_alpha),
|
||||||
(w - reveal_w) / 2.0,
|
rx,
|
||||||
reveal_y,
|
ry,
|
||||||
);
|
);
|
||||||
} else if shown_dots > 0 {
|
} else if shown_dots > 0 {
|
||||||
let start_x = start_x_for(shown_dots, pill_x, pill_w);
|
let start_x = start_x_for(shown_dots, pill_x, pill_w);
|
||||||
|
|
@ -702,7 +812,7 @@ fn compose_impl(
|
||||||
&path,
|
&path,
|
||||||
&paint,
|
&paint,
|
||||||
tiny_skia::FillRule::Winding,
|
tiny_skia::FillRule::Winding,
|
||||||
Transform::identity(),
|
pill_xf,
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -713,24 +823,26 @@ fn compose_impl(
|
||||||
// caret only appears with the first typed character, so the pill reads as
|
// caret only appears with the first typed character, so the pill reads as
|
||||||
// an input field rather than an empty dark bar. Centered on the exact
|
// an input field rather than an empty dark bar. Centered on the exact
|
||||||
// glyph box (origin anchors the text top, not the baseline).
|
// glyph box (origin anchors the text top, not the baseline).
|
||||||
if shown_dots == 0 && !inputs.failed {
|
if shown_dots == 0 && !inputs.failed && contents_live {
|
||||||
let hint = "Enter password";
|
let hint = "Enter password";
|
||||||
let hint_size = tokens::FONT_SIZE_BASE as f32;
|
let hint_size = tokens::FONT_SIZE_BASE as f32;
|
||||||
let hint_w = text.measure_line(hint, inputs.font_family, hint_size);
|
let hint_w = text.measure_line(hint, inputs.font_family, hint_size);
|
||||||
let (hint_top, hint_height) = text.measure_box(hint, inputs.font_family, hint_size);
|
let (hint_top, hint_height) = text.measure_box(hint, inputs.font_family, hint_size);
|
||||||
let hint_y = pill_y + (pill_h - hint_height) / 2.0 - hint_top;
|
let hint_y = pill_y + (pill_h - hint_height) / 2.0 - hint_top;
|
||||||
|
let (hx, hy) = map_pill((w - hint_w) / 2.0, hint_y);
|
||||||
text.draw_line(
|
text.draw_line(
|
||||||
&mut pixmap,
|
pixmap,
|
||||||
hint,
|
hint,
|
||||||
inputs.font_family,
|
inputs.font_family,
|
||||||
hint_size,
|
hint_size * scale,
|
||||||
faded(on_surface, pill_alpha * 0.5),
|
faded(on_surface, pill_alpha * 0.5),
|
||||||
(w - hint_w) / 2.0,
|
hx,
|
||||||
hint_y,
|
hy,
|
||||||
);
|
);
|
||||||
} else if shown_dots > 0 {
|
} else if shown_dots > 0 && !inputs.reveal {
|
||||||
// ---- Caret after the last dot: solid for half a second after a
|
// ---- Caret after the last dot: solid for half a second after a
|
||||||
// keystroke, then blinking at ~1.8 Hz.
|
// keystroke, then blinking at ~1.8 Hz. Hidden during hold-to-reveal
|
||||||
|
// (it used to sit at the last *dot* slot, overlapping the text).
|
||||||
let caret_x = start_x_for(shown_dots, pill_x, pill_w)
|
let caret_x = start_x_for(shown_dots, pill_x, pill_w)
|
||||||
+ (shown_dots - 1) as f32 * DOT_GAP
|
+ (shown_dots - 1) as f32 * DOT_GAP
|
||||||
+ DOT_R
|
+ DOT_R
|
||||||
|
|
@ -761,7 +873,7 @@ fn compose_impl(
|
||||||
&path,
|
&path,
|
||||||
&paint,
|
&paint,
|
||||||
tiny_skia::FillRule::Winding,
|
tiny_skia::FillRule::Winding,
|
||||||
Transform::identity(),
|
pill_xf,
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -779,11 +891,11 @@ fn compose_impl(
|
||||||
let color = if inputs.failed { red_color } else { on_surface };
|
let color = if inputs.failed { red_color } else { on_surface };
|
||||||
let status_y = pill_y_rest + pill_h + tokens::SPACE_MD as f32 + elem_y(status_e, DRIFT_STATUS)
|
let status_y = pill_y_rest + pill_h + tokens::SPACE_MD as f32 + elem_y(status_e, DRIFT_STATUS)
|
||||||
+ STATUS_SLIDE_PX * (1.0 - status_anim);
|
+ STATUS_SLIDE_PX * (1.0 - status_anim);
|
||||||
if let Some(r) = rects.as_deref_mut() {
|
if let Some(r) = rects.as_mut() {
|
||||||
r.expand((w - status_w) / 2.0, status_y, (w + status_w) / 2.0, status_y + status_size);
|
r.expand((w - status_w) / 2.0, status_y, (w + status_w) / 2.0, status_y + status_size);
|
||||||
}
|
}
|
||||||
text.draw_line(
|
text.draw_line(
|
||||||
&mut pixmap,
|
pixmap,
|
||||||
status,
|
status,
|
||||||
inputs.font_family,
|
inputs.font_family,
|
||||||
status_size,
|
status_size,
|
||||||
|
|
@ -801,11 +913,11 @@ fn start_x_for(shown_dots: usize, pill_x: f32, pill_w: f32) -> f32 {
|
||||||
pill_x + (pill_w - dots_w) / 2.0
|
pill_x + (pill_w - dots_w) / 2.0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Truncates a password for the hold-to-reveal view so it fits inside the
|
/// Truncates `password` (or any single-line string) so it fits in `max_w`,
|
||||||
/// pill, appending an ellipsis when trimmed. Returns the owned string to
|
/// appending an ellipsis when trimmed. Shared by hold-to-reveal and the
|
||||||
/// render (kept out of `compose` so the borrow of `text` ends before the
|
/// now-playing/battery line. Kept out of `compose` so the borrow of `text`
|
||||||
/// draw call).
|
/// ends before the draw call.
|
||||||
fn reveal_fit(
|
fn ellipsize(
|
||||||
text: &mut TextRenderer,
|
text: &mut TextRenderer,
|
||||||
password: &str,
|
password: &str,
|
||||||
font_family: &str,
|
font_family: &str,
|
||||||
|
|
@ -862,6 +974,9 @@ mod tests {
|
||||||
// Bottom row is y/h = 0.75 → dim = 0.34 + (0.16 - 0.34) * 0.75 = 0.205.
|
// Bottom row is y/h = 0.75 → dim = 0.34 + (0.16 - 0.34) * 0.75 = 0.205.
|
||||||
let expected = (255.0 * (1.0 - 0.205)) as u8;
|
let expected = (255.0 * (1.0 - 0.205)) as u8;
|
||||||
assert_eq!(bottom.red(), expected);
|
assert_eq!(bottom.red(), expected);
|
||||||
|
// GPU veil keeps A=1; software must not scale alpha.
|
||||||
|
assert_eq!(top.alpha(), 255);
|
||||||
|
assert_eq!(bottom.alpha(), 255);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -873,6 +988,7 @@ mod tests {
|
||||||
assert_eq!(p.pixels(), before.as_slice());
|
assert_eq!(p.pixels(), before.as_slice());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn inputs<'a>(
|
fn inputs<'a>(
|
||||||
bg: &'a Background,
|
bg: &'a Background,
|
||||||
palette: &'a breadlock_ui::theme::Palette,
|
palette: &'a breadlock_ui::theme::Palette,
|
||||||
|
|
@ -908,6 +1024,7 @@ mod tests {
|
||||||
breathe_t: 0.0,
|
breathe_t: 0.0,
|
||||||
status_t: 1.0,
|
status_t: 1.0,
|
||||||
status_text: None,
|
status_text: None,
|
||||||
|
info_text: "",
|
||||||
appear_t,
|
appear_t,
|
||||||
unlock_t,
|
unlock_t,
|
||||||
smooth_pan: false,
|
smooth_pan: false,
|
||||||
|
|
@ -951,6 +1068,7 @@ mod tests {
|
||||||
breathe_t: 0.0,
|
breathe_t: 0.0,
|
||||||
status_t: 1.0,
|
status_t: 1.0,
|
||||||
status_text: None,
|
status_text: None,
|
||||||
|
info_text: "",
|
||||||
appear_t: 1.0,
|
appear_t: 1.0,
|
||||||
unlock_t: 0.0,
|
unlock_t: 0.0,
|
||||||
smooth_pan: false,
|
smooth_pan: false,
|
||||||
|
|
@ -966,13 +1084,26 @@ mod tests {
|
||||||
let mut text = TextRenderer::new();
|
let mut text = TextRenderer::new();
|
||||||
// Wrong-password shake mid-flight.
|
// Wrong-password shake mid-flight.
|
||||||
let failed = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, true, 0.3, 0.4, 1.0, 0.0);
|
let failed = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, true, 0.3, 0.4, 1.0, 0.0);
|
||||||
assert!(compose(&mut text, &failed).is_some());
|
let failed_px = compose(&mut text, &failed).expect("failed compose");
|
||||||
// Success flash phase of the unlock.
|
assert_eq!((failed_px.width(), failed_px.height()), (400, 300));
|
||||||
|
assert!(failed_px.pixels().iter().any(|p| p.alpha() > 0));
|
||||||
|
// Success flash phase of the unlock: still fully opaque chrome (flash
|
||||||
|
// holds rest pose), not already faded.
|
||||||
let success = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 0.12);
|
let success = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 0.12);
|
||||||
assert!(compose(&mut text, &success).is_some());
|
let success_px = compose(&mut text, &success).expect("success compose");
|
||||||
|
let rest = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 0.0);
|
||||||
|
let rest_px = compose(&mut text, &rest).expect("rest compose");
|
||||||
|
// Flash frame should not be a near-empty fade — plenty of chrome left.
|
||||||
|
let flash_lit = success_px.pixels().iter().filter(|p| p.alpha() > 0).count();
|
||||||
|
let rest_lit = rest_px.pixels().iter().filter(|p| p.alpha() > 0).count();
|
||||||
|
assert!(
|
||||||
|
flash_lit as f32 > rest_lit as f32 * 0.5,
|
||||||
|
"success flash should keep chrome visible, lit {flash_lit} vs rest {rest_lit}"
|
||||||
|
);
|
||||||
// Fully faded unlock returns just the background.
|
// Fully faded unlock returns just the background.
|
||||||
let done = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 1.0);
|
let done = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 1.0);
|
||||||
assert!(compose(&mut text, &done).is_some());
|
let done_px = compose(&mut text, &done).expect("done compose");
|
||||||
|
assert_eq!((done_px.width(), done_px.height()), (400, 300));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -999,10 +1130,10 @@ mod tests {
|
||||||
fn reveal_fit_truncates_long_passwords() {
|
fn reveal_fit_truncates_long_passwords() {
|
||||||
let mut text = TextRenderer::new();
|
let mut text = TextRenderer::new();
|
||||||
// Short password fits unchanged.
|
// Short password fits unchanged.
|
||||||
assert_eq!(reveal_fit(&mut text, "hunter2", "sans-serif", 14.0, 200.0), "hunter2");
|
assert_eq!(ellipsize(&mut text, "hunter2", "sans-serif", 14.0, 200.0), "hunter2");
|
||||||
// A very long one is trimmed and ends with an ellipsis.
|
// A very long one is trimmed and ends with an ellipsis.
|
||||||
let long = "a".repeat(200);
|
let long = "a".repeat(200);
|
||||||
let fitted = reveal_fit(&mut text, &long, "sans-serif", 14.0, 60.0);
|
let fitted = ellipsize(&mut text, &long, "sans-serif", 14.0, 60.0);
|
||||||
assert!(fitted.ends_with('…'), "trimmed reveal should end with an ellipsis");
|
assert!(fitted.ends_with('…'), "trimmed reveal should end with an ellipsis");
|
||||||
assert!(fitted.len() < long.len());
|
assert!(fitted.len() < long.len());
|
||||||
// And it actually fits the budget.
|
// And it actually fits the budget.
|
||||||
|
|
@ -1098,6 +1229,19 @@ mod tests {
|
||||||
assert!(y < 0.0, "unlock should drift up from rest, got y={y}");
|
assert!(y < 0.0, "unlock should drift up from rest, got y={y}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overlay_motion_holds_rest_during_success_flash() {
|
||||||
|
// First FLASH_FRAC of unlock_t is the green flash at full opacity.
|
||||||
|
let (a_rest, y_rest) = overlay_motion(1.0, 0.0);
|
||||||
|
let (a_flash, y_flash) = overlay_motion(1.0, FLASH_FRAC * 0.5);
|
||||||
|
assert!((a_flash - a_rest).abs() < 1e-6, "flash must not fade chrome, got {a_flash}");
|
||||||
|
assert!((y_flash - y_rest).abs() < 1e-6, "flash must not drift chrome, got {y_flash}");
|
||||||
|
// After the flash, fade/drift begin.
|
||||||
|
let (a_fade, y_fade) = overlay_motion(1.0, (FLASH_FRAC + 1.0) * 0.5);
|
||||||
|
assert!(a_fade < a_rest, "post-flash should fade, got {a_fade}");
|
||||||
|
assert!(y_fade < y_rest, "post-flash should drift up, got {y_fade}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn compose_chrome_rect_contains_clock_and_pill() {
|
fn compose_chrome_rect_contains_clock_and_pill() {
|
||||||
let bg = Background::Color(Color::BLACK);
|
let bg = Background::Color(Color::BLACK);
|
||||||
|
|
@ -1116,25 +1260,109 @@ mod tests {
|
||||||
assert!(rect.y1 > 300.0 * 0.5 + 24.0, "rect must cover the pill band");
|
assert!(rect.y1 > 300.0 * 0.5 + 24.0, "rect must cover the pill band");
|
||||||
// Both are horizontally centered.
|
// Both are horizontally centered.
|
||||||
assert!(rect.x0 < 200.0 && rect.x1 > 200.0, "rect must straddle center");
|
assert!(rect.x0 < 200.0 && rect.x1 > 200.0, "rect must straddle center");
|
||||||
|
// Origin-stuck Default(0,0,…) used to pass the checks above (0.min
|
||||||
|
// never leaves 0, and 0 < 200 && x1 > 200 still holds). Fail that.
|
||||||
|
assert!(
|
||||||
|
rect.x0 > 0.0 && rect.y0 > 0.0,
|
||||||
|
"chrome rect must not be stuck at the origin, got {rect:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn compose_chrome_rect_empty_when_veil_hidden() {
|
fn compose_chrome_rect_empty_when_unlock_finished() {
|
||||||
let bg = Background::Color(Color::BLACK);
|
let bg = Background::Color(Color::BLACK);
|
||||||
let palette = breadlock_ui::theme::Palette::default();
|
let palette = breadlock_ui::theme::Palette::default();
|
||||||
let mut text = TextRenderer::new();
|
let mut text = TextRenderer::new();
|
||||||
let mut pixmap = Pixmap::new(400, 300).unwrap();
|
let mut pixmap = Pixmap::new(400, 300).unwrap();
|
||||||
// appear_t = 0 → veil_alpha 0 → nothing drawn, rect stays default.
|
// unlock_t = 1 → chrome is gone; appear_t = 0 still draws (invisible)
|
||||||
let inputs = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 0.0, 0.0);
|
// chrome so the GPU dirty rect is valid from the first frame.
|
||||||
|
let inputs = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 1.0);
|
||||||
let rect = compose_chrome(&mut pixmap, &mut text, &inputs);
|
let rect = compose_chrome(&mut pixmap, &mut text, &inputs);
|
||||||
assert!(rect.x1 <= rect.x0 && rect.y1 <= rect.y0, "hidden chrome must yield an empty rect");
|
assert!(rect.is_empty(), "finished unlock must yield an empty rect, got {rect:?}");
|
||||||
// And the pixmap is fully transparent.
|
|
||||||
assert!(
|
assert!(
|
||||||
pixmap.pixels().iter().all(|p| p.alpha() == 0),
|
pixmap.pixels().iter().all(|p| p.alpha() == 0),
|
||||||
"hidden chrome must leave the pixmap transparent"
|
"finished unlock must leave the pixmap transparent"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compose_chrome_rect_valid_at_appear_start() {
|
||||||
|
let bg = Background::Color(Color::BLACK);
|
||||||
|
let palette = breadlock_ui::theme::Palette::default();
|
||||||
|
let mut text = TextRenderer::new();
|
||||||
|
let mut pixmap = Pixmap::new(400, 300).unwrap();
|
||||||
|
let inputs = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 0.0, 0.0);
|
||||||
|
let rect = compose_chrome(&mut pixmap, &mut text, &inputs);
|
||||||
|
assert!(
|
||||||
|
!rect.is_empty() && rect.x0 > 0.0 && rect.y0 > 0.0,
|
||||||
|
"appear t=0 must still produce a real chrome rect, got {rect:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compose_chrome_failed_shake_shifts_rect_x() {
|
||||||
|
let bg = Background::Color(Color::BLACK);
|
||||||
|
let palette = breadlock_ui::theme::Palette::default();
|
||||||
|
let mut text = TextRenderer::new();
|
||||||
|
let mut pixmap = Pixmap::new(400, 300).unwrap();
|
||||||
|
let rest = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, true, 0.0, 1.0, 1.0, 0.0);
|
||||||
|
let r0 = compose_chrome(&mut pixmap, &mut text, &rest);
|
||||||
|
let mid = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, true, 0.3, 1.0, 1.0, 0.0);
|
||||||
|
let r1 = compose_chrome(&mut pixmap, &mut text, &mid);
|
||||||
|
assert!(
|
||||||
|
(r0.x0 - r1.x0).abs() > 0.5 || (r0.x1 - r1.x1).abs() > 0.5,
|
||||||
|
"failed shake must shift chrome rect x, rest={r0:?} shaken={r1:?}"
|
||||||
|
);
|
||||||
|
// Chrome-only pixmaps: full `compose()` is dominated by the opaque
|
||||||
|
// wallpaper, so a 4px pill shake would not move that centroid.
|
||||||
|
let mut rest_px = Pixmap::new(400, 300).unwrap();
|
||||||
|
compose_chrome(&mut rest_px, &mut text, &rest);
|
||||||
|
let mut shaken_px = Pixmap::new(400, 300).unwrap();
|
||||||
|
compose_chrome(&mut shaken_px, &mut text, &mid);
|
||||||
|
let centroid_x = |p: &Pixmap| -> f32 {
|
||||||
|
let mut sx = 0.0f32;
|
||||||
|
let mut n = 0.0f32;
|
||||||
|
for (i, px) in p.pixels().iter().enumerate() {
|
||||||
|
if px.alpha() > 32 {
|
||||||
|
sx += (i as u32 % p.width()) as f32;
|
||||||
|
n += 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n > 0.0 { sx / n } else { 0.0 }
|
||||||
|
};
|
||||||
|
let dx = (centroid_x(&shaken_px) - centroid_x(&rest_px)).abs();
|
||||||
|
assert!(
|
||||||
|
dx > 0.2,
|
||||||
|
"pill contents should shake with the pill, centroid dx={dx}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compose_chrome_info_text_draws_without_date() {
|
||||||
|
let bg = Background::Color(Color::BLACK);
|
||||||
|
let palette = breadlock_ui::theme::Palette::default();
|
||||||
|
let mut text = TextRenderer::new();
|
||||||
|
let mut pixmap = Pixmap::new(400, 300).unwrap();
|
||||||
|
let mut with_info = inputs(&bg, &palette, "12:34", "", 0, false, 0.0, 1.0, 1.0, 0.0);
|
||||||
|
with_info.info_text = "Battery 87% · charging";
|
||||||
|
let with = compose_chrome(&mut pixmap, &mut text, &with_info);
|
||||||
|
let mut pixmap2 = Pixmap::new(400, 300).unwrap();
|
||||||
|
let without_info = inputs(&bg, &palette, "12:34", "", 0, false, 0.0, 1.0, 1.0, 0.0);
|
||||||
|
let without = compose_chrome(&mut pixmap2, &mut text, &without_info);
|
||||||
|
// The pill still sits below the info line, so y1 is pill-dominated.
|
||||||
|
// The info line must still produce extra chrome pixels and a rect
|
||||||
|
// that is not stuck at the origin.
|
||||||
|
let lit = |p: &Pixmap| p.pixels().iter().filter(|px| px.alpha() > 32).count();
|
||||||
|
assert!(
|
||||||
|
lit(&pixmap) > lit(&pixmap2),
|
||||||
|
"info line must draw extra chrome when date_text is empty (with {} lit, without {})",
|
||||||
|
lit(&pixmap),
|
||||||
|
lit(&pixmap2)
|
||||||
|
);
|
||||||
|
assert!(with.x0 > 0.0 && with.y0 > 0.0 && !with.is_empty());
|
||||||
|
assert!(without.x0 > 0.0 && !without.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn compose_chrome_status_text_expands_the_rect_downward() {
|
fn compose_chrome_status_text_expands_the_rect_downward() {
|
||||||
let bg = Background::Color(Color::BLACK);
|
let bg = Background::Color(Color::BLACK);
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,21 @@ 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, Instant};
|
||||||
use wayland_client::protocol::{wl_keyboard, wl_output, wl_shm};
|
use wayland_client::protocol::{wl_keyboard, wl_output, wl_seat, wl_shm};
|
||||||
use wayland_client::{Connection, QueueHandle};
|
use wayland_client::{Connection, QueueHandle};
|
||||||
|
|
||||||
use crate::auth::AuthResult;
|
use crate::auth::AuthOutcome;
|
||||||
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
|
||||||
|
|
@ -26,27 +31,36 @@ 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`);
|
/// EGL-backed renderer for this surface (created on first `configure`);
|
||||||
/// `None` when the GPU path is unavailable, in which case the software
|
/// `None` when the GPU path is unavailable, in which case the software
|
||||||
/// wl_shm path is used.
|
/// wl_shm path is used.
|
||||||
pub gpu: Option<crate::gpu::GpuSurface>,
|
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 is ignored until it
|
/// A PAM check is running on its own thread; input other than Escape is
|
||||||
/// resolves so a second Enter can't race the first attempt.
|
/// ignored until it resolves so a second Enter can't race the first
|
||||||
|
/// attempt. Escape cancels the wait (the in-flight libpam call is not
|
||||||
|
/// aborted; its result is ignored).
|
||||||
Checking,
|
Checking,
|
||||||
/// The password (or account state) was rejected by PAM — an ordinary
|
/// The password was rejected by PAM — an ordinary wrong-password
|
||||||
/// wrong-password/locked-account outcome the user can retry.
|
/// outcome the user can retry. Input is not blocked.
|
||||||
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) — this is a config/deployment problem, not
|
/// missing or unreadable), or the process username could not be
|
||||||
/// something the user's password can fix. Rendered with a distinct
|
/// resolved — a config/deployment problem, not something the user's
|
||||||
/// message so a broken install doesn't look like an endless string of
|
/// password can fix. Rendered with a distinct message so a broken
|
||||||
/// typos with no way to discover the real cause. See `main.rs`'s
|
/// install doesn't look like an endless string of typos.
|
||||||
/// auth-result callback, which is the only place this is set.
|
|
||||||
ConfigError,
|
ConfigError,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,6 +76,9 @@ 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,
|
||||||
|
|
@ -79,8 +96,19 @@ 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<AuthResult>,
|
pub auth_tx: Sender<AuthOutcome>,
|
||||||
|
/// 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.
|
/// Monotonic clock reference — drives the idle caret blink cadence.
|
||||||
pub started: Instant,
|
pub started: Instant,
|
||||||
|
|
@ -101,8 +129,9 @@ pub struct AppState {
|
||||||
/// Clock text drawn last frame; a change starts a minute-rollover
|
/// Clock text drawn last frame; a change starts a minute-rollover
|
||||||
/// crossfade instead of a hard text swap.
|
/// crossfade instead of a hard text swap.
|
||||||
pub last_clock_text: String,
|
pub last_clock_text: String,
|
||||||
/// When the current clock crossfade started.
|
/// Outgoing clock string + when its crossfade started. Kept until the
|
||||||
pub clock_anim_started: Option<Instant>,
|
/// 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") —
|
/// When the current status line appeared ("Checking…" / "Wrong password") —
|
||||||
/// drives its slide-in. Reset whenever `auth_state` changes (see
|
/// drives its slide-in. Reset whenever `auth_state` changes (see
|
||||||
/// `last_auth_state`).
|
/// `last_auth_state`).
|
||||||
|
|
@ -135,6 +164,9 @@ pub struct AppState {
|
||||||
/// Consecutive failed password attempts this session — drives the
|
/// Consecutive failed password attempts this session — drives the
|
||||||
/// "N failed attempts" status line. Reset on a successful auth.
|
/// "N failed attempts" status line. Reset on a successful auth.
|
||||||
pub failed_attempts: u32,
|
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,
|
||||||
}
|
}
|
||||||
|
|
@ -188,7 +220,10 @@ impl AppState {
|
||||||
};
|
};
|
||||||
// While a PAM check runs, the status dots tick to signal progress.
|
// 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 => Some(format!("Checking{}", checking_dots(now))),
|
AuthState::Checking => {
|
||||||
|
let started = self.checking_started.unwrap_or(now);
|
||||||
|
Some(format!("Checking{}", checking_dots(started)))
|
||||||
|
}
|
||||||
AuthState::Failed => {
|
AuthState::Failed => {
|
||||||
// Repeat failures get a counter so the user can tell the
|
// Repeat failures get a counter so the user can tell the
|
||||||
// locker apart from a stuck/corrupt one ("Wrong password"
|
// locker apart from a stuck/corrupt one ("Wrong password"
|
||||||
|
|
@ -200,12 +235,26 @@ impl AppState {
|
||||||
"Wrong password".to_string()
|
"Wrong password".to_string()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
AuthState::AccountInvalid => Some("Account locked or expired".to_string()),
|
||||||
AuthState::ConfigError => Some(
|
AuthState::ConfigError => Some(
|
||||||
"PAM config error — check logs (breadlock service not set up correctly)"
|
"PAM config error — check logs (breadlock service not set up correctly)"
|
||||||
.to_string(),
|
.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 auto-dim: ramp 0..1 over IDLE_DIM_RAMP_MS once the configured
|
||||||
// idle threshold elapses with no keystrokes. 0 when disabled.
|
// idle threshold elapses with no keystrokes. 0 when disabled.
|
||||||
let idle_dim = if self.config.animation.idle_dim_after_secs > 0 {
|
let idle_dim = if self.config.animation.idle_dim_after_secs > 0 {
|
||||||
|
|
@ -224,17 +273,26 @@ impl AppState {
|
||||||
.map(|t| render::unit_progress(t, render::STATUS_SLIDE_MS))
|
.map(|t| render::unit_progress(t, render::STATUS_SLIDE_MS))
|
||||||
.unwrap_or(1.0);
|
.unwrap_or(1.0);
|
||||||
|
|
||||||
// Minute rollover: keep the previous clock text for a short crossfade.
|
// Minute rollover: keep the previous clock text in `clock_from`
|
||||||
let clock_old = if !self.last_clock_text.is_empty() && clock_text != self.last_clock_text {
|
// until the crossfade completes. Do not overwrite the outgoing string.
|
||||||
self.clock_anim_started = Some(now);
|
if let Some((_, started)) = self.clock_from {
|
||||||
Some((self.last_clock_text.clone(), 0.0))
|
if render::unit_progress(started, render::CLOCK_CROSSFADE_MS) >= 1.0 {
|
||||||
} else {
|
self.clock_from = None;
|
||||||
self.clock_anim_started
|
}
|
||||||
.map(|t| render::unit_progress(t, render::CLOCK_CROSSFADE_MS))
|
}
|
||||||
.filter(|t| *t < 1.0)
|
if self.clock_from.is_none()
|
||||||
.map(|t| (self.last_clock_text.clone(), t))
|
&& !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();
|
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
|
let appear_t = self
|
||||||
.appear_started
|
.appear_started
|
||||||
|
|
@ -253,6 +311,12 @@ impl AppState {
|
||||||
.map(|t| render::unit_progress(t, render::DOT_POP_MS))
|
.map(|t| render::unit_progress(t, render::DOT_POP_MS))
|
||||||
.unwrap_or(1.0);
|
.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 output_palette = self.palette_for_surface(surface);
|
||||||
let inputs = render::FrameInputs {
|
let inputs = render::FrameInputs {
|
||||||
width,
|
width,
|
||||||
|
|
@ -262,14 +326,17 @@ impl AppState {
|
||||||
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,
|
date_text: &date_text,
|
||||||
clock_old: clock_old.as_ref().map(|(s, t)| (s.as_str(), *t)),
|
clock_old,
|
||||||
password_len: self.password.len(),
|
password_len,
|
||||||
password: &self.password,
|
password: &self.password,
|
||||||
reveal: self.reveal_held,
|
reveal: self.reveal_held,
|
||||||
caps_lock: self.caps_lock,
|
caps_lock: self.caps_lock,
|
||||||
layout_index: self.layout_index,
|
layout_index: self.layout_index,
|
||||||
idle_dim,
|
idle_dim,
|
||||||
failed: matches!(self.auth_state, AuthState::Failed | AuthState::ConfigError),
|
failed: matches!(
|
||||||
|
self.auth_state,
|
||||||
|
AuthState::Failed | AuthState::AccountInvalid | AuthState::ConfigError
|
||||||
|
),
|
||||||
failed_t,
|
failed_t,
|
||||||
dot_pop_t,
|
dot_pop_t,
|
||||||
keystroke_age: self.last_keystroke.map(|t| t.elapsed().as_secs_f32()),
|
keystroke_age: self.last_keystroke.map(|t| t.elapsed().as_secs_f32()),
|
||||||
|
|
@ -277,6 +344,7 @@ impl AppState {
|
||||||
breathe_t,
|
breathe_t,
|
||||||
status_t,
|
status_t,
|
||||||
status_text: status_text.as_deref(),
|
status_text: status_text.as_deref(),
|
||||||
|
info_text: &info_text,
|
||||||
appear_t,
|
appear_t,
|
||||||
unlock_t,
|
unlock_t,
|
||||||
smooth_pan: !self.fast_anim_in_progress(),
|
smooth_pan: !self.fast_anim_in_progress(),
|
||||||
|
|
@ -286,58 +354,140 @@ impl AppState {
|
||||||
// shader) and the software-composed chrome on top. Disjoint-field
|
// shader) and the software-composed chrome on top. Disjoint-field
|
||||||
// borrows of `self` make `gpu` + `surfaces` + `text_renderer`
|
// borrows of `self` make `gpu` + `surfaces` + `text_renderer`
|
||||||
// simultaneously mutable.
|
// simultaneously mutable.
|
||||||
if self.gpu.is_some()
|
let wants_gpu = self.gpu.is_some()
|
||||||
&& self
|
&& self
|
||||||
.surfaces
|
.surfaces
|
||||||
.iter()
|
.iter()
|
||||||
.any(|s| s.surface.wl_surface() == surface.wl_surface() && s.gpu.is_some())
|
.any(|s| s.surface.wl_surface() == surface.wl_surface() && s.gpu.is_some());
|
||||||
{
|
if wants_gpu {
|
||||||
let renderer = self.gpu.as_mut().expect("checked above");
|
let Some(renderer) = self.gpu.as_mut() else {
|
||||||
let lock_surface = self
|
return;
|
||||||
|
};
|
||||||
|
let Some(lock_surface) = 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())
|
||||||
.expect("surface exists");
|
else {
|
||||||
let gpu_surface = lock_surface.gpu.as_mut().expect("checked above");
|
return;
|
||||||
renderer.render_frame(gpu_surface, &inputs, &mut self.text_renderer);
|
};
|
||||||
self.arm_anim_if_needed(qh);
|
let Some(gpu_surface) = lock_surface.gpu.as_mut() else {
|
||||||
return;
|
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
let stride = width as usize * 4;
|
self.present_shm(surface, width, height, &pixmap);
|
||||||
let pool =
|
self.arm_anim_if_needed(qh);
|
||||||
smithay_client_toolkit::shm::raw::RawPool::new(stride * height as usize, &self.shm);
|
}
|
||||||
let mut pool = match pool {
|
|
||||||
Ok(pool) => pool,
|
fn present_shm(
|
||||||
Err(err) => {
|
&mut self,
|
||||||
tracing::error!(%err, "failed to allocate shm pool for lock surface redraw");
|
surface: &SessionLockSurface,
|
||||||
return;
|
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,
|
||||||
};
|
};
|
||||||
render::blit_to_shm(&pixmap, pool.mmap());
|
|
||||||
|
|
||||||
let buffer = pool.create_buffer(
|
let idx = self
|
||||||
0,
|
.surfaces
|
||||||
width as i32,
|
.iter()
|
||||||
height as i32,
|
.position(|s| s.surface.wl_surface() == surface.wl_surface());
|
||||||
stride as i32,
|
let Some(idx) = idx else {
|
||||||
wl_shm::Format::Argb8888,
|
return;
|
||||||
(),
|
};
|
||||||
qh,
|
|
||||||
);
|
|
||||||
|
|
||||||
surface.wl_surface().attach(Some(&buffer), 0, 0);
|
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;
|
||||||
|
};
|
||||||
|
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() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
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();
|
|
||||||
|
|
||||||
self.arm_anim_if_needed(qh);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn palette_for_surface(&self, surface: &SessionLockSurface) -> breadlock_ui::theme::Palette {
|
fn palette_for_surface(&self, surface: &SessionLockSurface) -> breadlock_ui::theme::Palette {
|
||||||
|
|
@ -356,7 +506,14 @@ impl AppState {
|
||||||
let surfaces: Vec<(SessionLockSurface, u32, u32)> = self
|
let surfaces: Vec<(SessionLockSurface, u32, u32)> = self
|
||||||
.surfaces
|
.surfaces
|
||||||
.iter()
|
.iter()
|
||||||
.map(|s| (s.surface.clone(), s.width, s.height))
|
.map(|s| {
|
||||||
|
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);
|
||||||
|
|
@ -389,8 +546,9 @@ impl AppState {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clock_fade_in_progress(&self) -> bool {
|
fn clock_fade_in_progress(&self) -> bool {
|
||||||
self.clock_anim_started
|
self.clock_from
|
||||||
.map(|t| t.elapsed() < Duration::from_millis(render::CLOCK_CROSSFADE_MS))
|
.as_ref()
|
||||||
|
.map(|(_, t)| t.elapsed() < Duration::from_millis(render::CLOCK_CROSSFADE_MS))
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -417,10 +575,33 @@ impl AppState {
|
||||||
.unwrap_or(false)
|
.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
|
/// Any effect still running that needs the animation timer: the fast ones
|
||||||
/// (entrance, unlock flash+fade, shake, dot pop, clock rollover, status
|
/// (entrance, unlock flash+fade, shake, dot pop, clock rollover, status
|
||||||
/// slide, a live PAM check) plus the slow ones (idle breath, Ken Burns
|
/// slide, a live PAM check) plus the slow ones (idle breath, Ken Burns
|
||||||
/// pan) which run at a reduced cadence — see `tick_animation`.
|
/// pan, idle dim ramp, caret blink) which run at a reduced cadence — see
|
||||||
|
/// `tick_animation`.
|
||||||
fn anim_in_progress(&self) -> bool {
|
fn anim_in_progress(&self) -> bool {
|
||||||
self.unlocking.is_some()
|
self.unlocking.is_some()
|
||||||
|| self.appear_in_progress()
|
|| self.appear_in_progress()
|
||||||
|
|
@ -432,6 +613,8 @@ impl AppState {
|
||||||
|| self.breathe_due()
|
|| self.breathe_due()
|
||||||
|| self.auth_state == AuthState::Checking
|
|| self.auth_state == AuthState::Checking
|
||||||
|| self.background.ken_burns()
|
|| self.background.ken_burns()
|
||||||
|
|| self.idle_dim_in_progress()
|
||||||
|
|| self.caret_blink_in_progress()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Keep requesting frames while any effect is running.
|
/// Keep requesting frames while any effect is running.
|
||||||
|
|
@ -460,8 +643,9 @@ impl AppState {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A 60 fps animation is in flight (everything except the slow idle
|
/// A 60 fps animation is in flight (everything except the slow idle
|
||||||
/// effects: idle breath, Ken Burns pan). Drives both the timer cadence
|
/// effects: idle breath, Ken Burns pan, idle dim, caret blink). Drives
|
||||||
/// and whether background frames get sub-pixel panning.
|
/// both the timer cadence and whether background frames get sub-pixel
|
||||||
|
/// panning.
|
||||||
fn fast_anim_in_progress(&self) -> bool {
|
fn fast_anim_in_progress(&self) -> bool {
|
||||||
self.appear_in_progress()
|
self.appear_in_progress()
|
||||||
|| self.unlock_in_progress()
|
|| self.unlock_in_progress()
|
||||||
|
|
@ -478,8 +662,9 @@ impl AppState {
|
||||||
self.anim_timer_armed = false;
|
self.anim_timer_armed = false;
|
||||||
TimeoutAction::Drop
|
TimeoutAction::Drop
|
||||||
} else if self.anim_in_progress() {
|
} else if self.anim_in_progress() {
|
||||||
// Slow effects (idle breath, Ken Burns) don't need 60fps — halve
|
// Slow effects (idle breath, Ken Burns, dim, caret) don't need
|
||||||
// the redraw cost for them. Everything else stays at ~60Hz.
|
// 60fps — halve the redraw cost for them. Everything else stays
|
||||||
|
// at ~60Hz.
|
||||||
let fast = self.fast_anim_in_progress();
|
let fast = self.fast_anim_in_progress();
|
||||||
TimeoutAction::ToDuration(Duration::from_millis(if fast {
|
TimeoutAction::ToDuration(Duration::from_millis(if fast {
|
||||||
render::ANIM_FRAME_MS
|
render::ANIM_FRAME_MS
|
||||||
|
|
@ -509,28 +694,39 @@ impl AppState {
|
||||||
self.exit = true;
|
self.exit = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// After a failed attempt, clears the "wrong password" state (and
|
/// After a failed attempt, clears the red UI once `input.fail_timeout_ms`
|
||||||
/// re-enables the red pill) once `input.fail_timeout_ms` has elapsed —
|
/// has elapsed — unless a newer fail (or the user typing) has moved the
|
||||||
/// unless the user already cleared it themselves by typing again.
|
/// 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 matches!(state.auth_state, AuthState::Failed | AuthState::ConfigError) {
|
if fail_timer_applies(gen, state.failed_generation, state.auth_state) {
|
||||||
state.auth_state = AuthState::Idle;
|
state.auth_state = AuthState::Idle;
|
||||||
state.failed_at = None;
|
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:
|
/// The animated ellipsis for the "Checking" status while a PAM check runs:
|
||||||
/// cycles "", ".", "..", "…" every ~500ms (driven by the monotonic clock).
|
/// cycles "", ".", "..", "…" every ~500ms (driven by time since `started`).
|
||||||
fn checking_dots(now: Instant) -> &'static str {
|
fn checking_dots(started: Instant) -> &'static str {
|
||||||
match (now.elapsed().as_secs_f32() * 2.0) as usize % 4 {
|
match (started.elapsed().as_secs_f32() * 2.0) as usize % 4 {
|
||||||
0 => "",
|
0 => "",
|
||||||
1 => ".",
|
1 => ".",
|
||||||
2 => "..",
|
2 => "..",
|
||||||
|
|
@ -538,6 +734,16 @@ fn checking_dots(now: Instant) -> &'static str {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
fn shm_state(&mut self) -> &mut Shm {
|
fn shm_state(&mut self) -> &mut Shm {
|
||||||
&mut self.shm
|
&mut self.shm
|
||||||
|
|
@ -550,3 +756,30 @@ 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
389
breadlock/src/status.rs
Normal file
389
breadlock/src/status.rs
Normal file
|
|
@ -0,0 +1,389 @@
|
||||||
|
//! 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue