feat: add runtime sound control to device tray
The device tray now shows Sound Output and Microphone Input menus for running VMs. Each direction can be toggled per-VM at runtime via the vhost-device-sound control socket. Initial state comes from the existing sound.playback/sound.capture NixOS options. NixOS module passes --initial-streams and --control-socket to vhost-device-sound. The vhost-device flake input is updated to include the new control socket support.
This commit is contained in:
parent
d467ee444e
commit
72d50a50ee
13 changed files with 371 additions and 42 deletions
|
|
@ -159,6 +159,7 @@ See README.md for full usage details and options.
|
|||
- **Per-VM runtime dirs**: all sockets under `/run/vmsilo/<vmname>/` subdirectories (not flat). virtiofs instances get per-instance dirs at `/run/vmsilo/<vmname>/virtiofs-<tag>/`.
|
||||
- **USB passthrough**: usbip-over-vsock on port 5002. Guest runs `usbip-rs client listen`, host runs one `usbip-rs host connect` per device as `vmsilo-<vm>-usb@<devpath>.service`. Works with both crosvm and cloud-hypervisor.
|
||||
- **CID file**: `/run/vmsilo/<vmname>/cid` written by prep service, read by `vsock-connect` and dbus-proxy for autodetection.
|
||||
- **Sound control socket**: `/run/vmsilo/<vmname>/sound/control.socket` — written by vhost-device-sound when sound is enabled; used by the device tray to query and toggle playback/capture state at runtime. Initial state comes from `sound.playback`/`sound.capture` NixOS options (passed as `--initial-streams`).
|
||||
- **CH sandboxing**: CH VMs use NixOS confinement (chroot), PrivateUsers=identity, PrivateNetwork, PrivatePIDs, PrivateIPC, empty CapabilityBoundingSet. TAP FDs passed via `vmsilo-tap-open` + `ch-remote add-net`. All privileged operations in ExecStartPre=+/ExecStartPost=+/ExecStopPost=+. Gated by `cloud-hypervisor.disableSandbox`.
|
||||
- **virtiofsd sandboxing**: virtiofsd has built-in sandboxing (`--sandbox=namespace`): creates mount/PID/network namespaces, does pivot_root, drops capabilities, and applies its own seccomp filter. The systemd unit adds non-overlapping hardening: IPC/UTS namespace isolation, seccomp-based protections (clock/modules/logs/personality), capability bounding set (as defense-in-depth), and `LimitNOFILE=1048576`. Per-instance runtime dirs at `/run/vmsilo/<vmname>/virtiofs-<tag>/`. Gated by `virtiofs.disableSandbox`; seccomp controlled independently by `virtiofs.seccompPolicy`.
|
||||
|
||||
|
|
|
|||
|
|
@ -431,6 +431,12 @@ sound.logLevel = "debug"; # Set RUST_LOG for the sound service
|
|||
sound.seccompPolicy = "log"; # Log blocked syscalls instead of enforcing
|
||||
```
|
||||
|
||||
#### Runtime Sound Control
|
||||
|
||||
The device tray shows **Sound Output** and **Microphone Input** top-level menu items for running VMs that have sound enabled. Each submenu lists running VMs; a checkmark indicates that direction is currently enabled. Clicking a VM name toggles it on or off.
|
||||
|
||||
The `sound.playback` and `sound.capture` options set the initial enabled state when the VM starts (`playback` defaults to `true`, `capture` to `false`). The tray communicates with vhost-device-sound via a control socket at `/run/vmsilo/<name>/sound/control.socket`. VMs without an active control socket (e.g., sound not enabled, or service not yet started) are omitted from the sound menus.
|
||||
|
||||
### PCI Passthrough Configuration
|
||||
|
||||
```nix
|
||||
|
|
|
|||
8
flake.lock
generated
8
flake.lock
generated
|
|
@ -252,11 +252,11 @@
|
|||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1774221602,
|
||||
"narHash": "sha256-gK8iH1/foADfROW5kYXWo4fQgMJKYcO2oJtZW5WkYJ4=",
|
||||
"lastModified": 1774650636,
|
||||
"narHash": "sha256-OmeRy1jlilcoddZDnzccY1giYtfsjJi8Nijuompje9g=",
|
||||
"ref": "refs/heads/main",
|
||||
"rev": "1460f1be1b80dac79856e5403b8f36e6a9cbeca1",
|
||||
"revCount": 1389,
|
||||
"rev": "dac004f86b9113e0699d8aadf2942ee10cce3466",
|
||||
"revCount": 1391,
|
||||
"type": "git",
|
||||
"url": "https://git.dsg.is/dsg/vhost-device.git"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -389,13 +389,15 @@ let
|
|||
vm:
|
||||
let
|
||||
soundEnabled = vm.sound.playback || vm.sound.capture;
|
||||
streams =
|
||||
initialStreams =
|
||||
if vm.sound.playback && vm.sound.capture then
|
||||
"input,output"
|
||||
"output,input"
|
||||
else if vm.sound.playback then
|
||||
"output"
|
||||
else if vm.sound.capture then
|
||||
"input"
|
||||
else
|
||||
"input";
|
||||
"";
|
||||
in
|
||||
lib.optional soundEnabled (
|
||||
lib.nameValuePair "vmsilo-${vm.name}-sound" {
|
||||
|
|
@ -411,13 +413,16 @@ let
|
|||
};
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
ExecStart = lib.concatStringsSep " " [
|
||||
"${cfg._internal.vhost-device-sound}/bin/vhost-device-sound"
|
||||
"--socket /run/vmsilo/${vm.name}/sound/sound.socket"
|
||||
"--backend pipewire"
|
||||
"--streams ${streams}"
|
||||
];
|
||||
ExecStopPost = "-${pkgs.coreutils}/bin/rm -f /run/vmsilo/${vm.name}/sound/sound.socket";
|
||||
ExecStart = lib.concatStringsSep " " (
|
||||
[
|
||||
"${cfg._internal.vhost-device-sound}/bin/vhost-device-sound"
|
||||
"--socket /run/vmsilo/${vm.name}/sound/sound.socket"
|
||||
"--backend pipewire"
|
||||
"--control-socket /run/vmsilo/${vm.name}/sound/control.socket"
|
||||
]
|
||||
++ lib.optional (initialStreams != "") "--initial-streams ${initialStreams}"
|
||||
);
|
||||
ExecStopPost = "-${pkgs.coreutils}/bin/rm -f /run/vmsilo/${vm.name}/sound/sound.socket /run/vmsilo/${vm.name}/sound/control.socket";
|
||||
User = cfg.user;
|
||||
Environment = [
|
||||
"XDG_RUNTIME_DIR=/run/user/${toString userUid}"
|
||||
|
|
|
|||
3
vmsilo-device-tray/icons/microphone.svg
Normal file
3
vmsilo-device-tray/icons/microphone.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<path d="M8 1a2 2 0 0 0-2 2v4a2 2 0 0 0 4 0V3a2 2 0 0 0-2-2zM4 6.5a.5.5 0 0 0-1 0A5 5 0 0 0 7.5 11.45V13H6a.5.5 0 0 0 0 1h4a.5.5 0 0 0 0-1H8.5v-1.55A5 5 0 0 0 13 6.5a.5.5 0 0 0-1 0 4 4 0 0 1-8 0z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 269 B |
BIN
vmsilo-device-tray/icons/rendered/microphone.png
Normal file
BIN
vmsilo-device-tray/icons/rendered/microphone.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 392 B |
BIN
vmsilo-device-tray/icons/rendered/speaker.png
Normal file
BIN
vmsilo-device-tray/icons/rendered/speaker.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 294 B |
3
vmsilo-device-tray/icons/speaker.svg
Normal file
3
vmsilo-device-tray/icons/speaker.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<path d="M3 5h2l4-3v12l-4-3H3a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 134 B |
|
|
@ -29,6 +29,18 @@ let
|
|||
resvg --width "$size" --height "$size" /tmp/cable_colored.svg "$output"
|
||||
}
|
||||
|
||||
render_speaker() {
|
||||
local color="$1" size="$2" output="$3"
|
||||
sed "s|<path|<path fill=\"$color\"|" "$src/speaker.svg" > /tmp/speaker_colored.svg
|
||||
resvg --width "$size" --height "$size" /tmp/speaker_colored.svg "$output"
|
||||
}
|
||||
|
||||
render_microphone() {
|
||||
local color="$1" size="$2" output="$3"
|
||||
sed "s|<path|<path fill=\"$color\"|" "$src/microphone.svg" > /tmp/mic_colored.svg
|
||||
resvg --width "$size" --height "$size" /tmp/mic_colored.svg "$output"
|
||||
}
|
||||
|
||||
render_usb "$GREEN" 16 "$out/rendered/usb-attached.png"
|
||||
render_usb "$BREEZE_DARK" 16 "$out/rendered/usb-unattached.png"
|
||||
|
||||
|
|
@ -38,6 +50,9 @@ let
|
|||
render_cable "$BREEZE_DARK" "$size" "$out/rendered/tray-icon-dark-$size.png"
|
||||
render_cable "$BREEZE_LIGHT" "$size" "$out/rendered/tray-icon-light-$size.png"
|
||||
done
|
||||
|
||||
render_speaker "$BREEZE_DARK" 16 "$out/rendered/speaker.png"
|
||||
render_microphone "$BREEZE_DARK" 16 "$out/rendered/microphone.png"
|
||||
'';
|
||||
in
|
||||
pkgs.rustPlatform.buildRustPackage {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,12 @@ pub const USB_ATTACHED_PNG: &[u8] = include_bytes!("../icons/rendered/usb-attach
|
|||
/// 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");
|
||||
|
||||
/// PNG bytes for the speaker icon (sound output menu item).
|
||||
pub const SPEAKER_PNG: &[u8] = include_bytes!("../icons/rendered/speaker.png");
|
||||
|
||||
/// PNG bytes for the microphone icon (microphone input menu item).
|
||||
pub const MICROPHONE_PNG: &[u8] = include_bytes!("../icons/rendered/microphone.png");
|
||||
|
||||
// Tray icon pixmaps: light icons (for dark panels) and dark icons (for light panels)
|
||||
const TRAY_DARK_16: &[u8] = include_bytes!("../icons/rendered/tray-icon-dark-16.png");
|
||||
const TRAY_DARK_22: &[u8] = include_bytes!("../icons/rendered/tray-icon-dark-22.png");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
mod icons;
|
||||
mod systemd;
|
||||
mod tray;
|
||||
mod sound;
|
||||
mod usb;
|
||||
|
||||
use ksni::TrayMethods;
|
||||
|
|
|
|||
110
vmsilo-device-tray/src/sound.rs
Normal file
110
vmsilo-device-tray/src/sound.rs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Sound state for a single VM.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct VmSoundState {
|
||||
pub output_enabled: bool,
|
||||
pub input_enabled: bool,
|
||||
}
|
||||
|
||||
/// Query the sound state of all running VMs.
|
||||
/// Returns a map from VM name to its sound state.
|
||||
/// VMs whose control socket is missing or unreachable are silently skipped.
|
||||
pub fn query_all_vms(vm_names: &[String]) -> HashMap<String, VmSoundState> {
|
||||
let mut result = HashMap::new();
|
||||
for name in vm_names {
|
||||
let path = control_socket_path(name);
|
||||
if let Some(state) = query_vm(&path) {
|
||||
result.insert(name.clone(), state);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Set a direction for a single VM. Returns true on success.
|
||||
pub fn set_direction(vm_name: &str, direction: &str, enabled: bool) -> bool {
|
||||
let path = control_socket_path(vm_name);
|
||||
let value = if enabled { "on" } else { "off" };
|
||||
let command = format!("SET {}={}\n", direction, value);
|
||||
send_command(&path, &command)
|
||||
.map(|r| r.trim() == "OK")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn control_socket_path(vm_name: &str) -> PathBuf {
|
||||
PathBuf::from(format!("/run/vmsilo/{}/sound/control.socket", vm_name))
|
||||
}
|
||||
|
||||
fn query_vm(path: &PathBuf) -> Option<VmSoundState> {
|
||||
let response = send_command(path, "QUERY\n").ok()?;
|
||||
parse_query_response(&response)
|
||||
}
|
||||
|
||||
fn send_command(path: &PathBuf, command: &str) -> Result<String, std::io::Error> {
|
||||
let mut stream = UnixStream::connect(path)?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
|
||||
stream.set_write_timeout(Some(Duration::from_secs(2)))?;
|
||||
stream.write_all(command.as_bytes())?;
|
||||
stream.shutdown(std::net::Shutdown::Write)?;
|
||||
let mut response = String::new();
|
||||
BufReader::new(&mut stream).read_line(&mut response)?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn parse_query_response(response: &str) -> Option<VmSoundState> {
|
||||
let mut state = VmSoundState::default();
|
||||
for pair in response.trim().split(',') {
|
||||
if let Some(value) = pair.strip_prefix("OUTPUT=") {
|
||||
state.output_enabled = value == "on";
|
||||
} else if let Some(value) = pair.strip_prefix("INPUT=") {
|
||||
state.input_enabled = value == "on";
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(state)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_query_both_on() {
|
||||
let state = parse_query_response("OUTPUT=on,INPUT=on\n").unwrap();
|
||||
assert!(state.output_enabled);
|
||||
assert!(state.input_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_query_mixed() {
|
||||
let state = parse_query_response("OUTPUT=on,INPUT=off\n").unwrap();
|
||||
assert!(state.output_enabled);
|
||||
assert!(!state.input_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_query_both_off() {
|
||||
let state = parse_query_response("OUTPUT=off,INPUT=off").unwrap();
|
||||
assert!(!state.output_enabled);
|
||||
assert!(!state.input_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_query_invalid() {
|
||||
assert!(parse_query_response("GARBAGE").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_control_socket_path() {
|
||||
let path = control_socket_path("banking");
|
||||
assert_eq!(
|
||||
path,
|
||||
PathBuf::from("/run/vmsilo/banking/sound/control.socket")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
use crate::icons;
|
||||
use crate::sound;
|
||||
use crate::systemd::{self, SystemdState};
|
||||
use crate::usb::{self, UsbDevice};
|
||||
use ksni::menu::*;
|
||||
|
|
@ -20,29 +21,63 @@ impl DeviceTray {
|
|||
|
||||
fn build_menu(&self) -> Vec<MenuItem<Self>> {
|
||||
let connection = self.connection.clone();
|
||||
let (devices, state) = tokio::task::block_in_place(|| {
|
||||
let (devices, state, sound_states) = 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)
|
||||
let vm_names: Vec<String> =
|
||||
state.running_vms.iter().map(|vm| vm.name.clone()).collect();
|
||||
let sound_states = sound::query_all_vms(&vm_names);
|
||||
(devices, state, sound_states)
|
||||
})
|
||||
});
|
||||
self.menu_from_state(&devices, &state)
|
||||
self.menu_from_state(&devices, &state, &sound_states)
|
||||
}
|
||||
|
||||
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()];
|
||||
fn menu_from_state(
|
||||
&self,
|
||||
devices: &[UsbDevice],
|
||||
state: &SystemdState,
|
||||
sound_states: &std::collections::HashMap<String, sound::VmSoundState>,
|
||||
) -> Vec<MenuItem<Self>> {
|
||||
let mut items: Vec<MenuItem<Self>> = Vec::new();
|
||||
|
||||
// Sound Output submenu
|
||||
if !sound_states.is_empty() {
|
||||
items.push(self.build_sound_submenu(
|
||||
"Sound Output",
|
||||
icons::SPEAKER_PNG,
|
||||
"OUTPUT",
|
||||
state,
|
||||
sound_states,
|
||||
|s| s.output_enabled,
|
||||
));
|
||||
|
||||
// Microphone Input submenu
|
||||
items.push(self.build_sound_submenu(
|
||||
"Microphone Input",
|
||||
icons::MICROPHONE_PNG,
|
||||
"INPUT",
|
||||
state,
|
||||
sound_states,
|
||||
|s| s.input_enabled,
|
||||
));
|
||||
|
||||
items.push(MenuItem::Separator);
|
||||
}
|
||||
|
||||
devices
|
||||
.iter()
|
||||
.map(|dev| {
|
||||
// USB devices
|
||||
if devices.is_empty() {
|
||||
items.push(
|
||||
StandardItem {
|
||||
label: "No USB devices".to_string(),
|
||||
enabled: false,
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
} else {
|
||||
for dev in devices {
|
||||
let attached_vm = state.usb_attachments.get(&dev.sysfs_name).cloned();
|
||||
let is_attached = attached_vm.is_some();
|
||||
let icon_data = if is_attached {
|
||||
|
|
@ -50,17 +85,22 @@ impl DeviceTray {
|
|||
} else {
|
||||
icons::USB_UNATTACHED_PNG.to_vec()
|
||||
};
|
||||
let submenu = self.build_device_submenu(dev, &attached_vm, &state.running_vms);
|
||||
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()
|
||||
items.push(
|
||||
SubMenu {
|
||||
label: dev.display_name.clone(),
|
||||
icon_data,
|
||||
submenu,
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
fn build_device_submenu(
|
||||
|
|
@ -149,6 +189,75 @@ impl DeviceTray {
|
|||
|
||||
items
|
||||
}
|
||||
|
||||
fn build_sound_submenu(
|
||||
&self,
|
||||
label: &str,
|
||||
icon_png: &[u8],
|
||||
direction: &str,
|
||||
state: &SystemdState,
|
||||
sound_states: &std::collections::HashMap<String, sound::VmSoundState>,
|
||||
get_enabled: fn(&sound::VmSoundState) -> bool,
|
||||
) -> MenuItem<Self> {
|
||||
let mut submenu_items: Vec<MenuItem<Self>> = Vec::new();
|
||||
|
||||
for vm in &state.running_vms {
|
||||
let Some(sound_state) = sound_states.get(&vm.name) else {
|
||||
continue;
|
||||
};
|
||||
let is_enabled = get_enabled(sound_state);
|
||||
let vm_name = vm.name.clone();
|
||||
let dir = direction.to_string();
|
||||
let new_state = !is_enabled;
|
||||
|
||||
let label_text = if is_enabled {
|
||||
format!("{} \u{2713}", vm.name)
|
||||
} else {
|
||||
vm.name.clone()
|
||||
};
|
||||
|
||||
submenu_items.push(
|
||||
StandardItem {
|
||||
label: label_text,
|
||||
activate: Box::new(move |_tray: &mut Self| {
|
||||
let vm = vm_name.clone();
|
||||
let direction = dir.clone();
|
||||
tokio::spawn(async move {
|
||||
if !sound::set_direction(&vm, &direction, new_state) {
|
||||
log::error!(
|
||||
"Failed to set {} {} for VM {}",
|
||||
direction,
|
||||
if new_state { "on" } else { "off" },
|
||||
vm
|
||||
);
|
||||
}
|
||||
});
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
if submenu_items.is_empty() {
|
||||
submenu_items.push(
|
||||
StandardItem {
|
||||
label: "No running VMs".to_string(),
|
||||
enabled: false,
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
SubMenu {
|
||||
label: label.to_string(),
|
||||
icon_data: icon_png.to_vec(),
|
||||
submenu: submenu_items,
|
||||
..Default::default()
|
||||
}
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl KsniTray for DeviceTray {
|
||||
|
|
@ -174,6 +283,8 @@ impl KsniTray for DeviceTray {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
use crate::sound;
|
||||
|
||||
fn make_devices() -> Vec<UsbDevice> {
|
||||
vec![
|
||||
|
|
@ -305,7 +416,7 @@ mod tests {
|
|||
Err(_) => return, // skip if no D-Bus session available
|
||||
};
|
||||
let tray = DeviceTray::new(conn);
|
||||
let menu = tray.menu_from_state(&[], &make_state_empty());
|
||||
let menu = tray.menu_from_state(&[], &make_state_empty(), &HashMap::new());
|
||||
assert_eq!(count_items(&menu), 1);
|
||||
assert_eq!(standard_label(&menu, 0), Some("No USB devices"));
|
||||
assert_eq!(standard_enabled(&menu, 0), Some(false));
|
||||
|
|
@ -320,7 +431,7 @@ mod tests {
|
|||
let tray = DeviceTray::new(conn);
|
||||
let devices = make_devices();
|
||||
let state = make_state_empty();
|
||||
let menu = tray.menu_from_state(&devices, &state);
|
||||
let menu = tray.menu_from_state(&devices, &state, &HashMap::new());
|
||||
|
||||
assert_eq!(count_items(&menu), 2);
|
||||
assert_eq!(submenu_label(&menu, 0), Some("YubiKey"));
|
||||
|
|
@ -342,7 +453,7 @@ mod tests {
|
|||
let tray = DeviceTray::new(conn);
|
||||
let devices = make_devices();
|
||||
let state = make_state_with_vms();
|
||||
let menu = tray.menu_from_state(&devices, &state);
|
||||
let menu = tray.menu_from_state(&devices, &state, &HashMap::new());
|
||||
|
||||
assert_eq!(count_items(&menu), 2);
|
||||
|
||||
|
|
@ -366,7 +477,7 @@ mod tests {
|
|||
let tray = DeviceTray::new(conn);
|
||||
let devices = make_devices();
|
||||
let state = make_state_attached();
|
||||
let menu = tray.menu_from_state(&devices, &state);
|
||||
let menu = tray.menu_from_state(&devices, &state, &HashMap::new());
|
||||
|
||||
// First device (1-2) is attached to banking
|
||||
let sub0 = submenu_items(&menu, 0).unwrap();
|
||||
|
|
@ -383,4 +494,72 @@ mod tests {
|
|||
assert_eq!(standard_label(sub1, 0), Some("banking"));
|
||||
assert_eq!(standard_label(sub1, 1), Some("shopping"));
|
||||
}
|
||||
|
||||
fn make_sound_states() -> HashMap<String, sound::VmSoundState> {
|
||||
let mut states = HashMap::new();
|
||||
states.insert(
|
||||
"banking".to_string(),
|
||||
sound::VmSoundState {
|
||||
output_enabled: true,
|
||||
input_enabled: false,
|
||||
},
|
||||
);
|
||||
states.insert(
|
||||
"shopping".to_string(),
|
||||
sound::VmSoundState {
|
||||
output_enabled: true,
|
||||
input_enabled: true,
|
||||
},
|
||||
);
|
||||
states
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sound_menus() {
|
||||
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 sound_states = make_sound_states();
|
||||
let menu = tray.menu_from_state(&devices, &state, &sound_states);
|
||||
|
||||
// Sound Output, Microphone Input, Separator, then 2 USB devices
|
||||
assert_eq!(count_items(&menu), 5);
|
||||
assert_eq!(submenu_label(&menu, 0), Some("Sound Output"));
|
||||
assert_eq!(submenu_label(&menu, 1), Some("Microphone Input"));
|
||||
assert!(is_separator(&menu, 2));
|
||||
assert_eq!(submenu_label(&menu, 3), Some("YubiKey"));
|
||||
assert_eq!(submenu_label(&menu, 4), Some("USB Receiver"));
|
||||
|
||||
// Sound Output submenu: banking checked, shopping checked
|
||||
let output_sub = submenu_items(&menu, 0).unwrap();
|
||||
assert_eq!(count_items(output_sub), 2);
|
||||
assert_eq!(standard_label(output_sub, 0), Some("banking \u{2713}"));
|
||||
assert_eq!(standard_label(output_sub, 1), Some("shopping \u{2713}"));
|
||||
|
||||
// Microphone Input submenu: banking unchecked, shopping checked
|
||||
let input_sub = submenu_items(&menu, 1).unwrap();
|
||||
assert_eq!(count_items(input_sub), 2);
|
||||
assert_eq!(standard_label(input_sub, 0), Some("banking"));
|
||||
assert_eq!(standard_label(input_sub, 1), Some("shopping \u{2713}"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_no_sound_no_separator() {
|
||||
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, &HashMap::new());
|
||||
|
||||
// No sound menus, just USB devices
|
||||
assert_eq!(count_items(&menu), 2);
|
||||
assert_eq!(submenu_label(&menu, 0), Some("YubiKey"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue