diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4d816a4..cb4d0e1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,10 +3,13 @@ This repo is a Cargo workspace. Bakery-channel products shipped from here are `bakery` (the ecosystem package manager) and `bread-theme` (the shared theming crate). Shared crates that sibling apps pin — not bakery packages -of their own — are `bread-utils`, `bread-onnx`, `bread-screenshots`, and -`bread-capture`. Other ecosystem products (`bread`, `breadbar`, `breadbox`, -…) live in their own repos under `Breadway/` but follow the same workflow -described here. The product list is `registry/bread-ecosystem.toml`. +of their own — are `bread-utils`, `bread-app`, `bread-onnx`, +`bread-screenshots`, and `bread-capture`. `bread-polkit` is an in-tree +session agent, also not a bakery product. Other ecosystem products +(`bread`, `breadbar`, `breadbox`, …) live in their own repos under +`Breadway/` but follow the same workflow described here. The product list +is `registry/bread-ecosystem.toml`. New GTK tools should depend on +`bread-app` instead of copying another app's bootstrap. ## Branches @@ -88,10 +91,12 @@ cargo build --release -p bakery cargo test --release -p bakery ``` -`bakery`, `bread-theme`, `bread-utils`, `bread-onnx`, `bread-screenshots`, -and `bread-capture` are all workspace members. Run the same commands with -`-p bread-theme --bin bread-theme` for that crate, or `-p bread-utils ---features bread-client` for the IPC client. +`bakery`, `bread-theme`, `bread-utils`, `bread-app`, `bread-polkit`, +`bread-onnx`, `bread-screenshots`, and `bread-capture` are all workspace +members. Run the same commands with `-p bread-theme --bin bread-theme` +for that crate, `-p bread-utils --features bread-client` for the IPC +client, or `-p bread-app --features bread-client` for the GTK bootstrap +helpers. ## CI diff --git a/Cargo.lock b/Cargo.lock index 235b235..070c24d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -96,6 +96,40 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -151,6 +185,13 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bread-app" +version = "0.7.2" +dependencies = [ + "bread-utils", +] + [[package]] name = "bread-capture" version = "0.7.2" @@ -176,6 +217,21 @@ dependencies = [ "ureq", ] +[[package]] +name = "bread-polkit" +version = "0.7.2" +dependencies = [ + "anyhow", + "bread-app", + "bread-theme", + "gtk4", + "serde", + "tokio", + "tracing", + "tracing-subscriber", + "zbus", +] + [[package]] name = "bread-screenshots" version = "0.7.2" @@ -239,6 +295,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cairo-rs" version = "0.22.0" @@ -350,7 +412,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -478,7 +540,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.119", ] [[package]] @@ -489,7 +551,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -519,7 +581,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -529,7 +591,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn", + "syn 2.0.119", ] [[package]] @@ -571,7 +633,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -586,6 +648,33 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -611,6 +700,26 @@ dependencies = [ "cc", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -709,6 +818,19 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.33" @@ -717,7 +839,7 @@ checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -921,7 +1043,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1055,7 +1177,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1302,6 +1424,12 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libadwaita" version = "0.9.2" @@ -1397,6 +1525,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matrixmultiply" version = "0.3.11" @@ -1444,6 +1581,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "monostate" version = "0.1.18" @@ -1463,7 +1611,7 @@ checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1568,6 +1716,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "ort" version = "2.0.0-rc.12" @@ -1610,6 +1768,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "paste" version = "1.0.15" @@ -1960,7 +2124,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1976,6 +2140,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -2005,12 +2180,31 @@ dependencies = [ "digest", ] +[[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.10" @@ -2029,6 +2223,16 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "spm_precompiled" version = "0.1.4" @@ -2076,6 +2280,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -2084,7 +2299,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2145,7 +2360,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2156,7 +2371,16 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", ] [[package]] @@ -2203,6 +2427,34 @@ dependencies = [ "unicode_categories", ] +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "toml" version = "0.8.23" @@ -2314,7 +2566,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2326,12 +2578,38 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "once_cell", + "regex-automata", + "sharded-slab", + "thread_local", + "tracing", + "tracing-core", +] + [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2419,6 +2697,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "version-compare" version = "0.2.1" @@ -2478,7 +2767,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -2540,7 +2829,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2551,7 +2840,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2781,10 +3070,75 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.3", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.54" @@ -2802,7 +3156,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2822,7 +3176,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -2862,7 +3216,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2870,3 +3224,44 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.3", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.3", + "winnow 1.0.4", +] diff --git a/Cargo.toml b/Cargo.toml index 555bc60..edccbba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["bakery", "bread-theme", "bread-utils", "bread-onnx", "bread-screenshots", "bread-capture"] +members = ["bakery", "bread-theme", "bread-utils", "bread-onnx", "bread-screenshots", "bread-capture", "bread-app", "bread-polkit"] resolver = "2" [workspace.package] diff --git a/README.md b/README.md index b769262..34ccf27 100644 --- a/README.md +++ b/README.md @@ -145,13 +145,15 @@ Install all required deps with `sudo pacman -S `. Use `pacman -Q This repo is a Cargo workspace. Bakery-channel products shipped from here are `bakery` and `bread-theme`; the other members are shared crates sibling -apps pin, not bakery packages of their own. +apps pin, or in-tree tools that are not bakery packages of their own. ``` bread-ecosystem/ ├── bakery/ # package manager binary ├── bread-theme/ # shared pywal + fixed-dark-base theming crate ├── bread-utils/ # shared plumbing (Hyprland IPC, singleton, XDG, BreadClient, …) +├── bread-app/ # GTK bootstrap new tools should use (app id, singleton, overlay, command listen) +├── bread-polkit/ # themed PolicyKit authentication agent (not a bakery product) ├── bread-onnx/ # shared ONNX runtime helpers ├── bread-screenshots/ # grim capture primitive used by app `--screenshot` modes ├── bread-capture/ # orchestrator that drives those `--screenshot` modes @@ -162,6 +164,39 @@ bread-ecosystem/ └── gen-readme-products.sh # rewrites the Products table from the registry ``` +### New GTK tools + +Do not copy another app's `main.rs`. Depend on `bread-app`: + +- `bread_app::application_id` / `try_acquire` / `toggle_or_kill` for the + `com.breadway.*` application id and single-instance lock +- feature `gtk` re-exports `bread_utils::gtk_popup` (layer-shell overlay) +- feature `bread-client` for `listen_commands` on `bread.command..**` + +See the `bread-app` crate docs. Existing apps are not migrated in this +tree; `bread-polkit` is the first in-tree consumer. + +### bread-polkit + +A session PolicyKit authentication agent (password prompt, cancel, +identity). Not a wrapper around `polkit-gnome`. Not published via bakery +and not on the BOS ISO lockfile. + +```sh +cargo run -p bread-polkit +``` + +Autostart — pick one: + +```sh +cp bread-polkit/contrib/bread-polkit.desktop ~/.config/autostart/ +``` + +``` +# hyprland.conf +exec-once = bread-polkit +``` + ## Release pipeline Each product repo (`Breadway/bread`, `Breadway/breadbar`, …) has diff --git a/bread-app/Cargo.toml b/bread-app/Cargo.toml new file mode 100644 index 0000000..a212db3 --- /dev/null +++ b/bread-app/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "bread-app" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "GTK application bootstrap for bread desktop tools: app id, singleton, optional overlay popup, and command listen loop" +repository = "https://git.breadway.dev/Breadway/bread-ecosystem" +keywords = ["gtk4", "wayland", "hyprland"] + +[dependencies] +bread-utils = { path = "../bread-utils" } + +[features] +# Layer-shell overlay helper (`gtk_popup`). Matches `bread-utils/gtk` so a +# consumer that only wants app-id / singleton helpers does not pull GTK4. +gtk = ["bread-utils/gtk"] +# `BreadClient` listen loop on `bread.command..**`. Matches +# `bread-utils/bread-client`. +bread-client = ["bread-utils/bread-client"] diff --git a/bread-app/src/command.rs b/bread-app/src/command.rs new file mode 100644 index 0000000..41ad12a --- /dev/null +++ b/bread-app/src/command.rs @@ -0,0 +1,121 @@ +//! Command-bus helpers for `bread.command..**`. +//! +//! The `command_id` here is the breadd sibling-app id (`clip`, `box`, +//! `shot`) — often shorter than the GTK / singleton name (`breadclip`). + +use crate::id::{parse_app_name, InvalidAppId}; +use bread_utils::bread_client::{BreadClient, BreadEvent, Subscription}; + +/// Same charset as [`parse_app_name`]: a single command-bus segment. +pub fn parse_command_id(command_id: &str) -> Result<&str, InvalidAppId> { + parse_app_name(command_id) +} + +/// Subscribe glob: `bread.command..**`. +pub fn command_pattern(command_id: &str) -> Result { + let id = parse_command_id(command_id)?; + Ok(format!("bread.command.{id}.**")) +} + +/// The verb segment of `bread.command..` (and extra trailing +/// segments, if any). `None` when the event is not addressed to +/// `command_id` or the verb is missing. +/// +/// Extra dotted remainder (`bread.command.clip.stack.clear`) yields the +/// first remaining segment (`stack`) — a verb is one segment, matching +/// [`BreadClient::command`]. +pub fn command_verb<'a>(event: &'a str, command_id: &str) -> Option<&'a str> { + if command_id.is_empty() { + return None; + } + let prefix = format!("bread.command.{command_id}."); + let rest = event.strip_prefix(&prefix)?; + let verb = rest.split('.').next()?; + if verb.is_empty() { + None + } else { + Some(verb) + } +} + +/// Subscribe to `bread.command..**` and invoke `on_verb` with +/// the parsed verb plus the raw event. +/// +/// Fail-silent: constructing the client and holding the subscription never +/// requires breadd to be running. Drop the returned [`Subscription`] (or +/// call [`Subscription::stop`]) to end the loop. +pub fn listen_commands(command_id: &str, on_verb: F) -> Result +where + F: Fn(&str, BreadEvent) + Send + 'static, +{ + let id = parse_command_id(command_id)?.to_string(); + let client = BreadClient::connect(id.clone()); + let pattern = format!("bread.command.{id}.**"); + Ok(client.subscribe(pattern, move |event| { + let Some(verb) = command_verb(&event.event, &id).map(str::to_owned) else { + return; + }; + on_verb(&verb, event); + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_pattern_uses_double_star() { + assert_eq!(command_pattern("clip").unwrap(), "bread.command.clip.**"); + assert_eq!(command_pattern("shot").unwrap(), "bread.command.shot.**"); + } + + #[test] + fn command_pattern_rejects_invalid_id() { + assert!(command_pattern("").is_err()); + assert!(command_pattern("clip.clear").is_err()); + } + + #[test] + fn command_verb_strips_app_prefix() { + assert_eq!( + command_verb("bread.command.clip.clear", "clip"), + Some("clear") + ); + assert_eq!( + command_verb("bread.command.shot.region", "shot"), + Some("region") + ); + assert_eq!( + command_verb("bread.command.shot.annotate", "shot"), + Some("annotate") + ); + } + + #[test] + fn command_verb_takes_first_segment_only() { + assert_eq!( + command_verb("bread.command.clip.stack.clear", "clip"), + Some("stack") + ); + } + + #[test] + fn command_verb_rejects_other_apps_and_missing_verb() { + assert_eq!(command_verb("bread.command.clip.clear", "shot"), None); + assert_eq!(command_verb("bread.command.clip", "clip"), None); + assert_eq!(command_verb("bread.command.clip.", "clip"), None); + assert_eq!(command_verb("bread.clip.copied", "clip"), None); + assert_eq!(command_verb("bread.command.clip.clear", ""), None); + } + + #[test] + fn listen_commands_rejects_invalid_id() { + assert!(listen_commands("", |_, _| {}).is_err()); + } + + #[test] + fn listen_commands_stop_joins_without_a_daemon() { + let sub = listen_commands("clip", |_, _| {}).unwrap(); + sub.stop(); + } +} diff --git a/bread-app/src/id.rs b/bread-app/src/id.rs new file mode 100644 index 0000000..8fdaaab --- /dev/null +++ b/bread-app/src/id.rs @@ -0,0 +1,145 @@ +//! App-id helpers shared by GTK tools and the singleton lock. +//! +//! The process / pid-file name (`breadbox`, `bread-polkit`) is also the +//! last segment of the GApplication id (`com.breadway.breadbox`). That is +//! *not* always the breadd command-bus id (`box`, `clip`) — see +//! [`crate::command_verb`] under feature `bread-client`. + +use std::io; + +use crate::singleton::{self, Acquire, Toggle}; + +/// Why [`parse_app_name`] / [`application_id`] rejected a string. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InvalidAppId { + /// The rejected input, owned so the error is `'static`. + pub name: String, + /// Short reason suitable for an `io::Error` / clap message. + pub reason: &'static str, +} + +impl std::fmt::Display for InvalidAppId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "invalid app id '{}': {}", self.name, self.reason) + } +} + +impl std::error::Error for InvalidAppId {} + +/// Accept a process / GTK application name (`breadbox`, `bread-polkit`). +/// +/// Rules match a GApplication id *element*: non-empty, ASCII letter first, +/// then ASCII alphanumeric / `-` / `_`. Dots are rejected so the name can +/// sit in `com.breadway.` without creating extra segments. +pub fn parse_app_name(name: &str) -> Result<&str, InvalidAppId> { + if name.is_empty() { + return Err(InvalidAppId { + name: name.to_string(), + reason: "must not be empty", + }); + } + let mut chars = name.chars(); + let first = chars.next().expect("non-empty"); + if !first.is_ascii_alphabetic() { + return Err(InvalidAppId { + name: name.to_string(), + reason: "must start with an ASCII letter", + }); + } + if !chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { + return Err(InvalidAppId { + name: name.to_string(), + reason: "only ASCII letters, digits, '-' and '_' are allowed", + }); + } + Ok(name) +} + +/// Reverse-DNS GApplication id: `com.breadway.`. +pub fn application_id(app_name: &str) -> Result { + let name = parse_app_name(app_name)?; + Ok(format!("com.breadway.{name}")) +} + +/// [`singleton::try_acquire`] after [`parse_app_name`]. +/// +/// Invalid names become [`io::ErrorKind::InvalidInput`] and never touch +/// the pid file. +pub fn try_acquire(app_name: &str) -> io::Result { + let name = + parse_app_name(app_name).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; + singleton::try_acquire(name) +} + +/// [`singleton::toggle_or_kill`] after [`parse_app_name`]. +pub fn toggle_or_kill(app_name: &str) -> io::Result { + let name = + parse_app_name(app_name).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; + singleton::toggle_or_kill(name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_app_name_accepts_existing_tool_names() { + for name in ["breadbox", "breadclip", "bread-polkit", "breadcast"] { + assert_eq!(parse_app_name(name), Ok(name)); + } + } + + #[test] + fn parse_app_name_rejects_empty_dot_and_leading_digit() { + assert!(parse_app_name("").is_err()); + assert!(parse_app_name("bread.box").is_err()); + assert!(parse_app_name("1box").is_err()); + assert!(parse_app_name("-box").is_err()); + assert!(parse_app_name("bread box").is_err()); + } + + #[test] + fn application_id_uses_com_breadway_prefix() { + assert_eq!(application_id("breadbox").unwrap(), "com.breadway.breadbox"); + assert_eq!( + application_id("bread-polkit").unwrap(), + "com.breadway.bread-polkit" + ); + } + + #[test] + fn application_id_rejects_invalid_name() { + assert!(application_id("").is_err()); + assert!(application_id("bread.box").is_err()); + } + + #[test] + fn try_acquire_rejects_invalid_name_before_lock() { + match try_acquire("") { + Err(err) => assert_eq!(err.kind(), io::ErrorKind::InvalidInput), + Ok(_) => panic!("empty name must not acquire a lock"), + } + match try_acquire("bread.box") { + Err(err) => assert_eq!(err.kind(), io::ErrorKind::InvalidInput), + Ok(_) => panic!("dotted name must not acquire a lock"), + } + } + + #[test] + fn try_acquire_accepts_valid_name() { + let name = format!("bread-app-id-test-{}", std::process::id()); + match try_acquire(&name).unwrap() { + Acquire::Acquired(_guard) => {} + Acquire::HeldByOther(_) => panic!("expected first acquire to succeed"), + } + } + + #[test] + fn toggle_or_kill_starts_when_nothing_else_is_running() { + let name = format!("bread-app-toggle-test-{}", std::process::id()); + match toggle_or_kill(&name).unwrap() { + Toggle::Started(_guard) => {} + Toggle::KilledExisting => panic!("expected to start as the first instance"), + } + } +} diff --git a/bread-app/src/lib.rs b/bread-app/src/lib.rs new file mode 100644 index 0000000..f848509 --- /dev/null +++ b/bread-app/src/lib.rs @@ -0,0 +1,67 @@ +//! GTK application bootstrap for bread desktop tools. +//! +//! New GTK tools should depend on this crate instead of copying a sixth +//! `main.rs` that wires a `com.breadway.*` application id, a +//! [`bread_utils::singleton`] lock, a layer-shell overlay, and a +//! `bread.command..**` listen loop. +//! +//! # What this is +//! +//! The pieces every bread GTK binary already copies: +//! +//! - [`application_id`] / [`parse_app_name`] — reverse-DNS id +//! (`com.breadway.breadbox`) and the same name used for the singleton +//! pid file. +//! - [`try_acquire`] / [`toggle_or_kill`] — [`bread_utils::singleton`] +//! wrappers that reject an invalid name before touching the lock. +//! - feature `gtk` — re-exports [`gtk_popup`] (`bread_utils::gtk_popup`) +//! for the full-screen overlay breadbox / breadclip / breadcast start +//! from. +//! - feature `bread-client` — [`listen_commands`] plus [`command_verb`] / +//! [`command_pattern`] so a tool can honor `bread.command..**` +//! without re-deriving the prefix strip. +//! +//! This crate does **not** migrate existing apps. Callers still own their +//! widgets, CSS, and clap. Screenshot / `--screenshot` helpers stay in +//! [`bread_utils::screenshot_cli`]. +//! +//! # Example +//! +//! ```ignore +//! let _guard = match bread_app::try_acquire("breadbox")? { +//! bread_app::singleton::Acquire::Acquired(g) => g, +//! bread_app::singleton::Acquire::HeldByOther(_) => return Ok(()), +//! }; +//! let app = gtk4::Application::builder() +//! .application_id(&bread_app::application_id("breadbox")?) +//! .build(); +//! +//! #[cfg(feature = "gtk")] +//! app.connect_activate(|app| { +//! let window = bread_app::gtk_popup::new_overlay_window(app, "breadbox"); +//! window.present(); +//! }); +//! +//! #[cfg(feature = "bread-client")] +//! let _commands = bread_app::listen_commands("box", |verb, event| { +//! // verb is the single segment after `bread.command.box.` +//! let _ = (verb, event); +//! })?; +//! ``` + +pub use bread_utils::singleton; + +#[cfg(feature = "gtk")] +pub use bread_utils::gtk_popup; + +mod id; + +pub use id::{application_id, parse_app_name, toggle_or_kill, try_acquire, InvalidAppId}; + +#[cfg(feature = "bread-client")] +mod command; + +#[cfg(feature = "bread-client")] +pub use bread_utils::bread_client::{BreadClient, BreadEvent, Subscription}; +#[cfg(feature = "bread-client")] +pub use command::{command_pattern, command_verb, listen_commands, parse_command_id}; diff --git a/bread-polkit/Cargo.toml b/bread-polkit/Cargo.toml new file mode 100644 index 0000000..b0c7603 --- /dev/null +++ b/bread-polkit/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "bread-polkit" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "Themed PolicyKit authentication agent for the bread desktop" +repository = "https://git.breadway.dev/Breadway/bread-ecosystem" +keywords = ["polkit", "gtk4", "wayland"] + +[lib] +path = "src/lib.rs" + +[[bin]] +name = "bread-polkit" +path = "src/main.rs" + +[dependencies] +anyhow = { workspace = true } +bread-app = { path = "../bread-app", features = ["gtk"] } +bread-theme = { path = "../bread-theme", features = ["gtk"] } +gtk4 = { version = "0.11", features = ["v4_12"] } +serde = { workspace = true } +tokio = { version = "1", features = ["rt", "net", "sync", "time", "macros", "io-util", "process"] } +tracing = { workspace = true } +tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "env-filter", "std"] } +zbus = { version = "5", default-features = false, features = ["tokio"] } diff --git a/bread-polkit/contrib/bread-polkit.desktop b/bread-polkit/contrib/bread-polkit.desktop new file mode 100644 index 0000000..b30a167 --- /dev/null +++ b/bread-polkit/contrib/bread-polkit.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Type=Application +Name=Bread PolicyKit Agent +Comment=Themed PolicyKit authentication agent for the bread desktop +Exec=bread-polkit +Icon=dialog-password +Terminal=false +Categories=System;Security; +StartupNotify=false +X-GNOME-Autostart-Phase=Initialization +X-GNOME-AutoRestart=true +X-GNOME-Autostart-Notify=false diff --git a/bread-polkit/contrib/hyprland.conf b/bread-polkit/contrib/hyprland.conf new file mode 100644 index 0000000..50ce845 --- /dev/null +++ b/bread-polkit/contrib/hyprland.conf @@ -0,0 +1,10 @@ +# bread-polkit — add to hyprland.conf +# +# Session authentication agent. Copy contrib/bread-polkit.desktop to +# ~/.config/autostart/ instead if you prefer XDG autostart. + +exec-once = bread-polkit + +# Optional: blur the overlay panel (namespace is bread-polkit). +layerrule = blur, bread-polkit +layerrule = ignorezero, bread-polkit diff --git a/bread-polkit/src/agent.rs b/bread-polkit/src/agent.rs new file mode 100644 index 0000000..94840eb --- /dev/null +++ b/bread-polkit/src/agent.rs @@ -0,0 +1,300 @@ +//! Session-bus registration and the PolicyKit1 AuthenticationAgent. + +use std::collections::HashMap; +use std::sync::{Arc, OnceLock}; + +use anyhow::{Context, Result}; +use gtk4::glib; +use gtk4::prelude::*; +use serde::{Deserialize, Serialize}; +use tokio::sync::{mpsc, Mutex}; +use zbus::zvariant::{OwnedValue, Type, Value}; +use zbus::{connection, interface, proxy, DBusError}; + +use bread_polkit::helper::{discover_transport, Transport}; +use bread_polkit::identity::{current_uid, pick_user, read_passwd, users_from_uids, UnixUser}; +use bread_polkit::session::session_id; + +use crate::auth::{self, Outcome}; +use crate::ui::{self, Prompt}; + +pub const OBJECT_PATH: &str = "/com/breadway/PolicyKit1/AuthenticationAgent"; + +/// Reply from the GTK prompt. +#[derive(Debug)] +pub enum UserAction { + Submit { username: String, password: String }, + Cancel, +} + +#[derive(Debug, DBusError)] +#[zbus(prefix = "org.freedesktop.PolicyKit1.Error")] +enum AgentError { + #[zbus(error)] + ZBus(zbus::Error), + Failed(String), + Cancelled(String), +} + +#[derive(Debug, Deserialize, Serialize, Type)] +struct Identity { + kind: String, + details: HashMap, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Type)] +struct Subject { + kind: String, + details: HashMap, +} + +#[proxy( + interface = "org.freedesktop.PolicyKit1.Authority", + default_service = "org.freedesktop.PolicyKit1", + default_path = "/org/freedesktop/PolicyKit1/Authority" +)] +trait Authority { + fn register_authentication_agent( + &self, + subject: &Subject, + locale: &str, + object_path: &str, + ) -> zbus::Result<()>; + + fn unregister_authentication_agent( + &self, + subject: &Subject, + object_path: &str, + ) -> zbus::Result<()>; +} + +struct Agent { + transport: Transport, + pending: Arc>>>, +} + +#[interface(name = "org.freedesktop.PolicyKit1.AuthenticationAgent")] +impl Agent { + async fn begin_authentication( + &mut self, + action_id: String, + message: String, + _icon_name: String, + _details: HashMap, + cookie: String, + identities: Vec, + ) -> Result<(), AgentError> { + tracing::info!(%action_id, %cookie, "BeginAuthentication"); + + let users = unix_users(&identities); + let username = pick_user(&users, current_uid()) + .map(|u| u.name.clone()) + .ok_or_else(|| AgentError::Failed("no unix-user identity".into()))?; + + let (tx, mut rx) = mpsc::channel(4); + *self.pending.lock().await = Some(tx.clone()); + + let prompt = Prompt { + cookie: cookie.clone(), + message: message.clone(), + action_id: action_id.clone(), + username: username.clone(), + reply: tx, + }; + invoke_ui(move || { + if let Some(app) = running_app() { + ui::show_prompt(&app, prompt); + } + }); + + let result = self.drive_prompt(&cookie, &username, &mut rx).await; + + *self.pending.lock().await = None; + let cookie_close = cookie.clone(); + invoke_ui(move || ui::close_prompt(&cookie_close)); + result + } + + async fn cancel_authentication(&self, cookie: String) { + tracing::info!(%cookie, "CancelAuthentication"); + if let Some(tx) = self.pending.lock().await.as_ref() { + let _ = tx.try_send(UserAction::Cancel); + } + invoke_ui(move || ui::close_prompt(&cookie)); + } +} + +impl Agent { + async fn drive_prompt( + &self, + cookie: &str, + default_user: &str, + rx: &mut mpsc::Receiver, + ) -> Result<(), AgentError> { + loop { + match rx.recv().await { + None => { + return Err(AgentError::Cancelled("authentication prompt closed".into())); + } + Some(UserAction::Cancel) => { + return Err(AgentError::Cancelled("user cancelled".into())); + } + Some(UserAction::Submit { username, password }) => { + let user = if username.is_empty() { + default_user + } else { + username.as_str() + }; + match auth::authenticate(&self.transport, user, cookie, &password).await { + Ok(Outcome::Success) => return Ok(()), + Ok(Outcome::Failure { message }) => { + let text = message + .unwrap_or_else(|| auth::default_failure_message().to_string()); + let cookie = cookie.to_string(); + invoke_ui(move || ui::show_retry(&cookie, &text)); + } + Err(e) => { + tracing::warn!("helper: {e:#}"); + let text = e.to_string(); + let cookie = cookie.to_string(); + invoke_ui(move || ui::show_retry(&cookie, &text)); + } + } + } + } + } + } +} + +fn unix_users(identities: &[Identity]) -> Vec { + let mut uids = Vec::new(); + for identity in identities { + if identity.kind != "unix-user" { + continue; + } + if let Some(uid) = uid_from_details(&identity.details) { + uids.push(uid); + } + } + users_from_uids(&uids, &read_passwd()) +} + +fn uid_from_details(details: &HashMap) -> Option { + let value = details.get("uid")?; + u32::try_from(value).ok().or_else(|| { + i32::try_from(value) + .ok() + .and_then(|n| u32::try_from(n).ok()) + }) +} + +fn running_app() -> Option { + gtk4::gio::Application::default().and_then(|app| app.downcast::().ok()) +} + +/// GTK thread-default context, captured in [`spawn`] so the dbus thread +/// can `invoke` onto the UI thread instead of its own empty context. +static GTK_CTX: OnceLock = OnceLock::new(); + +fn invoke_ui(f: impl FnOnce() + Send + 'static) { + let ctx = GTK_CTX + .get() + .cloned() + .unwrap_or_else(glib::MainContext::default); + ctx.invoke(f); +} + +fn unix_session_subject(id: &str) -> Result { + let value = Value::from(id.to_string()); + let owned = OwnedValue::try_from(value).context("session-id variant")?; + let mut details = HashMap::new(); + details.insert("session-id".into(), owned); + Ok(Subject { + kind: "unix-session".into(), + details, + }) +} + +/// Spawn the system-bus agent on a background thread. Returns once the +/// thread has been started; registration errors quit the GTK app. +/// +/// Must be called from the GTK thread so the main context we capture is +/// the one driving the password prompt. +pub fn spawn() -> Result<()> { + let _ = GTK_CTX.set(glib::MainContext::default()); + std::thread::Builder::new() + .name("bread-polkit-dbus".into()) + .spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + invoke_ui(move || { + eprintln!("bread-polkit: tokio runtime failed: {e}"); + if let Some(app) = running_app() { + app.quit(); + } + }); + return; + } + }; + rt.block_on(async move { + if let Err(e) = run().await { + eprintln!("bread-polkit: {e:#}"); + invoke_ui(|| { + if let Some(app) = running_app() { + app.quit(); + } + }); + } + }); + }) + .context("spawn dbus thread")?; + Ok(()) +} + +async fn run() -> Result<()> { + let transport = discover_transport().context( + "no polkit helper: expected /run/polkit/agent-helper.socket \ + or /usr/lib/polkit-1/polkit-agent-helper-1", + )?; + tracing::info!(?transport, "using polkit helper"); + + let session = session_id().context( + "no session id (XDG_SESSION_ID / /proc/self/sessionid); \ + cannot register a session authentication agent", + )?; + let subject = unix_session_subject(&session)?; + let locale = std::env::var("LANG").unwrap_or_else(|_| "C".into()); + + let agent = Agent { + transport, + pending: Arc::new(Mutex::new(None)), + }; + + let connection = connection::Builder::system()? + .serve_at(OBJECT_PATH, agent)? + .build() + .await + .context("system bus")?; + + let authority = AuthorityProxy::new(&connection) + .await + .context("PolicyKit1 authority proxy")?; + authority + .register_authentication_agent(&subject, &locale, OBJECT_PATH) + .await + .context("RegisterAuthenticationAgent")?; + tracing::info!(%session, "registered as PolicyKit authentication agent"); + + std::future::pending::<()>().await; + #[allow(unreachable_code)] + { + let _ = authority + .unregister_authentication_agent(&subject, OBJECT_PATH) + .await; + Ok(()) + } +} diff --git a/bread-polkit/src/auth.rs b/bread-polkit/src/auth.rs new file mode 100644 index 0000000..b67bf20 --- /dev/null +++ b/bread-polkit/src/auth.rs @@ -0,0 +1,109 @@ +//! PAM conversation with the polkit agent helper. + +use std::process::Stdio; + +use anyhow::{Context, Result}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::UnixStream; +use tokio::process::Command; + +use bread_polkit::helper::{parse_helper_line, HelperLine, Transport}; + +/// Outcome of one helper conversation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Outcome { + Success, + Failure { message: Option }, +} + +/// Handshake + PAM loop for one password attempt. +pub async fn authenticate( + transport: &Transport, + username: &str, + cookie: &str, + password: &str, +) -> Result { + match transport { + Transport::Socket(path) => { + let mut stream = UnixStream::connect(path) + .await + .with_context(|| format!("connect {}", path.display()))?; + stream.write_all(username.as_bytes()).await?; + stream.write_all(b"\n").await?; + stream.write_all(cookie.as_bytes()).await?; + stream.write_all(b"\n").await?; + let (reader, writer) = stream.into_split(); + converse(BufReader::new(reader), writer, password).await + } + Transport::Exec(path) => { + let mut child = Command::new(path) + .arg(username) + .env("LC_ALL", "C") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .with_context(|| format!("spawn {}", path.display()))?; + let mut stdin = child.stdin.take().context("polkit helper has no stdin")?; + let stdout = child.stdout.take().context("polkit helper has no stdout")?; + stdin.write_all(cookie.as_bytes()).await?; + stdin.write_all(b"\n").await?; + let outcome = converse(BufReader::new(stdout), stdin, password).await; + let _ = child.wait().await; + outcome + } + } +} + +async fn converse(mut reader: BufReader, mut writer: W, password: &str) -> Result +where + R: tokio::io::AsyncRead + Unpin, + W: tokio::io::AsyncWrite + Unpin, +{ + let mut last_info: Option = None; + let mut line = String::new(); + loop { + line.clear(); + let n = reader.read_line(&mut line).await?; + if n == 0 { + return Ok(Outcome::Failure { + message: last_info.take(), + }); + } + match parse_helper_line(&line) { + HelperLine::PromptEchoOff(_) => { + writer.write_all(password.as_bytes()).await?; + writer.write_all(b"\n").await?; + writer.flush().await?; + } + HelperLine::PromptEchoOn(_) => { + // Visible prompt (username, etc.) — we already sent the + // identity in the handshake. An empty line is safer than + // echoing the password. + writer.write_all(b"\n").await?; + writer.flush().await?; + } + HelperLine::ErrorMsg(msg) | HelperLine::TextInfo(msg) => { + if !msg.is_empty() { + last_info = Some(msg); + } + } + HelperLine::Success => return Ok(Outcome::Success), + HelperLine::Failure => { + return Ok(Outcome::Failure { + message: last_info.take(), + }); + } + HelperLine::Other(other) => { + if !other.is_empty() { + tracing::debug!("helper: {other}"); + } + } + } + } +} + +/// Shared default when the helper gives no `PAM_*` text on failure. +pub fn default_failure_message() -> &'static str { + "Authentication failed. Try again." +} diff --git a/bread-polkit/src/helper.rs b/bread-polkit/src/helper.rs new file mode 100644 index 0000000..c35a520 --- /dev/null +++ b/bread-polkit/src/helper.rs @@ -0,0 +1,205 @@ +//! `polkit-agent-helper-1` transport and PAM line parser. +//! +//! Arch polkit 127+ talks over `/run/polkit/agent-helper.socket`. Older +//! builds still spawn the setuid helper at +//! `/usr/lib/polkit-1/polkit-agent-helper-1`. Prefer the socket when it +//! exists. + +use std::path::{Path, PathBuf}; + +/// How this agent will talk to polkit's helper. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Transport { + /// systemd socket-activated helper (polkit 127+). + Socket(PathBuf), + /// Legacy setuid helper binary. + Exec(PathBuf), +} + +const SOCKET_CANDIDATES: &[&str] = &["/run/polkit/agent-helper.socket"]; +const HELPER_CANDIDATES: &[&str] = &[ + "/usr/lib/polkit-1/polkit-agent-helper-1", + "/usr/libexec/polkit-1/polkit-agent-helper-1", +]; + +/// One stdout line from the helper after the cookie handshake. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HelperLine { + PromptEchoOff(String), + PromptEchoOn(String), + ErrorMsg(String), + TextInfo(String), + Success, + Failure, + Other(String), +} + +/// Pick a live transport: `BREAD_POLKIT_SOCKET` / `BREAD_POLKIT_HELPER` +/// if set and present, otherwise the first existing well-known path. +pub fn discover_transport() -> Option { + discover_transport_from( + std::env::var_os("BREAD_POLKIT_SOCKET") + .map(PathBuf::from) + .as_deref(), + std::env::var_os("BREAD_POLKIT_HELPER") + .map(PathBuf::from) + .as_deref(), + SOCKET_CANDIDATES, + HELPER_CANDIDATES, + |p| p.exists(), + ) +} + +/// Testable discovery: `exists` is injected so unit tests do not need a +/// real `/run/polkit` socket. +pub fn discover_transport_from( + socket_override: Option<&Path>, + helper_override: Option<&Path>, + sockets: &[&str], + helpers: &[&str], + exists: impl Fn(&Path) -> bool, +) -> Option { + if let Some(path) = socket_override { + if exists(path) { + return Some(Transport::Socket(path.to_path_buf())); + } + } + for candidate in sockets { + let path = Path::new(candidate); + if exists(path) { + return Some(Transport::Socket(path.to_path_buf())); + } + } + if let Some(path) = helper_override { + if exists(path) { + return Some(Transport::Exec(path.to_path_buf())); + } + } + for candidate in helpers { + let path = Path::new(candidate); + if exists(path) { + return Some(Transport::Exec(path.to_path_buf())); + } + } + None +} + +/// Parse one helper protocol line. Prefix match is case-sensitive and +/// matches polkit's own `PAM_*` / `SUCCESS` / `FAILURE` tokens. +pub fn parse_helper_line(line: &str) -> HelperLine { + let line = line.trim_end_matches(['\r', '\n']); + if line == "SUCCESS" || line.starts_with("SUCCESS") { + return HelperLine::Success; + } + if line == "FAILURE" || line.starts_with("FAILURE") { + return HelperLine::Failure; + } + if let Some(rest) = line.strip_prefix("PAM_PROMPT_ECHO_OFF") { + return HelperLine::PromptEchoOff(rest.trim().to_string()); + } + if let Some(rest) = line.strip_prefix("PAM_PROMPT_ECHO_ON") { + return HelperLine::PromptEchoOn(rest.trim().to_string()); + } + if let Some(rest) = line.strip_prefix("PAM_ERROR_MSG") { + return HelperLine::ErrorMsg(rest.trim().to_string()); + } + if let Some(rest) = line.strip_prefix("PAM_TEXT_INFO") { + return HelperLine::TextInfo(rest.trim().to_string()); + } + HelperLine::Other(line.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + use std::path::PathBuf; + + #[test] + fn parse_helper_line_known_tokens() { + assert_eq!(parse_helper_line("SUCCESS"), HelperLine::Success); + assert_eq!(parse_helper_line("SUCCESS\n"), HelperLine::Success); + assert_eq!(parse_helper_line("FAILURE"), HelperLine::Failure); + assert_eq!( + parse_helper_line("PAM_PROMPT_ECHO_OFF Password:"), + HelperLine::PromptEchoOff("Password:".into()) + ); + assert_eq!( + parse_helper_line("PAM_PROMPT_ECHO_OFF"), + HelperLine::PromptEchoOff(String::new()) + ); + assert_eq!( + parse_helper_line("PAM_PROMPT_ECHO_ON login:"), + HelperLine::PromptEchoOn("login:".into()) + ); + assert_eq!( + parse_helper_line("PAM_ERROR_MSG Authentication failure"), + HelperLine::ErrorMsg("Authentication failure".into()) + ); + assert_eq!( + parse_helper_line("PAM_TEXT_INFO Account locked"), + HelperLine::TextInfo("Account locked".into()) + ); + assert_eq!( + parse_helper_line("garbage"), + HelperLine::Other("garbage".into()) + ); + } + + #[test] + fn discover_prefers_socket_over_exec() { + let present: HashSet = [ + "/run/polkit/agent-helper.socket", + "/usr/lib/polkit-1/polkit-agent-helper-1", + ] + .into_iter() + .map(PathBuf::from) + .collect(); + let got = discover_transport_from(None, None, SOCKET_CANDIDATES, HELPER_CANDIDATES, |p| { + present.contains(p) + }); + assert_eq!( + got, + Some(Transport::Socket(PathBuf::from( + "/run/polkit/agent-helper.socket" + ))) + ); + } + + #[test] + fn discover_falls_back_to_helper_binary() { + let present: HashSet = ["/usr/lib/polkit-1/polkit-agent-helper-1"] + .into_iter() + .map(PathBuf::from) + .collect(); + let got = discover_transport_from(None, None, SOCKET_CANDIDATES, HELPER_CANDIDATES, |p| { + present.contains(p) + }); + assert_eq!( + got, + Some(Transport::Exec(PathBuf::from( + "/usr/lib/polkit-1/polkit-agent-helper-1" + ))) + ); + } + + #[test] + fn discover_override_socket_wins_when_present() { + let override_path = Path::new("/tmp/bread-polkit-test.sock"); + let got = discover_transport_from( + Some(override_path), + None, + SOCKET_CANDIDATES, + HELPER_CANDIDATES, + |p| p == override_path, + ); + assert_eq!(got, Some(Transport::Socket(override_path.to_path_buf()))); + } + + #[test] + fn discover_none_when_nothing_exists() { + let got = + discover_transport_from(None, None, SOCKET_CANDIDATES, HELPER_CANDIDATES, |_| false); + assert_eq!(got, None); + } +} diff --git a/bread-polkit/src/identity.rs b/bread-polkit/src/identity.rs new file mode 100644 index 0000000..e92e395 --- /dev/null +++ b/bread-polkit/src/identity.rs @@ -0,0 +1,128 @@ +//! Unix-user identities from a PolicyKit `BeginAuthentication` call. + +/// A `unix-user` identity the agent can authenticate as. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnixUser { + pub uid: u32, + pub name: String, +} + +/// Look up `uid` in a passwd-file dump (`name:x:uid:...` lines). +pub fn name_for_uid(uid: u32, passwd: &str) -> Option { + for line in passwd.lines() { + if line.starts_with('#') { + continue; + } + let mut parts = line.split(':'); + let name = parts.next()?; + let _pw = parts.next()?; + let id = parts.next()?.parse::().ok()?; + if id == uid && !name.is_empty() { + return Some(name.to_string()); + } + } + None +} + +/// Resolve each uid to a [`UnixUser`], falling back to `uid N` when +/// `/etc/passwd` has no name. +pub fn users_from_uids(uids: &[u32], passwd: &str) -> Vec { + uids.iter() + .copied() + .map(|uid| UnixUser { + uid, + name: name_for_uid(uid, passwd).unwrap_or_else(|| format!("uid {uid}")), + }) + .collect() +} + +/// Prefer the process's own uid when it is in `users`, otherwise the first. +pub fn pick_user<'a>(users: &'a [UnixUser], current_uid: Option) -> Option<&'a UnixUser> { + if let Some(uid) = current_uid { + if let Some(user) = users.iter().find(|u| u.uid == uid) { + return Some(user); + } + } + users.first() +} + +/// Real uid from a `/proc/self/status` dump (`Uid:\t ...`). +pub fn uid_from_status(status: &str) -> Option { + for line in status.lines() { + let Some(rest) = line.strip_prefix("Uid:") else { + continue; + }; + return rest.split_whitespace().next()?.parse().ok(); + } + None +} + +/// Current real uid, or `None` if `/proc/self/status` is unreadable. +pub fn current_uid() -> Option { + let status = std::fs::read_to_string("/proc/self/status").ok()?; + uid_from_status(&status) +} + +/// Contents of `/etc/passwd`, or empty if unreadable. +pub fn read_passwd() -> String { + std::fs::read_to_string("/etc/passwd").unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + const PASSWD: &str = "\ +# comment +root:x:0:0:root:/root:/bin/sh +alice:x:1000:1000:Alice:/home/alice:/bin/zsh +bob:x:1001:1001:Bob:/home/bob:/bin/bash +"; + + #[test] + fn name_for_uid_reads_passwd_lines() { + assert_eq!(name_for_uid(0, PASSWD).as_deref(), Some("root")); + assert_eq!(name_for_uid(1000, PASSWD).as_deref(), Some("alice")); + assert_eq!(name_for_uid(99, PASSWD), None); + } + + #[test] + fn users_from_uids_falls_back_to_uid_label() { + let users = users_from_uids(&[1000, 42], PASSWD); + assert_eq!( + users, + vec![ + UnixUser { + uid: 1000, + name: "alice".into() + }, + UnixUser { + uid: 42, + name: "uid 42".into() + }, + ] + ); + } + + #[test] + fn pick_user_prefers_current_uid() { + let users = users_from_uids(&[0, 1000], PASSWD); + let picked = pick_user(&users, Some(1000)).unwrap(); + assert_eq!(picked.name, "alice"); + } + + #[test] + fn pick_user_falls_back_to_first() { + let users = users_from_uids(&[0, 1000], PASSWD); + let picked = pick_user(&users, Some(7)).unwrap(); + assert_eq!(picked.name, "root"); + assert!(pick_user(&[], Some(1000)).is_none()); + } + + #[test] + fn uid_from_status_reads_real_uid() { + let status = "Name:\tbread-polkit\nUid:\t1000\t1000\t1000\t1000\n"; + assert_eq!(uid_from_status(status), Some(1000)); + assert_eq!(uid_from_status("Name:\tfoo\n"), None); + } +} diff --git a/bread-polkit/src/lib.rs b/bread-polkit/src/lib.rs new file mode 100644 index 0000000..2f9e574 --- /dev/null +++ b/bread-polkit/src/lib.rs @@ -0,0 +1,10 @@ +//! Non-GTK PolicyKit helper logic for `bread-polkit`. +//! +//! The binary (`bread-polkit`) registers as a session authentication +//! agent and shows a themed password prompt. This library is the +//! transport / identity / session parsing that can be unit-tested +//! without a display. + +pub mod helper; +pub mod identity; +pub mod session; diff --git a/bread-polkit/src/main.rs b/bread-polkit/src/main.rs new file mode 100644 index 0000000..ca28048 --- /dev/null +++ b/bread-polkit/src/main.rs @@ -0,0 +1,94 @@ +//! bread-polkit — themed PolicyKit authentication agent. +//! +//! Registers on the `org.freedesktop.PolicyKit1.AuthenticationAgent` +//! interface and shows a bread-theme GTK4 password prompt. This is an +//! agent, not a wrapper that execs `polkit-gnome`. +//! +//! Autostart: copy `contrib/bread-polkit.desktop` to +//! `~/.config/autostart/`, or add `exec-once = bread-polkit` to Hyprland. + +mod agent; +mod auth; +mod ui; + +use bread_app::singleton::Acquire; +use gtk4::prelude::*; + +const APP_NAME: &str = "bread-polkit"; + +fn main() { + let arg = std::env::args().nth(1); + match arg.as_deref() { + Some("-h") | Some("--help") => { + print_help(); + return; + } + Some("-V") | Some("--version") => { + println!("bread-polkit {}", env!("CARGO_PKG_VERSION")); + return; + } + Some(other) => { + eprintln!("bread-polkit: unknown argument '{other}'"); + print_help(); + std::process::exit(2); + } + None => {} + } + + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .with_target(false) + .init(); + + let _guard = match bread_app::try_acquire(APP_NAME) { + Ok(Acquire::Acquired(g)) => Some(g), + Ok(Acquire::HeldByOther(pid)) => { + eprintln!("bread-polkit: already running (pid {pid:?})"); + std::process::exit(0); + } + Err(e) => { + eprintln!("bread-polkit: singleton lock unavailable ({e}); continuing"); + None + } + }; + + let app_id = bread_app::application_id(APP_NAME).expect("static app name"); + let app = gtk4::Application::builder().application_id(&app_id).build(); + + app.connect_activate(|app| { + bread_theme::gtk::apply_shared(); + bread_theme::gtk::apply_app_css(ui::app_css); + // No window until polkit asks; hold so GApplication stays alive. + std::mem::forget(app.hold()); + if let Err(e) = agent::spawn() { + eprintln!("bread-polkit: {e:#}"); + app.quit(); + } + }); + + app.run(); +} + +fn print_help() { + print!( + "\ +bread-polkit — themed PolicyKit authentication agent + +Usage: + bread-polkit + bread-polkit --help + bread-polkit --version + +Autostart (pick one): + cp contrib/bread-polkit.desktop ~/.config/autostart/ + exec-once = bread-polkit # Hyprland + +The agent talks to the polkit1 AuthenticationAgent API and prompts for +a password. It does not exec polkit-gnome. Not a bakery product; not +on the BOS ISO lockfile. +" + ); +} diff --git a/bread-polkit/src/session.rs b/bread-polkit/src/session.rs new file mode 100644 index 0000000..c23cbe1 --- /dev/null +++ b/bread-polkit/src/session.rs @@ -0,0 +1,49 @@ +//! Session subject for `RegisterAuthenticationAgent`. + +/// Logind session id from `XDG_SESSION_ID`, falling back to +/// `/proc/self/sessionid` when the kernel has one. +pub fn session_id() -> Option { + let xdg = std::env::var("XDG_SESSION_ID").ok(); + let proc = std::fs::read_to_string("/proc/self/sessionid").ok(); + session_id_from(xdg.as_deref(), proc.as_deref()) +} + +/// `None` when both sources are empty or the kernel reports the +/// unsigned `-1` sentinel (`4294967295`) meaning "no session". +pub fn session_id_from(xdg: Option<&str>, proc_sessionid: Option<&str>) -> Option { + if let Some(id) = xdg.map(str::trim).filter(|s| !s.is_empty()) { + return Some(id.to_string()); + } + let raw = proc_sessionid?.trim(); + if raw.is_empty() || raw == "4294967295" { + return None; + } + Some(raw.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prefers_xdg_session_id() { + assert_eq!(session_id_from(Some("3"), Some("7")).as_deref(), Some("3")); + assert_eq!( + session_id_from(Some(" 3 "), Some("7")).as_deref(), + Some("3") + ); + } + + #[test] + fn falls_back_to_proc_sessionid() { + assert_eq!(session_id_from(Some(""), Some("7")).as_deref(), Some("7")); + assert_eq!(session_id_from(None, Some("7\n")).as_deref(), Some("7")); + } + + #[test] + fn rejects_unset_kernel_session() { + assert_eq!(session_id_from(None, Some("4294967295")), None); + assert_eq!(session_id_from(Some(""), Some("")), None); + assert_eq!(session_id_from(None, None), None); + } +} diff --git a/bread-polkit/src/ui.rs b/bread-polkit/src/ui.rs new file mode 100644 index 0000000..50275bd --- /dev/null +++ b/bread-polkit/src/ui.rs @@ -0,0 +1,285 @@ +//! GTK4 password prompt, themed with bread-theme. + +use std::cell::RefCell; +use std::rc::Rc; + +use gtk4::gdk::Key; +use gtk4::glib::{self, Propagation}; +use gtk4::prelude::*; +use gtk4::{ + Align, Application, ApplicationWindow, Box as GBox, Button, Entry, EventControllerKey, Label, + Orientation, +}; + +use bread_theme::tokens; + +use crate::agent::UserAction; + +const PANEL_WIDTH: i32 = 400; + +struct Active { + cookie: String, + window: ApplicationWindow, + password: Entry, + error: Label, + reply: tokio::sync::mpsc::Sender, + username: String, +} + +thread_local! { + static ACTIVE: RefCell> = const { RefCell::new(None) }; +} + +/// App-specific rules layered on the shared bread-theme stylesheet. +pub fn app_css() -> String { + format!( + ".polkit-panel {{\ + background-color: @surface; color: @on-surface;\ + border-radius: {r}px; padding: {pad}px;\ + min-width: {w}px;\ + }}\n\ + .polkit-title {{ font-size: 1.4em; font-weight: bold; }}\n\ + .polkit-message {{ opacity: 0.85; }}\n\ + .polkit-identity {{ opacity: 0.7; font-size: {sec}px; }}\n\ + .polkit-error {{ color: @on-red; }}\n\ + .polkit-buttons {{ padding-top: {sm}px; }}\n", + r = tokens::RADIUS_PRIMARY, + pad = tokens::SPACE_XL, + w = PANEL_WIDTH, + sec = tokens::FONT_SIZE_SECONDARY, + sm = tokens::SPACE_SM, + ) +} + +pub struct Prompt { + pub cookie: String, + pub message: String, + pub action_id: String, + pub username: String, + pub reply: tokio::sync::mpsc::Sender, +} + +/// Show (or replace) the password overlay for this cookie. +pub fn show_prompt(app: &Application, prompt: Prompt) { + close_if_other_cookie(&prompt.cookie); + + if ACTIVE.with(|a| { + a.borrow() + .as_ref() + .is_some_and(|active| active.cookie == prompt.cookie) + }) { + present_existing(&prompt); + return; + } + + let window = bread_app::gtk_popup::new_overlay_window(app, "bread-polkit"); + + let panel = GBox::new(Orientation::Vertical, tokens::SPACE_MD as i32); + panel.add_css_class("polkit-panel"); + panel.add_css_class("card"); + panel.set_halign(Align::Center); + panel.set_valign(Align::Center); + panel.set_size_request(PANEL_WIDTH, -1); + + let title = Label::new(Some("Authentication required")); + title.add_css_class("polkit-title"); + title.add_css_class("page-title"); + title.set_halign(Align::Start); + title.set_wrap(true); + panel.append(&title); + + let message = if prompt.message.trim().is_empty() { + prompt.action_id.clone() + } else { + prompt.message.clone() + }; + let msg = Label::new(Some(&message)); + msg.add_css_class("polkit-message"); + msg.set_halign(Align::Start); + msg.set_wrap(true); + msg.set_xalign(0.0); + panel.append(&msg); + + if !prompt.username.is_empty() { + let identity = Label::new(Some(&format!("Authenticating as {}", prompt.username))); + identity.add_css_class("polkit-identity"); + identity.add_css_class("dim-label"); + identity.set_halign(Align::Start); + panel.append(&identity); + } + + let error = Label::new(None); + error.add_css_class("polkit-error"); + error.set_halign(Align::Start); + error.set_wrap(true); + error.set_visible(false); + panel.append(&error); + + let password = Entry::builder() + .visibility(false) + .input_purpose(gtk4::InputPurpose::Password) + .placeholder_text("Password") + .hexpand(true) + .build(); + panel.append(&password); + + let buttons = GBox::new(Orientation::Horizontal, tokens::SPACE_SM as i32); + buttons.add_css_class("polkit-buttons"); + buttons.set_halign(Align::End); + let cancel = Button::with_label("Cancel"); + cancel.add_css_class("flat"); + let confirm = Button::with_label("Authenticate"); + confirm.add_css_class("suggested-action"); + buttons.append(&cancel); + buttons.append(&confirm); + panel.append(&buttons); + + window.set_child(Some(&panel)); + + let reply = prompt.reply.clone(); + let cookie = prompt.cookie.clone(); + let username = prompt.username.clone(); + + let submit = { + let password = password.clone(); + let reply = reply.clone(); + let username = username.clone(); + Rc::new(move || { + let secret = password.text().to_string(); + password.set_text(""); + let _ = reply.try_send(UserAction::Submit { + username: username.clone(), + password: secret, + }); + }) + }; + let cancel_fn = { + let reply = reply.clone(); + let window = window.clone(); + Rc::new(move || { + let _ = reply.try_send(UserAction::Cancel); + window.close(); + ACTIVE.with(|a| a.replace(None)); + }) + }; + + confirm.connect_clicked({ + let submit = submit.clone(); + move |_| submit() + }); + password.connect_activate({ + let submit = submit.clone(); + move |_| submit() + }); + cancel.connect_clicked({ + let cancel_fn = cancel_fn.clone(); + move |_| cancel_fn() + }); + + let keys = EventControllerKey::new(); + keys.connect_key_pressed({ + let cancel_fn = cancel_fn.clone(); + move |_, key, _, _| { + if key == Key::Escape { + cancel_fn(); + Propagation::Stop + } else { + Propagation::Proceed + } + } + }); + window.add_controller(keys); + + bread_app::gtk_popup::close_on_outside_click(&window, &panel, { + let cancel_fn = cancel_fn.clone(); + move || cancel_fn() + }); + + window.connect_close_request({ + let reply = reply.clone(); + move |_| { + let closing_ours = ACTIVE.with(|a| { + a.borrow() + .as_ref() + .is_some_and(|active| active.cookie == cookie) + }); + if closing_ours { + let _ = reply.try_send(UserAction::Cancel); + ACTIVE.with(|a| a.replace(None)); + } + glib::Propagation::Proceed + } + }); + + ACTIVE.with(|a| { + *a.borrow_mut() = Some(Active { + cookie: prompt.cookie, + window: window.clone(), + password: password.clone(), + error, + reply, + username, + }); + }); + + window.present(); + password.grab_focus(); +} + +fn present_existing(prompt: &Prompt) { + ACTIVE.with(|a| { + if let Some(active) = a.borrow_mut().as_mut() { + active.reply = prompt.reply.clone(); + active.username = prompt.username.clone(); + active.error.set_visible(false); + active.password.set_text(""); + active.window.present(); + active.password.grab_focus(); + } + }); +} + +/// Show a retry message on the open dialog for `cookie`. +pub fn show_retry(cookie: &str, message: &str) { + ACTIVE.with(|a| { + let mut guard = a.borrow_mut(); + let Some(active) = guard.as_mut() else { + return; + }; + if active.cookie != cookie { + return; + } + active.error.set_label(message); + active.error.set_visible(true); + active.password.set_text(""); + active.window.present(); + active.password.grab_focus(); + }); +} + +/// Close the dialog if it is still showing `cookie`. +pub fn close_prompt(cookie: &str) { + ACTIVE.with(|a| { + let Some(active) = a.borrow_mut().take() else { + return; + }; + if active.cookie == cookie { + active.window.close(); + } else { + *a.borrow_mut() = Some(active); + } + }); +} + +fn close_if_other_cookie(cookie: &str) { + ACTIVE.with(|a| { + let Some(active) = a.borrow_mut().take() else { + return; + }; + if active.cookie == cookie { + *a.borrow_mut() = Some(active); + } else { + active.window.close(); + } + }); +}