From 9f97f2c98971b83316d39ec75b85b0d2995ebc68 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 3 Jul 2026 13:37:31 +0800 Subject: [PATCH] Initial commit --- .gitignore | 21 + Cargo.lock | 2254 +++++++++++++++++++++ Cargo.toml | 18 + LICENSE | 21 + README.md | 83 + bakery.toml | 15 + breadgreet.example.toml | 26 + breadgreet/Cargo.toml | 25 + breadgreet/src/config.rs | 63 + breadgreet/src/greetd/client.rs | 250 +++ breadgreet/src/greetd/mod.rs | 3 + breadgreet/src/main.rs | 304 +++ breadgreet/src/sessions.rs | 94 + breadgreet/src/theme.rs | 35 + breadlock-ui/Cargo.toml | 20 + breadlock-ui/src/config.rs | 111 + breadlock-ui/src/desktop_entry.rs | 134 ++ breadlock-ui/src/lib.rs | 6 + breadlock-ui/src/painter.rs | 171 ++ breadlock-ui/src/theme.rs | 32 + breadlock.example.toml | 22 + breadlock/Cargo.toml | 31 + breadlock/src/auth/mod.rs | 44 + breadlock/src/auth/pam.rs | 35 + breadlock/src/background.rs | 68 + breadlock/src/bin/breadlock-auth-check.rs | 58 + breadlock/src/config.rs | 56 + breadlock/src/input/keyboard.rs | 167 ++ breadlock/src/input/mod.rs | 1 + breadlock/src/lock/mod.rs | 2 + breadlock/src/lock/session.rs | 48 + breadlock/src/lock/surface.rs | 96 + breadlock/src/main.rs | 151 ++ breadlock/src/render.rs | 179 ++ breadlock/src/state.rs | 174 ++ packaging/arch/PKGBUILD | 41 + packaging/pam.d/breadlock | 12 + 37 files changed, 4871 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 bakery.toml create mode 100644 breadgreet.example.toml create mode 100644 breadgreet/Cargo.toml create mode 100644 breadgreet/src/config.rs create mode 100644 breadgreet/src/greetd/client.rs create mode 100644 breadgreet/src/greetd/mod.rs create mode 100644 breadgreet/src/main.rs create mode 100644 breadgreet/src/sessions.rs create mode 100644 breadgreet/src/theme.rs create mode 100644 breadlock-ui/Cargo.toml create mode 100644 breadlock-ui/src/config.rs create mode 100644 breadlock-ui/src/desktop_entry.rs create mode 100644 breadlock-ui/src/lib.rs create mode 100644 breadlock-ui/src/painter.rs create mode 100644 breadlock-ui/src/theme.rs create mode 100644 breadlock.example.toml create mode 100644 breadlock/Cargo.toml create mode 100644 breadlock/src/auth/mod.rs create mode 100644 breadlock/src/auth/pam.rs create mode 100644 breadlock/src/background.rs create mode 100644 breadlock/src/bin/breadlock-auth-check.rs create mode 100644 breadlock/src/config.rs create mode 100644 breadlock/src/input/keyboard.rs create mode 100644 breadlock/src/input/mod.rs create mode 100644 breadlock/src/lock/mod.rs create mode 100644 breadlock/src/lock/session.rs create mode 100644 breadlock/src/lock/surface.rs create mode 100644 breadlock/src/main.rs create mode 100644 breadlock/src/render.rs create mode 100644 breadlock/src/state.rs create mode 100644 packaging/arch/PKGBUILD create mode 100644 packaging/pam.d/breadlock 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