Add device tray for USB passthrough management

System tray app (StatusNotifierItem + DBusMenu) for attaching/detaching
USB devices to VMs. Clicking the tray icon shows a menu of host USB
devices, each with a submenu of running VMs for attach/detach.

- vmsilo-device-tray/ Rust crate using ksni + zbus
- Polls sysfs + systemd D-Bus on each menu open (via patched ksni
  AboutToShow)
- Breeze/Breeze-dark themed icons rendered at build time from SVGs
- Systemd user service tied to graphical-session.target
- modules/tray.nix for NixOS integration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Davíð Steinn Geirsson 2026-03-24 17:26:31 +00:00
parent f77b4003fd
commit ca1299560f
18 changed files with 2165 additions and 0 deletions

1
.gitignore vendored
View file

@ -2,3 +2,4 @@ result
.worktrees
*.swp
/vmsilo-tools/target
/vmsilo-device-tray/target

View file

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

View file

@ -22,6 +22,7 @@ in
./desktop.nix
./overlay.nix
./package.nix
./tray.nix
];
config = lib.mkIf cfg.enable {

View file

@ -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.";

31
modules/tray.nix Normal file
View file

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

View file

@ -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<T: Tray> Service<T> {
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<T: Tray> DbusMenu<T> {
}
}
- async fn about_to_show(&self) -> zbus::fdo::Result<bool> {
- Ok(false)
+ async fn about_to_show(
+ &self,
+ #[zbus(connection)] conn: &Connection,
+ _id: i32,
+ ) -> zbus::fdo::Result<bool> {
+ 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<i32>, Vec<i32>)> {

1197
vmsilo-device-tray/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -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"

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
version="1.0"
width="475.24799"
height="228.092"
viewBox="0 0 475.248 228.092"
id="Layer_1"
xml:space="preserve"><defs
id="defs1337" />
<path
d="M 462.836,114.054 L 412.799,85.158 L 412.799,105.771 L 157.046,105.771 L 206.844,53.159 C 211.082,49.762 216.627,47.379 222.331,47.247 C 245.406,47.247 259.109,47.241 264.153,47.231 C 267.572,56.972 276.756,64.003 287.674,64.003 C 301.486,64.003 312.695,52.795 312.695,38.978 C 312.695,25.155 301.487,13.951 287.674,13.951 C 276.756,13.951 267.572,20.978 264.153,30.711 L 222.821,30.704 C 211.619,30.704 199.881,36.85 192.41,44.055 C 192.614,43.841 192.826,43.613 192.398,44.059 C 192.24,44.237 139.564,99.873 139.564,99.873 C 135.335,103.265 129.793,105.633 124.093,105.769 L 95.161,105.769 C 91.326,86.656 74.448,72.256 54.202,72.256 C 31.119,72.256 12.408,90.967 12.408,114.043 C 12.408,137.126 31.119,155.838 54.202,155.838 C 74.452,155.838 91.33,141.426 95.165,122.297 L 123.59,122.297 C 123.663,122.297 123.736,122.301 123.81,122.297 L 186.681,122.297 C 192.37,122.442 197.905,124.813 202.13,128.209 C 202.13,128.209 254.794,183.841 254.957,184.021 C 255.379,184.468 255.169,184.235 254.961,184.025 C 262.432,191.229 274.175,197.371 285.379,197.371 L 325.211,197.362 L 325.211,214.139 L 375.261,214.139 L 375.261,164.094 L 325.211,164.094 L 325.211,180.849 C 325.211,180.849 314.72,180.83 284.891,180.83 C 279.186,180.699 273.635,178.319 269.399,174.922 L 219.59,122.3 L 412.799,122.3 L 412.799,142.946 L 462.836,114.054 z "
id="path1334" />
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 B

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="800px" height="800px" viewBox="0 0 24 24" id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill:none;stroke:#020202;stroke-miterlimit:10;stroke-width:1.91px;}</style></defs><path class="cls-1" d="M1.5,5.32H7.23a0,0,0,0,1,0,0V8.18a1.91,1.91,0,0,1-1.91,1.91H3.41A1.91,1.91,0,0,1,1.5,8.18V5.32a0,0,0,0,1,0,0Z"/><rect class="cls-1" x="2.45" y="1.5" width="3.82" height="3.82"/><path class="cls-1" d="M16.77,15.82H22.5a0,0,0,0,1,0,0v4.77a1.91,1.91,0,0,1-1.91,1.91H18.68a1.91,1.91,0,0,1-1.91-1.91V15.82a0,0,0,0,1,0,0Z" transform="translate(39.27 38.32) rotate(180)"/><path class="cls-1" d="M4.36,10.09v8.59A3.82,3.82,0,0,0,8.18,22.5h0A3.82,3.82,0,0,0,12,18.68V5.32A3.82,3.82,0,0,1,15.82,1.5h0a3.82,3.82,0,0,1,3.82,3.82v10.5"/></svg>

After

Width:  |  Height:  |  Size: 817 B

View file

@ -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|<path|<path fill=\"$color\"|" "$src/USB_icon.svg" > /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" <<THEME
[Icon Theme]
Name=vmsilo-$theme
Directories=$dirs
$(for size in 16 22 32 48; do
cat <<SECTION
[status/$size]
Size=$size
Context=Status
Type=Fixed
SECTION
done)
THEME
done
'';
in
pkgs.rustPlatform.buildRustPackage {
pname = "vmsilo-device-tray";
version = "0.1.0";
src = ./.;
cargoLock = {
lockFile = ./Cargo.lock;
};
nativeBuildInputs = with pkgs; [ pkg-config ];
buildInputs = with pkgs; [ dbus ];
preBuild = ''
cp -r ${renderedIcons}/rendered icons/rendered
# Patch vendored ksni: rebuild menu on AboutToShow so the menu
# is always fresh when opened (ksni hardcodes this to false).
ksni_dir=$(echo ../cargo-vendor-dir/ksni-*)
chmod -R +w "$ksni_dir"
patch -d "$ksni_dir" -p1 < ${ksniPatch}
'';
postInstall = ''
mkdir -p $out/share/icons
cp -r ${renderedIcons}/breeze $out/share/icons/breeze
cp -r ${renderedIcons}/breeze-dark $out/share/icons/breeze-dark
'';
}

View file

@ -0,0 +1,5 @@
/// PNG bytes for the green USB icon (attached device), used in menu item icon_data.
pub const USB_ATTACHED_PNG: &[u8] = include_bytes!("../icons/rendered/usb-attached.png");
/// PNG bytes for the gray USB icon (unattached device), used in menu item icon_data.
pub const USB_UNATTACHED_PNG: &[u8] = include_bytes!("../icons/rendered/usb-unattached.png");

View file

@ -0,0 +1,23 @@
mod icons;
mod systemd;
mod tray;
mod usb;
use ksni::TrayMethods;
use tray::DeviceTray;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
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(())
}

View file

@ -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<RunningVm>,
pub usb_attachments: HashMap<String, String>,
}
pub async fn query_state(connection: &Connection) -> zbus::Result<SystemdState> {
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<String> {
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);
}
}

View file

@ -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<MenuItem<Self>> {
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<MenuItem<Self>> {
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<String>,
running_vms: &[systemd::RunningVm],
) -> Vec<MenuItem<Self>> {
let mut items: Vec<MenuItem<Self>> = 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<MenuItem<Self>> {
self.build_menu()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_devices() -> Vec<UsbDevice> {
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<T>(items: &[MenuItem<T>]) -> usize {
items.len()
}
/// Check if item at index is a separator.
fn is_separator<T>(items: &[MenuItem<T>], index: usize) -> bool {
matches!(items.get(index), Some(MenuItem::Separator))
}
/// Get the label of a StandardItem at a given index.
fn standard_label<T>(items: &[MenuItem<T>], 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<T>(items: &[MenuItem<T>], 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<T>(items: &[MenuItem<T>], index: usize) -> Option<&[MenuItem<T>]> {
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<T>(items: &[MenuItem<T>], index: usize) -> Option<bool> {
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<T>(items: &[MenuItem<T>], 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"));
}
}

View file

@ -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<UsbDevice> {
enumerate_usb_devices_from(Path::new("/sys/bus/usb/devices"))
}
fn enumerate_usb_devices_from(sysfs_path: &Path) -> Vec<UsbDevice> {
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<String> {
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());
}
}