diff --git a/.gitignore b/.gitignore index 7982b78..869cf26 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ result .worktrees *.swp /vmsilo-tools/target +/vmsilo-device-tray/target diff --git a/flake.nix b/flake.nix index bb2d978..11e8165 100644 --- a/flake.nix +++ b/flake.nix @@ -124,6 +124,16 @@ }; }; + # Build vmsilo-device-tray + buildVmsiloDeviceTray = + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + pkgs.callPackage ./vmsilo-device-tray/package.nix { + ksniPatch = ./patches/ksni-about-to-show-refresh.patch; + }; + # treefmt configuration treefmtConfig = { projectRootFile = "flake.nix"; @@ -143,6 +153,7 @@ vmsilo-dbus-proxy = buildVmsiloDbusProxy system; vmsilo-wayland-seccontext = buildVmsiloWaylandSeccontext system; vmsilo-tools = buildVmsiloTools system; + vmsilo-device-tray = buildVmsiloDeviceTray system; "cloud-hypervisor" = cloud-hypervisor.packages.${system}.cloud-hypervisor; decoration-tests = let @@ -237,6 +248,7 @@ vmsilo-dbus-proxy = buildVmsiloDbusProxy pkgs.stdenv.hostPlatform.system; vmsilo-tools = buildVmsiloTools pkgs.stdenv.hostPlatform.system; "usbip-rs" = usbip-rs-input.packages.${pkgs.stdenv.hostPlatform.system}.default; + "vmsilo-device-tray" = buildVmsiloDeviceTray pkgs.stdenv.hostPlatform.system; }; }; }; diff --git a/modules/default.nix b/modules/default.nix index a0d68df..650a134 100644 --- a/modules/default.nix +++ b/modules/default.nix @@ -22,6 +22,7 @@ in ./desktop.nix ./overlay.nix ./package.nix + ./tray.nix ]; config = lib.mkIf cfg.enable { diff --git a/modules/options.nix b/modules/options.nix index 3177a5f..9f216b7 100644 --- a/modules/options.nix +++ b/modules/options.nix @@ -1101,6 +1101,12 @@ in internal = true; }; + "vmsilo-device-tray" = lib.mkOption { + type = lib.types.package; + description = "vmsilo-device-tray package (injected by flake)."; + internal = true; + }; + userUid = lib.mkOption { type = lib.types.int; description = "UID of the configured vmsilo user."; diff --git a/modules/tray.nix b/modules/tray.nix new file mode 100644 index 0000000..a435c98 --- /dev/null +++ b/modules/tray.nix @@ -0,0 +1,31 @@ +# System tray for device passthrough management +{ + config, + pkgs, + lib, + ... +}: + +let + cfg = config.programs.vmsilo; + trayPkg = cfg._internal."vmsilo-device-tray"; +in +{ + config = lib.mkIf cfg.enable { + environment.systemPackages = [ trayPkg ]; + + systemd.user.services.vmsilo-device-tray = { + description = "vmsilo device tray"; + partOf = [ "graphical-session.target" ]; + after = [ "graphical-session.target" ]; + wantedBy = [ "graphical-session.target" ]; + serviceConfig = { + Type = "simple"; + ExecStart = "${trayPkg}/bin/vmsilo-device-tray"; + Restart = "on-failure"; + RestartSec = 5; + Environment = "VMSILO_ICON_THEME_PATH=${trayPkg}/share/icons"; + }; + }; + }; +} diff --git a/patches/ksni-about-to-show-refresh.patch b/patches/ksni-about-to-show-refresh.patch new file mode 100644 index 0000000..5bde8d7 --- /dev/null +++ b/patches/ksni-about-to-show-refresh.patch @@ -0,0 +1,41 @@ +Patch ksni to rebuild menu on AboutToShow D-Bus call. + +The DBusMenu spec's AboutToShow method is called by the host (e.g. KDE Plasma) +just before displaying a menu item. ksni hardcodes this to return false (no +update needed), which means the menu is served from cache. Additionally, ksni's +AboutToShow is missing the id parameter from the DBusMenu spec, causing KDE's +call to fail with a signature mismatch. + +This patch adds the id parameter and triggers a menu rebuild so the menu is +always fresh when opened. + +--- a/src/service.rs ++++ b/src/service.rs +@@ -275,7 +275,7 @@ impl Service { + Ok(()) + } + +- async fn update_menu(&mut self, conn: &Connection) -> zbus::Result<()> { ++ pub(crate) async fn update_menu(&mut self, conn: &Connection) -> zbus::Result<()> { + let new_menu = menu::menu_flatten(self.tray.menu()); + let mut all_updated_props = Vec::new(); + let mut all_removed_props = Vec::new(); +--- a/src/dbus_interface.rs ++++ b/src/dbus_interface.rs +@@ -334,8 +334,14 @@ impl DbusMenu { + } + } + +- async fn about_to_show(&self) -> zbus::fdo::Result { +- Ok(false) ++ async fn about_to_show( ++ &self, ++ #[zbus(connection)] conn: &Connection, ++ _id: i32, ++ ) -> zbus::fdo::Result { ++ let mut service = self.0.lock().await; ++ let _ = service.update_menu(conn).await; ++ Ok(true) + } + + async fn about_to_show_group(&self) -> zbus::fdo::Result<(Vec, Vec)> { diff --git a/vmsilo-device-tray/Cargo.lock b/vmsilo-device-tray/Cargo.lock new file mode 100644 index 0000000..f241d24 --- /dev/null +++ b/vmsilo-device-tray/Cargo.lock @@ -0,0 +1,1197 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "ksni" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b29c089f14ce24c5b25d9bdcb265413b5e0c3df0871823e0d96bd83bc52a24" +dependencies = [ + "futures-util", + "pastey", + "serde", + "tokio", + "zbus", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "pastey" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "tracing", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml_datetime" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow 1.0.0", +] + +[[package]] +name = "toml_parser" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +dependencies = [ + "winnow 1.0.0", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vmsilo-device-tray" +version = "0.1.0" +dependencies = [ + "env_logger", + "futures-util", + "ksni", + "log", + "tokio", + "zbus", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zbus" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +dependencies = [ + "async-broadcast", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys", + "winnow 0.7.15", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow 0.7.15", + "zvariant", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 0.7.15", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", + "winnow 0.7.15", +] diff --git a/vmsilo-device-tray/Cargo.toml b/vmsilo-device-tray/Cargo.toml new file mode 100644 index 0000000..496e8c1 --- /dev/null +++ b/vmsilo-device-tray/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "vmsilo-device-tray" +version = "0.1.0" +edition = "2021" + +[dependencies] +ksni = { version = "0.3", features = ["tokio"] } +zbus = { version = "5", default-features = false, features = ["tokio"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "time"] } +futures-util = "0.3" +log = "0.4" +env_logger = "0.11" diff --git a/vmsilo-device-tray/icons/USB_icon.svg b/vmsilo-device-tray/icons/USB_icon.svg new file mode 100644 index 0000000..e2ea762 --- /dev/null +++ b/vmsilo-device-tray/icons/USB_icon.svg @@ -0,0 +1,16 @@ + + + + + \ No newline at end of file diff --git a/vmsilo-device-tray/icons/rendered/usb-attached.png b/vmsilo-device-tray/icons/rendered/usb-attached.png new file mode 100644 index 0000000..fbc892b Binary files /dev/null and b/vmsilo-device-tray/icons/rendered/usb-attached.png differ diff --git a/vmsilo-device-tray/icons/rendered/usb-unattached.png b/vmsilo-device-tray/icons/rendered/usb-unattached.png new file mode 100644 index 0000000..a9ba035 Binary files /dev/null and b/vmsilo-device-tray/icons/rendered/usb-unattached.png differ diff --git a/vmsilo-device-tray/icons/usb-cable.svg b/vmsilo-device-tray/icons/usb-cable.svg new file mode 100644 index 0000000..942dd93 --- /dev/null +++ b/vmsilo-device-tray/icons/usb-cable.svg @@ -0,0 +1,2 @@ + + diff --git a/vmsilo-device-tray/package.nix b/vmsilo-device-tray/package.nix new file mode 100644 index 0000000..4f76e6a --- /dev/null +++ b/vmsilo-device-tray/package.nix @@ -0,0 +1,98 @@ +{ + pkgs, + ksniPatch, +}: + +let + renderedIcons = + pkgs.runCommand "vmsilo-device-tray-icons" + { + nativeBuildInputs = [ pkgs.resvg ]; + src = ./icons; + } + '' + mkdir -p $out/rendered $out/breeze $out/breeze-dark + + BREEZE_LIGHT="rgb(35,38,41)" + BREEZE_DARK="rgb(252,252,252)" + GREEN="rgb(39,174,96)" + + render_usb() { + local color="$1" size="$2" output="$3" + sed "s| /tmp/usb_colored.svg + resvg --width "$size" --height "$size" /tmp/usb_colored.svg "$output" + } + + render_cable() { + local color="$1" size="$2" output="$3" + sed "s|stroke:#020202|stroke:$color|" "$src/usb-cable.svg" > /tmp/cable_colored.svg + resvg --width "$size" --height "$size" /tmp/cable_colored.svg "$output" + } + + render_usb "$GREEN" 16 "$out/rendered/usb-attached.png" + render_usb "$BREEZE_DARK" 16 "$out/rendered/usb-unattached.png" + + for size in 16 22 32 48; do + mkdir -p "$out/breeze/status/$size" "$out/breeze-dark/status/$size" + + render_cable "$BREEZE_LIGHT" "$size" "$out/breeze/status/$size/vmsilo-device-tray.png" + render_cable "$BREEZE_DARK" "$size" "$out/breeze-dark/status/$size/vmsilo-device-tray.png" + + render_usb "$GREEN" "$size" "$out/breeze/status/$size/vmsilo-usb-attached.png" + render_usb "$GREEN" "$size" "$out/breeze-dark/status/$size/vmsilo-usb-attached.png" + + render_usb "$BREEZE_LIGHT" "$size" "$out/breeze/status/$size/vmsilo-usb-unattached.png" + render_usb "$BREEZE_DARK" "$size" "$out/breeze-dark/status/$size/vmsilo-usb-unattached.png" + done + + # Generate index.theme so KDE's icon engine can navigate these directories + for theme in breeze breeze-dark; do + dirs="" + for size in 16 22 32 48; do + [ -n "$dirs" ] && dirs="$dirs," + dirs="''${dirs}status/$size" + done + cat > "$out/$theme/index.theme" < Result<(), Box> { + env_logger::init(); + log::info!("Starting vmsilo-device-tray"); + + let connection = zbus::Connection::system().await?; + let tray = DeviceTray::new(connection); + let handle = tray.spawn().await?; + + tokio::signal::ctrl_c().await?; + log::info!("Shutting down"); + handle.shutdown().await; + + Ok(()) +} diff --git a/vmsilo-device-tray/src/systemd.rs b/vmsilo-device-tray/src/systemd.rs new file mode 100644 index 0000000..d8f9f80 --- /dev/null +++ b/vmsilo-device-tray/src/systemd.rs @@ -0,0 +1,218 @@ +use std::collections::HashMap; +use zbus::Connection; + +#[derive(Debug, Clone)] +pub struct RunningVm { + pub name: String, +} + +#[derive(Debug, Default)] +pub struct SystemdState { + pub running_vms: Vec, + pub usb_attachments: HashMap, +} + +pub async fn query_state(connection: &Connection) -> zbus::Result { + let proxy: zbus::Proxy<'_> = zbus::proxy::Builder::new(connection) + .destination("org.freedesktop.systemd1")? + .path("/org/freedesktop/systemd1")? + .interface("org.freedesktop.systemd1.Manager")? + .build() + .await?; + + let reply: Vec<( + String, String, String, String, String, + String, zbus::zvariant::OwnedObjectPath, u32, String, + zbus::zvariant::OwnedObjectPath, + )> = proxy + .call( + "ListUnitsByPatterns", + &( + vec!["active"], + vec!["vmsilo-*-vm.service", "vmsilo-*-usb@*.service"], + ), + ) + .await?; + + let mut state = SystemdState::default(); + + for (unit_name, _, _, _, _, _, _, _, _, _) in &reply { + if let Some(vm_name) = parse_vm_unit_name(unit_name) { + state.running_vms.push(RunningVm { name: vm_name }); + } else if let Some((vm_name, device)) = parse_usb_unit_name(unit_name) { + state.usb_attachments.insert(device, vm_name); + } + } + + state.running_vms.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(state) +} + +pub async fn start_unit(connection: &Connection, unit_name: &str) -> zbus::Result<()> { + let proxy: zbus::Proxy<'_> = zbus::proxy::Builder::new(connection) + .destination("org.freedesktop.systemd1")? + .path("/org/freedesktop/systemd1")? + .interface("org.freedesktop.systemd1.Manager")? + .build() + .await?; + + let _job_path: zbus::zvariant::OwnedObjectPath = + proxy.call("StartUnit", &(unit_name, "replace")).await?; + Ok(()) +} + +pub async fn stop_unit(connection: &Connection, unit_name: &str) -> zbus::Result<()> { + let proxy: zbus::Proxy<'_> = zbus::proxy::Builder::new(connection) + .destination("org.freedesktop.systemd1")? + .path("/org/freedesktop/systemd1")? + .interface("org.freedesktop.systemd1.Manager")? + .build() + .await?; + + let _job_path: zbus::zvariant::OwnedObjectPath = + proxy.call("StopUnit", &(unit_name, "replace")).await?; + Ok(()) +} + +pub async fn reassign_unit( + connection: &Connection, + stop_unit_name: &str, + start_unit_name: &str, +) -> zbus::Result<()> { + let proxy: zbus::Proxy<'_> = zbus::proxy::Builder::new(connection) + .destination("org.freedesktop.systemd1")? + .path("/org/freedesktop/systemd1")? + .interface("org.freedesktop.systemd1.Manager")? + .build() + .await?; + + let _: () = proxy.call("Subscribe", &()).await?; + + // Create the signal stream BEFORE calling StopUnit to avoid missing + // the JobRemoved signal if the stop completes quickly. + use futures_util::StreamExt; + let mut job_removed_stream = proxy + .receive_signal("JobRemoved") + .await?; + + let stop_job: zbus::zvariant::OwnedObjectPath = + proxy.call("StopUnit", &(stop_unit_name, "replace")).await?; + + use tokio::time::{timeout, Duration}; + match timeout(Duration::from_secs(30), async { + while let Some(signal) = job_removed_stream.next().await { + let args = signal.body(); + let (_, job_path, _, _): (u32, zbus::zvariant::ObjectPath<'_>, String, String) = + args.deserialize()?; + if job_path == stop_job.as_ref() { + break; + } + } + Ok::<_, zbus::Error>(()) + }) + .await + { + Ok(result) => result?, + Err(_) => log::error!("Timeout waiting for {} to stop", stop_unit_name), + } + + let _: () = proxy.call("Unsubscribe", &()).await?; + + let _job_path: zbus::zvariant::OwnedObjectPath = + proxy.call("StartUnit", &(start_unit_name, "replace")).await?; + + Ok(()) +} + +fn parse_vm_unit_name(unit: &str) -> Option { + let stripped = unit.strip_prefix("vmsilo-")?; + let name = stripped.strip_suffix("-vm.service")?; + if name.is_empty() { + return None; + } + Some(name.to_string()) +} + +fn parse_usb_unit_name(unit: &str) -> Option<(String, String)> { + let stripped = unit.strip_prefix("vmsilo-")?; + let without_suffix = stripped.strip_suffix(".service")?; + let usb_at_idx = without_suffix.find("-usb@")?; + let vm_name = &without_suffix[..usb_at_idx]; + let device_escaped = &without_suffix[usb_at_idx + 5..]; + if vm_name.is_empty() || device_escaped.is_empty() { + return None; + } + let device = systemd_unescape(device_escaped); + Some((vm_name.to_string(), device)) +} + +/// Only unescapes hyphens. Dots are NOT escaped by systemd-escape. +fn systemd_unescape(s: &str) -> String { + s.replace("\\x2d", "-") +} + +pub fn usb_unit_name(vm_name: &str, device_sysfs_name: &str) -> String { + let escaped = systemd_escape(device_sysfs_name); + format!("vmsilo-{}-usb@{}.service", vm_name, escaped) +} + +/// Only escapes hyphens. Dots are NOT escaped by systemd-escape. +fn systemd_escape(s: &str) -> String { + s.replace('-', "\\x2d") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_vm_unit_name() { + assert_eq!( + parse_vm_unit_name("vmsilo-banking-vm.service"), + Some("banking".to_string()) + ); + assert_eq!( + parse_vm_unit_name("vmsilo-my-vm-vm.service"), + Some("my-vm".to_string()) + ); + assert_eq!(parse_vm_unit_name("vmsilo--vm.service"), None); + assert_eq!(parse_vm_unit_name("other-banking-vm.service"), None); + assert_eq!(parse_vm_unit_name("vmsilo-banking-usb@1-2.service"), None); + } + + #[test] + fn test_parse_usb_unit_name() { + assert_eq!( + parse_usb_unit_name("vmsilo-banking-usb@1\\x2d2.service"), + Some(("banking".to_string(), "1-2".to_string())) + ); + // Dots are not escaped by systemd-escape + assert_eq!( + parse_usb_unit_name("vmsilo-my-vm-usb@2\\x2d1.3.service"), + Some(("my-vm".to_string(), "2-1.3".to_string())) + ); + assert_eq!(parse_usb_unit_name("vmsilo--usb@1\\x2d2.service"), None); + assert_eq!(parse_usb_unit_name("vmsilo-banking-vm.service"), None); + } + + #[test] + fn test_usb_unit_name() { + assert_eq!( + usb_unit_name("banking", "1-2"), + "vmsilo-banking-usb@1\\x2d2.service" + ); + // Dots are not escaped + assert_eq!( + usb_unit_name("my-vm", "2-1.3"), + "vmsilo-my-vm-usb@2\\x2d1.3.service" + ); + } + + #[test] + fn test_systemd_escape_roundtrip() { + let original = "1-2.3"; + let escaped = systemd_escape(original); + let unescaped = systemd_unescape(&escaped); + assert_eq!(unescaped, original); + } +} diff --git a/vmsilo-device-tray/src/tray.rs b/vmsilo-device-tray/src/tray.rs new file mode 100644 index 0000000..38d10ae --- /dev/null +++ b/vmsilo-device-tray/src/tray.rs @@ -0,0 +1,392 @@ +use crate::icons; +use crate::systemd::{self, SystemdState}; +use crate::usb::{self, UsbDevice}; +use ksni::menu::*; +use ksni::{MenuItem, Tray as KsniTray}; +use zbus::Connection; + +pub struct DeviceTray { + connection: Connection, + icon_theme_path: String, +} + +impl DeviceTray { + pub fn new(connection: Connection) -> Self { + let icon_theme_path = + std::env::var("VMSILO_ICON_THEME_PATH").unwrap_or_default(); + Self { + connection, + icon_theme_path, + } + } + + fn build_menu(&self) -> Vec> { + let connection = self.connection.clone(); + let (devices, state) = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + let devices = usb::enumerate_usb_devices(); + let state = systemd::query_state(&connection).await.unwrap_or_default(); + (devices, state) + }) + }); + self.menu_from_state(&devices, &state) + } + + fn menu_from_state(&self, devices: &[UsbDevice], state: &SystemdState) -> Vec> { + if devices.is_empty() { + return vec![StandardItem { + label: "No USB devices".to_string(), + enabled: false, + ..Default::default() + } + .into()]; + } + + devices + .iter() + .map(|dev| { + let attached_vm = state.usb_attachments.get(&dev.sysfs_name).cloned(); + let is_attached = attached_vm.is_some(); + let icon_data = if is_attached { + icons::USB_ATTACHED_PNG.to_vec() + } else { + icons::USB_UNATTACHED_PNG.to_vec() + }; + let submenu = self.build_device_submenu(dev, &attached_vm, &state.running_vms); + + SubMenu { + label: dev.display_name.clone(), + icon_data, + submenu, + ..Default::default() + } + .into() + }) + .collect() + } + + fn build_device_submenu( + &self, + device: &UsbDevice, + attached_vm: &Option, + running_vms: &[systemd::RunningVm], + ) -> Vec> { + let mut items: Vec> = Vec::new(); + + if let Some(vm_name) = attached_vm { + let dev_name = device.sysfs_name.clone(); + let vm = vm_name.clone(); + items.push( + StandardItem { + label: "Detach".to_string(), + activate: Box::new(move |tray: &mut Self| { + let unit = systemd::usb_unit_name(&vm, &dev_name); + let conn = tray.connection.clone(); + tokio::spawn(async move { + if let Err(e) = systemd::stop_unit(&conn, &unit).await { + log::error!("Failed to stop {}: {}", unit, e); + } + }); + }), + ..Default::default() + } + .into(), + ); + items.push(MenuItem::Separator); + } + + if running_vms.is_empty() { + items.push( + StandardItem { + label: "No running VMs".to_string(), + enabled: false, + ..Default::default() + } + .into(), + ); + return items; + } + + for vm in running_vms { + let is_current = attached_vm.as_deref() == Some(&vm.name); + let dev_name = device.sysfs_name.clone(); + let vm_name = vm.name.clone(); + let attached_to = attached_vm.clone(); + + let label = if is_current { + format!("{} \u{2713}", vm.name) + } else { + vm.name.clone() + }; + + items.push( + StandardItem { + label, + activate: Box::new(move |tray: &mut Self| { + if is_current { + return; + } + let new_unit = systemd::usb_unit_name(&vm_name, &dev_name); + let conn = tray.connection.clone(); + let old_vm = attached_to.clone(); + let dev = dev_name.clone(); + tokio::spawn(async move { + if let Some(old_vm_name) = &old_vm { + let old_unit = systemd::usb_unit_name(old_vm_name, &dev); + if let Err(e) = + systemd::reassign_unit(&conn, &old_unit, &new_unit).await + { + log::error!("Failed to reassign: {}", e); + } + } else if let Err(e) = systemd::start_unit(&conn, &new_unit).await { + log::error!("Failed to start {}: {}", new_unit, e); + } + }); + }), + ..Default::default() + } + .into(), + ); + } + + items + } +} + +impl KsniTray for DeviceTray { + const MENU_ON_ACTIVATE: bool = true; + + fn id(&self) -> String { + "vmsilo-device-tray".to_string() + } + + fn icon_theme_path(&self) -> String { + self.icon_theme_path.clone() + } + + fn icon_name(&self) -> String { + "vmsilo-device-tray".to_string() + } + + fn title(&self) -> String { + "Device Manager".to_string() + } + + fn menu(&self) -> Vec> { + self.build_menu() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_devices() -> Vec { + vec![ + UsbDevice { + sysfs_name: "1-2".to_string(), + id_vendor: "1050".to_string(), + id_product: "0407".to_string(), + display_name: "YubiKey".to_string(), + }, + UsbDevice { + sysfs_name: "2-1".to_string(), + id_vendor: "046d".to_string(), + id_product: "c52b".to_string(), + display_name: "USB Receiver".to_string(), + }, + ] + } + + fn make_state_empty() -> SystemdState { + SystemdState::default() + } + + fn make_state_with_vms() -> SystemdState { + SystemdState { + running_vms: vec![ + systemd::RunningVm { + name: "banking".to_string(), + }, + systemd::RunningVm { + name: "shopping".to_string(), + }, + ], + usb_attachments: std::collections::HashMap::new(), + } + } + + fn make_state_attached() -> SystemdState { + let mut attachments = std::collections::HashMap::new(); + attachments.insert("1-2".to_string(), "banking".to_string()); + SystemdState { + running_vms: vec![ + systemd::RunningVm { + name: "banking".to_string(), + }, + systemd::RunningVm { + name: "shopping".to_string(), + }, + ], + usb_attachments: attachments, + } + } + + /// Count the number of menu items, treating SubMenu as one item (not recursing). + fn count_items(items: &[MenuItem]) -> usize { + items.len() + } + + /// Check if item at index is a separator. + fn is_separator(items: &[MenuItem], index: usize) -> bool { + matches!(items.get(index), Some(MenuItem::Separator)) + } + + /// Get the label of a StandardItem at a given index. + fn standard_label(items: &[MenuItem], index: usize) -> Option<&str> { + match items.get(index) { + Some(MenuItem::Standard(item)) => Some(&item.label), + _ => None, + } + } + + /// Get the label of a SubMenu at a given index. + fn submenu_label(items: &[MenuItem], index: usize) -> Option<&str> { + match items.get(index) { + Some(MenuItem::SubMenu(item)) => Some(&item.label), + _ => None, + } + } + + /// Get the submenu items of a SubMenu at a given index. + fn submenu_items(items: &[MenuItem], index: usize) -> Option<&[MenuItem]> { + match items.get(index) { + Some(MenuItem::SubMenu(item)) => Some(&item.submenu), + _ => None, + } + } + + /// Get the enabled status of a StandardItem at a given index. + fn standard_enabled(items: &[MenuItem], index: usize) -> Option { + match items.get(index) { + Some(MenuItem::Standard(item)) => Some(item.enabled), + _ => None, + } + } + + /// Check if a SubMenu at a given index has non-empty icon_data. + fn submenu_has_icon(items: &[MenuItem], index: usize) -> bool { + match items.get(index) { + Some(MenuItem::SubMenu(item)) => !item.icon_data.is_empty(), + _ => false, + } + } + + // We cannot construct a DeviceTray without a real zbus::Connection, + // but we can test menu_from_state and build_device_submenu by constructing + // a DeviceTray with a dummy connection. Since zbus::Connection requires + // a real D-Bus, we test the pure logic via menu_from_state indirectly. + // + // For unit tests, we use a helper that creates a tray from an existing + // connection. We'll skip tests that need a real connection and focus on + // the menu structure logic tested via mocked data. + + // Since we can't easily create a DeviceTray without a real D-Bus connection, + // we test the menu structure logic by extracting it. The menu_from_state + // and build_device_submenu methods take &self only for connection cloning + // in activate callbacks, so we can test the structure with a test helper. + + // Actually, menu_from_state only uses self in build_device_submenu which + // only uses self.connection in closures (not called during test). + // So we need a DeviceTray with any connection. Let's use tokio::test + // to get a runtime for creating a connection. + + // We'll just test the menu structure expectations without a real connection + // by checking the counts and labels from a known state. + + #[tokio::test] + async fn test_no_devices_shows_placeholder() { + let conn = match zbus::Connection::session().await { + Ok(c) => c, + Err(_) => return, // skip if no D-Bus session available + }; + let tray = DeviceTray::new(conn); + let menu = tray.menu_from_state(&[], &make_state_empty()); + assert_eq!(count_items(&menu), 1); + assert_eq!(standard_label(&menu, 0), Some("No USB devices")); + assert_eq!(standard_enabled(&menu, 0), Some(false)); + } + + #[tokio::test] + async fn test_devices_no_vms() { + let conn = match zbus::Connection::session().await { + Ok(c) => c, + Err(_) => return, + }; + let tray = DeviceTray::new(conn); + let devices = make_devices(); + let state = make_state_empty(); + let menu = tray.menu_from_state(&devices, &state); + + assert_eq!(count_items(&menu), 2); + assert_eq!(submenu_label(&menu, 0), Some("YubiKey")); + assert_eq!(submenu_label(&menu, 1), Some("USB Receiver")); + + // Each device submenu should have "No running VMs" + let sub0 = submenu_items(&menu, 0).unwrap(); + assert_eq!(count_items(sub0), 1); + assert_eq!(standard_label(sub0, 0), Some("No running VMs")); + assert_eq!(standard_enabled(sub0, 0), Some(false)); + } + + #[tokio::test] + async fn test_devices_with_vms_unattached() { + let conn = match zbus::Connection::session().await { + Ok(c) => c, + Err(_) => return, + }; + let tray = DeviceTray::new(conn); + let devices = make_devices(); + let state = make_state_with_vms(); + let menu = tray.menu_from_state(&devices, &state); + + assert_eq!(count_items(&menu), 2); + + // Both devices should have unattached icons + assert!(submenu_has_icon(&menu, 0)); + assert!(submenu_has_icon(&menu, 1)); + + // Each submenu should list both VMs (no Detach, no separator) + let sub0 = submenu_items(&menu, 0).unwrap(); + assert_eq!(count_items(sub0), 2); + assert_eq!(standard_label(sub0, 0), Some("banking")); + assert_eq!(standard_label(sub0, 1), Some("shopping")); + } + + #[tokio::test] + async fn test_device_attached() { + let conn = match zbus::Connection::session().await { + Ok(c) => c, + Err(_) => return, + }; + let tray = DeviceTray::new(conn); + let devices = make_devices(); + let state = make_state_attached(); + let menu = tray.menu_from_state(&devices, &state); + + // First device (1-2) is attached to banking + let sub0 = submenu_items(&menu, 0).unwrap(); + // Should have: Detach, Separator, banking (checked), shopping + assert_eq!(count_items(sub0), 4); + assert_eq!(standard_label(sub0, 0), Some("Detach")); + assert!(is_separator(sub0, 1)); + assert_eq!(standard_label(sub0, 2), Some("banking \u{2713}")); + assert_eq!(standard_label(sub0, 3), Some("shopping")); + + // Second device (2-1) is not attached + let sub1 = submenu_items(&menu, 1).unwrap(); + assert_eq!(count_items(sub1), 2); + assert_eq!(standard_label(sub1, 0), Some("banking")); + assert_eq!(standard_label(sub1, 1), Some("shopping")); + } +} diff --git a/vmsilo-device-tray/src/usb.rs b/vmsilo-device-tray/src/usb.rs new file mode 100644 index 0000000..2565134 --- /dev/null +++ b/vmsilo-device-tray/src/usb.rs @@ -0,0 +1,110 @@ +use std::fs; +use std::path::Path; + +#[derive(Debug, Clone)] +pub struct UsbDevice { + /// Sysfs directory basename (e.g. "1-2"), used as systemd unit instance + pub sysfs_name: String, + /// Vendor ID (e.g. "1050") + pub id_vendor: String, + /// Product ID (e.g. "0407") + pub id_product: String, + /// Human-readable product name, or "vid:pid" fallback + pub display_name: String, +} + +/// Enumerate USB devices from sysfs. Filters out hubs, interfaces, and +/// devices without idVendor (root hubs). +pub fn enumerate_usb_devices() -> Vec { + enumerate_usb_devices_from(Path::new("/sys/bus/usb/devices")) +} + +fn enumerate_usb_devices_from(sysfs_path: &Path) -> Vec { + let entries = match fs::read_dir(sysfs_path) { + Ok(entries) => entries, + Err(e) => { + log::warn!("Failed to read {}: {}", sysfs_path.display(), e); + return Vec::new(); + } + }; + + let mut devices = Vec::new(); + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if !is_usb_device_name(&name) { + continue; + } + + let path = entry.path(); + let id_vendor = match read_sysfs_attr(&path, "idVendor") { + Some(v) => v, + None => continue, + }; + let id_product = read_sysfs_attr(&path, "idProduct").unwrap_or_default(); + + if read_sysfs_attr(&path, "bDeviceClass").as_deref() == Some("09") { + continue; + } + + let product = read_sysfs_attr(&path, "product"); + let display_name = product.unwrap_or_else(|| format!("{}:{}", id_vendor, id_product)); + + devices.push(UsbDevice { + sysfs_name: name, + id_vendor, + id_product, + display_name, + }); + } + devices.sort_by(|a, b| a.sysfs_name.cmp(&b.sysfs_name)); + devices +} + +fn is_usb_device_name(name: &str) -> bool { + let mut chars = name.chars(); + match chars.next() { + Some(c) if c.is_ascii_digit() => {} + _ => return false, + } + if !name.contains('-') { + return false; + } + if name.contains(':') { + return false; + } + name.chars().all(|c| c.is_ascii_digit() || c == '-' || c == '.') +} + +fn read_sysfs_attr(device_path: &Path, attr: &str) -> Option { + fs::read_to_string(device_path.join(attr)) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_usb_device_name() { + assert!(is_usb_device_name("1-2")); + assert!(is_usb_device_name("2-1.3")); + assert!(is_usb_device_name("1-2.4.1")); + assert!(is_usb_device_name("3-1")); + + assert!(!is_usb_device_name("1-2:1.0")); + assert!(!is_usb_device_name("2-1.3:1.0")); + + assert!(!is_usb_device_name("usb1")); + + assert!(!is_usb_device_name("")); + assert!(!is_usb_device_name("abc")); + } + + #[test] + fn test_enumerate_nonexistent_dir() { + let devices = enumerate_usb_devices_from(Path::new("/nonexistent")); + assert!(devices.is_empty()); + } +}