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:
parent
f77b4003fd
commit
ca1299560f
18 changed files with 2165 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,3 +2,4 @@ result
|
|||
.worktrees
|
||||
*.swp
|
||||
/vmsilo-tools/target
|
||||
/vmsilo-device-tray/target
|
||||
|
|
|
|||
12
flake.nix
12
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;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ in
|
|||
./desktop.nix
|
||||
./overlay.nix
|
||||
./package.nix
|
||||
./tray.nix
|
||||
];
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
|
|
|
|||
|
|
@ -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
31
modules/tray.nix
Normal 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";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
41
patches/ksni-about-to-show-refresh.patch
Normal file
41
patches/ksni-about-to-show-refresh.patch
Normal 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
1197
vmsilo-device-tray/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
12
vmsilo-device-tray/Cargo.toml
Normal file
12
vmsilo-device-tray/Cargo.toml
Normal 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"
|
||||
16
vmsilo-device-tray/icons/USB_icon.svg
Normal file
16
vmsilo-device-tray/icons/USB_icon.svg
Normal 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 |
BIN
vmsilo-device-tray/icons/rendered/usb-attached.png
Normal file
BIN
vmsilo-device-tray/icons/rendered/usb-attached.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 354 B |
BIN
vmsilo-device-tray/icons/rendered/usb-unattached.png
Normal file
BIN
vmsilo-device-tray/icons/rendered/usb-unattached.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 302 B |
2
vmsilo-device-tray/icons/usb-cable.svg
Normal file
2
vmsilo-device-tray/icons/usb-cable.svg
Normal 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 |
98
vmsilo-device-tray/package.nix
Normal file
98
vmsilo-device-tray/package.nix
Normal 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
|
||||
'';
|
||||
}
|
||||
5
vmsilo-device-tray/src/icons.rs
Normal file
5
vmsilo-device-tray/src/icons.rs
Normal 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");
|
||||
23
vmsilo-device-tray/src/main.rs
Normal file
23
vmsilo-device-tray/src/main.rs
Normal 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(())
|
||||
}
|
||||
218
vmsilo-device-tray/src/systemd.rs
Normal file
218
vmsilo-device-tray/src/systemd.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
392
vmsilo-device-tray/src/tray.rs
Normal file
392
vmsilo-device-tray/src/tray.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
110
vmsilo-device-tray/src/usb.rs
Normal file
110
vmsilo-device-tray/src/usb.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue