diff --git a/client/Cargo.toml b/client/Cargo.toml index 7d601e1..2887c49 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.11.26" +version = "0.11.27" edition = "2024" [dependencies] diff --git a/client/src/launcher/mod.rs b/client/src/launcher/mod.rs index 87843d6..f142578 100644 --- a/client/src/launcher/mod.rs +++ b/client/src/launcher/mod.rs @@ -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()]; diff --git a/client/src/launcher/state.rs b/client/src/launcher/state.rs index 2b38dde..c67869e 100644 --- a/client/src/launcher/state.rs +++ b/client/src/launcher/state.rs @@ -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) { @@ -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] diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index 6736c55..d2d8948 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -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, diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index f1b0030..1452ed1 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -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: >k::ComboBoxText) -> gtk::Box { - let block = gtk::Box::new(gtk::Orientation::Vertical, 6); +fn build_inline_selector_row(label: &str, combo: >k::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: >k::ComboBoxText) -> gtk::Box { block } +fn build_inline_combo_row( + label: &str, + combo: &impl IsA, + 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: >k::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); diff --git a/common/Cargo.toml b/common/Cargo.toml index 40a76a0..3067c98 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.11.26" +version = "0.11.27" edition = "2024" build = "build.rs" diff --git a/common/src/cli.rs b/common/src/cli.rs index c7f3156..2b09a41 100644 --- a/common/src/cli.rs +++ b/common/src/cli.rs @@ -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)"); } } diff --git a/scripts/install/client.sh b/scripts/install/client.sh index 3a1b2f2..3c8f90d 100755 --- a/scripts/install/client.sh +++ b/scripts/install/client.sh @@ -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." diff --git a/server/Cargo.toml b/server/Cargo.toml index 2fa3e32..f94f2cd 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.11.26" +version = "0.11.27" edition = "2024" autobins = false