diff --git a/Cargo.lock b/Cargo.lock index 41855fe..cd87b73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,137 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "async-trait" version = "0.1.92" @@ -49,6 +180,12 @@ dependencies = [ "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]] name = "autocfg" version = "1.5.1" @@ -64,6 +201,28 @@ dependencies = [ "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]] name = "bread-shared" version = "0.7.0" @@ -124,6 +283,7 @@ dependencies = [ "chrono", "glow", "khronos-egl", + "libc", "pam-client2", "serde", "serde_json", @@ -134,6 +294,7 @@ dependencies = [ "tracing", "tracing-subscriber", "wayland-client", + "zbus", "zeroize", ] @@ -142,7 +303,6 @@ name = "breadlock-ui" version = "0.2.0" dependencies = [ "bread-theme", - "chrono", "cosmic-text", "serde", "tiny-skia", @@ -255,6 +415,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chrono" version = "0.4.45" @@ -306,6 +472,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -321,12 +496,32 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "cursor-icon" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "dirs" version = "5.0.1" @@ -363,6 +558,33 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "equivalent" version = "1.0.2" @@ -379,6 +601,26 @@ dependencies = [ "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]] name = "fastrand" version = "2.5.0" @@ -524,6 +766,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "futures-macro" version = "0.3.34" @@ -621,6 +876,16 @@ dependencies = [ "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]] name = "getrandom" version = "0.2.17" @@ -876,6 +1141,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -1049,6 +1320,19 @@ dependencies = [ "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]] name = "nu-ansi-term" version = "0.50.3" @@ -1079,6 +1363,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "pam-client2" version = "0.5.5" @@ -1123,12 +1417,29 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "pkg-config" version = "0.3.34" @@ -1162,6 +1473,15 @@ dependencies = [ "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]] name = "proc-macro-crate" version = "3.5.0" @@ -1204,6 +1524,36 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "rangemap" version = "1.8.0" @@ -1407,6 +1757,17 @@ dependencies = [ "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]] name = "serde_spanned" version = "0.6.9" @@ -1425,6 +1786,17 @@ dependencies = [ "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]] name = "sharded-slab" version = "0.1.7" @@ -1542,6 +1914,12 @@ dependencies = [ "lock_api", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strict-num" version = "0.1.1" @@ -1609,6 +1987,19 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "thiserror" version = "1.0.69" @@ -1892,6 +2283,23 @@ version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "unicode-bidi" version = "0.3.18" @@ -2205,7 +2613,25 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 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]] @@ -2223,13 +2649,29 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "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]] @@ -2238,42 +2680,90 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "winnow" version = "0.7.15" @@ -2298,6 +2788,16 @@ version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "xkbcommon" version = "0.8.0" @@ -2324,12 +2824,94 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "zeno" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "zeroize" version = "1.9.0" @@ -2341,3 +2923,40 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" 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", +] diff --git a/EVENTS.md b/EVENTS.md index 51f2d63..6e0122d 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -27,7 +27,7 @@ from session lock/unlock. | Event | Data | When | |-------|------|------| | `bread.lock.locked` | `{}` | The compositor accepted the `ext-session-lock-v1` request (`SessionLockHandler::locked`). Not emitted merely because breadlock started or asked to lock. | -| `bread.lock.unlocked` | `{}` | PAM authenticated successfully and breadlock sent `unlock` to the compositor. 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.failed` | `{ "error": "" }` | `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. | diff --git a/breadlock-ui/Cargo.toml b/breadlock-ui/Cargo.toml index 10fe27c..d6088e7 100644 --- a/breadlock-ui/Cargo.toml +++ b/breadlock-ui/Cargo.toml @@ -14,7 +14,6 @@ toml.workspace = true # instead, so it builds without pulling these in). tiny-skia = { version = "0.12", optional = true } cosmic-text = { version = "0.14", optional = true } -chrono = { version = "0.4", optional = true } [features] -paint = ["dep:tiny-skia", "dep:cosmic-text", "dep:chrono"] +paint = ["dep:tiny-skia", "dep:cosmic-text"] diff --git a/breadlock-ui/src/painter.rs b/breadlock-ui/src/painter.rs index 5cc0adc..8013304 100644 --- a/breadlock-ui/src/painter.rs +++ b/breadlock-ui/src/painter.rs @@ -5,6 +5,7 @@ //! instead and doesn't need a font-shaping stack. pub use bread_theme::tokens; +pub use cosmic_text::Weight; use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping, SwashCache}; use std::collections::HashMap; use tiny_skia::{Path, PathBuilder, Pixmap, PremultipliedColorU8}; @@ -34,9 +35,13 @@ pub struct TextRenderer { font_system: FontSystem, swash_cache: SwashCache, /// Exact glyph-pixel span `(top, height)` per unique `(text, family, - /// size)` — see [`Self::measure_box`]. Keyed by size in centipixels so - /// fractional sizes don't thrash the cache. - boxes: HashMap<(String, String, u32), (f32, f32)>, + /// size, weight)` — see [`Self::measure_box`]. Keyed by size in + /// centipixels so fractional sizes don't thrash the cache. + boxes: HashMap<(String, String, u32, u16), (f32, f32)>, + /// Whether `Family::Name(family)` resolved to an installed face. Missing + /// families fall back to `Family::SansSerif` instead of panicking or + /// drawing tofu; the result is cached so we don't scan fontdb every frame. + family_ok: HashMap, } impl Default for TextRenderer { @@ -51,14 +56,51 @@ impl TextRenderer { font_system: FontSystem::new(), swash_cache: SwashCache::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 mut buffer = Buffer::new(&mut self.font_system, metrics); 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.shape_until_scroll(&mut self.font_system, false); buffer @@ -76,17 +118,46 @@ impl TextRenderer { /// (clock per minute, date per day, static hints once), so the one-off /// cost is negligible and the result is correct for any font. pub fn measure_box(&mut self, text: &str, family: &str, size_px: f32) -> (f32, f32) { - 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) { 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 mut probe = match Pixmap::new(w, h) { Some(p) => p, 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); for y in 0..h { for x in 0..w { @@ -108,7 +179,17 @@ impl TextRenderer { /// Width in pixels `text` would occupy if drawn via [`Self::draw_line`] /// 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 { - 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 .layout_runs() .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 /// into `pixmap` with its top-left baseline anchor at `(origin_x, /// 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)] pub fn draw_line( &mut self, @@ -129,7 +212,27 @@ impl TextRenderer { origin_x: 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(); // 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 (width, height) = (pixmap.width() as i32, pixmap.height() as i32); - let ox = origin_x as i32; - let oy = origin_y as i32; - buffer.draw( - &mut self.font_system, - &mut self.swash_cache, - text_color, - |x, y, _w, _h, glyph_color| { - let (px, py) = (ox + x, oy + y); - if px < 0 || py < 0 || px >= width || py >= height { - return; - } - let (r, g, b, a) = glyph_color.as_rgba_tuple(); - if a == 0 { - return; - } - // `a` is the glyph coverage; combine it with the requested - // color alpha for the true source alpha. - 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); - }, - ); + for run in buffer.layout_runs() { + for glyph in run.glyphs.iter() { + // Subpixel origin: X lands in CacheKey's subpixel bins; Y is + // hinted (cosmic-text truncates the Y offset) and then the + // run's line_y is rounded at blit so we don't trunc origin + // independently of glyph placement. + let physical = glyph.physical((origin_x, origin_y), 1.0); + let glyph_color = glyph.color_opt.unwrap_or(text_color); + self.swash_cache.with_pixels( + &mut self.font_system, + physical.cache_key, + glyph_color, + |x, y, color| { + let px = physical.x + x; + let py = run.line_y.round() as i32 + physical.y + y; + if px < 0 || py < 0 || px >= width || py >= height { + return; + } + let (r, g, b, a) = color.as_rgba_tuple(); + if a == 0 { + return; + } + let a = (a as u32 * base_alpha as u32 / 255) as u8; + if a == 0 { + return; + } + blend_over(pixmap, px as u32, py as u32, r, g, b, a); + }, + ); + } + } } } @@ -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] fn draw_line_onto_transparent_keeps_real_alpha() { // Regression: the GPU path (compose_chrome) draws text into a diff --git a/breadlock.example.toml b/breadlock.example.toml index 188090a..0f0a328 100644 --- a/breadlock.example.toml +++ b/breadlock.example.toml @@ -1,5 +1,7 @@ # 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. +# A malformed file also falls back to defaults (the locker/greeter warn +# rather than treating it as missing). [background] # "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 # background unblurred (needs a wlr-screencopy capture, not implemented yet). blur = false -# Slow Ken Burns pan on image backgrounds (gentle drift + zoom). Opt-in: the -# background redraws continuously at a low frame rate while locked. +# Slow Ken Burns pan on image backgrounds (gentle drift + zoom). Opt-in. +# Cheap on the GPU wallpaper path; the software fallback still redraws +# the background continuously at a low frame rate while locked. ken_burns = false [clock] @@ -23,13 +26,13 @@ date_format = "%A · %b %d" family = "Varela Round" [input] -# How long the "wrong password" state (red pill) shows before input -# re-enables, in milliseconds. +# How long the red "wrong password" UI shows, in milliseconds. Typing is +# still accepted during this window (it clears the failed state). fail_timeout_ms = 800 # Hold Tab to reveal the typed password as plain characters (instead of # dots) while held. Tab can never be part of a password, so it's always -# safe as a reveal gesture. -reveal_hold = true +# safe as a reveal gesture. Default off. +reveal_hold = false [animation] # 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). # A gentle extra darkening for OLED/burn-in or late-night comfort. idle_dim_after_secs = 0 + +[status] +# Now-playing (MPRIS) and battery (upower) shown as a small line under the +# clock. Each flag controls both display and whether that D-Bus source is +# polled (background thread, every few seconds). Both default on; they +# degrade silently (no line) when the service or bus is unavailable. +now_playing = true +battery = true diff --git a/breadlock/Cargo.toml b/breadlock/Cargo.toml index e9e91b4..c27ef81 100644 --- a/breadlock/Cargo.toml +++ b/breadlock/Cargo.toml @@ -33,8 +33,10 @@ tiny-skia = "0.12" khronos-egl = { version = "6", features = ["dynamic"] } glow = "0.16" chrono = "0.4" +zbus = "4" pam-client2 = { version = "0.5", default-features = false } zeroize = { version = "1", features = ["std"] } +libc = "0.2" serde.workspace = true serde_json.workspace = true toml.workspace = true diff --git a/breadlock/src/auth/mod.rs b/breadlock/src/auth/mod.rs index d1fad8d..5346c7a 100644 --- a/breadlock/src/auth/mod.rs +++ b/breadlock/src/auth/mod.rs @@ -8,24 +8,33 @@ 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::LoopHandle; +use std::time::Duration; 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 /// loop and returns the `Sender` to hand to [`spawn_check`] on each attempt. pub fn register( loop_handle: &LoopHandle<'static, Data>, - mut on_result: impl FnMut(&mut Data, AuthResult) + 'static, -) -> Sender { + mut on_result: impl FnMut(&mut Data, u64, AuthResult) + 'static, +) -> Sender { let (tx, channel) = channel::channel(); loop_handle .insert_source(channel, move |event, _, data| { - if let channel::Event::Msg(result) = event { - on_result(data, result); + if let channel::Event::Msg((generation, result)) = event { + on_result(data, generation, result); } }) .expect("failed to register auth-result channel on event loop"); @@ -38,13 +47,31 @@ pub fn register( /// conversation consumes it — it is never logged. It's a `Zeroizing` /// 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. +/// +/// `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( username: String, password: zeroize::Zeroizing, - result_tx: Sender, + generation: u64, + result_tx: Sender, ) { std::thread::spawn(move || { - let result = pam::check(&username, &password); - let _ = result_tx.send(result); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + 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)); }); } diff --git a/breadlock/src/auth/pam.rs b/breadlock/src/auth/pam.rs index e245dba..40dd7a6 100644 --- a/breadlock/src/auth/pam.rs +++ b/breadlock/src/auth/pam.rs @@ -4,6 +4,7 @@ use pam_client2::conv_mock::Conversation; use pam_client2::{Context, Flag}; +use std::ffi::CStr; use zeroize::Zeroize; /// The PAM service name — matches `/etc/pam.d/breadlock` @@ -46,3 +47,136 @@ pub fn check(username: &str, password: &str) -> Result<(), AuthError> { result } + +/// Copy a NUL-terminated `passwd.pw_name` into an owned `String`. +fn cstr_to_username(ptr: *const libc::c_char) -> Option { + 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 { + let mut pwd = std::mem::MaybeUninit::::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 { + 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 { + 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); + } +} diff --git a/breadlock/src/background.rs b/breadlock/src/background.rs index 6af5d92..eb8ceac 100644 --- a/breadlock/src/background.rs +++ b/breadlock/src/background.rs @@ -31,12 +31,18 @@ pub enum Background { /// A wallpaper with a lazily-built, output-sized copy. The first `paint` for /// a given output size does one downscale; every frame after that blits the /// cached copy with at most a translation (the Ken Burns pan). +/// Cap on cached scaled copies — enough for a typical multi-monitor setup +/// without unbounded growth if the compositor sends many sizes. +const SCALED_CACHE_SLOTS: usize = 4; + pub struct ImageBg { /// Original wallpaper. Kept so a different output size (hotplug) simply /// rebuilds the cache rather than needing the source reloaded. source: Pixmap, ken_burns: bool, - cache: RefCell>, + /// Last scaled copies **per target size**. A single slot thrashed every + /// frame under `redraw_all` with two monitors of different sizes. + cache: RefCell>, } 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 sw = src.width() as usize; let sh = src.height() as usize; - let sx = (-dx).clamp(0.0, (sw - tw).max(0) as f32); - let sy = (-dy).clamp(0.0, (sh - th).max(0) as f32); + let sx = (-dx).clamp(0.0, sw.saturating_sub(tw) as f32); + let sy = (-dy).clamp(0.0, sh.saturating_sub(th) as f32); let fx = (sx.fract() * 65536.0) as u32 & 0xFFFF; let fy = (sy.fract() * 65536.0) as u32 & 0xFFFF; @@ -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 // products stay well under 2^32, so lanes never interfere. #[inline(always)] + #[allow(clippy::too_many_arguments)] unsafe fn lerp4( sdata: &[u8], i00: usize, @@ -145,10 +152,10 @@ fn blit_translate(target: &mut Pixmap, src: &Pixmap, dx: f32, dy: f32, bilinear: let mut out = 0u32; for c in 0..4 { let shift = c * 8; - let av = ((a >> shift) & 0xFF) as u32; - let bv = ((b >> shift) & 0xFF) as u32; - let dv = ((d >> shift) & 0xFF) as u32; - let ev = ((e >> shift) & 0xFF) as u32; + let av = (a >> shift) & 0xFF; + let bv = (b >> shift) & 0xFF; + let dv = (d >> shift) & 0xFF; + let ev = (e >> shift) & 0xFF; let top = (av * wx_inv + bv * wx) >> 16; let bot = (dv * wx_inv + ev * wx) >> 16; out |= ((top * wy_inv + bot * wy) >> 16) << shift; @@ -227,7 +234,7 @@ impl Background { Ok(pixmap) => Background::Image(ImageBg { source: pixmap, ken_burns: cfg.ken_burns, - cache: RefCell::new(None), + cache: RefCell::new(Vec::new()), }), Err(err) => { 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; } let mut cache = bg.cache.borrow_mut(); - let stale = cache - .as_ref() - .map(|c| c.target_w != target.width() || c.target_h != target.height()) - .unwrap_or(true); - if stale { + let tw_px = target.width(); + let th_px = target.height(); + let hit = cache + .iter() + .position(|c| c.target_w == tw_px && c.target_h == th_px); + if let Some(i) = hit { + // LRU: most-recently used at the end. + if i + 1 != cache.len() { + let entry = cache.remove(i); + cache.push(entry); + } + } else { let cover = (tw / sw).max(th / sh); let scale = cover * if bg.ken_burns { KENBURNS_ZOOM } else { 1.0 }; let scaled_w = (sw * scale).round().max(1.0) as u32; @@ -281,7 +295,6 @@ impl Background { tracing::error!( "failed to allocate {scaled_w}x{scaled_h} scaled wallpaper — falling back to a palette-color background" ); - *cache = None; drop(cache); target.fill(breadlock_ui::theme::tiny_skia_color( &breadlock_ui::theme::Palette::default().background, @@ -292,8 +305,10 @@ impl Background { // The one real downscale in the pipeline: bilinear so the // cached layer is smooth (per-frame draws are pure copies // and don't re-filter). - let mut paint = PixmapPaint::default(); - paint.quality = tiny_skia::FilterQuality::Bilinear; + let paint = PixmapPaint { + quality: tiny_skia::FilterQuality::Bilinear, + ..Default::default() + }; pixmap.draw_pixmap( 0, 0, @@ -302,15 +317,18 @@ impl Background { Transform::from_scale(scale, scale), None, ); - *cache = Some(ScaledBg { + if cache.len() >= SCALED_CACHE_SLOTS { + cache.remove(0); + } + cache.push(ScaledBg { pixmap, pan_x: scaled_w as f32 - tw, pan_y: scaled_h as f32 - th, - target_w: target.width(), - target_h: target.height(), + target_w: tw_px, + 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); let (tx, ty) = if bg.ken_burns { let phase = t_secs * TAU / KENBURNS_PERIOD_S; @@ -400,7 +418,7 @@ mod tests { let bg = Background::Image(ImageBg { source, ken_burns: true, - cache: RefCell::new(None), + cache: RefCell::new(Vec::new()), }); let mut target = Pixmap::new(60, 30).unwrap(); for i in 0..90 { @@ -426,7 +444,7 @@ mod tests { // pixel averages src[x] and src[x + 1]. blit_translate(&mut dst, &src, -0.5, 0.0, true); let px = dst.pixels(); - assert_eq!(px[0].red(), ((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[5].red(), ((160 + 192) / 2) as u8); } @@ -439,10 +457,40 @@ mod tests { let bg = Background::Image(ImageBg { source, ken_burns: false, - cache: RefCell::new(None), + cache: RefCell::new(Vec::new()), }); let mut target = Pixmap::new(60, 30).unwrap(); bg.paint(&mut target, 0.0, true); assert!(target.pixels().iter().all(|p| p.red() == 200 && p.green() == 30)); } + + #[test] + fn scaled_cache_keeps_a_slot_per_target_size() { + // Two output sizes (two monitors) must not thrash a single slot. + let mut source = Pixmap::new(80, 40).unwrap(); + source.fill(tiny_skia::Color::from_rgba8(200, 30, 30, 255)); + let image = ImageBg { + source, + ken_burns: false, + cache: RefCell::new(Vec::new()), + }; + let bg = Background::Image(image); + let mut a = Pixmap::new(60, 30).unwrap(); + let mut b = Pixmap::new(40, 20).unwrap(); + bg.paint(&mut a, 0.0, false); + bg.paint(&mut b, 0.0, false); + bg.paint(&mut a, 0.0, false); + let Background::Image(image) = &bg else { + panic!("expected image background"); + }; + let cache = image.cache.borrow(); + assert_eq!( + cache.len(), + 2, + "two target sizes should occupy two slots, got {} slots", + cache.len() + ); + assert!(cache.iter().any(|c| c.target_w == 60 && c.target_h == 30)); + assert!(cache.iter().any(|c| c.target_w == 40 && c.target_h == 20)); + } } diff --git a/breadlock/src/bin/breadlock-auth-check.rs b/breadlock/src/bin/breadlock-auth-check.rs index c671ca8..5efffb0 100644 --- a/breadlock/src/bin/breadlock-auth-check.rs +++ b/breadlock/src/bin/breadlock-auth-check.rs @@ -8,17 +8,21 @@ //! `cargo run --bin breadlock-auth-check`. use std::io::Write; +use std::sync::atomic::{AtomicBool, Ordering}; +use zeroize::{Zeroize, Zeroizing}; #[path = "../auth/pam.rs"] mod pam; fn main() { - let username = std::env::var("USER").unwrap_or_else(|_| { + let username = pam::username_from_process().unwrap_or_else(|| { eprint!("Username: "); std::io::stdout().flush().ok(); let mut buf = String::new(); 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(); @@ -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 { + 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` /// crate — good enough for a dev tool, never shipped. -fn rpassword_prompt() -> String { +fn rpassword_prompt() -> Zeroizing { use std::io::BufRead; eprint!("Password: "); std::io::stderr().flush().ok(); - // Best-effort: disable echo via `stty` if a TTY is attached, restore after. - let stty_available = std::process::Command::new("stty") - .arg("-echo") - .status() - .map(|s| s.success()) - .unwrap_or(false); + let _echo = EchoOff::new(); let mut line = String::new(); std::io::stdin().lock().read_line(&mut line).ok(); - - if stty_available { - let _ = std::process::Command::new("stty").arg("echo").status(); - eprintln!(); - } - - line.trim_end_matches(['\n', '\r']).to_string() + let trimmed = line.trim_end_matches(['\n', '\r']); + let password = Zeroizing::new(trimmed.to_string()); + line.zeroize(); + password } diff --git a/breadlock/src/bin/breadlock-preview.rs b/breadlock/src/bin/breadlock-preview.rs index f9edd65..598f352 100644 --- a/breadlock/src/bin/breadlock-preview.rs +++ b/breadlock/src/bin/breadlock-preview.rs @@ -40,6 +40,10 @@ struct Scene { date: &'static str, clock_old: Option<(&'static str, f32)>, password_len: usize, + /// Actual password bytes. Empty except for the reveal scene: production + /// `submit()` zeros the secret (and `password_len` follows `password.len()`), + /// so checking frames show an empty pill under "Checking…". + password: &'static str, failed: bool, failed_t: f32, dot_pop_t: f32, @@ -49,6 +53,8 @@ struct Scene { /// lit below 0.5. t_secs: f32, status: Option<&'static str>, + /// Now-playing / battery line under the clock (empty hides it). + info: &'static str, appear_t: f32, unlock_t: f32, breathe_t: f32, @@ -67,12 +73,14 @@ impl Default for Scene { date: "Friday · Aug 21", clock_old: None, password_len: 0, + password: "", failed: false, failed_t: 0.0, dot_pop_t: 1.0, keystroke_age: None, t_secs: 0.2, status: None, + info: "", appear_t: 1.0, unlock_t: 0.0, breathe_t: 0.0, @@ -131,6 +139,7 @@ fn bench(args: &[String]) { breathe_t: 0.0, status_t: 1.0, status_text: None, + info_text: "", appear_t: 1.0, unlock_t: 0.0, smooth_pan: true, @@ -179,6 +188,7 @@ fn bench(args: &[String]) { breathe_t: (i % 10) as f32 / 10.0, status_t: 1.0, status_text: None, + info_text: "", appear_t: 1.0, unlock_t: 0.0, smooth_pan: true, @@ -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() }, // ---- Idle blink: two dots, caret lit (phase 0.36 → visible half-cycle). Scene { name: "07-idle-blink", password_len: 2, ..Scene::default() }, - // ---- Checking: status mid slide-in with the animated ellipsis. - Scene { name: "08-checking", status: Some("Checking…"), status_t: 0.5, ..Scene::default() }, + // ---- Checking: status mid slide-in. Live submit() zeros the secret + // so password_len is 0 — don't fake a filled pill here. + Scene { name: "08-checking", status: Some("Checking…"), status_t: 0.5, password_len: 0, password: "", ..Scene::default() }, // ---- Wrong password: mid-shake, red pill, red status (settled). Scene { name: "09-failed-shake", password_len: 6, failed: true, failed_t: 0.35, status: Some("Wrong password"), ..Scene::default() }, // ---- Success: green flash ring, dots cascading accent → white. @@ -245,11 +256,13 @@ fn main() { // ---- Non-default layout: layout chip instead of caps. Scene { name: "14-layout-2", password_len: 4, layout_index: 1, ..Scene::default() }, // ---- Hold-to-reveal: plain password characters instead of dots. - Scene { name: "15-reveal", password_len: 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). Scene { name: "16-idle-dim", idle_dim: 1.0, ..Scene::default() }, // ---- Repeat failure: attempt counter in the status line. Scene { name: "17-failed-3x", password_len: 6, failed: true, failed_t: 0.8, status: Some("Wrong password — 3 failed attempts"), ..Scene::default() }, + // ---- D-Bus status: now-playing + battery under the clock. + Scene { name: "18-status-info", info: "The War on Drugs — Red Eyes · 87% · charging", ..Scene::default() }, ]; let mut text = TextRenderer::new(); @@ -265,7 +278,7 @@ fn main() { date_text: scene.date, clock_old: scene.clock_old, password_len: scene.password_len, - password: "hunter2", + password: scene.password, reveal: scene.reveal, caps_lock: scene.caps_lock, layout_index: scene.layout_index, @@ -278,6 +291,7 @@ fn main() { breathe_t: scene.breathe_t, status_t: scene.status_t, status_text: scene.status, + info_text: scene.info, appear_t: scene.appear_t, unlock_t: scene.unlock_t, smooth_pan: false, diff --git a/breadlock/src/bread_events.rs b/breadlock/src/bread_events.rs index 5ece110..5bd3e11 100644 --- a/breadlock/src/bread_events.rs +++ b/breadlock/src/bread_events.rs @@ -15,6 +15,7 @@ //! calls compositor `unlock()` — that stays on the PAM path. use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::thread; 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`]. pub const LISTEN_APP: &str = "lock-listen"; +/// Set for the life of `run_lock` so [`locker_is_running`] is true without +/// a second `try_acquire("lock")` from the locker process (flock is +/// per-process, so that check would miss ourselves). +static LOCKER_RUNNING: AtomicBool = AtomicBool::new(false); + +/// RAII flag: [`locker_is_running`] is true until this drops. +pub struct LockerRunningGuard; + +impl Drop for LockerRunningGuard { + fn drop(&mut self) { + LOCKER_RUNNING.store(false, Ordering::SeqCst); + } +} + +/// Mark this process as the locker for the life of the returned guard. +pub fn enter_lock_process() -> LockerRunningGuard { + LOCKER_RUNNING.store(true, Ordering::SeqCst); + LockerRunningGuard +} + pub fn emit_locked() { BreadClient::connect(APP_ID).emit("bread.lock.locked", serde_json::json!({})); } @@ -59,11 +80,10 @@ pub fn emit_unlock_failed(error: &str) { ); } -/// True when another process holds the locker singleton — i.e. breadlock -/// is already locking this session. A `try_acquire` that succeeds is -/// released immediately; this is a check, not a claim. +/// True when this process is the locker, or another process holds the +/// locker singleton — i.e. breadlock is already locking this session. pub fn locker_is_running() -> bool { - singleton_held(APP_ID) + LOCKER_RUNNING.load(Ordering::SeqCst) || singleton_held(APP_ID) } 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 mut child = Command::new(exe) .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) .spawn() .map_err(|e| format!("failed to start breadlock: {e}"))?; thread::spawn(move || { @@ -94,7 +116,7 @@ pub fn start_locker() -> Result<(), String> { /// `ext-session-lock-v1` has been accepted — wait on `bread.lock.locked` /// for the compositor confirmation. pub fn honor_lock_command() { - honor_lock_command_with(start_locker); + honor_lock_command_with(locker_is_running(), start_locker); } /// 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); } -fn honor_lock_command_with(start: impl FnOnce() -> Result<(), String>) { - if locker_is_running() { +fn honor_lock_command_with(locked: bool, start: impl FnOnce() -> Result<(), String>) { + if locked { tracing::info!("bread.command.lock.lock: already locked"); emit_lock_done(); return; @@ -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. pub fn handle_command(event: &BreadEvent) { + handle_command_with(event, honor_lock_command, honor_unlock_command); +} + +fn handle_command_with(event: &BreadEvent, on_lock: impl FnOnce(), on_unlock: impl FnOnce()) { let Some(verb) = event.event.strip_prefix("bread.command.lock.") else { return; }; match verb { - "lock" => honor_lock_command(), - "unlock" => honor_unlock_command(), + "lock" => on_lock(), + "unlock" => on_unlock(), other => tracing::info!(verb = other, "ignoring unknown bread.command.lock verb"), } } @@ -181,6 +207,7 @@ pub fn subscribe_commands() -> Subscription { #[cfg(test)] mod tests { use super::*; + use std::cell::Cell; fn event(name: &str) -> BreadEvent { BreadEvent { @@ -192,9 +219,48 @@ mod tests { #[test] fn handle_command_ignores_unrecognized_verb() { - handle_command(&event("bread.command.lock.pin")); - handle_command(&event("bread.command.clip.clear")); - handle_command(&event("bread.lock.locked")); + let lock = Cell::new(false); + let unlock = Cell::new(false); + handle_command_with( + &event("bread.command.lock.pin"), + || lock.set(true), + || unlock.set(true), + ); + handle_command_with( + &event("bread.command.clip.clear"), + || lock.set(true), + || unlock.set(true), + ); + handle_command_with( + &event("bread.lock.locked"), + || lock.set(true), + || unlock.set(true), + ); + assert!(!lock.get()); + assert!(!unlock.get()); + } + + #[test] + fn handle_command_dispatches_only_lock_and_unlock() { + let lock = Cell::new(0u32); + let unlock = Cell::new(0u32); + handle_command_with( + &event("bread.command.lock.lock"), + || lock.set(lock.get() + 1), + || unlock.set(unlock.get() + 1), + ); + handle_command_with( + &event("bread.command.lock.unlock"), + || lock.set(lock.get() + 1), + || unlock.set(unlock.get() + 1), + ); + handle_command_with( + &event("bread.command.lock.pin"), + || lock.set(lock.get() + 1), + || unlock.set(unlock.get() + 1), + ); + assert_eq!(lock.get(), 1); + assert_eq!(unlock.get(), 1); } #[test] @@ -216,13 +282,33 @@ mod tests { } #[test] - fn honor_lock_command_with_failed_start_does_not_panic() { - honor_lock_command_with(|| Err("boom".into())); + fn honor_lock_command_with_failed_start_runs_start() { + let started = Cell::new(false); + honor_lock_command_with(false, || { + started.set(true); + Err("boom".into()) + }); + assert!(started.get()); } #[test] - fn honor_lock_command_with_successful_start_does_not_panic() { - honor_lock_command_with(|| Ok(())); + fn honor_lock_command_with_successful_start_runs_start() { + let started = Cell::new(false); + honor_lock_command_with(false, || { + started.set(true); + Ok(()) + }); + assert!(started.get()); + } + + #[test] + fn honor_lock_command_already_locked_does_not_start() { + let started = Cell::new(false); + honor_lock_command_with(true, || { + started.set(true); + Ok(()) + }); + assert!(!started.get()); } #[test] @@ -236,12 +322,33 @@ mod tests { } #[test] - fn honor_unlock_command_with_failed_loginctl_does_not_panic() { - honor_unlock_command_with(true, || Err("boom".into())); + fn honor_unlock_command_with_failed_loginctl_runs_unlock() { + let called = Cell::new(false); + honor_unlock_command_with(true, || { + called.set(true); + Err("boom".into()) + }); + assert!(called.get()); } #[test] - fn honor_unlock_command_with_successful_loginctl_does_not_panic() { - honor_unlock_command_with(true, || Ok(())); + fn honor_unlock_command_with_successful_loginctl_runs_unlock() { + 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)); } } diff --git a/breadlock/src/config.rs b/breadlock/src/config.rs index cb36ce0..04d70a7 100644 --- a/breadlock/src/config.rs +++ b/breadlock/src/config.rs @@ -9,16 +9,40 @@ pub struct Config { pub appearance: Appearance, pub input: Input, pub animation: Animation, + pub status: Status, +} + +/// System-status line under the clock (D-Bus). Both default on; they are +/// polled on a background thread and degrade silently when D-Bus or the +/// relevant service is unavailable. +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct Status { + /// Show the currently-playing MPRIS track under the clock. + pub now_playing: bool, + /// Show the upower battery percentage under the clock. + pub battery: bool, +} + +impl Default for Status { + fn default() -> Self { + Self { + now_playing: true, + battery: true, + } + } } #[derive(Debug, Clone, Deserialize)] #[serde(default)] 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, /// 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), - /// so holding it is always safe to use as a reveal gesture. + /// of dots. Off by default: plaintext would sit in compositor buffers + /// while held. Tab can never be part of a password (it produces no + /// utf8), so holding it is always safe to use as a reveal gesture. pub reveal_hold: bool, } @@ -26,7 +50,7 @@ impl Default for Input { fn default() -> Self { Self { 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); } + #[test] + fn default_reveal_hold_is_off() { + assert!(!Config::default().input.reveal_hold); + } + #[test] fn default_animation_breathe_is_on() { assert!(Config::default().animation.breathe); } + #[test] + fn status_defaults_on() { + let cfg = Config::default(); + assert!(cfg.status.now_playing); + assert!(cfg.status.battery); + } + + #[test] + fn status_can_be_turned_off() { + let toml = "[status]\nnow_playing = false\nbattery = false\n"; + let cfg: Config = toml::from_str(toml).unwrap(); + assert!(!cfg.status.now_playing); + assert!(!cfg.status.battery); + } + #[test] fn flattened_appearance_parses_alongside_input() { let toml = "[clock]\nformat = \"%H:%M:%S\"\n[input]\nfail_timeout_ms = 1200\n"; diff --git a/breadlock/src/gpu.rs b/breadlock/src/gpu.rs index 596c804..df6c60d 100644 --- a/breadlock/src/gpu.rs +++ b/breadlock/src/gpu.rs @@ -12,7 +12,9 @@ //! //! If EGL initialization fails for any reason (headless, no GPU, compositor //! 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 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_ZOOM: f32 = 1.06; -const EGL_ATTRIBS: [egl::Int; 11] = [ +const EGL_ATTRIBS: [egl::Int; 13] = [ 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, 8, egl::GREEN_SIZE, @@ -101,22 +105,41 @@ pub struct wl_egl_window { extern "C" { 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_destroy(window: *mut wl_egl_window); } -// EGL objects are intentionally not destroyed on the way out: the process -// exits immediately after unlock, and dropping the pbuffer/context while it -// might still be current would be UB — leaving them for the OS is cleaner. -const _: () = (); +/// EGL entry points captured at surface creation so `Drop` can destroy the +/// window/surface without holding a pointer into `GpuRenderer` (which would +/// dangle if AppState is moved). +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 -/// size is unknown before that) and resized on subsequent ones. The process -/// exits right after unlock, so EGL objects are deliberately not destroyed -/// individually. +/// size is unknown before that) and resized on subsequent ones. Dropped on +/// output unplug (`output_destroyed` retains the `LockSurface` out of the +/// vec), so the native window and EGL surface must be destroyed here — +/// process-exit-only was wrong for hotplug. pub struct GpuSurface { egl_window: *mut wl_egl_window, egl_surface: egl::Surface, + display: egl::Display, + egl_fns: Option, width: 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, + chrome_tex_size: (u32, u32), + chrome_pixmap: Option, } 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 { tex: glow::Texture, size: (u32, u32), @@ -143,11 +197,11 @@ pub struct GpuRenderer { config: egl::Config, context: egl::Context, /// 1x1 pbuffer used to make the context current during setup (before any - /// real lock surface exists). Kept alive for the renderer's lifetime — - /// the read is deliberate: dropping it while the context might still be - /// current on it is undefined behavior. + /// real lock surface exists). `None` when we fell back to a surfaceless + /// context. Kept alive for the renderer's lifetime — dropping it while + /// the context might still be current on it is undefined behavior. #[allow(dead_code)] - setup_surface: egl::Surface, + setup_surface: Option, gl: glow::Context, bg_program: glow::Program, chrome_program: glow::Program, @@ -158,10 +212,6 @@ pub struct GpuRenderer { /// the palette color). white_tex: glow::Texture, bg_color: [f32; 4], - chrome_tex: glow::Texture, - chrome_tex_size: (u32, u32), - /// Reused scratch for the chrome compose. - chrome_pixmap: Option, u_screen: [Option; 2], u_uv_scale: [Option; 2], u_uv_offset: [Option; 2], @@ -184,6 +234,7 @@ impl GpuRenderer { // 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) }?; egl.initialize(display).ok()?; + egl.bind_api(egl::OPENGL_ES_API).ok()?; let mut configs = Vec::with_capacity(1); egl.choose_config(display, &EGL_ATTRIBS, &mut configs).ok()?; let config = *configs.first()?; @@ -191,14 +242,23 @@ impl GpuRenderer { .create_context(display, config, None, &[egl::CONTEXT_CLIENT_VERSION, 2, egl::NONE]) .ok()?; // A 1x1 pbuffer is enough to make the context current for setup - // before any real lock surface exists (pbuffers size via - // EGL_WIDTH/EGL_HEIGHT). + // before any real lock surface exists. The chosen config is + // 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 .create_pbuffer_surface(display, config, &[egl::WIDTH, 1, egl::HEIGHT, 1, egl::NONE]) - .ok()?; - if egl - .make_current(display, Some(setup_surface), Some(setup_surface), Some(context)) - .is_err() + .ok(); + let made = match setup_surface { + Some(s) => egl + .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; } @@ -300,16 +360,6 @@ impl GpuRenderer { 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_color = [bg.red(), bg.green(), bg.blue(), 1.0]; @@ -344,9 +394,6 @@ impl GpuRenderer { wallpaper, white_tex, bg_color, - chrome_tex, - chrome_tex_size: (0, 0), - chrome_pixmap: None, u_screen, u_uv_scale, u_uv_offset, @@ -379,40 +426,62 @@ impl GpuRenderer { let egl_surface = unsafe { self.egl .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 { egl_window, egl_surface, + display: self.display, + egl_fns: load_egl_surface_fns(&self.egl), width, height, + chrome_tex: None, + chrome_tex_size: (0, 0), + chrome_pixmap: None, }) } /// Renders one frame for `surface`: wallpaper quad (pan + veil in the /// 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( &mut self, surface: &mut GpuSurface, inputs: &FrameInputs, text: &mut TextRenderer, - ) { + ) -> bool { let (w, h) = (surface.width, surface.height); if w == 0 || h == 0 { - return; + return false; } if self .egl .make_current(self.display, Some(surface.egl_surface), Some(surface.egl_surface), Some(self.context)) .is_err() { - return; + tracing::warn!("eglMakeCurrent failed; skipping GPU frame"); + return false; } let gl = &self.gl; unsafe { gl.viewport(0, 0, w as i32, h as i32) }; self.draw_background(w, h, inputs); - self.draw_chrome(w, h, inputs, text); - let _ = self.egl.swap_buffers(self.display, surface.egl_surface); + self.draw_chrome(surface, inputs, text); + 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) { @@ -498,19 +567,21 @@ impl GpuRenderer { } } - fn draw_chrome(&mut self, w: u32, h: u32, inputs: &FrameInputs, text: &mut TextRenderer) { - let dirty = self + fn draw_chrome(&mut self, surface: &mut GpuSurface, inputs: &FrameInputs, text: &mut TextRenderer) { + let (w, h) = (surface.width, surface.height); + let dirty = surface .chrome_pixmap .as_ref() .map(|p| (p.width(), p.height()) != (w, h)) .unwrap_or(true); 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; }; 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 y0 = rect.y0.max(0.0).floor() as i32; let x1 = (rect.x1.min(w as f32)).ceil() as i32; @@ -518,10 +589,47 @@ impl GpuRenderer { if x1 <= x0 || y1 <= y0 { 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 { - 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( glow::TEXTURE_2D, 0, @@ -531,18 +639,17 @@ impl GpuRenderer { 0, glow::RGBA, 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 rh = (y1 - y0) as usize; let data = pixmap.data(); - let gl = &self.gl; 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 // 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 @@ -554,8 +661,8 @@ impl GpuRenderer { gl.tex_sub_image_2d( glow::TEXTURE_2D, 0, - x0 as i32, - y0 as i32, + x0, + y0, rw as i32, rh as i32, glow::RGBA, @@ -567,9 +674,17 @@ impl GpuRenderer { gl.bind_buffer(glow::ARRAY_BUFFER, Some(self.quad_vbo)); let wf = w 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] = [ - 0.0, 0.0, wf, 0.0, 0.0, hf, // - wf, 0.0, wf, hf, 0.0, hf, + xf0, yf0, xf1, yf0, xf0, yf1, // + xf1, yf0, xf1, yf1, xf0, yf1, ]; 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() { @@ -585,7 +700,7 @@ impl GpuRenderer { gl.uniform_1_i32(Some(loc), 0); } 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.blend_func(glow::ONE, glow::ONE_MINUS_SRC_ALPHA); 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) } +/// 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) -> Option { + unsafe fn load(egl: &egl::DynamicInstance, name: &str) -> Option { + 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 /// into a tightly-strided `rw`-per-row buffer for `glTexSubImage2D`, which /// reads rows contiguously and has no stride concept on GLES2. @@ -822,7 +953,7 @@ mod tests { let h = 720.0; // 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). - 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. 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. @@ -844,6 +975,21 @@ mod tests { 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.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] @@ -867,7 +1013,7 @@ mod tests { } // Take the 2x1 rect at (1, 0). 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[4], 2); // Two rows: the second row must NOT be shifted by (w - rw) — the bug. diff --git a/breadlock/src/input/keyboard.rs b/breadlock/src/input/keyboard.rs index 07d474c..aa037a3 100644 --- a/breadlock/src/input/keyboard.rs +++ b/breadlock/src/input/keyboard.rs @@ -8,7 +8,7 @@ use wayland_client::{Connection, QueueHandle}; use zeroize::Zeroize; use crate::auth; -use crate::state::{AppState, AuthState}; +use crate::state::{AppState, AuthState, PASSWORD_CAP}; impl SeatHandler for AppState { fn seat_state(&mut self) -> &mut SeatState { @@ -25,48 +25,40 @@ impl SeatHandler for AppState { capability: Capability, ) { if capability == Capability::Keyboard && self.keyboard.is_none() { - // Plain `get_keyboard` never populates SCTK's internal repeat - // timer, so `KeyboardHandler::repeat_key` below only ever fires - // for compositors that implement server-side key repeat - // (wl_keyboard >= v10's "repeated" pseudo key-state) themselves — - // Hyprland does not reliably do this. `get_keyboard_with_repeat` - // registers SCTK's own client-side repeat timer driven by the - // compositor's `repeat_info` (delay/rate); if a compositor *does* - // do server-side repeat it advertises `rate = 0`, which this - // timer already treats as disabled, so the two mechanisms can't - // double-fire. - let repeat_qh = qh.clone(); - let loop_handle = self.loop_handle.clone(); - match self.seat_state.get_keyboard_with_repeat( - qh, - &seat, - None, - loop_handle, - Box::new(move |state: &mut AppState, _keyboard, event| { - state.handle_key(&repeat_qh, event); - }), - ) { - Ok(keyboard) => self.keyboard = Some(keyboard), - Err(err) => tracing::error!(%err, "failed to bind keyboard"), - } + self.try_bind_keyboard(qh, &seat); } } fn remove_capability( &mut self, _conn: &Connection, - _qh: &QueueHandle, - _seat: wl_seat::WlSeat, + qh: &QueueHandle, + seat: wl_seat::WlSeat, capability: Capability, ) { - if capability == Capability::Keyboard { - if let Some(keyboard) = self.keyboard.take() { - keyboard.release(); - } + if capability != Capability::Keyboard { + return; } + // 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, _seat: wl_seat::WlSeat) { + fn remove_seat(&mut self, _conn: &Connection, qh: &QueueHandle, 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( &mut self, _conn: &Connection, - _qh: &QueueHandle, + qh: &QueueHandle, _keyboard: &wl_keyboard::WlKeyboard, _surface: &wl_surface::WlSurface, _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( @@ -154,11 +151,75 @@ impl KeyboardHandler for AppState { } impl AppState { + fn try_bind_keyboard(&mut self, qh: &QueueHandle, 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) { + if self.keyboard.is_some() { + return; + } + let seats: Vec = 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, event: KeyEvent) { - // Ignore all input while a PAM check is in flight so a fast second - // Enter can't race the first attempt, and while the unlock fade is - // playing (auth already succeeded; surfaces stay up until it ends). - if self.auth_state == AuthState::Checking || self.unlocking.is_some() { + // Unlock fade: auth already succeeded; surfaces stay up until it ends. + if self.unlocking.is_some() { + return; + } + + // Escape during Checking cancels the wait (generation bump so a + // late PAM result cannot unlock). libpam itself is not aborted. + if self.auth_state == AuthState::Checking { + if 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; } @@ -198,6 +259,7 @@ impl AppState { } Keysym::Escape => { self.password.zeroize(); + self.password_display_len = 0; self.clear_failed_state(); } _ => { @@ -205,14 +267,21 @@ impl AppState { // Return/BackSpace/Escape are handled above by keysym; // this guards against a compositor also sending utf8 for // those (defensive — filters any stray control chars). + let mut grew = false; 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) { - 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; // 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.password_display_len = 0; } } @@ -233,7 +307,14 @@ impl AppState { if self.password.is_empty() { 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.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 // capacity up front so the next password typed doesn't reallocate // (see the `password` field doc in state.rs). The taken buffer is @@ -241,8 +322,61 @@ impl AppState { // check (`auth::spawn_check`/`pam::check`). let password = std::mem::replace( &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); } } diff --git a/breadlock/src/input/mod.rs b/breadlock/src/input/mod.rs index cce11f6..0360497 100644 --- a/breadlock/src/input/mod.rs +++ b/breadlock/src/input/mod.rs @@ -1 +1 @@ -mod keyboard; +pub(crate) mod keyboard; diff --git a/breadlock/src/lock/session.rs b/breadlock/src/lock/session.rs index 3641a49..d49d4eb 100644 --- a/breadlock/src/lock/session.rs +++ b/breadlock/src/lock/session.rs @@ -16,14 +16,27 @@ impl SessionLockHandler for AppState { /// 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 /// 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( &mut self, _conn: &Connection, _qh: &QueueHandle, _session_lock: SessionLock, ) { - tracing::warn!("compositor ended the session lock; exiting"); - self.session_lock = None; + // PAM unlock already took the stored lock; don't unlock/emit again. + 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; } @@ -36,22 +49,29 @@ impl SessionLockHandler for AppState { _serial: u32, ) { let (width, height) = configure.new_size; - if let Some(s) = self + let (buf_w, buf_h) = if let Some(s) = self .surfaces .iter_mut() .find(|s| s.surface.wl_surface() == surface.wl_surface()) { s.width = width; 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; - // resize the EGL window on subsequent ones. + // resize the EGL window on subsequent ones. Size is buffer pixels. if let Some(renderer) = &self.gpu { match &mut s.gpu { - None => s.gpu = renderer.create_surface(surface.wl_surface(), width, height), - Some(gs) => gs.resize(width, height), + None => s.gpu = renderer.create_surface(surface.wl_surface(), buf_w, buf_h), + Some(gs) => gs.resize(buf_w, buf_h), } } - } - self.redraw_surface(qh, &surface, width, height); + (buf_w, buf_h) + } else { + (width, height) + }; + self.redraw_surface(qh, &surface, buf_w, buf_h); } } diff --git a/breadlock/src/lock/surface.rs b/breadlock/src/lock/surface.rs index b1d20ac..0a238b1 100644 --- a/breadlock/src/lock/surface.rs +++ b/breadlock/src/lock/surface.rs @@ -9,10 +9,30 @@ impl CompositorHandler for AppState { fn scale_factor_changed( &mut self, _conn: &Connection, - _qh: &QueueHandle, - _surface: &wl_surface::WlSurface, - _new_factor: i32, + qh: &QueueHandle, + surface: &wl_surface::WlSurface, + 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( @@ -66,6 +86,12 @@ impl OutputHandler for AppState { qh: &QueueHandle, 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 { return; }; @@ -76,7 +102,10 @@ impl OutputHandler for AppState { output, width: 0, height: 0, + scale: 1, gpu: None, + shm_pool: None, + shm_buffer: None, }); } diff --git a/breadlock/src/main.rs b/breadlock/src/main.rs index 1d57862..ae4ab0a 100644 --- a/breadlock/src/main.rs +++ b/breadlock/src/main.rs @@ -7,6 +7,7 @@ mod input; mod lock; mod render; mod state; +mod status; use smithay_client_toolkit::compositor::CompositorState; use smithay_client_toolkit::output::OutputState; @@ -128,18 +129,21 @@ fn run_lock() { None } }; + let _running = bread_events::enter_lock_process(); // Honor bread.command.lock.lock / unlock while this locker is up // (already-locked is bread.lock.lock.done; unlock is loginctl, not // compositor unlock()). Unlocked-path commands need `breadlock listen`. let _commands = bread_events::subscribe_commands(); - let username = std::env::var("USER") - .or_else(|_| std::env::var("LOGNAME")) - .unwrap_or_else(|_| { - tracing::error!("neither $USER nor $LOGNAME is set — refusing to start without a username to authenticate"); - std::process::exit(1); - }); + let username = auth::username_from_process().unwrap_or_else(|| { + tracing::error!( + "could not resolve a username (passwd lookup and $USER/$LOGNAME all failed) — \ + taking the session lock anyway and refusing PAM" + ); + String::new() + }); + let username_missing = username.is_empty(); let config = config::load(); let palette = breadlock_ui::theme::load_palette(); @@ -163,7 +167,10 @@ fn run_lock() { let loop_handle = event_loop.handle(); 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 { Ok(()) => { // 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). tracing::info!("authenticated, fading out"); state.failed_attempts = 0; + state.auth_state = AuthState::Idle; + state.checking_started = None; if state.unlocking.is_none() { state.unlocking = Some(std::time::Instant::now()); } @@ -187,14 +196,16 @@ fn run_lock() { %err, "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.failed_at = Some(std::time::Instant::now()); + state.enter_fail(AuthState::ConfigError); } - auth::AuthError::Authenticate | auth::AuthError::AccountInvalid => { + auth::AuthError::Authenticate => { tracing::warn!(%err, "authentication failed"); state.failed_attempts = state.failed_attempts.saturating_add(1); - state.auth_state = AuthState::Failed; - state.failed_at = Some(std::time::Instant::now()); + state.enter_fail(AuthState::Failed); + } + auth::AuthError::AccountInvalid => { + tracing::warn!(%err, "account locked or expired"); + state.enter_fail(AuthState::AccountInvalid); } } state.schedule_clear_failed(auth_result_qh.clone()); @@ -203,6 +214,17 @@ fn run_lock() { 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 = CompositorState::bind(&globals, &qh).expect("compositor global not advertised"); let output_state = OutputState::new(&globals, &qh); @@ -221,6 +243,7 @@ fn run_lock() { session_lock: None, surfaces: Vec::new(), keyboard: None, + keyboard_seat: None, config, palette, background, @@ -230,16 +253,20 @@ fn run_lock() { // Pre-reserve capacity so ordinary typing doesn't reallocate — a // reallocation leaves the old (unzeroized) backing buffer, with the // 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_tx, + auth_generation: 0, + failed_generation: 0, + checking_started: None, started: std::time::Instant::now(), appear_started: None, unlocking: None, last_keystroke: None, failed_at: None, last_clock_text: String::new(), - clock_anim_started: None, + clock_from: None, status_anim_started: None, last_auth_state: AuthState::Idle, breathe_started: None, @@ -253,6 +280,7 @@ fn run_lock() { reveal_held: false, last_activity: std::time::Instant::now(), failed_attempts: 0, + status_info: status::StatusInfo::default(), exit: false, }; @@ -272,11 +300,18 @@ fn run_lock() { output, width: 0, height: 0, + scale: 1, gpu: None, + shm_pool: None, + shm_buffer: None, }); } app_state.session_lock = Some(session_lock); + if username_missing { + app_state.enter_fail(AuthState::ConfigError); + } + WaylandSource::new(conn, event_queue) .insert(loop_handle.clone()) .expect("failed to register the Wayland source on the event loop"); diff --git a/breadlock/src/render.rs b/breadlock/src/render.rs index 21b0e65..27290c8 100644 --- a/breadlock/src/render.rs +++ b/breadlock/src/render.rs @@ -12,7 +12,7 @@ //! little-endian machines. [`blit_to_shm`] does the swizzle. 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 std::f32::consts::PI; 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 + 1] = (data[i + 1] 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). pub status_t: f32, 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. pub appear_t: f32, /// 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) } +/// 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 — /// 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) { 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 y = APPEAR_SLIDE_PX * (1.0 - appear) - UNLOCK_DRIFT_PX * unlock; (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 /// surface pixels — the GPU path uses it to know which region of the chrome /// 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 x0: f32, pub y0: f32, @@ -283,6 +304,17 @@ pub struct ChromeRect { 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 { fn expand(&mut self, x0: f32, y0: f32, x1: f32, y1: f32) { self.x0 = self.x0.min(x0); @@ -290,6 +322,11 @@ impl ChromeRect { self.x1 = self.x1.max(x1); 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 @@ -302,8 +339,9 @@ pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option /// Composes only the chrome (clock/date/pill/status) into a transparent /// `pixmap`, returning the bounding rect of everything drawn. The background -/// and veil are the GPU's job in the accelerated path; colors are still -/// pre-faded by the veil alpha so the software and GPU paths match. +/// and veil are the GPU's job in the accelerated path; each element applies +/// its appear/unlock alpha once (the veil lives on the wallpaper, not baked +/// into chrome colors). pub fn compose_chrome( pixmap: &mut Pixmap, text: &mut TextRenderer, @@ -319,7 +357,7 @@ pub fn compose_chrome( /// background/veil are skipped (chrome-only) and each drawn element's box is /// recorded. fn compose_impl( - mut pixmap: &mut Pixmap, + pixmap: &mut Pixmap, text: &mut TextRenderer, inputs: &FrameInputs, mut rects: Option<&mut ChromeRect>, @@ -330,27 +368,28 @@ fn compose_impl( .paint(pixmap, inputs.t_secs, inputs.smooth_pan); } - // Overall chrome fade: appear eased in, unlock eased out. The unlock - // `fade` multiplies every element below. The dim veil deepens further - // once the idle auto-dim kicks in — chrome alpha stays at the base veil - // (0..1) while only the background dim scales past it. - let unlock = ease_out_cubic(inputs.unlock_t); + // Overall chrome fade: appear eased in, unlock eased out *after* the + // success flash. Skipping when `base_veil == 0` at appear t=0 flashed + // undimmed wallpaper (and left the GPU chrome rect empty); only skip + // once the unlock has fully finished. + if inputs.unlock_t >= 1.0 { + return; + } + let unlock = ease_out_cubic(unlock_fade_t(inputs.unlock_t)); let fade = 1.0 - unlock; 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); - if base_veil <= 0.0 { - return; - } let (w, h) = (inputs.width as f32, inputs.height as f32); - let surface_color = faded(tiny_skia_color(&inputs.palette.color0), base_veil); - let accent_color = faded(tiny_skia_color(&inputs.palette.color4), base_veil); - let green_color = faded(tiny_skia_color(&inputs.palette.color2), base_veil); - let on_surface = faded( - tiny_skia_color(breadlock_ui::theme::ink_on(&inputs.palette.color0)), - base_veil, - ); - let red_color = faded(tiny_skia_color(&inputs.palette.color1), base_veil); + // Palette colors are *not* pre-multiplied by the veil: each element + // applies its appear/unlock alpha once via `faded(..., elem_alpha)`. + // Pre-fading here and again with `pill_alpha` made the pill/status + // fainter than the clock (which only faded once). + let surface_color = tiny_skia_color(&inputs.palette.color0); + let accent_color = tiny_skia_color(&inputs.palette.color4); + let green_color = tiny_skia_color(&inputs.palette.color2); + 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 // (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() { let old_w = inputs .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); - 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); r.expand((w - cw) / 2.0, clock_y, (w + cw) / 2.0, clock_y + clock_size); } match inputs.clock_old { Some((old, t)) => { let t = t.clamp(0.0, 1.0); - let old_w = text.measure_line(old, inputs.font_family, clock_size); - text.draw_line( - &mut pixmap, + let old_w = text.measure_line_weighted(old, inputs.font_family, clock_size, Weight::BOLD); + text.draw_line_weighted( + pixmap, old, inputs.font_family, clock_size, faded(Color::WHITE, clock_alpha * (1.0 - t)), (w - old_w) / 2.0, clock_y - 6.0 * t, + Weight::BOLD, ); - let new_w = text.measure_line(inputs.clock_text, inputs.font_family, clock_size); - text.draw_line( - &mut pixmap, + let new_w = text.measure_line_weighted( + inputs.clock_text, + inputs.font_family, + clock_size, + Weight::BOLD, + ); + text.draw_line_weighted( + pixmap, inputs.clock_text, inputs.font_family, clock_size, faded(Color::WHITE, clock_alpha * t), (w - new_w) / 2.0, clock_y + 6.0 * (1.0 - t), + Weight::BOLD, ); } None => { - let clock_w = text.measure_line(inputs.clock_text, inputs.font_family, clock_size); - text.draw_line( - &mut pixmap, + let clock_w = text.measure_line_weighted( + inputs.clock_text, + inputs.font_family, + clock_size, + Weight::BOLD, + ); + text.draw_line_weighted( + pixmap, inputs.clock_text, inputs.font_family, clock_size, faded(Color::WHITE, clock_alpha), (w - clock_w) / 2.0, clock_y, + Weight::BOLD, ); } } - // ---- Date line under the clock (hidden when date_text is empty). The - // clock's `origin_y` anchors its *top*, so the date is placed below the - // clock's actual glyph box (exact per font, cached) with a small gap. + // ---- Date line under the clock (hidden when date_text is empty) and + // now-playing / battery under that. Info is *not* nested under the date + // — 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() { - let date_size = (w * 0.016).clamp(DATE_SIZE_MIN, DATE_SIZE_MAX); - 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_y = below_y; 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); } text.draw_line( - &mut pixmap, + pixmap, inputs.date_text, inputs.font_family, date_size, @@ -455,6 +513,37 @@ fn compose_impl( (w - date_w) / 2.0, 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 @@ -493,14 +582,23 @@ fn compose_impl( cx * (1.0 - scale) + shake_x, 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 - // 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() { const PILL_PAD: f32 = 26.0; r.expand( - pill_x - PILL_PAD, + pill_x + shake_x - 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, ); } @@ -545,8 +643,10 @@ fn compose_impl( // Hairline border for depth — dropped on the wrong/success states // (the sketch sets `border-color: transparent` there). if !inputs.failed && inputs.unlock_t == 0.0 { - let mut stroke = tiny_skia::Stroke::default(); - stroke.width = 1.0; + let stroke = tiny_skia::Stroke { + width: 1.0, + ..Default::default() + }; let mut paint = Paint::default(); paint.set_color(faded( Color::WHITE, @@ -558,8 +658,10 @@ fn compose_impl( // Idle breath: a faint accent ring blooms around the pill at the // breath peak (matches the sketch's `breathe` keyframes). if inputs.breathe_t > 0.0 && !inputs.failed && inputs.unlock_t == 0.0 { - let mut stroke = tiny_skia::Stroke::default(); - stroke.width = 1.5; + let stroke = tiny_skia::Stroke { + width: 1.5, + ..Default::default() + }; let mut paint = Paint::default(); paint.set_color(faded(accent_color, BREATHE_RING_ALPHA * inputs.breathe_t * pill_alpha)); pixmap.stroke_path(&path, &paint, &stroke, pill_xf, None); @@ -569,8 +671,10 @@ fn compose_impl( // `FLASH_MS` of the unlock. if inputs.unlock_t > 0.0 && inputs.unlock_t < FLASH_FRAC { let flash_t = inputs.unlock_t / FLASH_FRAC; - let mut stroke = tiny_skia::Stroke::default(); - stroke.width = 2.0 + 16.0 * flash_t; + let stroke = tiny_skia::Stroke { + width: 2.0 + 16.0 * flash_t, + ..Default::default() + }; let mut paint = Paint::default(); paint.set_color(faded(green_color, 0.55 * (1.0 - flash_t) * pill_alpha)); 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. let chip_y = pill_y - chip_h - tokens::SPACE_LG as f32; 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); } 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.anti_alias = true; pixmap.fill_path(&path, &paint, tiny_skia::FillRule::Winding, Transform::identity(), None); - let mut stroke = tiny_skia::Stroke::default(); - stroke.width = 1.0; + let stroke = tiny_skia::Stroke { + width: 1.0, + ..Default::default() + }; let mut paint = Paint::default(); paint.set_color(faded(Color::WHITE, PILL_BORDER_ALPHA * chip_alpha)); 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_w = text.measure_line(&label, inputs.font_family, chip_size); text.draw_line( - &mut pixmap, + pixmap, &label, inputs.font_family, chip_size, @@ -637,13 +743,16 @@ fn compose_impl( .max(1.0) as usize; let shown_dots = inputs.password_len.min(max_dots); 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 // the pill width (truncate with a trailing ellipsis on overflow). // Measured into locals first — `text` is borrowed mutably by // `draw_line`, so all `measure_*` calls must happen up front. let reveal_size = tokens::FONT_SIZE_BASE as f32; - let reveal_rendered = reveal_fit( + let reveal_rendered = ellipsize( text, inputs.password, inputs.font_family, @@ -654,22 +763,23 @@ fn compose_impl( 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_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( - pill_x + tokens::SPACE_LG as f32, + pill_x + shake_x + tokens::SPACE_LG as f32, 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, ); } text.draw_line( - &mut pixmap, + pixmap, &reveal_rendered, inputs.font_family, - reveal_size, + reveal_size * scale, faded(on_surface, pill_alpha), - (w - reveal_w) / 2.0, - reveal_y, + rx, + ry, ); } else if shown_dots > 0 { let start_x = start_x_for(shown_dots, pill_x, pill_w); @@ -702,7 +812,7 @@ fn compose_impl( &path, &paint, tiny_skia::FillRule::Winding, - Transform::identity(), + pill_xf, None, ); } @@ -713,24 +823,26 @@ fn compose_impl( // 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 // 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_size = tokens::FONT_SIZE_BASE as f32; 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_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( - &mut pixmap, + pixmap, hint, inputs.font_family, - hint_size, + hint_size * scale, faded(on_surface, pill_alpha * 0.5), - (w - hint_w) / 2.0, - hint_y, + hx, + 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 - // 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) + (shown_dots - 1) as f32 * DOT_GAP + DOT_R @@ -761,7 +873,7 @@ fn compose_impl( &path, &paint, tiny_skia::FillRule::Winding, - Transform::identity(), + pill_xf, None, ); } @@ -779,11 +891,11 @@ fn compose_impl( 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) + 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); } text.draw_line( - &mut pixmap, + pixmap, status, inputs.font_family, 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 } -/// Truncates a password for the hold-to-reveal view so it fits inside the -/// pill, appending an ellipsis when trimmed. Returns the owned string to -/// render (kept out of `compose` so the borrow of `text` ends before the -/// draw call). -fn reveal_fit( +/// Truncates `password` (or any single-line string) so it fits in `max_w`, +/// appending an ellipsis when trimmed. Shared by hold-to-reveal and the +/// now-playing/battery line. Kept out of `compose` so the borrow of `text` +/// ends before the draw call. +fn ellipsize( text: &mut TextRenderer, password: &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. let expected = (255.0 * (1.0 - 0.205)) as u8; 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] @@ -873,6 +988,7 @@ mod tests { assert_eq!(p.pixels(), before.as_slice()); } + #[allow(clippy::too_many_arguments)] fn inputs<'a>( bg: &'a Background, palette: &'a breadlock_ui::theme::Palette, @@ -908,6 +1024,7 @@ mod tests { breathe_t: 0.0, status_t: 1.0, status_text: None, + info_text: "", appear_t, unlock_t, smooth_pan: false, @@ -951,6 +1068,7 @@ mod tests { breathe_t: 0.0, status_t: 1.0, status_text: None, + info_text: "", appear_t: 1.0, unlock_t: 0.0, smooth_pan: false, @@ -966,13 +1084,26 @@ mod tests { let mut text = TextRenderer::new(); // 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); - assert!(compose(&mut text, &failed).is_some()); - // Success flash phase of the unlock. + let failed_px = compose(&mut text, &failed).expect("failed compose"); + 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); - 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. 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] @@ -999,10 +1130,10 @@ mod tests { fn reveal_fit_truncates_long_passwords() { let mut text = TextRenderer::new(); // 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. 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.len() < long.len()); // 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}"); } + #[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] fn compose_chrome_rect_contains_clock_and_pill() { 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"); // Both are horizontally centered. 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] - fn compose_chrome_rect_empty_when_veil_hidden() { + fn compose_chrome_rect_empty_when_unlock_finished() { 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(); - // appear_t = 0 → veil_alpha 0 → nothing drawn, rect stays default. - let inputs = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 0.0, 0.0); + // unlock_t = 1 → chrome is gone; appear_t = 0 still draws (invisible) + // 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); - assert!(rect.x1 <= rect.x0 && rect.y1 <= rect.y0, "hidden chrome must yield an empty rect"); - // And the pixmap is fully transparent. + assert!(rect.is_empty(), "finished unlock must yield an empty rect, got {rect:?}"); assert!( 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] fn compose_chrome_status_text_expands_the_rect_downward() { let bg = Background::Color(Color::BLACK); diff --git a/breadlock/src/state.rs b/breadlock/src/state.rs index c6ade48..7a67bfb 100644 --- a/breadlock/src/state.rs +++ b/breadlock/src/state.rs @@ -7,16 +7,21 @@ use smithay_client_toolkit::registry::{ProvidesRegistryState, RegistryState}; use smithay_client_toolkit::registry_handlers; use smithay_client_toolkit::seat::SeatState; 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 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 crate::auth::AuthResult; +use crate::auth::AuthOutcome; use crate::background::Background; use crate::config::Config; 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 /// to (0x0 until the first configure arrives). `output` is kept so /// `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 width: u32, pub height: u32, + /// `wl_surface` buffer scale. 1 until `scale_factor_changed`. Always >= 1. + pub scale: i32, /// EGL-backed renderer for this surface (created on first `configure`); /// `None` when the GPU path is unavailable, in which case the software /// wl_shm path is used. pub gpu: Option, + /// Reused shm pool + current buffer (software path). Not recreated every + /// frame; SlotPool waits for compositor release before reuse. + pub shm_pool: Option, + pub shm_buffer: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AuthState { Idle, - /// A PAM check is running on its own thread; input is ignored until it - /// resolves so a second Enter can't race the first attempt. + /// A PAM check is running on its own thread; input other than Escape is + /// 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, - /// The password (or account state) was rejected by PAM — an ordinary - /// wrong-password/locked-account outcome the user can retry. + /// The password was rejected by PAM — an ordinary wrong-password + /// outcome the user can retry. Input is not blocked. Failed, + /// PAM `acct_mgmt` rejected the account (locked, expired, etc.). + AccountInvalid, /// PAM itself failed to initialize (e.g. `/etc/pam.d/breadlock` is - /// missing or unreadable) — this is a config/deployment problem, not - /// something the user's password can fix. Rendered with a distinct - /// message so a broken install doesn't look like an endless string of - /// typos with no way to discover the real cause. See `main.rs`'s - /// auth-result callback, which is the only place this is set. + /// missing or unreadable), or the process username could not be + /// resolved — a config/deployment problem, not something the user's + /// password can fix. Rendered with a distinct message so a broken + /// install doesn't look like an endless string of typos. ConfigError, } @@ -62,6 +76,9 @@ pub struct AppState { pub session_lock: Option, pub surfaces: Vec, pub keyboard: Option, + /// Seat that owns [`Self::keyboard`]. `remove_capability` only releases + /// the keyboard if that seat lost Keyboard. + pub keyboard_seat: Option, pub config: Config, pub palette: breadlock_ui::theme::Palette, @@ -79,8 +96,19 @@ pub struct AppState { /// explicit zeroing — see `input/keyboard.rs` — since `Zeroizing` only /// hooks `Drop`, not in-place mutation. pub password: zeroize::Zeroizing, + /// 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_tx: Sender, + pub auth_tx: Sender, + /// 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, /// Monotonic clock reference — drives the idle caret blink cadence. pub started: Instant, @@ -101,8 +129,9 @@ pub struct AppState { /// Clock text drawn last frame; a change starts a minute-rollover /// crossfade instead of a hard text swap. pub last_clock_text: String, - /// When the current clock crossfade started. - pub clock_anim_started: Option, + /// Outgoing clock string + when its crossfade started. Kept until the + /// fade completes so later frames still pass the previous string. + pub clock_from: Option<(String, Instant)>, /// When the current status line appeared ("Checking…" / "Wrong password") — /// drives its slide-in. Reset whenever `auth_state` changes (see /// `last_auth_state`). @@ -135,6 +164,9 @@ pub struct AppState { /// Consecutive failed password attempts this session — drives the /// "N failed attempts" status line. Reset on a successful auth. pub failed_attempts: u32, + /// Latest D-Bus snapshot (now-playing / battery) from the status poller. + /// Empty fields render nothing; replaced wholesale on each poll. + pub status_info: crate::status::StatusInfo, pub exit: bool, } @@ -188,7 +220,10 @@ impl AppState { }; // While a PAM check runs, the status dots tick to signal progress. 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 => { // Repeat failures get a counter so the user can tell the // locker apart from a stuck/corrupt one ("Wrong password" @@ -200,12 +235,26 @@ impl AppState { "Wrong password".to_string() }) } + AuthState::AccountInvalid => Some("Account locked or expired".to_string()), AuthState::ConfigError => Some( "PAM config error — check logs (breadlock service not set up correctly)" .to_string(), ), AuthState::Idle => None, }; + // D-Bus status line under the clock: now-playing and/or battery, + // joined with a dot separator. Fades in with the appear animation + // (render.rs keys `info_text` off `appear_t`, so no per-frame state + // is needed here). + let mut info_parts: Vec<&str> = Vec::new(); + if self.config.status.now_playing && !self.status_info.now_playing.is_empty() { + info_parts.push(&self.status_info.now_playing); + } + if self.config.status.battery && !self.status_info.battery.is_empty() { + info_parts.push(&self.status_info.battery); + } + let info_text = info_parts.join(" · "); + // Idle auto-dim: ramp 0..1 over IDLE_DIM_RAMP_MS once the configured // idle threshold elapses with no keystrokes. 0 when disabled. let idle_dim = if self.config.animation.idle_dim_after_secs > 0 { @@ -224,17 +273,26 @@ impl AppState { .map(|t| render::unit_progress(t, render::STATUS_SLIDE_MS)) .unwrap_or(1.0); - // Minute rollover: keep the previous clock text for a short crossfade. - let clock_old = if !self.last_clock_text.is_empty() && clock_text != self.last_clock_text { - self.clock_anim_started = Some(now); - Some((self.last_clock_text.clone(), 0.0)) - } else { - self.clock_anim_started - .map(|t| render::unit_progress(t, render::CLOCK_CROSSFADE_MS)) - .filter(|t| *t < 1.0) - .map(|t| (self.last_clock_text.clone(), t)) - }; + // Minute rollover: keep the previous clock text in `clock_from` + // until the crossfade completes. Do not overwrite the outgoing string. + if let Some((_, started)) = self.clock_from { + if render::unit_progress(started, render::CLOCK_CROSSFADE_MS) >= 1.0 { + self.clock_from = None; + } + } + if self.clock_from.is_none() + && !self.last_clock_text.is_empty() + && clock_text != self.last_clock_text + { + self.clock_from = Some((self.last_clock_text.clone(), now)); + } self.last_clock_text = clock_text.clone(); + let clock_old = self.clock_from.as_ref().map(|(from, started)| { + ( + from.as_str(), + render::unit_progress(*started, render::CLOCK_CROSSFADE_MS), + ) + }); let appear_t = self .appear_started @@ -253,6 +311,12 @@ impl AppState { .map(|t| render::unit_progress(t, render::DOT_POP_MS)) .unwrap_or(1.0); + let password_len = if self.password.is_empty() { + self.password_display_len + } else { + self.password.chars().count() + }; + let output_palette = self.palette_for_surface(surface); let inputs = render::FrameInputs { width, @@ -262,14 +326,17 @@ impl AppState { font_family: &self.config.appearance.font.family, clock_text: &clock_text, date_text: &date_text, - clock_old: clock_old.as_ref().map(|(s, t)| (s.as_str(), *t)), - password_len: self.password.len(), + clock_old, + password_len, password: &self.password, reveal: self.reveal_held, caps_lock: self.caps_lock, layout_index: self.layout_index, idle_dim, - failed: matches!(self.auth_state, AuthState::Failed | AuthState::ConfigError), + failed: matches!( + self.auth_state, + AuthState::Failed | AuthState::AccountInvalid | AuthState::ConfigError + ), failed_t, dot_pop_t, keystroke_age: self.last_keystroke.map(|t| t.elapsed().as_secs_f32()), @@ -277,6 +344,7 @@ impl AppState { breathe_t, status_t, status_text: status_text.as_deref(), + info_text: &info_text, appear_t, unlock_t, smooth_pan: !self.fast_anim_in_progress(), @@ -286,58 +354,140 @@ impl AppState { // shader) and the software-composed chrome on top. Disjoint-field // borrows of `self` make `gpu` + `surfaces` + `text_renderer` // simultaneously mutable. - if self.gpu.is_some() + let wants_gpu = self.gpu.is_some() && self .surfaces .iter() - .any(|s| s.surface.wl_surface() == surface.wl_surface() && s.gpu.is_some()) - { - let renderer = self.gpu.as_mut().expect("checked above"); - let lock_surface = self + .any(|s| s.surface.wl_surface() == surface.wl_surface() && s.gpu.is_some()); + if wants_gpu { + let Some(renderer) = self.gpu.as_mut() else { + return; + }; + let Some(lock_surface) = self .surfaces .iter_mut() .find(|s| s.surface.wl_surface() == surface.wl_surface()) - .expect("surface exists"); - let gpu_surface = lock_surface.gpu.as_mut().expect("checked above"); - renderer.render_frame(gpu_surface, &inputs, &mut self.text_renderer); - self.arm_anim_if_needed(qh); - return; + else { + return; + }; + let Some(gpu_surface) = lock_surface.gpu.as_mut() else { + return; + }; + if renderer.render_frame(gpu_surface, &inputs, &mut self.text_renderer) { + self.arm_anim_if_needed(qh); + return; + } + tracing::warn!("GPU frame failed — dropping EGL window and falling back to software"); + } + + // An EGL window on this wl_surface makes a later shm attach illegal; + // Drop of GpuSurface destroys the native window first. + if wants_gpu { + if let Some(s) = self + .surfaces + .iter_mut() + .find(|s| s.surface.wl_surface() == surface.wl_surface()) + { + s.gpu = None; + } } let Some(pixmap) = render::compose(&mut self.text_renderer, &inputs) else { return; }; - let stride = width as usize * 4; - let pool = - smithay_client_toolkit::shm::raw::RawPool::new(stride * height as usize, &self.shm); - let mut pool = match pool { - Ok(pool) => pool, - Err(err) => { - tracing::error!(%err, "failed to allocate shm pool for lock surface redraw"); - return; - } + self.present_shm(surface, width, height, &pixmap); + self.arm_anim_if_needed(qh); + } + + fn present_shm( + &mut self, + surface: &SessionLockSurface, + 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( - 0, - width as i32, - height as i32, - stride as i32, - wl_shm::Format::Argb8888, - (), - qh, - ); + let idx = self + .surfaces + .iter() + .position(|s| s.surface.wl_surface() == surface.wl_surface()); + let Some(idx) = idx else { + return; + }; - 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 .wl_surface() .damage_buffer(0, 0, width as i32, height as i32); surface.wl_surface().commit(); - buffer.destroy(); - - self.arm_anim_if_needed(qh); } fn palette_for_surface(&self, surface: &SessionLockSurface) -> breadlock_ui::theme::Palette { @@ -356,7 +506,14 @@ impl AppState { let surfaces: Vec<(SessionLockSurface, u32, u32)> = self .surfaces .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(); for (surface, width, height) in surfaces { self.redraw_surface(qh, &surface, width, height); @@ -389,8 +546,9 @@ impl AppState { } fn clock_fade_in_progress(&self) -> bool { - self.clock_anim_started - .map(|t| t.elapsed() < Duration::from_millis(render::CLOCK_CROSSFADE_MS)) + self.clock_from + .as_ref() + .map(|(_, t)| t.elapsed() < Duration::from_millis(render::CLOCK_CROSSFADE_MS)) .unwrap_or(false) } @@ -417,10 +575,33 @@ impl AppState { .unwrap_or(false) } + fn idle_dim_in_progress(&self) -> bool { + if self.config.animation.idle_dim_after_secs == 0 { + return false; + } + let idle_s = self.last_activity.elapsed().as_secs_f64(); + let threshold = self.config.animation.idle_dim_after_secs as f64; + let ramp_s = render::IDLE_DIM_RAMP_MS as f64 / 1000.0; + idle_s > threshold && idle_s < threshold + ramp_s + } + + fn caret_blink_in_progress(&self) -> bool { + if self.unlocking.is_some() { + return false; + } + let len = if self.password.is_empty() { + self.password_display_len + } else { + self.password.chars().count() + }; + len > 0 + } + /// Any effect still running that needs the animation timer: the fast ones /// (entrance, unlock flash+fade, shake, dot pop, clock rollover, status /// slide, a live PAM check) plus the slow ones (idle breath, Ken Burns - /// pan) 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 { self.unlocking.is_some() || self.appear_in_progress() @@ -432,6 +613,8 @@ impl AppState { || self.breathe_due() || self.auth_state == AuthState::Checking || self.background.ken_burns() + || self.idle_dim_in_progress() + || self.caret_blink_in_progress() } /// Keep requesting frames while any effect is running. @@ -460,8 +643,9 @@ impl AppState { } /// A 60 fps animation is in flight (everything except the slow idle - /// effects: idle breath, Ken Burns pan). Drives both the timer cadence - /// and whether background frames get sub-pixel panning. + /// effects: idle breath, Ken Burns pan, idle dim, caret blink). Drives + /// both the timer cadence and whether background frames get sub-pixel + /// panning. fn fast_anim_in_progress(&self) -> bool { self.appear_in_progress() || self.unlock_in_progress() @@ -478,8 +662,9 @@ impl AppState { self.anim_timer_armed = false; TimeoutAction::Drop } else if self.anim_in_progress() { - // Slow effects (idle breath, Ken Burns) don't need 60fps — halve - // the redraw cost for them. Everything else stays at ~60Hz. + // Slow effects (idle breath, Ken Burns, dim, caret) don't need + // 60fps — halve the redraw cost for them. Everything else stays + // at ~60Hz. let fast = self.fast_anim_in_progress(); TimeoutAction::ToDuration(Duration::from_millis(if fast { render::ANIM_FRAME_MS @@ -509,28 +694,39 @@ impl AppState { self.exit = true; } - /// After a failed attempt, clears the "wrong password" state (and - /// re-enables the red pill) once `input.fail_timeout_ms` has elapsed — - /// unless the user already cleared it themselves by typing again. + /// After a failed attempt, clears the red UI once `input.fail_timeout_ms` + /// has elapsed — unless a newer fail (or the user typing) has moved the + /// generation. Input is not blocked during Failed. pub fn schedule_clear_failed(&self, qh: QueueHandle) { let timeout = Duration::from_millis(self.config.input.fail_timeout_ms); + let gen = self.failed_generation; let _ = self.loop_handle .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.failed_at = None; + state.password_display_len = 0; state.redraw_all(&qh); } TimeoutAction::Drop }); } + + /// Record a Failed / AccountInvalid / ConfigError and bump the + /// generation so an older fail-clear timer cannot wipe this one. + pub fn enter_fail(&mut self, next: AuthState) { + self.auth_state = next; + self.failed_at = Some(Instant::now()); + self.checking_started = None; + self.failed_generation = self.failed_generation.wrapping_add(1); + } } /// The animated ellipsis for the "Checking" status while a PAM check runs: -/// cycles "", ".", "..", "…" every ~500ms (driven by the monotonic clock). -fn checking_dots(now: Instant) -> &'static str { - match (now.elapsed().as_secs_f32() * 2.0) as usize % 4 { +/// cycles "", ".", "..", "…" every ~500ms (driven by time since `started`). +fn checking_dots(started: Instant) -> &'static str { + match (started.elapsed().as_secs_f32() * 2.0) as usize % 4 { 0 => "", 1 => ".", 2 => "..", @@ -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 { fn shm_state(&mut self) -> &mut Shm { &mut self.shm @@ -550,3 +756,30 @@ impl ProvidesRegistryState for AppState { } 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)); + } +} diff --git a/breadlock/src/status.rs b/breadlock/src/status.rs new file mode 100644 index 0000000..84bba49 --- /dev/null +++ b/breadlock/src/status.rs @@ -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( + loop_handle: &LoopHandle<'static, Data>, + mut on_update: impl FnMut(&mut Data, StatusInfo) + 'static, +) -> Sender { + 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, now_playing: bool, battery: bool) { + if !now_playing && !battery { + return; + } + std::thread::spawn(move || { + let mut session: Option = None; + let mut system: Option = 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, + system: &mut Option, + 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) -> 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 { + let names = conn + .call_method( + Some("org.freedesktop.DBus"), + "/org/freedesktop/DBus", + Some("org.freedesktop.DBus"), + "ListNames", + &(), + ) + .and_then(|reply| reply.body().deserialize::>()) + .map_err(|_| ())?; + + let mut paused: Option = 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, Option)> { + let props = conn + .call_method( + Some(name), + "/org/mpris/MediaPlayer2", + Some("org.freedesktop.DBus.Properties"), + "GetAll", + &("org.mpris.MediaPlayer2.Player",), + ) + .ok()?; + let dict: HashMap = 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::().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::>() + .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::>().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) -> 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 { + let path = conn + .call_method( + Some("org.freedesktop.UPower"), + "/org/freedesktop/UPower", + Some("org.freedesktop.UPower"), + "GetDisplayDevice", + &(), + ) + .and_then(|reply| reply.body().deserialize::()) + .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::>() + }) + .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::().ok()) + .unwrap_or(false); + if let Some(kind) = props.get("Type").and_then(|v| v.downcast_ref::().ok()) { + if kind != UPOWER_TYPE_BATTERY { + return Ok(String::new()); + } + } + let Some(pct) = props + .get("Percentage") + .and_then(|v| v.downcast_ref::().ok()) + else { + return Ok(String::new()); + }; + let state = props + .get("State") + .and_then(|v| v.downcast_ref::().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); + } +}