lesavka: honor staged audio devices

This commit is contained in:
Brad Stein 2026-04-20 13:59:34 -03:00
parent 74893c97ae
commit 11aa2b5ec4
9 changed files with 104 additions and 31 deletions

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package]
name = "lesavka_client"
version = "0.11.26"
version = "0.11.27"
edition = "2024"
[dependencies]

View File

@ -221,6 +221,17 @@ mod tests {
assert!(!envs.contains_key("LESAVKA_DISABLE_VIDEO_RENDER"));
}
#[test]
fn runtime_env_vars_leave_auto_audio_devices_unset() {
let mut state = LauncherState::new();
state.select_microphone(Some("auto".to_string()));
state.select_speaker(Some("auto".to_string()));
let envs = runtime_env_vars(&state);
assert!(!envs.contains_key("LESAVKA_MIC_SOURCE"));
assert!(!envs.contains_key("LESAVKA_AUDIO_SINK"));
}
#[test]
fn maybe_run_launcher_returns_false_with_explicit_opt_out() {
let args = vec!["--no-launcher".to_string()];

View File

@ -650,12 +650,6 @@ impl LauncherState {
if self.devices.camera.is_none() {
self.devices.camera = catalog.cameras.first().cloned();
}
if self.devices.microphone.is_none() {
self.devices.microphone = catalog.microphones.first().cloned();
}
if self.devices.speaker.is_none() {
self.devices.speaker = catalog.speakers.first().cloned();
}
}
pub fn set_swap_key(&mut self, swap_key: impl Into<String>) {
@ -1078,8 +1072,8 @@ mod tests {
state.apply_catalog_defaults(&catalog);
assert_eq!(state.devices.camera.as_deref(), Some("/dev/video-special"));
assert_eq!(state.devices.microphone.as_deref(), Some("alsa_input.usb"));
assert_eq!(state.devices.speaker.as_deref(), Some("alsa_output.usb"));
assert!(state.devices.microphone.is_none());
assert!(state.devices.speaker.is_none());
}
#[test]

View File

@ -61,6 +61,15 @@ enum ClipboardMessage {
#[cfg(not(coverage))]
const NETWORK_TELEMETRY_WINDOW: Duration = Duration::from_secs(8);
#[cfg(not(coverage))]
fn usb_audio_kernel_support_missing() -> bool {
Command::new("modinfo")
.arg("snd_usb_audio")
.status()
.map(|status| !status.success())
.unwrap_or(true)
}
#[cfg(not(coverage))]
#[derive(Default)]
struct NetworkTelemetry {
@ -1140,9 +1149,12 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
.status_label
.set_text(&format!("Device refresh succeeded, but the webcam test could not switch cleanly: {err}"));
} else {
widgets_handle.status_label.set_text(
"Device staging refreshed. Newly attached devices are ready for local tests; reconnect the relay if you want the live session to use a new webcam, mic, or speaker.",
);
let message = if usb_audio_kernel_support_missing() {
"Device staging refreshed. USB audio devices may still stay invisible until the host boots a kernel with snd_usb_audio available; reconnect the relay if you want the live session to use a new webcam, mic, or speaker."
} else {
"Device staging refreshed. Newly attached devices are ready for local tests; reconnect the relay if you want the live session to use a new webcam, mic, or speaker."
};
widgets_handle.status_label.set_text(message);
}
refresh_launcher_ui(
&widgets_handle,

View File

@ -212,9 +212,8 @@ pub fn build_launcher_view(
devices_body.set_spacing(8);
let control_group = build_subgroup("Control Inputs");
let control_row = gtk::Box::new(gtk::Orientation::Horizontal, 12);
control_row.set_homogeneous(true);
control_group.append(&control_row);
let control_stack = gtk::Box::new(gtk::Orientation::Vertical, 10);
control_group.append(&control_stack);
let camera_combo = gtk::ComboBoxText::new();
camera_combo.append(Some("auto"), "auto");
@ -249,8 +248,8 @@ pub fn build_launcher_view(
keyboard_combo.set_tooltip_text(Some(
"Leave this on all keyboards to relay every keyboard, or pick one specific device.",
));
let keyboard_block = build_selector_block("Keyboard", &keyboard_combo);
control_row.append(&keyboard_block);
let keyboard_row = build_inline_selector_row("Keyboard", &keyboard_combo);
control_stack.append(&keyboard_row);
let mouse_combo = gtk::ComboBoxText::new();
mouse_combo.append(Some("all"), "all mice");
@ -261,8 +260,8 @@ pub fn build_launcher_view(
mouse_combo.set_tooltip_text(Some(
"Leave this on all mice to relay every pointer, or pick one specific device.",
));
let mouse_block = build_selector_block("Mouse", &mouse_combo);
control_row.append(&mouse_block);
let mouse_row = build_inline_selector_row("Mouse", &mouse_combo);
control_stack.append(&mouse_row);
devices_body.append(&control_group);
let media_group = build_subgroup("Media Controls");
@ -1050,10 +1049,12 @@ fn attach_device_row(
grid.attach(test_button, 2, row, 1, 1);
}
fn build_selector_block(label: &str, combo: &gtk::ComboBoxText) -> gtk::Box {
let block = gtk::Box::new(gtk::Orientation::Vertical, 6);
fn build_inline_selector_row(label: &str, combo: &gtk::ComboBoxText) -> gtk::Box {
let block = gtk::Box::new(gtk::Orientation::Horizontal, 8);
let label_widget = gtk::Label::new(Some(label));
label_widget.set_halign(gtk::Align::Start);
label_widget.set_width_chars(9);
label_widget.set_xalign(0.0);
combo.set_hexpand(true);
combo.set_size_request(0, -1);
block.append(&label_widget);
@ -1061,6 +1062,22 @@ fn build_selector_block(label: &str, combo: &gtk::ComboBoxText) -> gtk::Box {
block
}
fn build_inline_combo_row(
label: &str,
combo: &impl IsA<gtk::Widget>,
min_label_chars: i32,
) -> gtk::Box {
let row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
let label_widget = gtk::Label::new(Some(label));
label_widget.add_css_class("dim-label");
label_widget.set_width_chars(min_label_chars);
label_widget.set_xalign(0.0);
label_widget.set_halign(gtk::Align::Start);
row.append(&label_widget);
row.append(combo);
row
}
fn append_input_choice(combo: &gtk::ComboBoxText, value: &str) {
let short = value.rsplit('/').next().unwrap_or(value);
let label = Device::open(value)
@ -1178,7 +1195,8 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
feed_source_combo.set_tooltip_text(Some(
"Choose which physical eye feed appears in this pane. Off disables the pane; the opposite-eye option mirrors the other physical feed while preserving a separate stream load for realistic validation.",
));
feed_source_combo.set_size_request(118, -1);
feed_source_combo.set_hexpand(true);
feed_source_combo.set_size_request(0, -1);
let capture_resolution_combo = gtk::ComboBoxText::new();
capture_resolution_combo.set_tooltip_text(Some(
"Choose the eye-stream source mode for this feed. Source keeps the HDMI device's own H.264 stream; cheaper source-device modes will appear here once the hardware proves it supports them.",
@ -1199,9 +1217,15 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
controls_grid.set_column_spacing(8);
controls_grid.set_row_spacing(8);
controls_grid.set_hexpand(true);
controls_grid.attach(&feed_source_combo, 0, 0, 1, 1);
controls_grid.attach(&capture_resolution_combo, 1, 0, 1, 1);
controls_grid.attach(&breakout_combo, 0, 1, 1, 1);
let feed_row = build_inline_combo_row("Feed", &feed_source_combo, 4);
let capture_row = build_inline_combo_row("Capture", &capture_resolution_combo, 7);
let breakout_row = build_inline_combo_row("Display", &breakout_combo, 7);
feed_row.set_hexpand(true);
capture_row.set_hexpand(true);
breakout_row.set_hexpand(true);
controls_grid.attach(&feed_row, 0, 0, 1, 1);
controls_grid.attach(&capture_row, 1, 0, 1, 1);
controls_grid.attach(&breakout_row, 0, 1, 1, 1);
controls_grid.attach(&action_button, 1, 1, 1, 1);
footer_shell.append(&controls_grid);
root.append(&footer_shell);

View File

@ -1,6 +1,6 @@
[package]
name = "lesavka_common"
version = "0.11.26"
version = "0.11.27"
edition = "2024"
build = "build.rs"

View File

@ -17,6 +17,6 @@ mod tests {
#[test]
fn banner_includes_version() {
assert_eq!(banner("0.11.26"), "lesavka-common CLI (v0.11.26)");
assert_eq!(banner("0.11.27"), "lesavka-common CLI (v0.11.27)");
}
}

View File

@ -36,6 +36,28 @@ require_linkable() {
fi
}
require_gst_element() {
local element=$1
if gst-inspect-1.0 "$element" >/dev/null 2>&1; then
return 0
fi
echo "❌ required GStreamer element '$element' is unavailable after install." >&2
exit 1
}
require_kernel_module() {
local module=$1
local why=$2
if modinfo "$module" >/dev/null 2>&1; then
return 0
fi
echo "❌ required kernel module '$module' is unavailable for the running kernel $(uname -r)." >&2
echo " Lesavka needs it for $why." >&2
echo " This usually means the machine booted an older kernel than the modules that are currently installed." >&2
echo " Reboot into the freshly installed kernel, then rerun the installer." >&2
exit 1
}
run_as_user() {
sudo -u "$ORIG_USER" env HOME="$USER_HOME" SSH_AUTH_SOCK="${SSH_AUTH_SOCK:-}" "$@"
}
@ -48,11 +70,9 @@ fi
REPO_URL=${REPO_URL:-$DEFAULT_REPO_URL}
log "1. Installing base packages"
# Intentionally leave the host audio stack alone. Workstations often carry
# tightly versioned PipeWire packages, and Lesavka should not force an audio
# stack upgrade just to install the client.
sudo pacman -Sq --needed --noconfirm \
git rustup protobuf abseil-cpp gcc clang llvm-libs compiler-rt evtest base-devel libpulse \
pipewire pipewire-pulse wireplumber alsa-utils gst-plugin-pipewire \
gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav \
wmctrl qt6-tools wl-clipboard xclip xsel desktop-file-utils
@ -85,6 +105,9 @@ sudo usermod -aG input "$ORIG_USER"
log "1d. Verifying runtime tools"
require_command pactl "libpulse"
require_command gst-inspect-1.0 "gstreamer"
require_command arecord "alsa-utils"
require_command speaker-test "alsa-utils"
require_command wmctrl "wmctrl"
require_command qdbus6 "qt6-tools"
require_command protoc "protobuf"
@ -98,6 +121,15 @@ require_linkable "$(command -v protoc)" "protoc"
if [[ -e /usr/lib/libclang.so ]]; then
require_linkable /usr/lib/libclang.so "libclang"
fi
if [[ ! -d /lib/modules/$(uname -r) ]]; then
echo "❌ no kernel module tree exists for the running kernel $(uname -r)." >&2
echo " Reboot into the freshly installed kernel, then rerun the installer." >&2
exit 1
fi
require_kernel_module snd_usb_audio "USB microphones and USB headsets"
require_gst_element pulsesrc
require_gst_element pulsesink
require_gst_element pipewiresrc
protoc --version >/dev/null
if ! run_as_user pactl info >/dev/null 2>&1; then
echo "⚠️ pactl is installed, but no PulseAudio/PipeWire Pulse server is reachable right now."

View File

@ -10,7 +10,7 @@ bench = false
[package]
name = "lesavka_server"
version = "0.11.26"
version = "0.11.27"
edition = "2024"
autobins = false