commit 9f97f2c98971b83316d39ec75b85b0d2995ebc68 Author: Breadway Date: Fri Jul 3 13:37:31 2026 +0800 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3bd9af --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +/target/ + +# Editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS artifacts +.DS_Store +Thumbs.db + +# Environment / secrets +.env +.env.* +*.pem +*.key + +# Claude Code session data +.claude/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..b933889 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2254 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bread-theme" +version = "0.2.3" +source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.9#10f62fb1a62fc5ca4eab90ae5e1bfc8cd9bf9fc9" +dependencies = [ + "dirs", + "gtk4", + "serde", + "serde_json", +] + +[[package]] +name = "breadgreet" +version = "0.1.0" +dependencies = [ + "bread-theme", + "breadlock-ui", + "chrono", + "greetd_ipc", + "gtk4", + "relm4", + "serde", + "thiserror 2.0.18", + "tokio", + "toml 0.8.23", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "breadlock" +version = "0.1.0" +dependencies = [ + "breadlock-ui", + "chrono", + "pam-client2", + "serde", + "smithay-client-toolkit", + "thiserror 2.0.18", + "tiny-skia", + "toml 0.8.23", + "tracing", + "tracing-subscriber", + "wayland-client", +] + +[[package]] +name = "breadlock-ui" +version = "0.1.0" +dependencies = [ + "bread-theme", + "chrono", + "cosmic-text", + "serde", + "tiny-skia", + "toml 0.8.23", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cairo-rs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95" +dependencies = [ + "bitflags", + "cairo-sys-rs", + "glib", + "libc", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags", + "polling", + "rustix", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop", + "rustix", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-expr" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cosmic-text" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da46a9d5a8905cc538a4a5bceb6a4510de7a51049c5588c0114efce102bcbbe8" +dependencies = [ + "bitflags", + "fontdb", + "log", + "rangemap", + "rustc-hash", + "rustybuzz", + "self_cell", + "smol_str", + "swash", + "sys-locale", + "ttf-parser 0.21.1", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "fastrand", + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0299020c3ef3f60f526a4f64ab4a3d4ce116b1acbf24cdd22da0068e5d81dc3" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser 0.20.0", +] + +[[package]] +name = "fragile" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8878864ba14bb86e818a412bfd6f18f9eabd4ec0f008a28e8f7eb61db532fcf9" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f420376dbee041b2db374ce4573892a36222bb3f6c0c43e24f0d67eae9b646" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f31b37b1fc4b48b54f6b91b7ef04c18e00b4585d98359dd7b998774bbd91fb" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk4" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81e2a6c6ecba2aab60633a98df1868b03fa0bfdce8105edc27c1bccf71f0e39" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d8f608d8d7d229975c4d0d026f5d3071598c4ddab3c5262b0a31840fec78d13" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "gio" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b3e1f669909c326b9413bde5a742097b8c90a7d78f45326db13668984769ded" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353fdc7da7cd16da916104b1e0e4e7de380ec9c8aaa20d4d742d66310ab4b0d5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys 0.61.2", +] + +[[package]] +name = "glib" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddbcf514bd1881fc1b960e4e52b4e82873f4da3bceddbd58d42827b508888100" +dependencies = [ + "bitflags", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-macros" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "506d23499707c7142898429757e8d9a3871d965239a2cb66dfa05052be6d6f19" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "glib-sys" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "graphene-rs" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb856b9c558971c3f13ab692358926da710b046932a4e087aedcc35b040d7dff" +dependencies = [ + "glib", + "graphene-sys", +] + +[[package]] +name = "graphene-sys" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "greetd_ipc" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "700d2e5bfc91a042d1d531b46de245085c6a179debedf149aa97c7ca71492078" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "gsk4" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b867be1c5f14dcb8f552c0eff6e9a9b1da5f8b43943e8efc3a63c889d84952ff" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b7c7eb2e681ee896646cfb8872b431f24d09f53ba9283289d9b10caa6707088" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk4" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98a0a0466484f64b07b5b8184d43fa46be78eb0b8e04ae4e179af31d770b76d9" +dependencies = [ + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac7179400a36a04de039c24206bb841c5596992b907b43b23ee8d5bdc40d00e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "gtk4-sys" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f954786af0b1984425c4446b77f5ff6594346181316be3f850caab1c6f01" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pam-client2" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "407daa00f98b05147dbbaa6d2f6bce574e76794fa4952a5b38764681a23dde5f" +dependencies = [ + "bitflags", + "libc", + "pam-sys2", + "rustversion", +] + +[[package]] +name = "pam-sys2" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e07ea89c210813e1a48fc32cb358ba693aae8ca70163461e22d11f1884bf1d" +dependencies = [ + "libc", +] + +[[package]] +name = "pango" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d800d8d0de2ad5d0fb046f5344dbaba14a003cf3dd27cc21d85893d35ea316c" +dependencies = [ + "gio", + "glib", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd111a20ca90fedf03e09c59783c679c00900f1d8491cca5399f5e33609d5d6" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + +[[package]] +name = "read-fonts" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "font-types", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "relm4" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6420f090f0545e9ec9656469d139a4e1b66ff9c30b808fe2247892724f71a198" +dependencies = [ + "flume", + "fragile", + "futures", + "gtk4", + "once_cell", + "relm4-css", + "relm4-macros", + "tokio", + "tracing", +] + +[[package]] +name = "relm4-css" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3b81d263f784b103c815afa29124486b59741eca069ce7a5999efb14f13c368" + +[[package]] +name = "relm4-macros" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c9dbf50a60c82375e66b61d522c936b187a11b25c0a42e91c516326ad24a4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rustybuzz" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c" +dependencies = [ + "bitflags", + "bytemuck", + "libm", + "smallvec", + "ttf-parser 0.21.1", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "skrifa" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags", + "bytemuck", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "pkg-config", + "rustix", + "thiserror 2.0.18", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkbcommon", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "swash" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0811b01ca2c4e8718760713911feaf4675c24f94e50530a015ec646cfb622f7c" +dependencies = [ + "skrifa", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml 1.1.2+spec-1.1.0", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "ttf-parser" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" + +[[package]] +name = "ttf-parser" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cb788ffebc92c5948d0e997106233eeb1d8b9512f93f41651f52b6c5f5af86" + +[[package]] +name = "unicode-ccc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df77b101bcc4ea3d78dafc5ad7e4f58ceffe0b2b16bf446aeb50b6cb4157656" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +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", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[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_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[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_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xkbcommon" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9" +dependencies = [ + "libc", + "memmap2", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9c978fa --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,18 @@ +[workspace] +members = ["breadlock-ui", "breadlock", "breadgreet"] +resolver = "2" + +[workspace.dependencies] +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.9" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "net", "sync", "signal"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +thiserror = "2" + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = "symbols" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..373e4ee --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Breadway + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ef28da --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# breadlock + +Session locker and graphical [greetd](https://git.sr.ht/~kennylevinsen/greetd) greeter for [Hyprland](https://hyprland.org/) on Wayland — the bread-ecosystem replacement for `hyprlock` and the TUI greeter (`tuigreet`) BOS currently ships. + +Two binaries, one workspace: + +- **`breadlock`** — locks the *already running* Hyprland session via `ext-session-lock-v1`. Drop-in for `hyprlock`. +- **`breadgreet`** — a graphical greeter that speaks `greetd`'s own IPC protocol (the same architecture as `gtkgreet`/`regreet`). `greetd` keeps owning PAM auth, VT switching, and session launching; `breadgreet` only draws the login UI and relays the conversation. This is a deliberate choice over reimplementing a display manager from scratch — `greetd` is already installed and battle-tested. + +Both use [`bread-theme`](https://github.com/Breadway/bread-ecosystem) for palette loading, matching the rest of the bread* ecosystem (breadbar, breadbox, bos-settings). + +## Architecture + +``` +breadlock/ +├── breadlock-ui/ shared: bread-theme wrapper, TOML config, .desktop parsing, +│ software-rendering primitives (tiny-skia + cosmic-text, +│ behind the "paint" feature — only breadlock needs them) +├── breadlock/ the locker (SCTK + PAM) +└── breadgreet/ the greeter (GTK4 + relm4 + greetd_ipc) +``` + +### breadlock + +- **Protocol**: `ext-session-lock-v1` via [`smithay-client-toolkit`](https://docs.rs/smithay-client-toolkit) — GTK has no session-lock support, so this is a raw Wayland client, not a layer-shell surface like breadbar. +- **Rendering**: fully software — `tiny-skia` composites each frame (background, rounded password pill, clock, status line) into a `wl_shm` buffer; `cosmic-text` shapes and rasterizes text (loads "Varela Round" by family name). No EGL/GL. +- **Background**: a solid palette color or a static PNG (cover-fit). Live blur-of-desktop (hyprlock-style) is a **v2 follow-up** — it needs a `wlr-screencopy` capture (`libwayshot` is the right crate when this gets picked up); `background.blur = true` is accepted today but just logs a warning. +- **Auth**: [`pam-client2`](https://crates.io/crates/pam-client2) against the `breadlock` PAM service (`packaging/pam.d/breadlock`, installed to `/etc/pam.d/breadlock` by the package). Runs on its own OS thread — libpam's conversation callback is blocking FFI — and reports back through a `calloop::channel` registered on the render loop. + +### breadgreet + +- **Protocol**: [`greetd_ipc`](https://crates.io/crates/greetd_ipc) (greetd's own crate) over the Unix socket at `$GREETD_SOCK`: `CreateSession` → answer each `AuthMessage` via `PostAuthMessageResponse` → `StartSession` hands the resolved session command to `greetd`, which execs it and owns the VT switch away. +- **UI**: GTK4 + [relm4](https://relm4.org/), matching breadbar's stack — **without** `gtk4-layer-shell`. `greetd` hosts the greeter under a single-client kiosk compositor (`cage -s`), which already fullscreens its one client, so layer-shell's multi-surface/anchor semantics don't apply. Confirmed against ReGreet's real dependency list, which has no layer-shell dependency either. +- **Sessions**: scans `/usr/share/wayland-sessions` and `/usr/share/xsessions` for `.desktop` entries and auto-selects the configured default (or the only one found). BOS ships one session today, so there's no picker UI in v1 — a natural v2 addition if that changes. + +## Config + +Copy [`breadlock.example.toml`](breadlock.example.toml) to `~/.config/breadlock/breadlock.toml` and [`breadgreet.example.toml`](breadgreet.example.toml) to `/etc/greetd/breadgreet.toml` (or `~/.config/breadgreet/breadgreet.toml` for local testing under a normal session — `breadgreet` checks the system path first since it typically runs as the dedicated `greeter` user). Every field is optional; both binaries run with sensible defaults and no config at all. + +## Building + +```sh +cargo build --release --bin breadlock --bin breadgreet +cargo test --workspace +``` + +Requires GTK4 (≥ 4.12), `libxkbcommon`, and PAM development headers. On Arch: + +```sh +sudo pacman -S gtk4 wayland libxkbcommon pam rust cargo +``` + +`breadlock-auth-check` is a third, dev-only binary in the `breadlock` package (see Verification below) — not installed by the package, build it explicitly with `cargo build --bin breadlock-auth-check` if you need it. + +## Packaging + +`packaging/arch/PKGBUILD` builds and installs both binaries plus `/etc/pam.d/breadlock`. `bakery.toml` is the bread-ecosystem package index entry. + +**Not included, by design**: this repo does not touch `/etc/greetd/config.toml`, install a lock keybind, or wire up `hypridle`. Once packaged, wiring BOS to actually use these binaries means: + +```toml +# /etc/greetd/config.toml — replace the current tuigreet line +[default_session] +command = "cage -s -- breadgreet" +``` + +``` +# hyprland.conf +bind = SUPER, L, exec, breadlock +``` + +That's a separate, later BOS task — deliberately kept out of this change so the existing `tuigreet` login path stays untouched and available as a fallback while these binaries are tested. + +## Verification (why this is safe to test without a lockout risk) + +1. **PAM logic in isolation first**: `cargo run --bin breadlock-auth-check` exercises the exact PAM flow `breadlock` uses, against a typed password, with **no Wayland surface at all**. A bad `/etc/pam.d/breadlock` just prints an error here — it can never lock a session. +2. **Locker rendering/lock lifecycle nested, never against the live session**: run `breadlock` inside a nested Hyprland instance or under `cage -- breadlock`. `ext-session-lock-v1` only ever affects the compositor instance the client is connected to (scoped to `$WAYLAND_DISPLAY`), so a nested lock can never lock the real outer session. Verify the full type-password → PAM check → unlock cycle there, including the wrong-password path, before ever binding a real keybind. +3. **If testing against a live session**: keep a second TTY or SSH session open the whole time. Killing the `breadlock` process is **not** a safe unlock path — per the protocol, an abnormally-terminated lock client is expected to leave the compositor still locked. The real recovery path is "kill it, then use the second session to restart Hyprland or switch VT." +4. **breadgreet**: `cargo test -p breadgreet` runs the `greetd_ipc` framing/state-machine tests against a mock Unix-socket server — no real `greetd` or PAM involved. Manual testing against a real `greetd` should happen on a disposable VT, leaving the existing `tuigreet` config on VT1 untouched as a fallback. + +## License + +MIT diff --git a/bakery.toml b/bakery.toml new file mode 100644 index 0000000..4674b71 --- /dev/null +++ b/bakery.toml @@ -0,0 +1,15 @@ +name = "breadlock" +description = "Session locker and greetd greeter for Hyprland / Wayland" +binaries = ["breadlock", "breadgreet"] +system_deps = ["pam", "wayland", "libxkbcommon", "gtk4"] +optional_system_deps = ["cage", "hyprland"] +bread_deps = [] + +[config] +dir = "~/.config/breadlock" +example = "breadlock.example.toml" + +[install] +post_install = [ + "echo 'breadlock installed. /etc/pam.d/breadlock is installed by the package; wiring greetd (cage -s -- breadgreet) and a lock keybind/hypridle is a separate manual step.'", +] diff --git a/breadgreet.example.toml b/breadgreet.example.toml new file mode 100644 index 0000000..c5f2093 --- /dev/null +++ b/breadgreet.example.toml @@ -0,0 +1,26 @@ +# breadgreet commonly runs as the dedicated `greeter` system user (per +# greetd's own convention), so it checks /etc/greetd/breadgreet.toml first; +# copy this there for a real install. For local dev/testing under a normal +# user session it falls back to ~/.config/breadgreet/breadgreet.toml. +# +# Every field is optional and defaults to the value shown here. + +[background] +mode = "color" +path = "" +blur = false + +[clock] +format = "%H:%M" + +[font] +family = "Varela Round" + +[sessions] +# Directories scanned for .desktop session entries, in order. +wayland_dirs = ["/usr/share/wayland-sessions"] +xsessions_dirs = ["/usr/share/xsessions"] +# .desktop file stem (without extension) to auto-select. Falls back to the +# first entry found if this isn't present. v1 has no session picker UI — +# BOS only ships one session (Hyprland) today. +default = "hyprland" diff --git a/breadgreet/Cargo.toml b/breadgreet/Cargo.toml new file mode 100644 index 0000000..40be455 --- /dev/null +++ b/breadgreet/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "breadgreet" +version = "0.1.0" +edition = "2021" +license = "MIT" +authors = ["Breadway "] +description = "Graphical greetd greeter for Hyprland / Wayland" + +[[bin]] +name = "breadgreet" +path = "src/main.rs" + +[dependencies] +breadlock-ui = { path = "../breadlock-ui" } +bread-theme = { workspace = true, features = ["gtk"] } +gtk4 = { version = "0.11", features = ["v4_12"] } +relm4 = { version = "0.11", features = ["macros"] } +greetd_ipc = { version = "0.10", default-features = false, features = ["tokio-codec"] } +chrono = "0.4" +tokio.workspace = true +serde.workspace = true +toml.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +thiserror.workspace = true diff --git a/breadgreet/src/config.rs b/breadgreet/src/config.rs new file mode 100644 index 0000000..a560945 --- /dev/null +++ b/breadgreet/src/config.rs @@ -0,0 +1,63 @@ +use breadlock_ui::config::Appearance; +use serde::Deserialize; +use std::path::PathBuf; + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct Config { + #[serde(flatten)] + pub appearance: Appearance, + pub sessions: Sessions, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct Sessions { + pub wayland_dirs: Vec, + pub xsessions_dirs: Vec, + /// `.desktop` file stem (without extension) to auto-select. + pub default: String, +} + +impl Default for Sessions { + fn default() -> Self { + Self { + wayland_dirs: vec!["/usr/share/wayland-sessions".to_string()], + xsessions_dirs: vec!["/usr/share/xsessions".to_string()], + default: "hyprland".to_string(), + } + } +} + +/// `breadgreet` commonly runs as the dedicated `greeter` system user (per +/// BOS's `/etc/greetd/config.toml` `user = "greeter"`), so a fixed system +/// path is checked first; XDG is the fallback for local dev/testing under a +/// normal user session. +pub fn load() -> Config { + let system_path = std::path::Path::new("/etc/greetd/breadgreet.toml"); + if system_path.exists() { + return breadlock_ui::config::load_or_default(system_path); + } + breadlock_ui::config::load_or_default(&xdg_config_path()) +} + +fn xdg_config_path() -> PathBuf { + let base = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) + .unwrap_or_else(|| PathBuf::from(".")); + base.join("breadgreet").join("breadgreet.toml") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_sessions_match_standard_greetd_dirs() { + let s = Sessions::default(); + assert_eq!(s.wayland_dirs, vec!["/usr/share/wayland-sessions"]); + assert_eq!(s.xsessions_dirs, vec!["/usr/share/xsessions"]); + assert_eq!(s.default, "hyprland"); + } +} diff --git a/breadgreet/src/greetd/client.rs b/breadgreet/src/greetd/client.rs new file mode 100644 index 0000000..721acd3 --- /dev/null +++ b/breadgreet/src/greetd/client.rs @@ -0,0 +1,250 @@ +//! Thin async wrapper around `greetd_ipc`'s wire protocol — connects to +//! `$GREETD_SOCK`, and turns greetd's `Request`/`Response` enums into a +//! small state machine ([`Outcome`]) the UI drives. + +use greetd_ipc::codec::TokioCodec; +use greetd_ipc::{AuthMessageType, ErrorType, Request, Response}; +use tokio::net::UnixStream; + +#[derive(Debug, thiserror::Error)] +pub enum GreetdError { + #[error("$GREETD_SOCK is not set — breadgreet must be launched by greetd")] + NoSocketEnv, + #[error("failed to connect to greetd socket: {0}")] + Connect(#[source] std::io::Error), + #[error("greetd IPC error: {0}")] + Codec(#[from] greetd_ipc::codec::Error), + #[error("{description}")] + Greetd { + description: String, + is_auth_error: bool, + }, +} + +/// One step of a PAM conversation, as relayed by greetd. +#[derive(Debug, Clone)] +pub enum AuthPrompt { + /// Answer should be shown as typed (e.g. a username confirmation). + Visible(String), + /// Answer should be masked (a password). + Secret(String), + /// Informational message — no answer needed, just display and continue. + Info(String), + /// Non-fatal error message from a PAM module — display and continue. + Error(String), +} + +#[derive(Debug, Clone)] +pub enum Outcome { + /// Login flow complete — call [`Client::start_session`] next. + Success, + /// Another prompt to answer via [`Client::respond`]. + Prompt(AuthPrompt), +} + +#[derive(Debug)] +pub struct Client { + stream: UnixStream, +} + +impl Client { + /// Connects to the Unix socket greetd set in `$GREETD_SOCK` when it + /// launched this process. + pub async fn connect() -> Result { + let path = std::env::var_os("GREETD_SOCK").ok_or(GreetdError::NoSocketEnv)?; + Self::connect_to(path).await + } + + /// Connects to an explicit socket path, bypassing `$GREETD_SOCK` — + /// used directly by tests against a mock server so they don't need to + /// mutate process-global environment state (which parallel `cargo test` + /// threads would race on). + async fn connect_to(path: impl AsRef) -> Result { + let stream = UnixStream::connect(path) + .await + .map_err(GreetdError::Connect)?; + Ok(Self { stream }) + } + + /// Starts a login attempt for `username`. Answer any resulting + /// [`Outcome::Prompt`] via [`Self::respond`] until [`Outcome::Success`]. + pub async fn create_session(&mut self, username: &str) -> Result { + let req = Request::CreateSession { + username: username.to_string(), + }; + self.roundtrip(req).await + } + + /// Answers the most recent [`AuthPrompt`]. `None` for prompts that don't + /// need an answer (info/error messages). + pub async fn respond(&mut self, answer: Option) -> Result { + self.roundtrip(Request::PostAuthMessageResponse { response: answer }) + .await + } + + /// Hands the chosen session off to greetd, which execs it and owns the + /// VT switch away from the greeter. Only valid after an [`Outcome::Success`]. + pub async fn start_session( + &mut self, + cmd: Vec, + env: Vec, + ) -> Result<(), GreetdError> { + Request::StartSession { cmd, env } + .write_to(&mut self.stream) + .await?; + match Response::read_from(&mut self.stream).await? { + Response::Success => Ok(()), + Response::Error { + error_type, + description, + } => Err(GreetdError::Greetd { + description, + is_auth_error: matches!(error_type, ErrorType::AuthError), + }), + Response::AuthMessage { .. } => Err(GreetdError::Greetd { + description: "greetd sent an unexpected auth message after StartSession" + .to_string(), + is_auth_error: false, + }), + } + } + + /// Aborts an in-progress login flow (e.g. the user hit Escape). Per the + /// protocol this can only be called before `StartSession` — best-effort, + /// errors here don't matter to the caller since the flow is being torn + /// down either way. + pub async fn cancel_session(&mut self) { + let _ = Request::CancelSession.write_to(&mut self.stream).await; + let _ = Response::read_from(&mut self.stream).await; + } + + async fn roundtrip(&mut self, req: Request) -> Result { + req.write_to(&mut self.stream).await?; + match Response::read_from(&mut self.stream).await? { + Response::Success => Ok(Outcome::Success), + Response::AuthMessage { + auth_message_type, + auth_message, + } => Ok(Outcome::Prompt(match auth_message_type { + AuthMessageType::Visible => AuthPrompt::Visible(auth_message), + AuthMessageType::Secret => AuthPrompt::Secret(auth_message), + AuthMessageType::Info => AuthPrompt::Info(auth_message), + AuthMessageType::Error => AuthPrompt::Error(auth_message), + })), + Response::Error { + error_type, + description, + } => Err(GreetdError::Greetd { + description, + is_auth_error: matches!(error_type, ErrorType::AuthError), + }), + } + } +} + +#[cfg(test)] +mod tests { + //! Exercises the framing/state-machine logic against a mock Unix-socket + //! server speaking the real greetd_ipc wire format — no real greetd or + //! PAM involved. This is the safe way to test this module: a bug here + //! just fails a test, it can never affect a real login. + use super::*; + use tokio::net::UnixListener; + + async fn mock_server(path: std::path::PathBuf, script: Vec) { + let listener = UnixListener::bind(&path).unwrap(); + let (mut stream, _) = listener.accept().await.unwrap(); + for response in script { + // Drain the request that prompted this response — we don't need + // to inspect it, just keep the framing in lockstep. + let _ = Request::read_from(&mut stream).await; + response.write_to(&mut stream).await.unwrap(); + } + } + + fn socket_path(name: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "breadgreet-test-{name}-{}.sock", + std::process::id() + )) + } + + #[tokio::test] + async fn create_session_success_flows_straight_through() { + let path = socket_path("success"); + std::fs::remove_file(&path).ok(); + let server = tokio::spawn(mock_server(path.clone(), vec![Response::Success])); + + // Give the listener a moment to bind before connecting. + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + let mut client = Client::connect_to(&path).await.unwrap(); + let outcome = client.create_session("bob").await.unwrap(); + assert!(matches!(outcome, Outcome::Success)); + + server.await.unwrap(); + std::fs::remove_file(&path).ok(); + } + + #[tokio::test] + async fn create_session_prompts_for_password_then_succeeds() { + let path = socket_path("prompt"); + std::fs::remove_file(&path).ok(); + let server = tokio::spawn(mock_server( + path.clone(), + vec![ + Response::AuthMessage { + auth_message_type: AuthMessageType::Secret, + auth_message: "Password:".to_string(), + }, + Response::Success, + ], + )); + + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + let mut client = Client::connect_to(&path).await.unwrap(); + + let outcome = client.create_session("bob").await.unwrap(); + let Outcome::Prompt(AuthPrompt::Secret(msg)) = outcome else { + panic!("expected a secret prompt, got {outcome:?}"); + }; + assert_eq!(msg, "Password:"); + + let outcome = client.respond(Some("hunter2".to_string())).await.unwrap(); + assert!(matches!(outcome, Outcome::Success)); + + server.await.unwrap(); + std::fs::remove_file(&path).ok(); + } + + #[tokio::test] + async fn auth_error_is_reported_as_such() { + let path = socket_path("autherr"); + std::fs::remove_file(&path).ok(); + let server = tokio::spawn(mock_server( + path.clone(), + vec![Response::Error { + error_type: ErrorType::AuthError, + description: "denied".to_string(), + }], + )); + + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + let mut client = Client::connect_to(&path).await.unwrap(); + + let err = client.create_session("bob").await.unwrap_err(); + match err { + GreetdError::Greetd { is_auth_error, .. } => assert!(is_auth_error), + other => panic!("expected a greetd auth error, got {other:?}"), + } + + server.await.unwrap(); + std::fs::remove_file(&path).ok(); + } + + #[tokio::test] + async fn connect_without_greetd_sock_env_fails_cleanly() { + std::env::remove_var("GREETD_SOCK"); + let err = Client::connect().await.unwrap_err(); + assert!(matches!(err, GreetdError::NoSocketEnv)); + } +} diff --git a/breadgreet/src/greetd/mod.rs b/breadgreet/src/greetd/mod.rs new file mode 100644 index 0000000..01b15c6 --- /dev/null +++ b/breadgreet/src/greetd/mod.rs @@ -0,0 +1,3 @@ +mod client; + +pub use client::{AuthPrompt, Client, Outcome}; diff --git a/breadgreet/src/main.rs b/breadgreet/src/main.rs new file mode 100644 index 0000000..8666e1d --- /dev/null +++ b/breadgreet/src/main.rs @@ -0,0 +1,304 @@ +mod config; +mod greetd; +mod sessions; +mod theme; + +use greetd::{AuthPrompt, Client, Outcome}; +use gtk4::prelude::*; +use relm4::prelude::*; +use tokio::sync::mpsc; + +/// Commands sent from the UI thread to the greetd actor task (see +/// [`spawn_greetd_actor`]), which owns the single stateful connection to +/// `$GREETD_SOCK` for the lifetime of one login attempt. +enum GreetdCommand { + CreateSession(String), + Respond(Option), + StartSession { cmd: Vec, env: Vec }, +} + +#[derive(Debug, Clone)] +enum Stage { + /// Waiting for a username in `entry`. + Username, + /// greetd/PAM asked a question; `entry` holds the answer (masking is + /// applied imperatively on the entry widget when the prompt arrives). + Prompt, + /// A request is in flight — input is disabled so a second Enter can't + /// race it. + Working, +} + +#[derive(Debug)] +enum AppInput { + ClockTick, + /// Enter pressed in the entry — behavior depends on `Stage`. + Submit, + Outcome(Outcome), + Error(String), + SessionStarted, +} + +struct App { + clock_lbl: gtk4::Label, + status_lbl: gtk4::Label, + entry: gtk4::Entry, + stage: Stage, + username: String, + session: Option, + clock_format: String, + cmd_tx: mpsc::UnboundedSender, +} + +#[relm4::component] +impl SimpleComponent for App { + type Init = (); + type Input = AppInput; + type Output = (); + + view! { + gtk4::ApplicationWindow { + add_css_class: "breadgreet", + set_title: Some("breadgreet"), + + #[name = "root_box"] + gtk4::Box { + set_orientation: gtk4::Orientation::Vertical, + set_halign: gtk4::Align::Center, + set_valign: gtk4::Align::Center, + } + } + } + + fn init( + _init: Self::Init, + root: Self::Root, + sender: ComponentSender, + ) -> ComponentParts { + root.fullscreen(); + + let config = config::load(); + let session = sessions::discover( + &config.sessions.wayland_dirs, + &config.sessions.xsessions_dirs, + &config.sessions.default, + ); + + let clock_lbl = gtk4::Label::new(None); + clock_lbl.add_css_class("login-clock"); + + let entry = gtk4::Entry::new(); + entry.add_css_class("login-entry"); + entry.set_placeholder_text(Some("Username")); + entry.set_width_chars(24); + { + let sender = sender.clone(); + entry.connect_activate(move |_| sender.input(AppInput::Submit)); + } + + let status_lbl = gtk4::Label::new(None); + status_lbl.add_css_class("login-status"); + + let session_lbl = gtk4::Label::new(session.as_ref().map(|s| s.name.as_str())); + session_lbl.add_css_class("login-session"); + if session.is_none() { + session_lbl.set_label("No session found — cannot log in"); + } + + let card = gtk4::Box::new(gtk4::Orientation::Vertical, 8); + card.add_css_class("login-card"); + card.append(&entry); + card.append(&status_lbl); + card.append(&session_lbl); + + let widgets = view_output!(); + widgets.root_box.append(&clock_lbl); + widgets.root_box.append(&card); + + let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); + spawn_greetd_actor(cmd_rx, sender.clone()); + + theme::apply(); + spawn_clock_ticker(sender.clone()); + + let model = App { + clock_lbl, + status_lbl, + entry, + stage: Stage::Username, + username: String::new(), + session, + clock_format: config.appearance.clock.format.clone(), + cmd_tx, + }; + model + .clock_lbl + .set_label(¤t_time(&model.clock_format)); + + ComponentParts { model, widgets } + } + + fn update(&mut self, msg: Self::Input, _sender: ComponentSender) { + match msg { + AppInput::ClockTick => { + self.clock_lbl.set_label(¤t_time(&self.clock_format)); + } + AppInput::Submit => self.handle_submit(), + AppInput::Outcome(Outcome::Success) => self.start_session(), + AppInput::Outcome(Outcome::Prompt(prompt)) => self.handle_prompt(prompt), + AppInput::Error(description) => { + self.status_lbl.set_label(&description); + self.status_lbl.add_css_class("error"); + self.entry.set_text(""); + self.entry.set_visibility(true); + self.entry.set_placeholder_text(Some("Username")); + self.entry.set_sensitive(true); + self.stage = Stage::Username; + self.username.clear(); + } + AppInput::SessionStarted => { + // greetd now owns the VT switch to the started session — + // nothing left for the greeter to do. + self.status_lbl.set_label("Starting session…"); + } + } + } +} + +impl App { + fn handle_submit(&mut self) { + if matches!(self.stage, Stage::Working) { + return; + } + let text = self.entry.text().to_string(); + + match &self.stage { + Stage::Username => { + if text.is_empty() { + return; + } + self.username = text; + self.entry.set_text(""); + self.entry.set_sensitive(false); + self.stage = Stage::Working; + let _ = self + .cmd_tx + .send(GreetdCommand::CreateSession(self.username.clone())); + } + Stage::Prompt => { + self.entry.set_text(""); + self.entry.set_sensitive(false); + self.stage = Stage::Working; + let answer = if text.is_empty() { None } else { Some(text) }; + let _ = self.cmd_tx.send(GreetdCommand::Respond(answer)); + } + Stage::Working => {} + } + } + + fn handle_prompt(&mut self, prompt: AuthPrompt) { + self.status_lbl.remove_css_class("error"); + match prompt { + AuthPrompt::Info(message) | AuthPrompt::Error(message) => { + // No answer needed — display and immediately continue the + // conversation with an empty response. + self.status_lbl.set_label(&message); + let _ = self.cmd_tx.send(GreetdCommand::Respond(None)); + } + AuthPrompt::Visible(message) => { + self.status_lbl.set_label(&message); + self.entry.set_visibility(true); + self.entry.set_placeholder_text(Some(&message)); + self.entry.set_sensitive(true); + self.entry.grab_focus(); + self.stage = Stage::Prompt; + } + AuthPrompt::Secret(message) => { + self.status_lbl.set_label(&message); + self.entry.set_visibility(false); + self.entry.set_placeholder_text(Some(&message)); + self.entry.set_sensitive(true); + self.entry.grab_focus(); + self.stage = Stage::Prompt; + } + } + } + + fn start_session(&mut self) { + let Some(session) = &self.session else { + self.status_lbl.set_label("No session available to start"); + self.status_lbl.add_css_class("error"); + return; + }; + self.status_lbl.set_label("Starting session…"); + let _ = self.cmd_tx.send(GreetdCommand::StartSession { + cmd: session.exec.clone(), + env: Vec::new(), + }); + } +} + +/// Owns the single stateful connection to `$GREETD_SOCK` for one login +/// attempt and translates the UI's [`GreetdCommand`]s into greetd IPC +/// round-trips, forwarding each outcome back as an [`AppInput`]. +fn spawn_greetd_actor( + mut cmd_rx: mpsc::UnboundedReceiver, + sender: ComponentSender, +) { + relm4::spawn(async move { + let mut client = match Client::connect().await { + Ok(client) => client, + Err(err) => { + sender.input(AppInput::Error(format!("Cannot reach greetd: {err}"))); + return; + } + }; + + while let Some(cmd) = cmd_rx.recv().await { + let result = match cmd { + GreetdCommand::CreateSession(username) => client.create_session(&username).await, + GreetdCommand::Respond(answer) => client.respond(answer).await, + GreetdCommand::StartSession { cmd, env } => { + match client.start_session(cmd, env).await { + Ok(()) => { + sender.input(AppInput::SessionStarted); + continue; + } + Err(err) => Err(err), + } + } + }; + + match result { + Ok(outcome) => sender.input(AppInput::Outcome(outcome)), + Err(err) => { + tracing::warn!(%err, "greetd reported an error"); + client.cancel_session().await; + sender.input(AppInput::Error(err.to_string())); + } + } + } + }); +} + +fn spawn_clock_ticker(sender: ComponentSender) { + relm4::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + sender.input(AppInput::ClockTick); + } + }); +} + +fn current_time(format: &str) -> String { + chrono::Local::now().format(format).to_string() +} + +fn main() { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + let app = RelmApp::new("sh.breadway.breadgreet"); + app.run::(()); +} diff --git a/breadgreet/src/sessions.rs b/breadgreet/src/sessions.rs new file mode 100644 index 0000000..d7d9dcc --- /dev/null +++ b/breadgreet/src/sessions.rs @@ -0,0 +1,94 @@ +//! Session discovery: scans the standard greetd-greeter session directories +//! for `.desktop` entries. BOS effectively ships one session (Hyprland via +//! `bos-session`), so v1 has no picker UI — it just auto-selects the +//! configured default (or the only entry found) and resolves its `Exec=` +//! line to hand to `greetd`'s `StartSession`. + +use breadlock_ui::desktop_entry::{scan_dir, DesktopEntry}; +use std::path::Path; + +pub struct Session { + pub name: String, + pub exec: Vec, +} + +/// Scans `wayland_dirs` then `xsessions_dirs` (in that order) and returns +/// the entry matching `default` (by `.desktop` file stem), falling back to +/// the first entry found in either directory. `None` if nothing is +/// installed — the greeter has no session to offer. +pub fn discover( + wayland_dirs: &[String], + xsessions_dirs: &[String], + default: &str, +) -> Option { + let mut all: Vec<(String, DesktopEntry)> = Vec::new(); + for dir in wayland_dirs.iter().chain(xsessions_dirs) { + all.extend(scan_dir(Path::new(dir))); + } + + let chosen = all + .iter() + .find(|(stem, _)| stem == default) + .or_else(|| all.first())?; + + Some(Session { + name: chosen.1.name.clone(), + exec: split_exec(&chosen.1.exec), + }) +} + +/// Splits a `.desktop` `Exec=` line into an argv. Only handles plain +/// whitespace-separated commands (BOS's own `hyprland.desktop` is +/// `Exec=Hyprland`) — full field-code (`%f`, `%u`, …) and quoting support +/// isn't needed for a greeter that never launches file-manager-style +/// entries. +fn split_exec(exec: &str) -> Vec { + exec.split_whitespace() + .filter(|arg| !arg.starts_with('%')) + .map(str::to_string) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn split_exec_drops_field_codes() { + assert_eq!(split_exec("Hyprland"), vec!["Hyprland"]); + assert_eq!(split_exec("gnome-session %U"), vec!["gnome-session"]); + } + + #[test] + fn discover_returns_none_when_no_directories_exist() { + assert!(discover( + &["/nonexistent/a".to_string()], + &["/nonexistent/b".to_string()], + "hyprland" + ) + .is_none()); + } + + #[test] + fn discover_prefers_configured_default_over_first_entry() { + let dir = std::env::temp_dir().join("breadgreet-test-sessions-discover"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("aaa.desktop"), + "[Desktop Entry]\nName=A\nExec=a-cmd\n", + ) + .unwrap(); + std::fs::write( + dir.join("hyprland.desktop"), + "[Desktop Entry]\nName=Hyprland\nExec=Hyprland\n", + ) + .unwrap(); + + let dir_str = dir.to_str().unwrap().to_string(); + let session = discover(&[dir_str], &[], "hyprland").unwrap(); + assert_eq!(session.name, "Hyprland"); + assert_eq!(session.exec, vec!["Hyprland"]); + + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/breadgreet/src/theme.rs b/breadgreet/src/theme.rs new file mode 100644 index 0000000..30a8aa2 --- /dev/null +++ b/breadgreet/src/theme.rs @@ -0,0 +1,35 @@ +use bread_theme::{gtk as bgtk, ink_on, load_palette}; +use gtk4::CssProvider; +use std::cell::RefCell; + +thread_local! { + static USER_PROVIDER: RefCell> = const { RefCell::new(None) }; +} + +fn load_css() -> String { + let p = load_palette(); + format!( + "window.breadgreet {{ background-color: {bg}; color: {on_bg}; }}\ + .login-card {{ background: {surface}; color: {on_surface}; border-radius: 8px;\ + padding: 20px; min-width: 320px; }}\ + .login-clock {{ font-size: 48px; font-weight: bold; margin-bottom: 20px; }}\ + .login-entry {{ font-size: 14px; }}\ + .login-status {{ font-size: 12px; opacity: 0.75; margin-top: 8px; }}\ + .login-status.error {{ color: {red}; opacity: 1; }}\ + .login-session {{ font-size: 12px; opacity: 0.6; margin-top: 12px; }}", + bg = p.background, + surface = p.color0, + red = p.color1, + on_bg = ink_on(&p.background), + on_surface = ink_on(&p.color0), + ) +} + +pub fn apply() { + bgtk::apply_shared(); + bgtk::apply_app_css(load_css); + + let home = std::env::var("HOME").unwrap_or_default(); + let user_path = std::path::PathBuf::from(format!("{home}/.config/breadgreet/style.css")); + USER_PROVIDER.with(|cell| bgtk::apply_user_css(&user_path, cell)); +} diff --git a/breadlock-ui/Cargo.toml b/breadlock-ui/Cargo.toml new file mode 100644 index 0000000..7416edb --- /dev/null +++ b/breadlock-ui/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "breadlock-ui" +version = "0.1.0" +edition = "2021" +license = "MIT" +authors = ["Breadway "] + +[dependencies] +bread-theme.workspace = true +serde.workspace = true +toml.workspace = true + +# Software-rendering primitives (breadlock only — breadgreet draws via GTK/CSS +# 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"] diff --git a/breadlock-ui/src/config.rs b/breadlock-ui/src/config.rs new file mode 100644 index 0000000..2747769 --- /dev/null +++ b/breadlock-ui/src/config.rs @@ -0,0 +1,111 @@ +use serde::Deserialize; +use std::path::Path; + +/// Appearance settings shared by `breadlock.toml` and `breadgreet.toml`. +/// `breadgreet` embeds this and adds its own `[sessions]` table. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct Appearance { + pub background: Background, + pub clock: Clock, + pub font: Font, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum BackgroundMode { + Color, + Image, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct Background { + pub mode: BackgroundMode, + pub path: String, + /// v2 feature flag — no-op (with a warning) in v1, which only supports a + /// static color or image background. + pub blur: bool, +} + +impl Default for Background { + fn default() -> Self { + Self { + mode: BackgroundMode::Color, + path: String::new(), + blur: false, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct Clock { + pub format: String, +} + +impl Default for Clock { + fn default() -> Self { + Self { + format: "%H:%M".to_string(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct Font { + pub family: String, +} + +impl Default for Font { + fn default() -> Self { + Self { + family: bread_theme::tokens::FONT_FAMILY + .split(',') + .next() + .unwrap_or("Varela Round") + .trim() + .to_string(), + } + } +} + +/// Reads and parses a TOML config file, falling back to `T::default()` if the +/// file is missing or malformed — every bread* app runs with sensible +/// defaults and no required config. +pub fn load_or_default(path: &Path) -> T { + std::fs::read_to_string(path) + .ok() + .and_then(|s| toml::from_str(&s).ok()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_match_design_system() { + let a = Appearance::default(); + assert_eq!(a.background.mode, BackgroundMode::Color); + assert_eq!(a.clock.format, "%H:%M"); + assert_eq!(a.font.family, "Varela Round"); + } + + #[test] + fn missing_file_falls_back_to_default() { + let a: Appearance = load_or_default(Path::new("/nonexistent/breadlock-test.toml")); + assert_eq!(a.font.family, "Varela Round"); + } + + #[test] + fn parses_partial_toml_with_defaults_for_rest() { + let dir = std::env::temp_dir().join("breadlock-ui-test-partial.toml"); + std::fs::write(&dir, "[clock]\nformat = \"%I:%M %p\"\n").unwrap(); + let a: Appearance = load_or_default(&dir); + assert_eq!(a.clock.format, "%I:%M %p"); + assert_eq!(a.background.mode, BackgroundMode::Color); + std::fs::remove_file(&dir).ok(); + } +} diff --git a/breadlock-ui/src/desktop_entry.rs b/breadlock-ui/src/desktop_entry.rs new file mode 100644 index 0000000..8c66c78 --- /dev/null +++ b/breadlock-ui/src/desktop_entry.rs @@ -0,0 +1,134 @@ +//! Minimal freedesktop `.desktop` entry parsing — just enough to discover +//! session launchers (`Name=`, `Exec=`, `Type=`) under +//! `/usr/share/wayland-sessions` and `/usr/share/xsessions`. BOS only ships +//! one session today, so this deliberately doesn't handle the full spec +//! (localized `Name[xx]=`, `Exec=` quoting/field codes, `Actions=`, etc.) — +//! only the three keys a greeter needs to list and launch a session. + +use std::path::Path; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DesktopEntry { + pub name: String, + pub exec: String, + pub entry_type: String, +} + +/// Parses the `[Desktop Entry]` section of a `.desktop` file's contents. +/// Returns `None` if `Name=` or `Exec=` is missing. +pub fn parse(contents: &str) -> Option { + let mut name = None; + let mut exec = None; + let mut entry_type = None; + let mut in_desktop_entry = false; + + for line in contents.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some(section) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { + in_desktop_entry = section == "Desktop Entry"; + continue; + } + if !in_desktop_entry { + continue; + } + if let Some((key, value)) = line.split_once('=') { + match key.trim() { + "Name" => name = Some(value.trim().to_string()), + "Exec" => exec = Some(value.trim().to_string()), + "Type" => entry_type = Some(value.trim().to_string()), + _ => {} + } + } + } + + Some(DesktopEntry { + name: name?, + exec: exec?, + entry_type: entry_type.unwrap_or_else(|| "Application".to_string()), + }) +} + +/// Scans a directory for `*.desktop` files, returning `(file stem, entry)` +/// pairs. Unreadable directories and unparsable entries are silently skipped +/// — a missing session directory is normal (e.g. no X11 sessions installed). +pub fn scan_dir(dir: &Path) -> Vec<(String, DesktopEntry)> { + let Ok(read_dir) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + + let mut entries: Vec<(String, DesktopEntry)> = read_dir + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().is_some_and(|ext| ext == "desktop")) + .filter_map(|e| { + let stem = e.path().file_stem()?.to_str()?.to_string(); + let contents = std::fs::read_to_string(e.path()).ok()?; + Some((stem, parse(&contents)?)) + }) + .collect(); + + entries.sort_by(|a, b| a.0.cmp(&b.0)); + entries +} + +#[cfg(test)] +mod tests { + use super::*; + + const HYPRLAND_DESKTOP: &str = "[Desktop Entry]\n\ + Name=Hyprland\n\ + Comment=An intelligent dynamic tiling Wayland compositor\n\ + Exec=Hyprland\n\ + Type=Application\n"; + + #[test] + fn parses_name_exec_type() { + let e = parse(HYPRLAND_DESKTOP).unwrap(); + assert_eq!(e.name, "Hyprland"); + assert_eq!(e.exec, "Hyprland"); + assert_eq!(e.entry_type, "Application"); + } + + #[test] + fn ignores_keys_outside_desktop_entry_section() { + let contents = "[Desktop Action foo]\nName=Not this one\n\ + [Desktop Entry]\nName=Real\nExec=real-cmd\n"; + let e = parse(contents).unwrap(); + assert_eq!(e.name, "Real"); + assert_eq!(e.exec, "real-cmd"); + } + + #[test] + fn missing_exec_returns_none() { + assert!(parse("[Desktop Entry]\nName=Broken\n").is_none()); + } + + #[test] + fn missing_type_defaults_to_application() { + let e = parse("[Desktop Entry]\nName=X\nExec=x\n").unwrap(); + assert_eq!(e.entry_type, "Application"); + } + + #[test] + fn scan_dir_on_missing_directory_returns_empty() { + assert!(scan_dir(Path::new("/nonexistent/wayland-sessions")).is_empty()); + } + + #[test] + fn scan_dir_finds_and_sorts_desktop_files() { + let dir = std::env::temp_dir().join("breadlock-ui-test-sessions"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("zzz.desktop"), HYPRLAND_DESKTOP).unwrap(); + std::fs::write(dir.join("aaa.desktop"), "[Desktop Entry]\nName=A\nExec=a\n").unwrap(); + std::fs::write(dir.join("not-a-session.txt"), "ignored").unwrap(); + + let found = scan_dir(&dir); + assert_eq!(found.len(), 2); + assert_eq!(found[0].0, "aaa"); + assert_eq!(found[1].0, "zzz"); + + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/breadlock-ui/src/lib.rs b/breadlock-ui/src/lib.rs new file mode 100644 index 0000000..5cdfae3 --- /dev/null +++ b/breadlock-ui/src/lib.rs @@ -0,0 +1,6 @@ +pub mod config; +pub mod desktop_entry; +pub mod theme; + +#[cfg(feature = "paint")] +pub mod painter; diff --git a/breadlock-ui/src/painter.rs b/breadlock-ui/src/painter.rs new file mode 100644 index 0000000..d383058 --- /dev/null +++ b/breadlock-ui/src/painter.rs @@ -0,0 +1,171 @@ +//! Software-rendering primitives shared by `breadlock`'s frame composition: +//! rounded-rect paths (radius tokens from [`bread_theme::tokens`]) and text +//! layout/rasterization via `cosmic-text`, blitted into a `tiny-skia` +//! `Pixmap`. Only linked into `breadlock` — `breadgreet` draws through GTK/CSS +//! instead and doesn't need a font-shaping stack. + +pub use bread_theme::tokens; +use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping, SwashCache}; +use tiny_skia::{Path, PathBuilder, Pixmap, PremultipliedColorU8}; + +/// Builds a rounded-rectangle path. `radius` is clamped so it never exceeds +/// half the shorter side (a degenerate radius would otherwise self-intersect). +pub fn rounded_rect(x: f32, y: f32, w: f32, h: f32, radius: f32) -> Option { + let r = radius.max(0.0).min(w / 2.0).min(h / 2.0); + let mut pb = PathBuilder::new(); + pb.move_to(x + r, y); + pb.line_to(x + w - r, y); + pb.quad_to(x + w, y, x + w, y + r); + pb.line_to(x + w, y + h - r); + pb.quad_to(x + w, y + h, x + w - r, y + h); + pb.line_to(x + r, y + h); + pb.quad_to(x, y + h, x, y + h - r); + pb.line_to(x, y + r); + pb.quad_to(x, y, x + r, y); + pb.close(); + pb.finish() +} + +/// Owns the font database and glyph raster cache. Expensive to create +/// (`FontSystem::new()` scans installed fonts), so construct once and reuse +/// across every frame. +pub struct TextRenderer { + font_system: FontSystem, + swash_cache: SwashCache, +} + +impl Default for TextRenderer { + fn default() -> Self { + Self::new() + } +} + +impl TextRenderer { + pub fn new() -> Self { + Self { + font_system: FontSystem::new(), + swash_cache: SwashCache::new(), + } + } + + fn shape_line(&mut self, text: &str, family: &str, size_px: f32, max_width: f32) -> Buffer { + 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)); + buffer.set_text(&mut self.font_system, text, &attrs, Shaping::Advanced); + buffer.shape_until_scroll(&mut self.font_system, false); + buffer + } + + /// 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); + buffer + .layout_runs() + .map(|run| run.line_w) + .fold(0.0, f32::max) + } + + /// 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. + #[allow(clippy::too_many_arguments)] + pub fn draw_line( + &mut self, + pixmap: &mut Pixmap, + text: &str, + family: &str, + size_px: f32, + color: tiny_skia::Color, + origin_x: f32, + origin_y: f32, + ) { + let buffer = self.shape_line(text, family, size_px, pixmap.width() as f32); + + let c8 = color.to_color_u8(); + let text_color = cosmic_text::Color::rgba(c8.red(), c8.green(), c8.blue(), c8.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; + } + blend_over_opaque(pixmap, px as u32, py as u32, r, g, b, a); + }, + ); + } +} + +/// Alpha-blends a straight-alpha `(r, g, b, a)` source pixel over an +/// **opaque** destination pixel (always true here — the lock screen +/// background is painted fully opaque before any text or UI chrome). +/// Because the destination alpha is always 255, the blended result is also +/// opaque, so the `PremultipliedColorU8` invariant (`rgb <= a`) always holds. +fn blend_over_opaque(pixmap: &mut Pixmap, x: u32, y: u32, r: u8, g: u8, b: u8, a: u8) { + let idx = (y * pixmap.width() + x) as usize; + let pixels = pixmap.pixels_mut(); + let Some(dst) = pixels.get(idx).copied() else { + return; + }; + let a32 = a as u32; + let mix = |s: u8, d: u8| -> u8 { ((s as u32 * a32 + d as u32 * (255 - a32)) / 255) as u8 }; + let blended = PremultipliedColorU8::from_rgba( + mix(r, dst.red()), + mix(g, dst.green()), + mix(b, dst.blue()), + 255, + ); + if let Some(blended) = blended { + pixels[idx] = blended; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rounded_rect_produces_closed_path() { + let path = rounded_rect(0.0, 0.0, 100.0, 40.0, tokens::RADIUS_SECONDARY as f32).unwrap(); + assert!(!path.is_empty()); + } + + #[test] + fn rounded_rect_clamps_oversized_radius() { + // radius larger than half the shorter side must not panic or produce garbage + let path = rounded_rect(0.0, 0.0, 10.0, 10.0, 999.0); + assert!(path.is_some()); + } + + #[test] + fn text_renderer_draws_without_panicking_on_tiny_pixmap() { + 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", + "sans-serif", + 12.0, + tiny_skia::Color::WHITE, + 2.0, + 2.0, + ); + // No panic and the pixmap remains fully opaque is the property under test — + // exact glyph coverage depends on whatever fonts are installed on the CI host. + assert!(pixmap.pixels().iter().all(|p| p.alpha() == 255)); + } +} diff --git a/breadlock-ui/src/theme.rs b/breadlock-ui/src/theme.rs new file mode 100644 index 0000000..364c3d0 --- /dev/null +++ b/breadlock-ui/src/theme.rs @@ -0,0 +1,32 @@ +pub use bread_theme::{ink_on, load_palette, Palette}; + +/// Parse a `#rrggbb` hex colour. Falls back to opaque black on malformed input +/// (palette slots are always produced by [`bread_theme`], which guarantees +/// valid hex, but a user-supplied override in a future config field might not). +pub fn parse_hex(hex: &str) -> (u8, u8, u8) { + let h = hex.trim_start_matches('#'); + let byte = |i: usize| u8::from_str_radix(h.get(i..i + 2).unwrap_or("00"), 16).unwrap_or(0); + (byte(0), byte(2), byte(4)) +} + +#[cfg(feature = "paint")] +pub fn tiny_skia_color(hex: &str) -> tiny_skia::Color { + let (r, g, b) = parse_hex(hex); + tiny_skia::Color::from_rgba8(r, g, b, 255) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_known_hex() { + assert_eq!(parse_hex("#1e1e2e"), (0x1e, 0x1e, 0x2e)); + assert_eq!(parse_hex("89b4fa"), (0x89, 0xb4, 0xfa)); + } + + #[test] + fn malformed_hex_falls_back_to_black() { + assert_eq!(parse_hex("nope"), (0, 0, 0)); + } +} diff --git a/breadlock.example.toml b/breadlock.example.toml new file mode 100644 index 0000000..223c563 --- /dev/null +++ b/breadlock.example.toml @@ -0,0 +1,22 @@ +# 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. + +[background] +# "color" (bread-theme palette background) or "image" (a PNG, cover-fit) +mode = "color" +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 + +[clock] +# strftime format +format = "%H:%M" + +[font] +family = "Varela Round" + +[input] +# How long the "wrong password" state (red pill) shows before input +# re-enables, in milliseconds. +fail_timeout_ms = 800 diff --git a/breadlock/Cargo.toml b/breadlock/Cargo.toml new file mode 100644 index 0000000..9969f3a --- /dev/null +++ b/breadlock/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "breadlock" +version = "0.1.0" +edition = "2021" +license = "MIT" +authors = ["Breadway "] +description = "Session locker for Hyprland / Wayland (ext-session-lock-v1)" + +[[bin]] +name = "breadlock" +path = "src/main.rs" + +# Dev-only harness: exercises the PAM auth flow against a typed password with +# no Wayland surface involved at all — see the "safe verification" plan in +# the repo's design notes. Not installed by the package. +[[bin]] +name = "breadlock-auth-check" +path = "src/bin/breadlock-auth-check.rs" + +[dependencies] +breadlock-ui = { path = "../breadlock-ui", features = ["paint"] } +smithay-client-toolkit = "0.20" +wayland-client = "0.31" +tiny-skia = "0.12" +chrono = "0.4" +pam-client2 = { version = "0.5", default-features = false } +serde.workspace = true +toml.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +thiserror.workspace = true diff --git a/breadlock/src/auth/mod.rs b/breadlock/src/auth/mod.rs new file mode 100644 index 0000000..49eaf40 --- /dev/null +++ b/breadlock/src/auth/mod.rs @@ -0,0 +1,44 @@ +//! Bridges blocking PAM auth (see [`pam`]) onto the calloop event loop that +//! drives the Wayland connection and rendering. +//! +//! Each attempt runs on its own throwaway OS thread (libpam's conversation +//! callback is synchronous FFI and must never block the render loop); the +//! result is posted back through a `calloop::channel` registered once at +//! startup, so the main loop just gets an ordinary event. + +pub mod pam; + +pub use pam::AuthError; + +use smithay_client_toolkit::reexports::calloop::channel::{self, Sender}; +use smithay_client_toolkit::reexports::calloop::LoopHandle; + +pub type AuthResult = Result<(), AuthError>; + +/// 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 { + let (tx, channel) = channel::channel(); + loop_handle + .insert_source(channel, move |event, _, data| { + if let channel::Event::Msg(result) = event { + on_result(data, result); + } + }) + .expect("failed to register auth-result channel on event loop"); + tx +} + +/// Spawns a PAM check for `username`/`password` on its own thread; the +/// outcome arrives later as an event on the loop registered via +/// [`register`]. `password` is moved in and dropped as soon as the PAM +/// conversation consumes it — it is never logged. +pub fn spawn_check(username: String, password: String, result_tx: Sender) { + std::thread::spawn(move || { + let result = pam::check(&username, &password); + let _ = result_tx.send(result); + }); +} diff --git a/breadlock/src/auth/pam.rs b/breadlock/src/auth/pam.rs new file mode 100644 index 0000000..c41d9cc --- /dev/null +++ b/breadlock/src/auth/pam.rs @@ -0,0 +1,35 @@ +//! Blocking PAM authentication. libpam's conversation callback is synchronous +//! FFI, so [`check`] must never be called on the render/event-loop thread — +//! see [`super`] for the thread hand-off. + +use pam_client2::conv_mock::Conversation; +use pam_client2::{Context, Flag}; + +/// The PAM service name — matches `/etc/pam.d/breadlock` +/// (packaging/pam.d/breadlock), which is what actually determines the auth +/// stack (pam_unix, pam_faillock, etc.). This string is just the lookup key. +const SERVICE: &str = "breadlock"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum AuthError { + #[error("PAM authentication failed")] + Authenticate, + #[error("PAM account validation failed (expired, locked, etc.)")] + AccountInvalid, + #[error("failed to initialize PAM context")] + ContextInit, +} + +/// Verifies `password` for `username` via PAM. Runs `authenticate` + +/// `acct_mgmt` (no `open_session` — the graphical session is already open; +/// this only re-proves who's sitting at the keyboard). +pub fn check(username: &str, password: &str) -> Result<(), AuthError> { + let conv = Conversation::with_credentials(username, password); + let mut ctx = + Context::new(SERVICE, Some(username), conv).map_err(|_| AuthError::ContextInit)?; + ctx.authenticate(Flag::NONE) + .map_err(|_| AuthError::Authenticate)?; + ctx.acct_mgmt(Flag::NONE) + .map_err(|_| AuthError::AccountInvalid)?; + Ok(()) +} diff --git a/breadlock/src/background.rs b/breadlock/src/background.rs new file mode 100644 index 0000000..fa65b49 --- /dev/null +++ b/breadlock/src/background.rs @@ -0,0 +1,68 @@ +//! Lock-screen background: a solid palette color, or a static image scaled +//! to cover the surface. Live blur-of-desktop (hyprlock-style) is a v2 +//! follow-up (see README) — `blur = true` is accepted but only logs a +//! warning in v1. + +use breadlock_ui::config::{Background as BackgroundConfig, BackgroundMode}; +use tiny_skia::{Pixmap, PixmapPaint, Transform}; + +pub enum Background { + Color(tiny_skia::Color), + Image(Pixmap), +} + +impl Background { + pub fn load(cfg: &BackgroundConfig, palette: &breadlock_ui::theme::Palette) -> Self { + let fallback = + || Background::Color(breadlock_ui::theme::tiny_skia_color(&palette.background)); + + if cfg.blur { + tracing::warn!( + "background.blur is not implemented yet (planned v2 feature, needs a wlr-screencopy \ + capture) — showing the configured background unblurred" + ); + } + + match cfg.mode { + BackgroundMode::Color => fallback(), + BackgroundMode::Image => { + if cfg.path.is_empty() { + tracing::warn!("background.mode = \"image\" but background.path is empty, falling back to palette color"); + return fallback(); + } + match Pixmap::load_png(&cfg.path) { + Ok(pixmap) => Background::Image(pixmap), + Err(err) => { + tracing::warn!(path = %cfg.path, %err, "failed to load background image (PNG only in v1), falling back to palette color"); + fallback() + } + } + } + } + } + + /// Paints this background into `target`, cover-fit (scaled uniformly to + /// fill the surface, cropping any overflow — never letterboxed). + pub fn paint(&self, target: &mut Pixmap) { + match self { + Background::Color(c) => target.fill(*c), + Background::Image(source) => { + let (tw, th) = (target.width() as f32, target.height() as f32); + let (sw, sh) = (source.width() as f32, source.height() as f32); + if sw <= 0.0 || sh <= 0.0 { + return; + } + let scale = (tw / sw).max(th / sh); + target.fill(tiny_skia::Color::BLACK); + target.draw_pixmap( + 0, + 0, + source.as_ref(), + &PixmapPaint::default(), + Transform::from_scale(scale, scale), + None, + ); + } + } + } +} diff --git a/breadlock/src/bin/breadlock-auth-check.rs b/breadlock/src/bin/breadlock-auth-check.rs new file mode 100644 index 0000000..c671ca8 --- /dev/null +++ b/breadlock/src/bin/breadlock-auth-check.rs @@ -0,0 +1,58 @@ +//! Dev-only harness: exercises the exact PAM flow `breadlock` uses +//! (`auth::pam::check`) against a typed password, with no Wayland surface +//! involved at all. This is the safest way to validate the +//! `/etc/pam.d/breadlock` service file and the auth logic in isolation — a +//! bad PAM config here just prints an error, it can never lock a session. +//! +//! Not installed by the package; run from a build tree with +//! `cargo run --bin breadlock-auth-check`. + +use std::io::Write; + +#[path = "../auth/pam.rs"] +mod pam; + +fn main() { + let username = std::env::var("USER").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 password = rpassword_prompt(); + + match pam::check(&username, &password) { + Ok(()) => println!("OK: {username} authenticated"), + Err(e) => { + eprintln!("FAILED: {e}"); + std::process::exit(1); + } + } +} + +/// 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 { + 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 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() +} diff --git a/breadlock/src/config.rs b/breadlock/src/config.rs new file mode 100644 index 0000000..49579e7 --- /dev/null +++ b/breadlock/src/config.rs @@ -0,0 +1,56 @@ +use breadlock_ui::config::Appearance; +use serde::Deserialize; +use std::path::PathBuf; + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct Config { + #[serde(flatten)] + pub appearance: Appearance, + pub input: Input, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct Input { + /// How long the "wrong password" shake shows before input re-enables. + pub fail_timeout_ms: u64, +} + +impl Default for Input { + fn default() -> Self { + Self { + fail_timeout_ms: 800, + } + } +} + +pub fn load() -> Config { + breadlock_ui::config::load_or_default(&config_path()) +} + +fn config_path() -> PathBuf { + let base = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) + .unwrap_or_else(|| PathBuf::from(".")); + base.join("breadlock").join("breadlock.toml") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_config_has_expected_fail_timeout() { + assert_eq!(Config::default().input.fail_timeout_ms, 800); + } + + #[test] + fn flattened_appearance_parses_alongside_input() { + let toml = "[clock]\nformat = \"%H:%M:%S\"\n[input]\nfail_timeout_ms = 1200\n"; + let cfg: Config = toml::from_str(toml).unwrap(); + assert_eq!(cfg.appearance.clock.format, "%H:%M:%S"); + assert_eq!(cfg.input.fail_timeout_ms, 1200); + } +} diff --git a/breadlock/src/input/keyboard.rs b/breadlock/src/input/keyboard.rs new file mode 100644 index 0000000..75c6c74 --- /dev/null +++ b/breadlock/src/input/keyboard.rs @@ -0,0 +1,167 @@ +use smithay_client_toolkit::seat::keyboard::{ + KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers, +}; +use smithay_client_toolkit::seat::{Capability, SeatHandler, SeatState}; +use wayland_client::protocol::{wl_keyboard, wl_seat, wl_surface}; +use wayland_client::{Connection, QueueHandle}; + +use crate::auth; +use crate::state::{AppState, AuthState}; + +impl SeatHandler for AppState { + fn seat_state(&mut self) -> &mut SeatState { + &mut self.seat_state + } + + fn new_seat(&mut self, _conn: &Connection, _qh: &QueueHandle, _seat: wl_seat::WlSeat) {} + + fn new_capability( + &mut self, + _conn: &Connection, + qh: &QueueHandle, + seat: wl_seat::WlSeat, + capability: Capability, + ) { + if capability == Capability::Keyboard && self.keyboard.is_none() { + match self.seat_state.get_keyboard(qh, &seat, None) { + Ok(keyboard) => self.keyboard = Some(keyboard), + Err(err) => tracing::error!(%err, "failed to bind keyboard"), + } + } + } + + fn remove_capability( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _seat: wl_seat::WlSeat, + capability: Capability, + ) { + if capability == Capability::Keyboard { + if let Some(keyboard) = self.keyboard.take() { + keyboard.release(); + } + } + } + + fn remove_seat(&mut self, _conn: &Connection, _qh: &QueueHandle, _seat: wl_seat::WlSeat) { + } +} + +impl KeyboardHandler for AppState { + fn enter( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _keyboard: &wl_keyboard::WlKeyboard, + _surface: &wl_surface::WlSurface, + _serial: u32, + _raw: &[u32], + _keysyms: &[Keysym], + ) { + } + + fn leave( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _keyboard: &wl_keyboard::WlKeyboard, + _surface: &wl_surface::WlSurface, + _serial: u32, + ) { + } + + fn press_key( + &mut self, + _conn: &Connection, + qh: &QueueHandle, + _keyboard: &wl_keyboard::WlKeyboard, + _serial: u32, + event: KeyEvent, + ) { + self.handle_key(qh, event); + } + + fn repeat_key( + &mut self, + _conn: &Connection, + qh: &QueueHandle, + _keyboard: &wl_keyboard::WlKeyboard, + _serial: u32, + event: KeyEvent, + ) { + self.handle_key(qh, event); + } + + fn release_key( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _keyboard: &wl_keyboard::WlKeyboard, + _serial: u32, + _event: KeyEvent, + ) { + } + + fn update_modifiers( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _keyboard: &wl_keyboard::WlKeyboard, + _serial: u32, + _modifiers: Modifiers, + _raw_modifiers: RawModifiers, + _layout: u32, + ) { + } +} + +impl AppState { + 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. + if self.auth_state == AuthState::Checking { + return; + } + + match event.keysym { + Keysym::Return | Keysym::KP_Enter => self.submit(), + Keysym::BackSpace => { + self.password.pop(); + self.clear_failed_state(); + } + Keysym::Escape => { + self.password.clear(); + self.clear_failed_state(); + } + _ => { + if let Some(text) = event.utf8 { + // Return/BackSpace/Escape are handled above by keysym; + // this guards against a compositor also sending utf8 for + // those (defensive — filters any stray control chars). + for ch in text.chars().filter(|c| !c.is_control()) { + self.password.push(ch); + } + self.clear_failed_state(); + } + } + } + + self.redraw_all(qh); + } + + fn clear_failed_state(&mut self) { + if self.auth_state == AuthState::Failed { + self.auth_state = AuthState::Idle; + } + } + + fn submit(&mut self) { + if self.password.is_empty() { + return; + } + self.auth_state = AuthState::Checking; + let password = std::mem::take(&mut self.password); + auth::spawn_check(self.username.clone(), password, self.auth_tx.clone()); + } +} diff --git a/breadlock/src/input/mod.rs b/breadlock/src/input/mod.rs new file mode 100644 index 0000000..cce11f6 --- /dev/null +++ b/breadlock/src/input/mod.rs @@ -0,0 +1 @@ +mod keyboard; diff --git a/breadlock/src/lock/mod.rs b/breadlock/src/lock/mod.rs new file mode 100644 index 0000000..e42e386 --- /dev/null +++ b/breadlock/src/lock/mod.rs @@ -0,0 +1,2 @@ +mod session; +mod surface; diff --git a/breadlock/src/lock/session.rs b/breadlock/src/lock/session.rs new file mode 100644 index 0000000..a6adf14 --- /dev/null +++ b/breadlock/src/lock/session.rs @@ -0,0 +1,48 @@ +use smithay_client_toolkit::session_lock::{ + SessionLock, SessionLockHandler, SessionLockSurface, SessionLockSurfaceConfigure, +}; +use wayland_client::{Connection, QueueHandle}; + +use crate::state::AppState; + +impl SessionLockHandler for AppState { + fn locked(&mut self, _conn: &Connection, _qh: &QueueHandle, session_lock: SessionLock) { + tracing::info!("session locked"); + self.session_lock = Some(session_lock); + } + + /// The compositor denied the lock request, or ended an active lock out + /// from under us (e.g. protocol error). Either way there's no lock left + /// to protect, so the only sane move is to exit — staying resident + /// unlocked would be worse than not running at all. + fn finished( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _session_lock: SessionLock, + ) { + tracing::warn!("compositor ended the session lock; exiting"); + self.session_lock = None; + self.exit = true; + } + + fn configure( + &mut self, + _conn: &Connection, + qh: &QueueHandle, + surface: SessionLockSurface, + configure: SessionLockSurfaceConfigure, + _serial: u32, + ) { + let (width, height) = configure.new_size; + if let Some(s) = self + .surfaces + .iter_mut() + .find(|s| s.surface.wl_surface() == surface.wl_surface()) + { + s.width = width; + s.height = height; + } + self.redraw_surface(qh, &surface, width, height); + } +} diff --git a/breadlock/src/lock/surface.rs b/breadlock/src/lock/surface.rs new file mode 100644 index 0000000..691a840 --- /dev/null +++ b/breadlock/src/lock/surface.rs @@ -0,0 +1,96 @@ +use smithay_client_toolkit::compositor::{CompositorHandler, CompositorState}; +use smithay_client_toolkit::output::{OutputHandler, OutputState}; +use wayland_client::protocol::{wl_output, wl_surface}; +use wayland_client::{Connection, QueueHandle}; + +use crate::state::{AppState, LockSurface}; + +impl CompositorHandler for AppState { + fn scale_factor_changed( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _surface: &wl_surface::WlSurface, + _new_factor: i32, + ) { + } + + fn transform_changed( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _surface: &wl_surface::WlSurface, + _new_transform: wl_output::Transform, + ) { + } + + fn frame( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _surface: &wl_surface::WlSurface, + _time: u32, + ) { + } + + fn surface_enter( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _surface: &wl_surface::WlSurface, + _output: &wl_output::WlOutput, + ) { + } + + fn surface_leave( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _surface: &wl_surface::WlSurface, + _output: &wl_output::WlOutput, + ) { + } +} + +impl OutputHandler for AppState { + fn output_state(&mut self) -> &mut OutputState { + &mut self.output_state + } + + /// A monitor appeared. If we're already locked, give it a lock surface + /// too — the initial set (for outputs present at lock time) is created + /// once in `main`, right after `SessionLockState::lock`. + fn new_output( + &mut self, + _conn: &Connection, + qh: &QueueHandle, + output: wl_output::WlOutput, + ) { + let Some(session_lock) = self.session_lock.clone() else { + return; + }; + let surface = CompositorState::create_surface(&self.compositor_state, qh); + let lock_surface = session_lock.create_lock_surface(surface, &output, qh); + self.surfaces.push(LockSurface { + surface: lock_surface, + width: 0, + height: 0, + }); + } + + fn update_output( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _output: wl_output::WlOutput, + ) { + } + + fn output_destroyed( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _output: wl_output::WlOutput, + ) { + } +} diff --git a/breadlock/src/main.rs b/breadlock/src/main.rs new file mode 100644 index 0000000..e351b1a --- /dev/null +++ b/breadlock/src/main.rs @@ -0,0 +1,151 @@ +mod auth; +mod background; +mod config; +mod input; +mod lock; +mod render; +mod state; + +use smithay_client_toolkit::compositor::CompositorState; +use smithay_client_toolkit::output::OutputState; +use smithay_client_toolkit::reexports::calloop::timer::{TimeoutAction, Timer}; +use smithay_client_toolkit::reexports::calloop::EventLoop; +use smithay_client_toolkit::reexports::calloop_wayland_source::WaylandSource; +use smithay_client_toolkit::registry::RegistryState; +use smithay_client_toolkit::seat::SeatState; +use smithay_client_toolkit::session_lock::SessionLockState; +use smithay_client_toolkit::shm::Shm; +use std::time::Duration; +use wayland_client::globals::registry_queue_init; +use wayland_client::{protocol::wl_buffer, Connection, QueueHandle}; + +use background::Background; +use state::{AppState, AuthState, LockSurface}; + +fn main() { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + 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 config = config::load(); + let palette = breadlock_ui::theme::load_palette(); + let background = Background::load(&config.appearance.background, &palette); + + let conn = Connection::connect_to_env().expect("failed to connect to the Wayland display — breadlock must run inside an active Wayland session"); + let (globals, event_queue) = + registry_queue_init::(&conn).expect("failed to initialize Wayland registry"); + let qh: QueueHandle = event_queue.handle(); + let mut event_loop: EventLoop = + EventLoop::try_new().expect("failed to create the calloop event loop"); + let loop_handle = event_loop.handle(); + + let auth_result_qh = qh.clone(); + let auth_tx = auth::register(&loop_handle, move |state: &mut AppState, result| { + match result { + Ok(()) => { + tracing::info!("authenticated, unlocking"); + if let Some(lock) = state.session_lock.take() { + lock.unlock(); + } + state.exit = true; + } + Err(err) => { + tracing::warn!(%err, "authentication failed"); + state.auth_state = AuthState::Failed; + state.schedule_clear_failed(auth_result_qh.clone()); + } + } + state.redraw_all(&auth_result_qh); + }); + + let compositor_state = + CompositorState::bind(&globals, &qh).expect("compositor global not advertised"); + let output_state = OutputState::new(&globals, &qh); + let shm = Shm::bind(&globals, &qh).expect("wl_shm global not advertised"); + let session_lock_state = SessionLockState::new(&globals, &qh); + + let mut app_state = AppState { + loop_handle: loop_handle.clone(), + conn: conn.clone(), + compositor_state, + output_state, + registry_state: RegistryState::new(&globals), + seat_state: SeatState::new(&globals, &qh), + shm, + session_lock_state, + session_lock: None, + surfaces: Vec::new(), + keyboard: None, + config, + palette, + background, + text_renderer: breadlock_ui::painter::TextRenderer::new(), + username, + password: String::new(), + auth_state: AuthState::Idle, + auth_tx, + exit: false, + }; + + // Request the lock immediately — locking the session is the entire + // purpose of this process, not a toggle. Per ext-session-lock-v1, a lock + // surface must exist for every output *before* the compositor sends + // `locked` (see SessionLockHandler::locked's doc comment upstream). + let session_lock = app_state + .session_lock_state + .lock(&qh) + .expect("compositor does not support ext-session-lock-v1 — cannot lock the session"); + for output in app_state.output_state.outputs() { + let surface = app_state.compositor_state.create_surface(&qh); + let lock_surface = session_lock.create_lock_surface(surface, &output, &qh); + app_state.surfaces.push(LockSurface { + surface: lock_surface, + width: 0, + height: 0, + }); + } + app_state.session_lock = Some(session_lock); + + WaylandSource::new(conn, event_queue) + .insert(loop_handle.clone()) + .expect("failed to register the Wayland source on the event loop"); + + // Redraw every surface once a second so the clock stays live even with + // no keyboard input. + loop_handle + .insert_source( + Timer::from_duration(Duration::from_secs(1)), + move |_, _, state| { + state.redraw_all(&qh); + TimeoutAction::ToDuration(Duration::from_secs(1)) + }, + ) + .expect("failed to register the clock-tick timer"); + + while !app_state.exit { + if let Err(err) = event_loop.dispatch(Duration::from_millis(250), &mut app_state) { + tracing::error!(%err, "event loop dispatch failed"); + break; + } + } + + // Make sure the compositor actually receives the unlock/destroy + // requests queued above before the process exits. + let _ = app_state.conn.roundtrip(); +} + +smithay_client_toolkit::delegate_compositor!(AppState); +smithay_client_toolkit::delegate_output!(AppState); +smithay_client_toolkit::delegate_session_lock!(AppState); +smithay_client_toolkit::delegate_shm!(AppState); +smithay_client_toolkit::delegate_seat!(AppState); +smithay_client_toolkit::delegate_keyboard!(AppState); +smithay_client_toolkit::delegate_registry!(AppState); +wayland_client::delegate_noop!(AppState: ignore wl_buffer::WlBuffer); diff --git a/breadlock/src/render.rs b/breadlock/src/render.rs new file mode 100644 index 0000000..37796dc --- /dev/null +++ b/breadlock/src/render.rs @@ -0,0 +1,179 @@ +//! Frame composition: paints one full lock-screen frame (background, +//! password pill, clock, status line) into a `tiny_skia::Pixmap`, then +//! copies it into a Wayland `wl_shm` buffer. +//! +//! tiny-skia's in-memory pixel format is byte-order RGBA; `wl_shm`'s +//! `Argb8888` format is host-endian `0xAARRGGBB`, i.e. byte-order BGRA on +//! little-endian machines. [`blit_to_shm`] does the swizzle. + +use crate::background::Background; +use breadlock_ui::painter::{rounded_rect, tokens, TextRenderer}; +use breadlock_ui::theme::tiny_skia_color; +use tiny_skia::{Color, Paint, Pixmap}; + +pub struct FrameInputs<'a> { + pub width: u32, + pub height: u32, + pub background: &'a Background, + pub palette: &'a breadlock_ui::theme::Palette, + pub font_family: &'a str, + pub clock_text: &'a str, + pub password_len: usize, + /// True while showing a failed-attempt state (red pill). No animated + /// shake in v1 — just a color/status-text indicator. + pub failed: bool, + pub status_text: Option<&'a str>, +} + +/// Composes one frame. Returns `None` only if `width`/`height` are degenerate +/// (a `0x0` `configure`, which some compositors send transiently). +pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option { + let mut pixmap = Pixmap::new(inputs.width, inputs.height)?; + inputs.background.paint(&mut pixmap); + + let (w, h) = (inputs.width as f32, inputs.height as f32); + let surface_color = tiny_skia_color(&inputs.palette.color0); + let accent_color = tiny_skia_color(&inputs.palette.color4); + let on_surface = tiny_skia_color(breadlock_ui::theme::ink_on(&inputs.palette.color0)); + let red_color = tiny_skia_color(&inputs.palette.color1); + + // Clock, large, centered in the upper third. + let clock_size = 64.0; + let clock_w = text.measure_line(inputs.clock_text, inputs.font_family, clock_size); + text.draw_line( + &mut pixmap, + inputs.clock_text, + inputs.font_family, + clock_size, + Color::WHITE, + (w - clock_w) / 2.0, + h * 0.28, + ); + + // Password pill, centered; turns red while showing a failed attempt. + let pill_w = 280.0_f32.min(w - tokens::SPACE_XL as f32 * 2.0); + let pill_h = 48.0; + let pill_x = (w - pill_w) / 2.0; + let pill_y = h * 0.5; + + if let Some(path) = rounded_rect( + pill_x, + pill_y, + pill_w, + pill_h, + tokens::RADIUS_SECONDARY as f32, + ) { + let mut paint = Paint::default(); + paint.set_color(if inputs.failed { + red_color + } else { + surface_color + }); + paint.anti_alias = true; + pixmap.fill_path( + &path, + &paint, + tiny_skia::FillRule::Winding, + tiny_skia::Transform::identity(), + None, + ); + } + + // Password dots — one filled circle per typed character, capped so a + // very long password can't overflow the pill. + let dot_r = 5.0; + let dot_gap = 18.0; + let max_dots = ((pill_w - tokens::SPACE_LG as f32 * 2.0) / dot_gap) + .floor() + .max(1.0) as usize; + let shown_dots = inputs.password_len.min(max_dots); + if shown_dots > 0 { + let dots_w = (shown_dots as f32 - 1.0).max(0.0) * dot_gap; + let start_x = pill_x + (pill_w - dots_w) / 2.0; + let dot_y = pill_y + pill_h / 2.0; + for i in 0..shown_dots { + if let Some(path) = + tiny_skia::PathBuilder::from_circle(start_x + i as f32 * dot_gap, dot_y, dot_r) + { + let mut paint = Paint::default(); + paint.set_color(if inputs.failed { + Color::WHITE + } else { + accent_color + }); + paint.anti_alias = true; + pixmap.fill_path( + &path, + &paint, + tiny_skia::FillRule::Winding, + tiny_skia::Transform::identity(), + None, + ); + } + } + } + + // Status line (e.g. "wrong password" / "checking…") below the pill. + if let Some(status) = inputs.status_text { + let status_size = tokens::FONT_SIZE_SECONDARY as f32; + let status_w = text.measure_line(status, inputs.font_family, status_size); + text.draw_line( + &mut pixmap, + status, + inputs.font_family, + status_size, + on_surface, + (w - status_w) / 2.0, + pill_y + pill_h + tokens::SPACE_MD as f32, + ); + } + + Some(pixmap) +} + +/// Copies a composed frame into a `wl_shm` `Argb8888` buffer, swizzling +/// tiny-skia's RGBA byte order to the host-endian `0xAARRGGBB` `wl_shm` +/// expects (BGRA bytes on little-endian, which is every target this ships +/// on). +pub fn blit_to_shm(pixmap: &Pixmap, shm_bytes: &mut [u8]) { + for (src, dst) in pixmap.pixels().iter().zip(shm_bytes.chunks_exact_mut(4)) { + dst[0] = src.blue(); + dst[1] = src.green(); + dst[2] = src.red(); + dst[3] = src.alpha(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blit_swizzles_rgba_to_bgra() { + let mut pixmap = Pixmap::new(1, 1).unwrap(); + pixmap.fill(Color::from_rgba8(10, 20, 30, 255)); + let mut shm = vec![0u8; 4]; + blit_to_shm(&pixmap, &mut shm); + assert_eq!(shm, vec![30, 20, 10, 255]); + } + + #[test] + fn compose_handles_empty_password_and_no_status() { + let bg = Background::Color(Color::BLACK); + let palette = breadlock_ui::theme::Palette::default(); + let mut text = TextRenderer::new(); + let inputs = FrameInputs { + width: 400, + height: 300, + background: &bg, + palette: &palette, + font_family: "sans-serif", + clock_text: "12:34", + password_len: 0, + failed: false, + status_text: None, + }; + let pixmap = compose(&mut text, &inputs).unwrap(); + assert_eq!((pixmap.width(), pixmap.height()), (400, 300)); + } +} diff --git a/breadlock/src/state.rs b/breadlock/src/state.rs new file mode 100644 index 0000000..0dc852e --- /dev/null +++ b/breadlock/src/state.rs @@ -0,0 +1,174 @@ +use smithay_client_toolkit::compositor::CompositorState; +use smithay_client_toolkit::output::OutputState; +use smithay_client_toolkit::reexports::calloop::channel::Sender; +use smithay_client_toolkit::reexports::calloop::timer::{TimeoutAction, Timer}; +use smithay_client_toolkit::reexports::calloop::LoopHandle; +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::{Shm, ShmHandler}; +use std::time::Duration; +use wayland_client::protocol::{wl_keyboard, wl_shm}; +use wayland_client::{Connection, QueueHandle}; + +use crate::auth::AuthResult; +use crate::background::Background; +use crate::config::Config; +use crate::render; + +/// Per-output lock surface plus the size the compositor last `configure`d it +/// to (0x0 until the first configure arrives). +pub struct LockSurface { + pub surface: SessionLockSurface, + pub width: u32, + pub height: u32, +} + +#[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. + Checking, + Failed, +} + +pub struct AppState { + pub loop_handle: LoopHandle<'static, AppState>, + pub conn: Connection, + pub compositor_state: CompositorState, + pub output_state: OutputState, + pub registry_state: RegistryState, + pub seat_state: SeatState, + pub shm: Shm, + pub session_lock_state: SessionLockState, + pub session_lock: Option, + pub surfaces: Vec, + pub keyboard: Option, + + pub config: Config, + pub palette: breadlock_ui::theme::Palette, + pub background: Background, + pub text_renderer: breadlock_ui::painter::TextRenderer, + + pub username: String, + pub password: String, + pub auth_state: AuthState, + pub auth_tx: Sender, + + pub exit: bool, +} + +impl AppState { + /// Composes and uploads a fresh frame for one lock surface. A `0x0` size + /// (surfaces are created at that size before their first `configure`) is + /// skipped rather than allocating a degenerate shm pool. + pub fn redraw_surface( + &mut self, + qh: &QueueHandle, + surface: &SessionLockSurface, + width: u32, + height: u32, + ) { + if width == 0 || height == 0 { + return; + } + + let clock_text = chrono::Local::now() + .format(&self.config.appearance.clock.format) + .to_string(); + let status_text = match self.auth_state { + AuthState::Checking => Some("Checking…".to_string()), + AuthState::Failed => Some("Wrong password".to_string()), + AuthState::Idle => None, + }; + + let inputs = render::FrameInputs { + width, + height, + background: &self.background, + palette: &self.palette, + font_family: &self.config.appearance.font.family, + clock_text: &clock_text, + password_len: self.password.len(), + failed: self.auth_state == AuthState::Failed, + status_text: status_text.as_deref(), + }; + + 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; + } + }; + 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, + ); + + surface.wl_surface().attach(Some(&buffer), 0, 0); + surface + .wl_surface() + .damage_buffer(0, 0, width as i32, height as i32); + surface.wl_surface().commit(); + buffer.destroy(); + } + + /// Redraws every currently-configured surface — used for the clock tick + /// and after any password/auth-state change. + pub fn redraw_all(&mut self, qh: &QueueHandle) { + let surfaces: Vec<(SessionLockSurface, u32, u32)> = self + .surfaces + .iter() + .map(|s| (s.surface.clone(), s.width, s.height)) + .collect(); + for (surface, width, height) in surfaces { + self.redraw_surface(qh, &surface, width, height); + } + } + + /// 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. + pub fn schedule_clear_failed(&self, qh: QueueHandle) { + let timeout = Duration::from_millis(self.config.input.fail_timeout_ms); + let _ = + self.loop_handle + .insert_source(Timer::from_duration(timeout), move |_, _, state| { + if state.auth_state == AuthState::Failed { + state.auth_state = AuthState::Idle; + state.redraw_all(&qh); + } + TimeoutAction::Drop + }); + } +} + +impl ShmHandler for AppState { + fn shm_state(&mut self) -> &mut Shm { + &mut self.shm + } +} + +impl ProvidesRegistryState for AppState { + fn registry(&mut self) -> &mut RegistryState { + &mut self.registry_state + } + registry_handlers![OutputState, SeatState]; +} diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD new file mode 100644 index 0000000..2b5768d --- /dev/null +++ b/packaging/arch/PKGBUILD @@ -0,0 +1,41 @@ +# Maintainer: Breadway + +pkgname=breadlock +pkgver=0.1.0 +pkgrel=1 +pkgdesc="Session locker and greetd greeter for Hyprland / Wayland" +arch=('x86_64') +url="https://github.com/Breadway/breadlock" +license=('MIT') +# Some Rust deps build vendored C/asm into static archives; makepkg's default +# -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read, causing +# undefined-symbol errors. Disable LTO. (Same issue as breadbar/breadbox.) +options=(!lto !debug) +depends=('pam' 'wayland' 'libxkbcommon' 'gtk4') +optdepends=( + 'cage: minimal Wayland compositor to host breadgreet under greetd' + 'hyprland: the session breadlock protects and breadgreet launches' +) +makedepends=('rust' 'cargo') +source=("${pkgname}-${pkgver}.tar.gz") +sha256sums=('SKIP') + +build() { + cd "${srcdir}/${pkgname}-${pkgver}" + # --bin (not -p breadlock) deliberately excludes the breadlock-auth-check + # dev harness, which shares the breadlock package but isn't installed. + cargo build --release --locked --bin breadlock --bin breadgreet +} + +check() { + cd "${srcdir}/${pkgname}-${pkgver}" + cargo test --release --locked --workspace +} + +package() { + cd "${srcdir}/${pkgname}-${pkgver}" + install -Dm755 target/release/breadlock "${pkgdir}/usr/bin/breadlock" + install -Dm755 target/release/breadgreet "${pkgdir}/usr/bin/breadgreet" + install -Dm644 packaging/pam.d/breadlock "${pkgdir}/etc/pam.d/breadlock" + install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" +} diff --git a/packaging/pam.d/breadlock b/packaging/pam.d/breadlock new file mode 100644 index 0000000..e7d698c --- /dev/null +++ b/packaging/pam.d/breadlock @@ -0,0 +1,12 @@ +#%PAM-1.0 +# Installed to /etc/pam.d/breadlock by the breadlock package. +# +# Mirrors this stack's real system-auth (so pam_faillock lockout protection +# applies here too) rather than the thinner `login`-only include some lock +# screens use. The extra `session` line avoids a known pam_end()-on- +# uninitialized-handle crash class seen in minimal PAM files for lock +# screens; breadlock never opens a real session (the graphical session is +# already open), so no `session required pam_unix.so` line is needed. +auth include system-auth +account include system-auth +session optional pam_keyinit.so force revoke