WIP: Launcher UI

This commit is contained in:
Albert Armea 2025-12-26 20:01:22 -05:00
parent ac2d2abfed
commit e2013eb694
19 changed files with 2971 additions and 19 deletions

700
Cargo.lock generated
View file

@ -123,6 +123,29 @@ version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" 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]] [[package]]
name = "cc" name = "cc"
version = "1.2.51" version = "1.2.51"
@ -133,6 +156,16 @@ dependencies = [
"shlex", "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]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.4" version = "1.0.4"
@ -245,12 +278,143 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 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]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.6" version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" 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]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.3.4" version = "0.3.4"
@ -263,6 +427,245 @@ dependencies = [
"wasip2", "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]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.14.5" version = "0.14.5"
@ -349,6 +752,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "khronos_api"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@ -471,6 +880,30 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 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]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@ -500,12 +933,27 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.32" version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 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]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.103" version = "1.0.103"
@ -570,6 +1018,15 @@ dependencies = [
"smallvec", "smallvec",
] ]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.3" version = "1.1.3"
@ -595,6 +1052,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.228" version = "1.0.228"
@ -647,6 +1110,15 @@ dependencies = [
"serde", "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]] [[package]]
name = "sharded-slab" name = "sharded-slab"
version = "0.1.7" version = "0.1.7"
@ -678,7 +1150,7 @@ dependencies = [
"shepherd-util", "shepherd-util",
"tempfile", "tempfile",
"thiserror", "thiserror",
"toml", "toml 0.8.23",
"tracing", "tracing",
] ]
@ -729,6 +1201,25 @@ dependencies = [
"tracing", "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]] [[package]]
name = "shepherd-ipc" name = "shepherd-ipc"
version = "0.1.0" version = "0.1.0"
@ -744,6 +1235,24 @@ dependencies = [
"tracing", "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]] [[package]]
name = "shepherd-store" name = "shepherd-store"
version = "0.1.0" version = "0.1.0"
@ -813,6 +1322,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "slab"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.15.1" version = "1.15.1"
@ -846,6 +1361,25 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.24.0" version = "3.24.0"
@ -923,9 +1457,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [ dependencies = [
"serde", "serde",
"serde_spanned", "serde_spanned 0.6.9",
"toml_datetime", "toml_datetime 0.6.11",
"toml_edit", "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]] [[package]]
@ -937,6 +1486,15 @@ dependencies = [
"serde", "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]] [[package]]
name = "toml_edit" name = "toml_edit"
version = "0.22.27" version = "0.22.27"
@ -945,18 +1503,45 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"serde", "serde",
"serde_spanned", "serde_spanned 0.6.9",
"toml_datetime", "toml_datetime 0.6.11",
"toml_write", "toml_write",
"winnow", "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]] [[package]]
name = "toml_write" name = "toml_write"
version = "0.1.2" version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" 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]] [[package]]
name = "tracing" name = "tracing"
version = "0.1.44" version = "0.1.44"
@ -1067,6 +1652,12 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"
@ -1192,13 +1783,22 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.60.2" version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [ dependencies = [
"windows-targets", "windows-targets 0.53.5",
] ]
[[package]] [[package]]
@ -1210,6 +1810,22 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.53.5" version = "0.53.5"
@ -1217,58 +1833,106 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [ dependencies = [
"windows-link", "windows-link",
"windows_aarch64_gnullvm", "windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc", "windows_aarch64_msvc 0.53.1",
"windows_i686_gnu", "windows_i686_gnu 0.53.1",
"windows_i686_gnullvm", "windows_i686_gnullvm 0.53.1",
"windows_i686_msvc", "windows_i686_msvc 0.53.1",
"windows_x86_64_gnu", "windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm", "windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc", "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]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.53.1" version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.53.1" version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.53.1" version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]] [[package]]
name = "windows_i686_gnullvm" name = "windows_i686_gnullvm"
version = "0.53.1" version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.53.1" version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" 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]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.53.1" version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" 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]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.53.1" version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" 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]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.53.1" version = "0.53.1"
@ -1290,6 +1954,12 @@ version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
[[package]]
name = "xml-rs"
version = "0.8.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.31" version = "0.8.31"

View file

@ -10,14 +10,16 @@ members = [
"crates/shepherd-host-linux", "crates/shepherd-host-linux",
"crates/shepherd-ipc", "crates/shepherd-ipc",
"crates/shepherdd", "crates/shepherdd",
"crates/shepherd-launcher-ui",
"crates/shepherd-hud",
] ]
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2024"
license = "MIT" license = "GPL-3.0"
authors = ["Shepherd Contributors"] authors = ["Albert Armea"]
repository = "https://github.com/shepherd-project/shepherdd" repository = "https://github.com/aarmea/shepherd-launcher"
[workspace.dependencies] [workspace.dependencies]
# Internal crates # Internal crates
@ -59,5 +61,12 @@ bitflags = "2.4"
# Unix-specific # Unix-specific
nix = { version = "0.29", features = ["signal", "process", "user", "socket"] } 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 # Testing
tempfile = "3.9" tempfile = "3.9"

View file

@ -212,6 +212,7 @@ mod tests {
policy_loaded: true, policy_loaded: true,
current_session: None, current_session: None,
entry_count: 5, entry_count: 5,
entries: vec![],
}), }),
); );

View file

@ -169,6 +169,9 @@ pub struct DaemonStateSnapshot {
pub policy_loaded: bool, pub policy_loaded: bool,
pub current_session: Option<SessionInfo>, pub current_session: Option<SessionInfo>,
pub entry_count: usize, pub entry_count: usize,
/// Available entries for UI display
#[serde(default)]
pub entries: Vec<EntryView>,
} }
/// Role for authorization /// Role for authorization

View file

@ -456,11 +456,15 @@ impl CoreEngine {
s.to_session_info(MonotonicInstant::now()) s.to_session_info(MonotonicInstant::now())
}); });
// Build entry views for the snapshot
let entries = self.list_entries(Local::now());
DaemonStateSnapshot { DaemonStateSnapshot {
api_version: API_VERSION, api_version: API_VERSION,
policy_loaded: true, policy_loaded: true,
current_session, current_session,
entry_count: self.policy.entries.len(), entry_count: self.policy.entries.len(),
entries,
} }
} }

View file

@ -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 = []

View file

@ -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: &gtk4::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(
&gtk4::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
}

View file

@ -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<u8>,
/// 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<bool> {
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());
}
}

View file

@ -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);
}

View file

@ -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<u64>,
time_remaining_secs: Option<u64>,
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<u8>,
/// Whether battery is charging
pub battery_charging: bool,
/// Volume percentage (0-100)
pub volume_percent: Option<u8>,
/// Whether volume is muted
pub volume_muted: bool,
}
/// Shared state for the HUD
#[derive(Clone)]
pub struct SharedState {
/// Session state sender
session_tx: Arc<watch::Sender<SessionState>>,
/// Session state receiver
session_rx: watch::Receiver<SessionState>,
/// System metrics sender
metrics_tx: Arc<watch::Sender<SystemMetrics>>,
/// System metrics receiver
metrics_rx: watch::Receiver<SystemMetrics>,
}
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<SessionState> {
self.session_rx.clone()
}
/// Subscribe to metrics changes
pub fn subscribe_metrics(&self) -> watch::Receiver<SystemMetrics> {
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()
}
}

View file

@ -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<Option<gtk4::Label>>,
pub total_secs: RefCell<Option<u64>>,
pub remaining_secs: RefCell<Option<u64>>,
pub paused: RefCell<bool>,
}
#[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<imp::TimeDisplay>)
@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<u64>) {
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<u64>) {
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");
}
}

View file

@ -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::<u8>() {
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");
}
}

View file

@ -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 = []

View file

@ -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: &gtk4::Application, socket_path: PathBuf) {
// Load CSS
let provider = gtk4::CssProvider::new();
provider.load_from_string(LAUNCHER_CSS);
gtk4::style_context_add_provider_for_display(
&gtk4::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)
}
}

View file

@ -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<ClientCommand>,
}
impl DaemonClient {
pub fn new(
socket_path: impl AsRef<Path>,
state: SharedState,
command_rx: mpsc::UnboundedReceiver<ClientCommand>,
) -> 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::<Vec<_>>()
.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<Path>) -> Self {
Self {
socket_path: socket_path.as_ref().to_path_buf(),
}
}
pub async fn launch(&self, entry_id: &EntryId) -> Result<Response> {
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<Response> {
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<Response> {
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<Response> {
let mut client = IpcClient::connect(&self.socket_path).await?;
client.send(Command::ListEntries { at_time: None }).await.map_err(Into::into)
}
}

View file

@ -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<Vec<LauncherTile>>,
pub on_launch: RefCell<Option<Box<dyn Fn(EntryId) + 'static>>>,
}
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<imp::LauncherGrid>)
@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<F: Fn(EntryId) + 'static>(&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<EntryView>) {
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()
}
}

View file

@ -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);
}

View file

@ -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<EntryView> },
/// Launch requested, waiting for response
Launching { entry_id: String },
/// Session is running
SessionActive {
session_id: SessionId,
entry_label: String,
time_remaining: Option<Duration>,
},
/// 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<LauncherState>,
receiver: watch::Receiver<LauncherState>,
}
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<LauncherState> {
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()
}
}

View file

@ -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<Option<EntryView>>,
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<imp::LauncherTile>)
@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<EntryView> {
self.imp().entry.borrow().clone()
}
pub fn entry_id(&self) -> Option<shepherd_util::EntryId> {
self.imp().entry.borrow().as_ref().map(|e| e.entry_id.clone())
}
}
impl Default for LauncherTile {
fn default() -> Self {
Self::new()
}
}