diff --git a/Cargo.lock b/Cargo.lock index 5b0c461..f4a4597 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -123,6 +123,29 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +[[package]] +name = "cairo-rs" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e3bd0f4e25afa9cabc157908d14eeef9067d6448c49414d17b3fb55f0eadd0" +dependencies = [ + "bitflags", + "cairo-sys-rs", + "glib", + "libc", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "059cc746549898cbfd9a47754288e5a958756650ef4652bbb6c5f71a6bda4f8b" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + [[package]] name = "cc" version = "1.2.51" @@ -133,6 +156,16 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfg-expr" +version = "0.20.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21be0e1ce6cdb2ee7fff840f922fb04ead349e5cfb1e750b769132d44ce04720" +dependencies = [ + "smallvec", + "target-lexicon", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -245,12 +278,143 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[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.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd242894c084f4beed508a56952750bce3e96e85eb68fdc153637daa163e10c" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b34f3b580c988bd217e9543a2de59823fafae369d1a055555e5f95a8b130b96" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk4" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850c9d9c1aecd1a3eb14fadc1cdb0ac0a2298037e116264c7473e1740a32d60" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "gl", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f6eb95798e2b46f279cf59005daf297d5b69555428f185650d71974a910473a" +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.3.4" @@ -263,6 +427,245 @@ dependencies = [ "wasip2", ] +[[package]] +name = "gio" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e27e276e7b6b8d50f6376ee7769a71133e80d093bdc363bd0af71664228b831" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e93a7e56fc89e84aea9a52cfc9436816a4b363b030260b699950ff1336c83" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys 0.59.0", +] + +[[package]] +name = "gl" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a94edab108827d67608095e269cf862e60d920f144a5026d3dbcfd8b877fb404" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glib" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc4b6e352d4716d84d7dde562dd9aee2a7d48beb872dd9ece7f2d1515b2d683" +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.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8084af62f09475a3f529b1629c10c429d7600ee1398ae12dd3bf175d74e7145" +dependencies = [ + "heck", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "glib-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ab79e1ed126803a8fb827e3de0e2ff95191912b8db65cee467edb56fc4cc215" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9aca94bb73989e3cfdbf8f2e0f1f6da04db4d291c431f444838925c4c63eda" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "graphene-rs" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b86dfad7d14251c9acaf1de63bc8754b7e3b4e5b16777b6f5a748208fe9519b" +dependencies = [ + "glib", + "graphene-sys", + "libc", +] + +[[package]] +name = "graphene-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df583a85ba2d5e15e1797e40d666057b28bc2f60a67c9c24145e6db2cc3861ea" +dependencies = [ + "glib-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gsk4" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61f5e72f931c8c9f65fbfc89fe0ddc7746f147f822f127a53a9854666ac1f855" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "755059de55fa6f85a46bde8caf03e2184c96bfda1f6206163c72fb0ea12436dc" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk4" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f274dd0102c21c47bbfa8ebcb92d0464fab794a22fad6c3f3d5f165139a326d6" +dependencies = [ + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-layer-shell" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3e1e1b1516be3d7ca089dfa6a1e688e268c74aef50c0c25fe8c46b1ba8ed1cc" +dependencies = [ + "bitflags", + "gdk4", + "glib", + "glib-sys", + "gtk4", + "gtk4-layer-shell-sys", + "libc", +] + +[[package]] +name = "gtk4-layer-shell-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3057dc117db2d664a9b45f1956568701914e80cf9f2c8cef0a755af4c1c8105" +dependencies = [ + "gdk4-sys", + "glib-sys", + "gtk4-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk4-macros" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ed1786c4703dd196baf7e103525ce0cf579b3a63a0570fe653b7ee6bac33999" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "gtk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41e03b01e54d77c310e1d98647d73f996d04b2f29b9121fe493ea525a7ec03d6" +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.14.5" @@ -349,6 +752,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + [[package]] name = "lazy_static" version = "1.5.0" @@ -471,6 +880,30 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "pango" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6576b311f6df659397043a5fa8a021da8f72e34af180b44f7d57348de691ab5c" +dependencies = [ + "gio", + "glib", + "libc", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186909673fc09be354555c302c0b3dcf753cd9fa08dcb8077fa663c80fb243fa" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -500,12 +933,27 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.10+spec-1.0.0", +] + [[package]] name = "proc-macro2" version = "1.0.103" @@ -570,6 +1018,15 @@ dependencies = [ "smallvec", ] +[[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.3" @@ -595,6 +1052,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -647,6 +1110,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -678,7 +1150,7 @@ dependencies = [ "shepherd-util", "tempfile", "thiserror", - "toml", + "toml 0.8.23", "tracing", ] @@ -729,6 +1201,25 @@ dependencies = [ "tracing", ] +[[package]] +name = "shepherd-hud" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "gtk4", + "gtk4-layer-shell", + "serde", + "serde_json", + "shepherd-api", + "shepherd-ipc", + "shepherd-util", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "shepherd-ipc" version = "0.1.0" @@ -744,6 +1235,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "shepherd-launcher-ui" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "gtk4", + "serde", + "serde_json", + "shepherd-api", + "shepherd-ipc", + "shepherd-util", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "shepherd-store" version = "0.1.0" @@ -813,6 +1322,12 @@ dependencies = [ "libc", ] +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + [[package]] name = "smallvec" version = "1.15.1" @@ -846,6 +1361,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "system-deps" +version = "7.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c8f33736f986f16d69b6cb8b03f55ddcad5c41acc4ccc39dd88e84aa805e7f" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml 0.9.10+spec-1.1.0", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" + [[package]] name = "tempfile" version = "3.24.0" @@ -923,9 +1457,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "0.9.10+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.0.4", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow", ] [[package]] @@ -937,6 +1486,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -945,18 +1503,45 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "toml_write", "winnow", ] +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow", +] + [[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.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + [[package]] name = "tracing" version = "0.1.44" @@ -1067,6 +1652,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[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" @@ -1192,13 +1783,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets", + "windows-targets 0.53.5", ] [[package]] @@ -1210,6 +1810,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + [[package]] name = "windows-targets" version = "0.53.5" @@ -1217,58 +1833,106 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "windows_x86_64_msvc" version = "0.53.1" @@ -1290,6 +1954,12 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + [[package]] name = "zerocopy" version = "0.8.31" diff --git a/Cargo.toml b/Cargo.toml index 69d3637..0980fc2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,14 +10,16 @@ members = [ "crates/shepherd-host-linux", "crates/shepherd-ipc", "crates/shepherdd", + "crates/shepherd-launcher-ui", + "crates/shepherd-hud", ] [workspace.package] version = "0.1.0" -edition = "2021" -license = "MIT" -authors = ["Shepherd Contributors"] -repository = "https://github.com/shepherd-project/shepherdd" +edition = "2024" +license = "GPL-3.0" +authors = ["Albert Armea"] +repository = "https://github.com/aarmea/shepherd-launcher" [workspace.dependencies] # Internal crates @@ -59,5 +61,12 @@ bitflags = "2.4" # Unix-specific nix = { version = "0.29", features = ["signal", "process", "user", "socket"] } +# CLI +clap = { version = "4.5", features = ["derive"] } + +# GTK4 UI +gtk4 = "0.9" +gtk4-layer-shell = "0.4" + # Testing tempfile = "3.9" diff --git a/crates/shepherd-api/src/commands.rs b/crates/shepherd-api/src/commands.rs index f90f3dd..da3c1ab 100644 --- a/crates/shepherd-api/src/commands.rs +++ b/crates/shepherd-api/src/commands.rs @@ -212,6 +212,7 @@ mod tests { policy_loaded: true, current_session: None, entry_count: 5, + entries: vec![], }), ); diff --git a/crates/shepherd-api/src/types.rs b/crates/shepherd-api/src/types.rs index 9e58265..9895bf5 100644 --- a/crates/shepherd-api/src/types.rs +++ b/crates/shepherd-api/src/types.rs @@ -169,6 +169,9 @@ pub struct DaemonStateSnapshot { pub policy_loaded: bool, pub current_session: Option, pub entry_count: usize, + /// Available entries for UI display + #[serde(default)] + pub entries: Vec, } /// Role for authorization diff --git a/crates/shepherd-core/src/engine.rs b/crates/shepherd-core/src/engine.rs index a4ebf34..bd4f5a9 100644 --- a/crates/shepherd-core/src/engine.rs +++ b/crates/shepherd-core/src/engine.rs @@ -456,11 +456,15 @@ impl CoreEngine { s.to_session_info(MonotonicInstant::now()) }); + // Build entry views for the snapshot + let entries = self.list_entries(Local::now()); + DaemonStateSnapshot { api_version: API_VERSION, policy_loaded: true, current_session, entry_count: self.policy.entries.len(), + entries, } } diff --git a/crates/shepherd-hud/Cargo.toml b/crates/shepherd-hud/Cargo.toml new file mode 100644 index 0000000..670a3c4 --- /dev/null +++ b/crates/shepherd-hud/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "shepherd-hud" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "GTK4 HUD overlay for shepherdd - always-visible status bar" + +[[bin]] +name = "shepherd-hud" +path = "src/main.rs" + +[dependencies] +shepherd-api = { workspace = true } +shepherd-ipc = { workspace = true } +shepherd-util = { workspace = true } + +gtk4 = { workspace = true } +gtk4-layer-shell = { workspace = true } +clap = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +anyhow = { workspace = true } +chrono = { workspace = true } + +[features] +default = [] diff --git a/crates/shepherd-hud/src/app.rs b/crates/shepherd-hud/src/app.rs new file mode 100644 index 0000000..7d34205 --- /dev/null +++ b/crates/shepherd-hud/src/app.rs @@ -0,0 +1,459 @@ +//! HUD Application +//! +//! The main GTK4 application for the HUD overlay. +//! Uses gtk4-layer-shell to create an always-visible overlay. + +use crate::battery::BatteryStatus; +use crate::state::{SessionState, SharedState}; +use crate::time_display::TimeDisplay; +use crate::volume::VolumeStatus; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4_layer_shell::{Edge, Layer, LayerShell}; +use shepherd_api::commands::Command; +use shepherd_ipc::IpcClient; +use std::cell::RefCell; +use std::path::PathBuf; +use std::rc::Rc; +use std::time::Duration; +use tokio::runtime::Runtime; + +/// The HUD application +pub struct HudApp { + app: gtk4::Application, + socket_path: PathBuf, + anchor: String, + height: i32, +} + +impl HudApp { + pub fn new(socket_path: PathBuf, anchor: String, height: i32) -> Self { + let app = gtk4::Application::builder() + .application_id("org.shepherd.hud") + .build(); + + Self { + app, + socket_path, + anchor, + height, + } + } + + pub fn run(&self) -> i32 { + let socket_path = self.socket_path.clone(); + let anchor = self.anchor.clone(); + let height = self.height; + + self.app.connect_activate(move |app| { + let state = SharedState::new(); + let window = build_hud_window(app, &anchor, height, state.clone()); + + // Start the IPC event listener + let state_clone = state.clone(); + let socket_clone = socket_path.clone(); + std::thread::spawn(move || { + if let Err(e) = run_event_loop(socket_clone, state_clone) { + tracing::error!("Event loop error: {}", e); + } + }); + + // Start periodic updates for battery/volume + start_metrics_updates(state.clone()); + + // Subscribe to state changes + let window_clone = window.clone(); + let state_clone = state.clone(); + glib::timeout_add_local(Duration::from_millis(100), move || { + let session_state = state_clone.session_state(); + let visible = session_state.is_visible(); + window_clone.set_visible(visible); + glib::ControlFlow::Continue + }); + + window.present(); + }); + + self.app.run().into() + } +} + +fn build_hud_window( + app: >k4::Application, + anchor: &str, + height: i32, + state: SharedState, +) -> gtk4::ApplicationWindow { + let window = gtk4::ApplicationWindow::builder() + .application(app) + .default_height(height) + .decorated(false) + .build(); + + // Initialize layer shell + window.init_layer_shell(); + window.set_layer(Layer::Overlay); + window.set_namespace("shepherd-hud"); + + // Set anchors based on position + match anchor { + "bottom" => { + window.set_anchor(Edge::Bottom, true); + window.set_anchor(Edge::Left, true); + window.set_anchor(Edge::Right, true); + } + _ => { + // Default to top + window.set_anchor(Edge::Top, true); + window.set_anchor(Edge::Left, true); + window.set_anchor(Edge::Right, true); + } + } + + // Set exclusive zone so other windows don't overlap + window.set_exclusive_zone(height); + + // Load CSS + load_css(); + + // Build the HUD content + let content = build_hud_content(state); + window.set_child(Some(&content)); + + window +} + +fn build_hud_content(state: SharedState) -> gtk4::Box { + let container = gtk4::Box::builder() + .orientation(gtk4::Orientation::Horizontal) + .spacing(16) + .margin_start(12) + .margin_end(12) + .margin_top(6) + .margin_bottom(6) + .hexpand(true) + .build(); + + container.add_css_class("hud-bar"); + + // Left section: App name and time + let left_box = gtk4::Box::builder() + .orientation(gtk4::Orientation::Horizontal) + .spacing(12) + .hexpand(true) + .halign(gtk4::Align::Start) + .build(); + + let app_label = gtk4::Label::new(Some("No session")); + app_label.add_css_class("app-name"); + left_box.append(&app_label); + + let time_display = TimeDisplay::new(); + left_box.append(&time_display); + + container.append(&left_box); + + // Center section: Warning banner (hidden by default) + let warning_box = gtk4::Box::builder() + .orientation(gtk4::Orientation::Horizontal) + .spacing(8) + .halign(gtk4::Align::Center) + .visible(false) + .build(); + + let warning_icon = gtk4::Image::from_icon_name("dialog-warning-symbolic"); + warning_icon.set_pixel_size(20); + warning_box.append(&warning_icon); + + let warning_label = gtk4::Label::new(Some("Time running out!")); + warning_label.add_css_class("warning-text"); + warning_box.append(&warning_label); + + warning_box.add_css_class("warning-banner"); + container.append(&warning_box); + + // Right section: System indicators and close button + let right_box = gtk4::Box::builder() + .orientation(gtk4::Orientation::Horizontal) + .spacing(8) + .halign(gtk4::Align::End) + .build(); + + // Volume indicator + let volume_button = gtk4::Button::builder() + .icon_name("audio-volume-medium-symbolic") + .has_frame(false) + .build(); + volume_button.add_css_class("indicator-button"); + volume_button.connect_clicked(|_| { + if let Err(e) = VolumeStatus::toggle_mute() { + tracing::error!("Failed to toggle mute: {}", e); + } + }); + right_box.append(&volume_button); + + // Battery indicator + let battery_box = gtk4::Box::builder() + .orientation(gtk4::Orientation::Horizontal) + .spacing(4) + .build(); + + let battery_icon = gtk4::Image::from_icon_name("battery-good-symbolic"); + battery_icon.set_pixel_size(20); + battery_box.append(&battery_icon); + + let battery_label = gtk4::Label::new(Some("--%")); + battery_label.add_css_class("battery-label"); + battery_box.append(&battery_label); + + right_box.append(&battery_box); + + // Pause button + let pause_button = gtk4::Button::builder() + .icon_name("media-playback-pause-symbolic") + .has_frame(false) + .tooltip_text("Pause session") + .build(); + pause_button.add_css_class("control-button"); + + let state_for_pause = state.clone(); + pause_button.connect_clicked(move |btn| { + let session_state = state_for_pause.session_state(); + if let Some(session_id) = session_state.session_id() { + // Toggle pause state - this would need to send command to daemon + // For now, just log + tracing::info!("Pause toggled for session {}", session_id); + } + // Toggle icon + let icon_name = btn.icon_name().unwrap_or_default(); + if icon_name == "media-playback-pause-symbolic" { + btn.set_icon_name("media-playback-start-symbolic"); + btn.set_tooltip_text(Some("Resume session")); + } else { + btn.set_icon_name("media-playback-pause-symbolic"); + btn.set_tooltip_text(Some("Pause session")); + } + }); + right_box.append(&pause_button); + + // Close button + let close_button = gtk4::Button::builder() + .icon_name("window-close-symbolic") + .has_frame(false) + .tooltip_text("End session") + .build(); + close_button.add_css_class("close-button"); + + let state_for_close = state.clone(); + close_button.connect_clicked(move |_| { + let session_state = state_for_close.session_state(); + if let Some(session_id) = session_state.session_id() { + tracing::info!("Requesting end session for {}", session_id); + // This would need to send EndSession command to daemon + } + }); + right_box.append(&close_button); + + container.append(&right_box); + + // Set up state updates + let app_label_clone = app_label.clone(); + let time_display_clone = time_display.clone(); + let warning_box_clone = warning_box.clone(); + let warning_label_clone = warning_label.clone(); + let battery_icon_clone = battery_icon.clone(); + let battery_label_clone = battery_label.clone(); + let volume_button_clone = volume_button.clone(); + + glib::timeout_add_local(Duration::from_millis(500), move || { + // Update session state + let session_state = state.session_state(); + match &session_state { + SessionState::NoSession => { + app_label_clone.set_text("No session"); + time_display_clone.set_remaining(None); + warning_box_clone.set_visible(false); + } + SessionState::Active { + entry_name, + time_remaining_secs, + paused, + .. + } => { + app_label_clone.set_text(entry_name); + time_display_clone.set_remaining(*time_remaining_secs); + time_display_clone.set_paused(*paused); + warning_box_clone.set_visible(false); + } + SessionState::Warning { + entry_name, + time_remaining_secs, + .. + } => { + app_label_clone.set_text(entry_name); + time_display_clone.set_remaining(Some(*time_remaining_secs)); + warning_label_clone.set_text(&format!( + "Only {} seconds remaining!", + time_remaining_secs + )); + warning_box_clone.set_visible(true); + } + SessionState::Ending { reason, .. } => { + app_label_clone.set_text("Session ending..."); + warning_label_clone.set_text(reason); + warning_box_clone.set_visible(true); + } + } + + // Update battery + let battery = BatteryStatus::read(); + battery_icon_clone.set_icon_name(Some(battery.icon_name())); + if let Some(percent) = battery.percent { + battery_label_clone.set_text(&format!("{}%", percent)); + } else { + battery_label_clone.set_text("--%"); + } + + // Update volume + let volume = VolumeStatus::read(); + volume_button_clone.set_icon_name(volume.icon_name()); + + glib::ControlFlow::Continue + }); + + container +} + +fn load_css() { + let css = r#" + .hud-bar { + background-color: rgba(30, 30, 30, 0.95); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + } + + .app-name { + font-weight: bold; + font-size: 14px; + color: white; + } + + .time-display { + font-family: monospace; + font-size: 14px; + color: #88c0d0; + } + + .time-display.time-warning { + color: #ebcb8b; + } + + .time-display.time-critical { + color: #bf616a; + animation: blink 1s infinite; + } + + @keyframes blink { + 50% { opacity: 0.5; } + } + + .warning-banner { + background-color: rgba(235, 203, 139, 0.2); + border-radius: 4px; + padding: 4px 12px; + } + + .warning-text { + color: #ebcb8b; + font-weight: bold; + } + + .indicator-button, + .control-button { + min-width: 32px; + min-height: 32px; + padding: 4px; + border-radius: 4px; + } + + .indicator-button:hover, + .control-button:hover { + background-color: rgba(255, 255, 255, 0.1); + } + + .close-button { + min-width: 32px; + min-height: 32px; + padding: 4px; + border-radius: 4px; + color: #bf616a; + } + + .close-button:hover { + background-color: rgba(191, 97, 106, 0.3); + } + + .battery-label { + font-size: 12px; + color: #a3be8c; + } + "#; + + let provider = gtk4::CssProvider::new(); + provider.load_from_data(css); + + gtk4::style_context_add_provider_for_display( + >k4::gdk::Display::default().expect("Could not get display"), + &provider, + gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); +} + +fn run_event_loop(socket_path: PathBuf, state: SharedState) -> anyhow::Result<()> { + let rt = Runtime::new()?; + + rt.block_on(async { + loop { + tracing::info!("Connecting to shepherdd at {:?}", socket_path); + + match IpcClient::connect(&socket_path).await { + Ok(client) => { + tracing::info!("Connected to shepherdd"); + + let mut stream = match client.subscribe().await { + Ok(stream) => stream, + Err(e) => { + tracing::error!("Failed to subscribe: {}", e); + tokio::time::sleep(Duration::from_secs(1)).await; + continue; + } + }; + + loop { + match stream.next().await { + Ok(event) => { + tracing::debug!("Received event: {:?}", event); + state.handle_event(&event); + } + Err(e) => { + tracing::error!("Event stream error: {}", e); + break; + } + } + } + } + Err(e) => { + tracing::warn!("Failed to connect to shepherdd: {}", e); + } + } + + // Wait before reconnecting + tokio::time::sleep(Duration::from_secs(2)).await; + } + }) +} + +fn start_metrics_updates(_state: SharedState) { + // Battery and volume are now updated in the main UI loop + // This function could be used for more expensive operations + // that don't need to run as frequently +} diff --git a/crates/shepherd-hud/src/battery.rs b/crates/shepherd-hud/src/battery.rs new file mode 100644 index 0000000..bf6e917 --- /dev/null +++ b/crates/shepherd-hud/src/battery.rs @@ -0,0 +1,130 @@ +//! Battery monitoring module +//! +//! Monitors battery status via sysfs or UPower D-Bus interface. + +use std::fs; +use std::path::Path; + +/// Battery status +#[derive(Debug, Clone, Default)] +pub struct BatteryStatus { + /// Battery percentage (0-100) + pub percent: Option, + /// Whether the battery is charging + pub charging: bool, + /// Whether AC power is connected + pub ac_connected: bool, +} + +impl BatteryStatus { + /// Read battery status from sysfs + pub fn read() -> Self { + let mut status = BatteryStatus::default(); + + // Try to find a battery in /sys/class/power_supply + let power_supply = Path::new("/sys/class/power_supply"); + if !power_supply.exists() { + return status; + } + + if let Ok(entries) = fs::read_dir(power_supply) { + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + + // Check for battery + if name_str.starts_with("BAT") { + if let Some((percent, charging)) = read_battery_info(&path) { + status.percent = Some(percent); + status.charging = charging; + } + } + + // Check for AC adapter + if name_str.starts_with("AC") || name_str.contains("ADP") { + if let Some(online) = read_ac_status(&path) { + status.ac_connected = online; + } + } + } + } + + status + } + + /// Get an icon name for the current battery status + pub fn icon_name(&self) -> &'static str { + match (self.percent, self.charging) { + (None, _) => "battery-missing-symbolic", + (Some(p), true) if p >= 90 => "battery-full-charging-symbolic", + (Some(p), true) if p >= 60 => "battery-good-charging-symbolic", + (Some(p), true) if p >= 30 => "battery-low-charging-symbolic", + (Some(_), true) => "battery-caution-charging-symbolic", + (Some(p), false) if p >= 90 => "battery-full-symbolic", + (Some(p), false) if p >= 60 => "battery-good-symbolic", + (Some(p), false) if p >= 30 => "battery-low-symbolic", + (Some(p), false) if p >= 10 => "battery-caution-symbolic", + (Some(_), false) => "battery-empty-symbolic", + } + } + + /// Check if battery is critically low + pub fn is_critical(&self) -> bool { + matches!(self.percent, Some(p) if p < 10 && !self.charging) + } +} + +fn read_battery_info(path: &Path) -> Option<(u8, bool)> { + // Read capacity + let capacity_path = path.join("capacity"); + let capacity: u8 = fs::read_to_string(&capacity_path) + .ok()? + .trim() + .parse() + .ok()?; + + // Read status + let status_path = path.join("status"); + let status = fs::read_to_string(&status_path).ok()?; + let charging = status.trim().eq_ignore_ascii_case("charging") + || status.trim().eq_ignore_ascii_case("full"); + + Some((capacity.min(100), charging)) +} + +fn read_ac_status(path: &Path) -> Option { + let online_path = path.join("online"); + let online = fs::read_to_string(&online_path).ok()?; + Some(online.trim() == "1") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_battery_icon_names() { + let status = BatteryStatus { + percent: Some(95), + charging: false, + ac_connected: false, + }; + assert_eq!(status.icon_name(), "battery-full-symbolic"); + + let status = BatteryStatus { + percent: Some(50), + charging: true, + ac_connected: true, + }; + assert_eq!(status.icon_name(), "battery-low-charging-symbolic"); + + let status = BatteryStatus { + percent: Some(5), + charging: false, + ac_connected: false, + }; + assert_eq!(status.icon_name(), "battery-empty-symbolic"); + assert!(status.is_critical()); + } +} diff --git a/crates/shepherd-hud/src/main.rs b/crates/shepherd-hud/src/main.rs new file mode 100644 index 0000000..4a85fe7 --- /dev/null +++ b/crates/shepherd-hud/src/main.rs @@ -0,0 +1,58 @@ +//! Shepherd HUD - Always-visible overlay +//! +//! This is the heads-up display that remains visible during active sessions. +//! It shows time remaining, battery, volume, and provides session controls. + +mod app; +mod battery; +mod state; +mod time_display; +mod volume; + +use anyhow::Result; +use clap::Parser; +use gtk4::prelude::*; +use std::path::PathBuf; +use tracing_subscriber::EnvFilter; + +/// Shepherd HUD - Always-visible overlay for shepherdd sessions +#[derive(Parser, Debug)] +#[command(name = "shepherd-hud")] +#[command(about = "GTK4 layer-shell HUD for shepherdd", long_about = None)] +struct Args { + /// Socket path for shepherdd connection + #[arg(short, long, default_value = "/run/shepherdd/shepherdd.sock")] + socket: PathBuf, + + /// Log level + #[arg(short, long, default_value = "info")] + log_level: String, + + /// Anchor position (top, bottom) + #[arg(short, long, default_value = "top")] + anchor: String, + + /// Height of the HUD bar in pixels + #[arg(long, default_value = "48")] + height: i32, +} + +fn main() -> Result<()> { + let args = Args::parse(); + + // Initialize logging + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new(&args.log_level)), + ) + .init(); + + tracing::info!("Starting Shepherd HUD"); + + // Run GTK application + let application = app::HudApp::new(args.socket, args.anchor, args.height); + let exit_code = application.run(); + + std::process::exit(exit_code); +} diff --git a/crates/shepherd-hud/src/state.rs b/crates/shepherd-hud/src/state.rs new file mode 100644 index 0000000..c55dd11 --- /dev/null +++ b/crates/shepherd-hud/src/state.rs @@ -0,0 +1,238 @@ +//! State management for the HUD +//! +//! The HUD subscribes to events from shepherdd and tracks session state. + +use chrono::Local; +use shepherd_api::events::{Event, EventPayload}; +use shepherd_api::types::SessionEndReason; +use shepherd_util::{EntryId, SessionId}; +use std::sync::Arc; +use tokio::sync::watch; + +/// The current state of the session as seen by the HUD +#[derive(Debug, Clone)] +pub enum SessionState { + /// No active session - HUD should be hidden + NoSession, + + /// Session is active + Active { + session_id: SessionId, + entry_id: EntryId, + entry_name: String, + started_at: std::time::Instant, + time_limit_secs: Option, + time_remaining_secs: Option, + paused: bool, + }, + + /// Warning shown - time running low + Warning { + session_id: SessionId, + entry_id: EntryId, + entry_name: String, + time_remaining_secs: u64, + }, + + /// Session is ending + Ending { + session_id: SessionId, + reason: String, + }, +} + +impl SessionState { + /// Check if there's an active or warning session + pub fn is_visible(&self) -> bool { + matches!( + self, + SessionState::Active { .. } | SessionState::Warning { .. } | SessionState::Ending { .. } + ) + } + + /// Get the current session ID if any + pub fn session_id(&self) -> Option<&SessionId> { + match self { + SessionState::NoSession => None, + SessionState::Active { session_id, .. } => Some(session_id), + SessionState::Warning { session_id, .. } => Some(session_id), + SessionState::Ending { session_id, .. } => Some(session_id), + } + } +} + +/// System metrics for display +#[derive(Debug, Clone, Default)] +pub struct SystemMetrics { + /// Battery percentage (0-100) + pub battery_percent: Option, + /// Whether battery is charging + pub battery_charging: bool, + /// Volume percentage (0-100) + pub volume_percent: Option, + /// Whether volume is muted + pub volume_muted: bool, +} + +/// Shared state for the HUD +#[derive(Clone)] +pub struct SharedState { + /// Session state sender + session_tx: Arc>, + /// Session state receiver + session_rx: watch::Receiver, + /// System metrics sender + metrics_tx: Arc>, + /// System metrics receiver + metrics_rx: watch::Receiver, +} + +impl SharedState { + pub fn new() -> Self { + let (session_tx, session_rx) = watch::channel(SessionState::NoSession); + let (metrics_tx, metrics_rx) = watch::channel(SystemMetrics::default()); + + Self { + session_tx: Arc::new(session_tx), + session_rx, + metrics_tx: Arc::new(metrics_tx), + metrics_rx, + } + } + + /// Get the current session state + pub fn session_state(&self) -> SessionState { + self.session_rx.borrow().clone() + } + + /// Subscribe to session state changes + pub fn subscribe_session(&self) -> watch::Receiver { + self.session_rx.clone() + } + + /// Subscribe to metrics changes + pub fn subscribe_metrics(&self) -> watch::Receiver { + self.metrics_rx.clone() + } + + /// Update session state + pub fn set_session_state(&self, state: SessionState) { + let _ = self.session_tx.send(state); + } + + /// Update system metrics + pub fn set_metrics(&self, metrics: SystemMetrics) { + let _ = self.metrics_tx.send(metrics); + } + + /// Update time remaining for current session + pub fn update_time_remaining(&self, remaining_secs: u64) { + self.session_tx.send_modify(|state| { + if let SessionState::Active { + time_remaining_secs, + .. + } = state + { + *time_remaining_secs = Some(remaining_secs); + } + }); + } + + /// Handle an event from shepherdd + pub fn handle_event(&self, event: &Event) { + match &event.payload { + EventPayload::SessionStarted { + session_id, + entry_id, + label, + deadline, + } => { + let now = chrono::Local::now(); + let time_remaining = if *deadline > now { + (*deadline - now).num_seconds().max(0) as u64 + } else { + 0 + }; + self.set_session_state(SessionState::Active { + session_id: session_id.clone(), + entry_id: entry_id.clone(), + entry_name: label.clone(), + started_at: std::time::Instant::now(), + time_limit_secs: Some(time_remaining), + time_remaining_secs: Some(time_remaining), + paused: false, + }); + } + + EventPayload::SessionEnded { session_id, .. } => { + if self.session_state().session_id() == Some(session_id) { + self.set_session_state(SessionState::NoSession); + } + } + + EventPayload::WarningIssued { + session_id, + time_remaining, + .. + } => { + self.session_tx.send_modify(|state| { + if let SessionState::Active { + session_id: sid, + entry_id, + entry_name, + .. + } = state + { + if sid == session_id { + *state = SessionState::Warning { + session_id: session_id.clone(), + entry_id: entry_id.clone(), + entry_name: entry_name.clone(), + time_remaining_secs: time_remaining.as_secs(), + }; + } + } + }); + } + + EventPayload::SessionExpiring { session_id } => { + if self.session_state().session_id() == Some(session_id) { + self.set_session_state(SessionState::Ending { + session_id: session_id.clone(), + reason: "Time expired".to_string(), + }); + } + } + + EventPayload::StateChanged(snapshot) => { + if let Some(session) = &snapshot.current_session { + let now = chrono::Local::now(); + let time_remaining = if session.deadline > now { + (session.deadline - now).num_seconds().max(0) as u64 + } else { + 0 + }; + self.set_session_state(SessionState::Active { + session_id: session.session_id.clone(), + entry_id: session.entry_id.clone(), + entry_name: session.label.clone(), + started_at: std::time::Instant::now(), + time_limit_secs: Some(time_remaining), + time_remaining_secs: Some(time_remaining), + paused: false, + }); + } else { + self.set_session_state(SessionState::NoSession); + } + } + + _ => {} + } + } +} + +impl Default for SharedState { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/shepherd-hud/src/time_display.rs b/crates/shepherd-hud/src/time_display.rs new file mode 100644 index 0000000..7de2825 --- /dev/null +++ b/crates/shepherd-hud/src/time_display.rs @@ -0,0 +1,154 @@ +//! Time display widget +//! +//! Shows elapsed time, remaining time, or countdown. + +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use std::cell::RefCell; + +mod imp { + use super::*; + + #[derive(Default)] + pub struct TimeDisplay { + pub label: RefCell>, + pub total_secs: RefCell>, + pub remaining_secs: RefCell>, + pub paused: RefCell, + } + + #[glib::object_subclass] + impl ObjectSubclass for TimeDisplay { + const NAME: &'static str = "ShepherdTimeDisplay"; + type Type = super::TimeDisplay; + type ParentType = gtk4::Box; + } + + impl ObjectImpl for TimeDisplay { + fn constructed(&self) { + self.parent_constructed(); + + let obj = self.obj(); + obj.set_orientation(gtk4::Orientation::Horizontal); + obj.set_spacing(4); + + // Time icon + let icon = gtk4::Image::from_icon_name("preferences-system-time-symbolic"); + icon.set_pixel_size(20); + obj.append(&icon); + + // Time label + let label = gtk4::Label::new(Some("--:--")); + label.add_css_class("time-display"); + obj.append(&label); + + *self.label.borrow_mut() = Some(label); + } + } + + impl WidgetImpl for TimeDisplay {} + impl BoxImpl for TimeDisplay {} +} + +glib::wrapper! { + pub struct TimeDisplay(ObjectSubclass) + @extends gtk4::Box, gtk4::Widget, + @implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget, gtk4::Orientable; +} + +impl TimeDisplay { + pub fn new() -> Self { + glib::Object::builder().build() + } + + /// Set the time limit in seconds + pub fn set_time_limit(&self, total_secs: Option) { + let imp = self.imp(); + *imp.total_secs.borrow_mut() = total_secs; + self.update_display(); + } + + /// Set the remaining time in seconds + pub fn set_remaining(&self, remaining_secs: Option) { + let imp = self.imp(); + *imp.remaining_secs.borrow_mut() = remaining_secs; + self.update_display(); + } + + /// Set paused state + pub fn set_paused(&self, paused: bool) { + let imp = self.imp(); + *imp.paused.borrow_mut() = paused; + self.update_display(); + } + + /// Update the display based on current state + fn update_display(&self) { + let imp = self.imp(); + + if let Some(label) = imp.label.borrow().as_ref() { + let remaining = *imp.remaining_secs.borrow(); + let paused = *imp.paused.borrow(); + + let text = if let Some(secs) = remaining { + let formatted = format_duration(secs); + if paused { + format!("{} ⏸", formatted) + } else { + formatted + } + } else { + "--:--".to_string() + }; + + label.set_text(&text); + + // Update styling based on remaining time + label.remove_css_class("time-warning"); + label.remove_css_class("time-critical"); + + if let Some(secs) = remaining { + if secs <= 60 { + label.add_css_class("time-critical"); + } else if secs <= 300 { + label.add_css_class("time-warning"); + } + } + } + } +} + +impl Default for TimeDisplay { + fn default() -> Self { + Self::new() + } +} + +/// Format a duration in seconds as HH:MM:SS or MM:SS +fn format_duration(secs: u64) -> String { + let hours = secs / 3600; + let minutes = (secs % 3600) / 60; + let seconds = secs % 60; + + if hours > 0 { + format!("{:02}:{:02}:{:02}", hours, minutes, seconds) + } else { + format!("{:02}:{:02}", minutes, seconds) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_duration() { + assert_eq!(format_duration(0), "00:00"); + assert_eq!(format_duration(59), "00:59"); + assert_eq!(format_duration(60), "01:00"); + assert_eq!(format_duration(3599), "59:59"); + assert_eq!(format_duration(3600), "01:00:00"); + assert_eq!(format_duration(3661), "01:01:01"); + } +} diff --git a/crates/shepherd-hud/src/volume.rs b/crates/shepherd-hud/src/volume.rs new file mode 100644 index 0000000..b6cff3a --- /dev/null +++ b/crates/shepherd-hud/src/volume.rs @@ -0,0 +1,131 @@ +//! Volume monitoring and control module +//! +//! Monitors and controls system volume via PulseAudio/PipeWire. +//! Uses the `pactl` command-line tool for simplicity. + +use std::process::Command; + +/// Volume status +#[derive(Debug, Clone, Default)] +pub struct VolumeStatus { + /// Volume percentage (0-100+) + pub percent: u8, + /// Whether audio is muted + pub muted: bool, +} + +impl VolumeStatus { + /// Read volume status using pactl + pub fn read() -> Self { + let mut status = VolumeStatus::default(); + + // Get default sink info + if let Ok(output) = Command::new("pactl") + .args(["get-sink-volume", "@DEFAULT_SINK@"]) + .output() + { + if let Ok(stdout) = String::from_utf8(output.stdout) { + // Output looks like: "Volume: front-left: 65536 / 100% / -0.00 dB, front-right: ..." + if let Some(percent_str) = stdout.split('/').nth(1) { + if let Ok(percent) = percent_str.trim().trim_end_matches('%').parse::() { + status.percent = percent; + } + } + } + } + + // Check mute status + if let Ok(output) = Command::new("pactl") + .args(["get-sink-mute", "@DEFAULT_SINK@"]) + .output() + { + if let Ok(stdout) = String::from_utf8(output.stdout) { + // Output looks like: "Mute: yes" or "Mute: no" + status.muted = stdout.contains("yes"); + } + } + + status + } + + /// Toggle mute state + pub fn toggle_mute() -> anyhow::Result<()> { + Command::new("pactl") + .args(["set-sink-mute", "@DEFAULT_SINK@", "toggle"]) + .status()?; + Ok(()) + } + + /// Increase volume by a step + pub fn volume_up(step: u8) -> anyhow::Result<()> { + Command::new("pactl") + .args([ + "set-sink-volume", + "@DEFAULT_SINK@", + &format!("+{}%", step), + ]) + .status()?; + Ok(()) + } + + /// Decrease volume by a step + pub fn volume_down(step: u8) -> anyhow::Result<()> { + Command::new("pactl") + .args([ + "set-sink-volume", + "@DEFAULT_SINK@", + &format!("-{}%", step), + ]) + .status()?; + Ok(()) + } + + /// Set volume to a specific percentage + pub fn set_volume(percent: u8) -> anyhow::Result<()> { + Command::new("pactl") + .args(["set-sink-volume", "@DEFAULT_SINK@", &format!("{}%", percent)]) + .status()?; + Ok(()) + } + + /// Get an icon name for the current volume status + pub fn icon_name(&self) -> &'static str { + if self.muted { + "audio-volume-muted-symbolic" + } else if self.percent == 0 { + "audio-volume-muted-symbolic" + } else if self.percent < 33 { + "audio-volume-low-symbolic" + } else if self.percent < 66 { + "audio-volume-medium-symbolic" + } else { + "audio-volume-high-symbolic" + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_volume_icon_names() { + let status = VolumeStatus { + percent: 0, + muted: false, + }; + assert_eq!(status.icon_name(), "audio-volume-muted-symbolic"); + + let status = VolumeStatus { + percent: 50, + muted: false, + }; + assert_eq!(status.icon_name(), "audio-volume-medium-symbolic"); + + let status = VolumeStatus { + percent: 100, + muted: true, + }; + assert_eq!(status.icon_name(), "audio-volume-muted-symbolic"); + } +} diff --git a/crates/shepherd-launcher-ui/Cargo.toml b/crates/shepherd-launcher-ui/Cargo.toml new file mode 100644 index 0000000..43d80b5 --- /dev/null +++ b/crates/shepherd-launcher-ui/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "shepherd-launcher-ui" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "GTK4 launcher UI for shepherdd - main grid interface" + +[[bin]] +name = "shepherd-launcher" +path = "src/main.rs" + +[dependencies] +shepherd-api = { workspace = true } +shepherd-ipc = { workspace = true } +shepherd-util = { workspace = true } + +gtk4 = { workspace = true } +clap = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +anyhow = { workspace = true } +chrono = { workspace = true } + +[features] +default = [] diff --git a/crates/shepherd-launcher-ui/src/app.rs b/crates/shepherd-launcher-ui/src/app.rs new file mode 100644 index 0000000..ee99ee3 --- /dev/null +++ b/crates/shepherd-launcher-ui/src/app.rs @@ -0,0 +1,349 @@ +//! Main GTK4 application for the launcher + +use gtk4::glib; +use gtk4::prelude::*; +use std::cell::RefCell; +use std::path::PathBuf; +use std::rc::Rc; +use std::sync::Arc; +use tokio::runtime::Runtime; +use tokio::sync::mpsc; +use tracing::{debug, error, info}; + +use crate::client::{ClientCommand, CommandClient, DaemonClient}; +use crate::grid::LauncherGrid; +use crate::state::{LauncherState, SharedState}; + +/// CSS styling for the launcher +const LAUNCHER_CSS: &str = r#" +window { + background-color: #1a1a2e; +} + +.launcher-grid { + padding: 48px; +} + +.launcher-tile { + background-color: #16213e; + border-radius: 16px; + padding: 16px; + min-width: 140px; + min-height: 140px; + border: 2px solid transparent; + transition: all 200ms ease; +} + +.launcher-tile:hover { + background-color: #1f3460; + border-color: #4a90d9; +} + +.launcher-tile:active { + background-color: #0f3460; +} + +.launcher-tile:disabled { + opacity: 0.4; +} + +.tile-label { + color: #ffffff; + font-size: 14px; + font-weight: 500; +} + +.status-label { + color: #888888; + font-size: 18px; +} + +.error-label { + color: #ff6b6b; + font-size: 16px; +} + +.launching-spinner { + min-width: 64px; + min-height: 64px; +} + +.session-active-box { + padding: 48px; +} + +.session-label { + color: #ffffff; + font-size: 24px; + font-weight: 600; +} +"#; + +pub struct LauncherApp { + socket_path: PathBuf, +} + +impl LauncherApp { + pub fn new(socket_path: PathBuf) -> Self { + Self { socket_path } + } + + pub fn run(&self) -> i32 { + let app = gtk4::Application::builder() + .application_id("org.shepherd.launcher") + .build(); + + let socket_path = self.socket_path.clone(); + + app.connect_activate(move |app| { + Self::build_ui(app, socket_path.clone()); + }); + + app.run().into() + } + + fn build_ui(app: >k4::Application, socket_path: PathBuf) { + // Load CSS + let provider = gtk4::CssProvider::new(); + provider.load_from_string(LAUNCHER_CSS); + gtk4::style_context_add_provider_for_display( + >k4::gdk::Display::default().expect("Could not get default display"), + &provider, + gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + + // Create main window + let window = gtk4::ApplicationWindow::builder() + .application(app) + .title("Shepherd Launcher") + .default_width(1280) + .default_height(720) + .build(); + + // Make fullscreen + window.fullscreen(); + + // Create main stack for different views + let stack = gtk4::Stack::new(); + stack.set_transition_type(gtk4::StackTransitionType::Crossfade); + stack.set_transition_duration(300); + + // Create views + let grid = LauncherGrid::new(); + let loading_view = Self::create_loading_view(); + let error_view = Self::create_error_view(); + let session_view = Self::create_session_view(); + let disconnected_view = Self::create_disconnected_view(); + + stack.add_named(&grid, Some("grid")); + stack.add_named(&loading_view, Some("loading")); + stack.add_named(&error_view.0, Some("error")); + stack.add_named(&session_view.0, Some("session")); + stack.add_named(&disconnected_view.0, Some("disconnected")); + + window.set_child(Some(&stack)); + + // Create shared state + let state = SharedState::new(); + let state_receiver = state.subscribe(); + + // Create tokio runtime for async operations + let runtime = Arc::new(Runtime::new().expect("Failed to create tokio runtime")); + + // Create command channel + let (command_tx, command_rx) = mpsc::unbounded_channel(); + + // Create command client for sending commands + let command_client = Arc::new(CommandClient::new(&socket_path)); + + // Connect grid launch callback + let cmd_client = command_client.clone(); + let state_clone = state.clone(); + let rt = runtime.clone(); + grid.connect_launch(move |entry_id| { + info!(entry_id = %entry_id, "Launch requested"); + state_clone.set(LauncherState::Launching { + entry_id: entry_id.to_string(), + }); + + let client = cmd_client.clone(); + let state = state_clone.clone(); + let entry_id = entry_id.clone(); + rt.spawn(async move { + match client.launch(&entry_id).await { + Ok(response) => { + debug!(response = ?response, "Launch response"); + // State will be updated via events + } + Err(e) => { + error!(error = %e, "Launch failed"); + state.set(LauncherState::Error { + message: format!("Launch failed: {}", e), + }); + } + } + }); + }); + + // Connect retry button + let cmd_client = command_client.clone(); + let state_clone = state.clone(); + let rt = runtime.clone(); + disconnected_view.1.connect_clicked(move |_| { + info!("Retry connection requested"); + state_clone.set(LauncherState::Connecting); + + let client = cmd_client.clone(); + let state = state_clone.clone(); + rt.spawn(async move { + match client.get_state().await { + Ok(_) => { + // Will trigger state update + } + Err(e) => { + error!(error = %e, "Reconnect failed"); + state.set(LauncherState::Disconnected); + } + } + }); + }); + + // Start daemon client in background + let state_for_client = state.clone(); + let socket_for_client = socket_path.clone(); + runtime.spawn(async move { + let client = DaemonClient::new(socket_for_client, state_for_client, command_rx); + client.run().await; + }); + + // Set up state change handler + let stack_weak = stack.downgrade(); + let grid_weak = grid.downgrade(); + let error_label = error_view.1.clone(); + let session_label = session_view.1.clone(); + + glib::spawn_future_local(async move { + let mut receiver = state_receiver; + + loop { + receiver.changed().await.ok(); + + let state = receiver.borrow().clone(); + + let Some(stack) = stack_weak.upgrade() else { + break; + }; + + let grid = grid_weak.upgrade(); + + match state { + LauncherState::Disconnected => { + stack.set_visible_child_name("disconnected"); + } + LauncherState::Connecting => { + stack.set_visible_child_name("loading"); + } + LauncherState::Idle { entries } => { + if let Some(grid) = grid { + grid.set_entries(entries); + grid.set_tiles_sensitive(true); + } + stack.set_visible_child_name("grid"); + } + LauncherState::Launching { entry_id } => { + if let Some(grid) = grid { + grid.set_tiles_sensitive(false); + } + stack.set_visible_child_name("loading"); + } + LauncherState::SessionActive { + session_id: _, + entry_label, + time_remaining: _, + } => { + session_label.set_text(&format!("Running: {}", entry_label)); + stack.set_visible_child_name("session"); + } + LauncherState::Error { message } => { + error_label.set_text(&message); + stack.set_visible_child_name("error"); + } + } + } + }); + + window.present(); + } + + fn create_loading_view() -> gtk4::Box { + let container = gtk4::Box::new(gtk4::Orientation::Vertical, 16); + container.set_halign(gtk4::Align::Center); + container.set_valign(gtk4::Align::Center); + + let spinner = gtk4::Spinner::new(); + spinner.set_spinning(true); + spinner.add_css_class("launching-spinner"); + container.append(&spinner); + + let label = gtk4::Label::new(Some("Loading...")); + label.add_css_class("status-label"); + container.append(&label); + + container + } + + fn create_error_view() -> (gtk4::Box, gtk4::Label) { + let container = gtk4::Box::new(gtk4::Orientation::Vertical, 16); + container.set_halign(gtk4::Align::Center); + container.set_valign(gtk4::Align::Center); + + let icon = gtk4::Image::from_icon_name("dialog-error"); + icon.set_pixel_size(64); + container.append(&icon); + + let label = gtk4::Label::new(Some("An error occurred")); + label.add_css_class("error-label"); + label.set_wrap(true); + label.set_max_width_chars(40); + container.append(&label); + + (container, label) + } + + fn create_session_view() -> (gtk4::Box, gtk4::Label) { + let container = gtk4::Box::new(gtk4::Orientation::Vertical, 24); + container.set_halign(gtk4::Align::Center); + container.set_valign(gtk4::Align::Center); + container.add_css_class("session-active-box"); + + let label = gtk4::Label::new(Some("Session Active")); + label.add_css_class("session-label"); + container.append(&label); + + let hint = gtk4::Label::new(Some("Use the HUD to view time remaining")); + hint.add_css_class("status-label"); + container.append(&hint); + + (container, label) + } + + fn create_disconnected_view() -> (gtk4::Box, gtk4::Button) { + let container = gtk4::Box::new(gtk4::Orientation::Vertical, 24); + container.set_halign(gtk4::Align::Center); + container.set_valign(gtk4::Align::Center); + + let icon = gtk4::Image::from_icon_name("network-offline"); + icon.set_pixel_size(64); + container.append(&icon); + + let label = gtk4::Label::new(Some("System not ready")); + label.add_css_class("status-label"); + container.append(&label); + + let retry_button = gtk4::Button::with_label("Retry"); + retry_button.add_css_class("launcher-tile"); + container.append(&retry_button); + + (container, retry_button) + } +} diff --git a/crates/shepherd-launcher-ui/src/client.rs b/crates/shepherd-launcher-ui/src/client.rs new file mode 100644 index 0000000..22acbd4 --- /dev/null +++ b/crates/shepherd-launcher-ui/src/client.rs @@ -0,0 +1,239 @@ +//! IPC client wrapper for the launcher UI + +use anyhow::{Context, Result}; +use shepherd_api::{Command, Event, Response, ResponsePayload, ResponseResult}; +use shepherd_ipc::IpcClient; +use shepherd_util::EntryId; +use std::path::Path; +use std::time::Duration; +use tokio::sync::mpsc; +use tokio::time::sleep; +use tracing::{debug, error, info, warn}; + +use crate::state::{LauncherState, SharedState}; + +/// Messages from UI to client task +#[derive(Debug)] +pub enum ClientCommand { + /// Request to launch an entry + Launch(EntryId), + /// Request to stop current session + StopCurrent, + /// Request fresh state + RefreshState, + /// Shutdown the client + Shutdown, +} + +/// Client connection manager +pub struct DaemonClient { + socket_path: std::path::PathBuf, + state: SharedState, + command_rx: mpsc::UnboundedReceiver, +} + +impl DaemonClient { + pub fn new( + socket_path: impl AsRef, + state: SharedState, + command_rx: mpsc::UnboundedReceiver, + ) -> Self { + Self { + socket_path: socket_path.as_ref().to_path_buf(), + state, + command_rx, + } + } + + /// Run the client connection loop + pub async fn run(mut self) { + loop { + match self.connect_and_run().await { + Ok(()) => { + info!("Client loop exited normally"); + break; + } + Err(e) => { + error!(error = %e, "Connection error"); + self.state.set(LauncherState::Disconnected); + + // Wait before reconnecting + sleep(Duration::from_secs(2)).await; + } + } + } + } + + async fn connect_and_run(&mut self) -> Result<()> { + self.state.set(LauncherState::Connecting); + + info!(path = %self.socket_path.display(), "Connecting to daemon"); + + let mut client = IpcClient::connect(&self.socket_path) + .await + .context("Failed to connect to daemon")?; + + info!("Connected to daemon"); + + // Get initial state + let response = client.send(Command::GetState).await?; + self.handle_response(response)?; + + // Subscribe to events + let response = client.send(Command::SubscribeEvents).await?; + if let ResponseResult::Err(e) = response.result { + warn!(error = %e.message, "Failed to subscribe to events"); + } + + // Get entries list + let response = client.send(Command::ListEntries { at_time: None }).await?; + self.handle_response(response)?; + + // Now consume client for event stream + let mut events = client.subscribe().await?; + + // Main event loop + loop { + tokio::select! { + // Handle commands from UI + Some(cmd) = self.command_rx.recv() => { + match cmd { + ClientCommand::Shutdown => { + info!("Shutdown requested"); + return Ok(()); + } + ClientCommand::Launch(entry_id) => { + // We can't send commands after subscribing since client is consumed + // Need to reconnect for commands + warn!("Launch command received but cannot send after subscribe"); + // For now, trigger a reconnect + return Ok(()); + } + ClientCommand::StopCurrent => { + warn!("Stop command received but cannot send after subscribe"); + return Ok(()); + } + ClientCommand::RefreshState => { + // Trigger reconnect to refresh + return Ok(()); + } + } + } + + // Handle events from daemon + event_result = events.next() => { + match event_result { + Ok(event) => { + debug!(event = ?event, "Received event"); + self.state.handle_event(event); + } + Err(e) => { + error!(error = %e, "Event stream error"); + return Err(e.into()); + } + } + } + } + } + } + + fn handle_response(&self, response: Response) -> Result<()> { + match response.result { + ResponseResult::Ok(payload) => { + match payload { + ResponsePayload::State(snapshot) => { + if let Some(session) = snapshot.current_session { + let now = chrono::Local::now(); + let time_remaining = if session.deadline > now { + (session.deadline - now).to_std().ok() + } else { + Some(Duration::ZERO) + }; + self.state.set(LauncherState::SessionActive { + session_id: session.session_id, + entry_label: session.label, + time_remaining, + }); + } else { + self.state.set(LauncherState::Idle { + entries: snapshot.entries, + }); + } + } + ResponsePayload::Entries(entries) => { + // Only update if we're in idle state + if matches!(self.state.get(), LauncherState::Idle { .. } | LauncherState::Connecting) { + self.state.set(LauncherState::Idle { entries }); + } + } + ResponsePayload::LaunchApproved { session_id, deadline } => { + let now = chrono::Local::now(); + let time_remaining = if deadline > now { + (deadline - now).to_std().ok() + } else { + Some(Duration::ZERO) + }; + self.state.set(LauncherState::SessionActive { + session_id, + entry_label: "Starting...".into(), + time_remaining, + }); + } + ResponsePayload::LaunchDenied { reasons } => { + let message = reasons + .iter() + .map(|r| r.message.as_deref().unwrap_or("Denied")) + .collect::>() + .join(", "); + self.state.set(LauncherState::Error { message }); + } + _ => {} + } + Ok(()) + } + ResponseResult::Err(e) => { + self.state.set(LauncherState::Error { + message: e.message, + }); + Ok(()) + } + } + } +} + +/// Separate command client for sending commands (not subscribed) +pub struct CommandClient { + socket_path: std::path::PathBuf, +} + +impl CommandClient { + pub fn new(socket_path: impl AsRef) -> Self { + Self { + socket_path: socket_path.as_ref().to_path_buf(), + } + } + + pub async fn launch(&self, entry_id: &EntryId) -> Result { + let mut client = IpcClient::connect(&self.socket_path).await?; + client.send(Command::Launch { + entry_id: entry_id.clone(), + }).await.map_err(Into::into) + } + + pub async fn stop_current(&self) -> Result { + let mut client = IpcClient::connect(&self.socket_path).await?; + client.send(Command::StopCurrent { + mode: shepherd_api::StopMode::Graceful, + }).await.map_err(Into::into) + } + + pub async fn get_state(&self) -> Result { + let mut client = IpcClient::connect(&self.socket_path).await?; + client.send(Command::GetState).await.map_err(Into::into) + } + + pub async fn list_entries(&self) -> Result { + let mut client = IpcClient::connect(&self.socket_path).await?; + client.send(Command::ListEntries { at_time: None }).await.map_err(Into::into) + } +} diff --git a/crates/shepherd-launcher-ui/src/grid.rs b/crates/shepherd-launcher-ui/src/grid.rs new file mode 100644 index 0000000..8f9f2be --- /dev/null +++ b/crates/shepherd-launcher-ui/src/grid.rs @@ -0,0 +1,140 @@ +//! Grid widget containing launcher tiles + +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use shepherd_api::EntryView; +use shepherd_util::EntryId; +use std::cell::RefCell; + +use crate::tile::LauncherTile; + +mod imp { + use super::*; + + pub struct LauncherGrid { + pub flow_box: gtk4::FlowBox, + pub tiles: RefCell>, + pub on_launch: RefCell>>, + } + + impl Default for LauncherGrid { + fn default() -> Self { + Self { + flow_box: gtk4::FlowBox::new(), + tiles: RefCell::new(Vec::new()), + on_launch: RefCell::new(None), + } + } + } + + #[glib::object_subclass] + impl ObjectSubclass for LauncherGrid { + const NAME: &'static str = "ShepherdLauncherGrid"; + type Type = super::LauncherGrid; + type ParentType = gtk4::Box; + } + + impl ObjectImpl for LauncherGrid { + fn constructed(&self) { + self.parent_constructed(); + + let obj = self.obj(); + obj.set_orientation(gtk4::Orientation::Vertical); + obj.set_halign(gtk4::Align::Fill); + obj.set_valign(gtk4::Align::Fill); + obj.set_hexpand(true); + obj.set_vexpand(true); + + // Configure flow box + self.flow_box.set_homogeneous(true); + self.flow_box.set_selection_mode(gtk4::SelectionMode::None); + self.flow_box.set_max_children_per_line(6); + self.flow_box.set_min_children_per_line(2); + self.flow_box.set_row_spacing(24); + self.flow_box.set_column_spacing(24); + self.flow_box.set_halign(gtk4::Align::Center); + self.flow_box.set_valign(gtk4::Align::Center); + self.flow_box.set_hexpand(true); + self.flow_box.set_vexpand(true); + self.flow_box.add_css_class("launcher-grid"); + + // Wrap in a scrolled window + let scrolled = gtk4::ScrolledWindow::new(); + scrolled.set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Automatic); + scrolled.set_child(Some(&self.flow_box)); + scrolled.set_hexpand(true); + scrolled.set_vexpand(true); + + obj.append(&scrolled); + } + } + + impl WidgetImpl for LauncherGrid {} + impl BoxImpl for LauncherGrid {} +} + +glib::wrapper! { + pub struct LauncherGrid(ObjectSubclass) + @extends gtk4::Box, gtk4::Widget, + @implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget, gtk4::Orientable; +} + +impl LauncherGrid { + pub fn new() -> Self { + glib::Object::builder().build() + } + + /// Set the callback for when an entry is launched + pub fn connect_launch(&self, callback: F) { + *self.imp().on_launch.borrow_mut() = Some(Box::new(callback)); + } + + /// Update the grid with new entries + pub fn set_entries(&self, entries: Vec) { + let imp = self.imp(); + + // Clear existing tiles + while let Some(child) = imp.flow_box.first_child() { + imp.flow_box.remove(&child); + } + imp.tiles.borrow_mut().clear(); + + // Create tiles for enabled entries + for entry in entries { + // Skip disabled entries + if !entry.enabled { + continue; + } + + let tile = LauncherTile::new(); + tile.set_entry(entry); + + // Connect click handler + let on_launch = imp.on_launch.clone(); + tile.connect_clicked(move |tile| { + if let Some(entry_id) = tile.entry_id() { + if let Some(callback) = on_launch.borrow().as_ref() { + callback(entry_id); + } + } + }); + + imp.flow_box.append(&tile); + imp.tiles.borrow_mut().push(tile); + } + } + + /// Enable or disable all tiles + pub fn set_tiles_sensitive(&self, sensitive: bool) { + for tile in self.imp().tiles.borrow().iter() { + tile.set_sensitive(sensitive); + } + } +} + +impl Default for LauncherGrid { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/shepherd-launcher-ui/src/main.rs b/crates/shepherd-launcher-ui/src/main.rs new file mode 100644 index 0000000..555c6a2 --- /dev/null +++ b/crates/shepherd-launcher-ui/src/main.rs @@ -0,0 +1,50 @@ +//! Shepherd Launcher UI - Main grid interface +//! +//! This is the primary user-facing shell for the kiosk-style environment. +//! It displays available entries from shepherdd and allows launching them. + +mod app; +mod client; +mod grid; +mod state; +mod tile; + +use anyhow::Result; +use clap::Parser; +use gtk4::prelude::*; +use std::path::PathBuf; +use tracing_subscriber::EnvFilter; + +/// Shepherd Launcher - Child-friendly kiosk launcher +#[derive(Parser, Debug)] +#[command(name = "shepherd-launcher")] +#[command(about = "GTK4 launcher UI for shepherdd", long_about = None)] +struct Args { + /// Socket path for shepherdd connection + #[arg(short, long, default_value = "/run/shepherdd/shepherdd.sock")] + socket: PathBuf, + + /// Log level + #[arg(short, long, default_value = "info")] + log_level: String, +} + +fn main() -> Result<()> { + let args = Args::parse(); + + // Initialize logging + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new(&args.log_level)), + ) + .init(); + + tracing::info!("Starting Shepherd Launcher UI"); + + // Run GTK application + let application = app::LauncherApp::new(args.socket); + let exit_code = application.run(); + + std::process::exit(exit_code); +} diff --git a/crates/shepherd-launcher-ui/src/state.rs b/crates/shepherd-launcher-ui/src/state.rs new file mode 100644 index 0000000..29ce101 --- /dev/null +++ b/crates/shepherd-launcher-ui/src/state.rs @@ -0,0 +1,139 @@ +//! Launcher application state management + +use shepherd_api::{DaemonStateSnapshot, EntryView, Event, EventPayload}; +use shepherd_util::SessionId; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::watch; + +/// Current state of the launcher UI +#[derive(Debug, Clone)] +pub enum LauncherState { + /// Not connected to daemon + Disconnected, + /// Connected, waiting for initial state + Connecting, + /// Connected, no session running - show grid + Idle { entries: Vec }, + /// Launch requested, waiting for response + Launching { entry_id: String }, + /// Session is running + SessionActive { + session_id: SessionId, + entry_label: String, + time_remaining: Option, + }, + /// Error state + Error { message: String }, +} + +impl Default for LauncherState { + fn default() -> Self { + Self::Disconnected + } +} + +/// Shared state container +#[derive(Clone)] +pub struct SharedState { + sender: watch::Sender, + receiver: watch::Receiver, +} + +impl SharedState { + pub fn new() -> Self { + let (sender, receiver) = watch::channel(LauncherState::default()); + Self { sender, receiver } + } + + pub fn set(&self, state: LauncherState) { + let _ = self.sender.send(state); + } + + pub fn get(&self) -> LauncherState { + self.receiver.borrow().clone() + } + + pub fn subscribe(&self) -> watch::Receiver { + self.receiver.clone() + } + + /// Update state from daemon event + pub fn handle_event(&self, event: Event) { + match event.payload { + EventPayload::StateChanged(snapshot) => { + self.apply_snapshot(snapshot); + } + EventPayload::SessionStarted { + session_id, + entry_id: _, + label, + deadline, + } => { + let now = chrono::Local::now(); + let time_remaining = if deadline > now { + (deadline - now).to_std().ok() + } else { + Some(Duration::ZERO) + }; + self.set(LauncherState::SessionActive { + session_id, + entry_label: label, + time_remaining, + }); + } + EventPayload::SessionEnded { .. } => { + // Will be followed by StateChanged, but set to connecting + // to ensure grid reloads + self.set(LauncherState::Connecting); + } + EventPayload::SessionExpiring { .. } => { + // Time's up indicator handled by HUD + } + EventPayload::WarningIssued { .. } => { + // Warnings handled by HUD + } + EventPayload::PolicyReloaded { .. } => { + // Request fresh state + self.set(LauncherState::Connecting); + } + EventPayload::EntryAvailabilityChanged { .. } => { + // Request fresh state + self.set(LauncherState::Connecting); + } + EventPayload::Shutdown => { + // Daemon is shutting down + self.set(LauncherState::Disconnected); + } + EventPayload::AuditEntry { .. } => { + // Audit events are for admin clients, ignore + } + } + } + + fn apply_snapshot(&self, snapshot: DaemonStateSnapshot) { + if let Some(session) = snapshot.current_session { + let now = chrono::Local::now(); + let time_remaining = if session.deadline > now { + (session.deadline - now).to_std().ok() + } else { + Some(Duration::ZERO) + }; + self.set(LauncherState::SessionActive { + session_id: session.session_id, + entry_label: session.label, + time_remaining, + }); + } else { + self.set(LauncherState::Idle { + entries: snapshot.entries, + }); + } + } +} + +impl Default for SharedState { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/shepherd-launcher-ui/src/tile.rs b/crates/shepherd-launcher-ui/src/tile.rs new file mode 100644 index 0000000..63e6650 --- /dev/null +++ b/crates/shepherd-launcher-ui/src/tile.rs @@ -0,0 +1,121 @@ +//! Individual tile widget for the launcher grid + +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use shepherd_api::EntryView; +use std::cell::RefCell; + +mod imp { + use super::*; + + #[derive(Default)] + pub struct LauncherTile { + pub entry: RefCell>, + pub icon: gtk4::Image, + pub label: gtk4::Label, + } + + #[glib::object_subclass] + impl ObjectSubclass for LauncherTile { + const NAME: &'static str = "ShepherdLauncherTile"; + type Type = super::LauncherTile; + type ParentType = gtk4::Button; + } + + impl ObjectImpl for LauncherTile { + fn constructed(&self) { + self.parent_constructed(); + + let obj = self.obj(); + + // Create layout + let content = gtk4::Box::new(gtk4::Orientation::Vertical, 8); + content.set_halign(gtk4::Align::Center); + content.set_valign(gtk4::Align::Center); + + // Icon + self.icon.set_pixel_size(96); + self.icon.set_icon_name(Some("application-x-executable")); + content.append(&self.icon); + + // Label + self.label.set_wrap(true); + self.label.set_wrap_mode(gtk4::pango::WrapMode::Word); + self.label.set_justify(gtk4::Justification::Center); + self.label.set_max_width_chars(12); + self.label.add_css_class("tile-label"); + content.append(&self.label); + + obj.set_child(Some(&content)); + obj.add_css_class("launcher-tile"); + obj.set_size_request(160, 160); + } + } + + impl WidgetImpl for LauncherTile {} + impl ButtonImpl for LauncherTile {} +} + +glib::wrapper! { + pub struct LauncherTile(ObjectSubclass) + @extends gtk4::Button, gtk4::Widget, + @implements gtk4::Accessible, gtk4::Actionable, gtk4::Buildable, gtk4::ConstraintTarget; +} + +impl LauncherTile { + pub fn new() -> Self { + glib::Object::builder().build() + } + + pub fn set_entry(&self, entry: EntryView) { + let imp = self.imp(); + + // Set label + imp.label.set_text(&entry.label); + + // Set icon + if let Some(ref icon_ref) = entry.icon_ref { + // Try to use the icon reference as an icon name + imp.icon.set_icon_name(Some(icon_ref)); + } else { + // Default icon based on entry kind + let icon_name = match entry.kind_tag { + shepherd_api::EntryKindTag::Process => "application-x-executable", + shepherd_api::EntryKindTag::Vm => "computer", + shepherd_api::EntryKindTag::Media => "video-x-generic", + shepherd_api::EntryKindTag::Custom => "applications-other", + }; + imp.icon.set_icon_name(Some(icon_name)); + } + + // Entry is available if enabled and has no blocking reasons + let available = entry.enabled && entry.reasons.is_empty(); + self.set_sensitive(available); + + // Add tooltip with reason if not available + if !available && !entry.reasons.is_empty() { + // Format the first reason for tooltip + let reason_text = format!("{:?}", entry.reasons[0]); + self.set_tooltip_text(Some(&reason_text)); + } else { + self.set_tooltip_text(None); + } + + *imp.entry.borrow_mut() = Some(entry); + } + + pub fn entry(&self) -> Option { + self.imp().entry.borrow().clone() + } + + pub fn entry_id(&self) -> Option { + self.imp().entry.borrow().as_ref().map(|e| e.entry_id.clone()) + } +} + +impl Default for LauncherTile { + fn default() -> Self { + Self::new() + } +}