fix(client): restore audio recovery and input capture
This commit is contained in:
parent
6fb1ad527e
commit
e3f9fd2610
@ -4,7 +4,7 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.11.33"
|
version = "0.11.34"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
use tokio::sync::{broadcast, mpsc};
|
use tokio::sync::{broadcast, mpsc};
|
||||||
use tokio_stream::{
|
use tokio_stream::{
|
||||||
StreamExt,
|
StreamExt,
|
||||||
@ -18,7 +18,7 @@ use winit::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use lesavka_common::lesavka::{
|
use lesavka_common::lesavka::{
|
||||||
AudioPacket, KeyboardReport, MonitorRequest, MouseReport, VideoPacket,
|
AudioPacket, Empty, KeyboardReport, MonitorRequest, MouseReport, VideoPacket,
|
||||||
relay_client::RelayClient,
|
relay_client::RelayClient,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -498,6 +498,8 @@ impl LesavkaClientApp {
|
|||||||
/*──────────────── audio stream ───────────────*/
|
/*──────────────── audio stream ───────────────*/
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
async fn audio_loop(ep: Channel, out: AudioOut) {
|
async fn audio_loop(ep: Channel, out: AudioOut) {
|
||||||
|
let mut consecutive_source_failures = 0_u32;
|
||||||
|
let mut last_usb_recovery_at: Option<Instant> = None;
|
||||||
loop {
|
loop {
|
||||||
let mut cli = RelayClient::new(ep.clone());
|
let mut cli = RelayClient::new(ep.clone());
|
||||||
let req = MonitorRequest {
|
let req = MonitorRequest {
|
||||||
@ -537,7 +539,15 @@ impl LesavkaClientApp {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Ok(Err(err)) => {
|
Ok(Err(err)) => {
|
||||||
tracing::warn!("❌🔊 audio stream recv error: {err}");
|
let message = err.to_string();
|
||||||
|
tracing::warn!("❌🔊 audio stream recv error: {message}");
|
||||||
|
Self::maybe_recover_audio_usb(
|
||||||
|
&ep,
|
||||||
|
&mut consecutive_source_failures,
|
||||||
|
&mut last_usb_recovery_at,
|
||||||
|
&message,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@ -551,12 +561,71 @@ impl LesavkaClientApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => tracing::warn!("❌🔊 audio stream err: {e}"),
|
Err(e) => {
|
||||||
|
let message = e.to_string();
|
||||||
|
tracing::warn!("❌🔊 audio stream err: {message}");
|
||||||
|
Self::maybe_recover_audio_usb(
|
||||||
|
&ep,
|
||||||
|
&mut consecutive_source_failures,
|
||||||
|
&mut last_usb_recovery_at,
|
||||||
|
&message,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
async fn maybe_recover_audio_usb(
|
||||||
|
ep: &Channel,
|
||||||
|
consecutive_source_failures: &mut u32,
|
||||||
|
last_usb_recovery_at: &mut Option<Instant>,
|
||||||
|
message: &str,
|
||||||
|
) {
|
||||||
|
if !audio_usb_auto_recover_enabled() || !is_recoverable_remote_audio_error(message) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
*consecutive_source_failures = consecutive_source_failures.saturating_add(1);
|
||||||
|
let threshold = audio_usb_recover_after();
|
||||||
|
if *consecutive_source_failures < threshold {
|
||||||
|
tracing::warn!(
|
||||||
|
failures = *consecutive_source_failures,
|
||||||
|
threshold,
|
||||||
|
"🔊🛟 remote speaker capture is unhealthy; waiting before USB recovery"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cooldown = audio_usb_recover_cooldown();
|
||||||
|
if last_usb_recovery_at.is_some_and(|last| last.elapsed() < cooldown) {
|
||||||
|
tracing::warn!(
|
||||||
|
cooldown_ms = cooldown.as_millis(),
|
||||||
|
"🔊🛟 remote speaker capture is still unhealthy, but USB recovery is cooling down"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
*consecutive_source_failures = 0;
|
||||||
|
*last_usb_recovery_at = Some(Instant::now());
|
||||||
|
tracing::warn!("🔊🛟 requesting USB gadget recovery for stalled remote speaker capture");
|
||||||
|
let mut cli = RelayClient::new(ep.clone());
|
||||||
|
match cli.reset_usb(Request::new(Empty {})).await {
|
||||||
|
Ok(reply) => {
|
||||||
|
if reply.into_inner().ok {
|
||||||
|
tracing::warn!("🔊🛟 USB gadget recovery completed; audio will reconnect");
|
||||||
|
} else {
|
||||||
|
tracing::warn!("🔊🛟 USB gadget recovery returned ok=false");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!("🔊🛟 USB gadget recovery failed: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/*──────────────── mic stream ─────────────────*/
|
/*──────────────── mic stream ─────────────────*/
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
async fn voice_loop(ep: Channel, mic: Arc<MicrophoneCapture>) {
|
async fn voice_loop(ep: Channel, mic: Arc<MicrophoneCapture>) {
|
||||||
@ -655,6 +724,43 @@ impl LesavkaClientApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn audio_usb_auto_recover_enabled() -> bool {
|
||||||
|
std::env::var("LESAVKA_AUDIO_AUTO_RECOVER_USB")
|
||||||
|
.map(|raw| {
|
||||||
|
!matches!(
|
||||||
|
raw.trim().to_ascii_lowercase().as_str(),
|
||||||
|
"0" | "false" | "no" | "off"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn audio_usb_recover_after() -> u32 {
|
||||||
|
std::env::var("LESAVKA_AUDIO_AUTO_RECOVER_AFTER")
|
||||||
|
.ok()
|
||||||
|
.and_then(|raw| raw.parse::<u32>().ok())
|
||||||
|
.filter(|value| *value > 0)
|
||||||
|
.unwrap_or(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn audio_usb_recover_cooldown() -> Duration {
|
||||||
|
let millis = std::env::var("LESAVKA_AUDIO_AUTO_RECOVER_COOLDOWN_MS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|raw| raw.parse::<u64>().ok())
|
||||||
|
.unwrap_or(60_000);
|
||||||
|
Duration::from_millis(millis)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn is_recoverable_remote_audio_error(message: &str) -> bool {
|
||||||
|
message.contains("remote speaker capture produced no audio samples")
|
||||||
|
|| message.contains("remote speaker capture stalled")
|
||||||
|
|| message.contains("remote speaker capture cadence is too low")
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn keyboard_stream_report(
|
pub(crate) fn keyboard_stream_report(
|
||||||
report: Result<KeyboardReport, BroadcastStreamRecvError>,
|
report: Result<KeyboardReport, BroadcastStreamRecvError>,
|
||||||
remote_capture_enabled: bool,
|
remote_capture_enabled: bool,
|
||||||
|
|||||||
@ -42,6 +42,8 @@ pub struct InputAggregator {
|
|||||||
mice: Vec<MouseAggregator>,
|
mice: Vec<MouseAggregator>,
|
||||||
selected_keyboard_path: Option<String>,
|
selected_keyboard_path: Option<String>,
|
||||||
selected_mouse_path: Option<String>,
|
selected_mouse_path: Option<String>,
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
known_input_paths: HashSet<PathBuf>,
|
||||||
capture_remote_boot: bool,
|
capture_remote_boot: bool,
|
||||||
quick_toggle_key: Option<KeyCode>,
|
quick_toggle_key: Option<KeyCode>,
|
||||||
quick_toggle_down: bool,
|
quick_toggle_down: bool,
|
||||||
@ -113,6 +115,8 @@ impl InputAggregator {
|
|||||||
mice: Vec::new(),
|
mice: Vec::new(),
|
||||||
selected_keyboard_path: input_device_override_from_env("LESAVKA_KEYBOARD_DEVICE"),
|
selected_keyboard_path: input_device_override_from_env("LESAVKA_KEYBOARD_DEVICE"),
|
||||||
selected_mouse_path: input_device_override_from_env("LESAVKA_MOUSE_DEVICE"),
|
selected_mouse_path: input_device_override_from_env("LESAVKA_MOUSE_DEVICE"),
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
known_input_paths: HashSet::new(),
|
||||||
capture_remote_boot,
|
capture_remote_boot,
|
||||||
quick_toggle_key,
|
quick_toggle_key,
|
||||||
quick_toggle_down: false,
|
quick_toggle_down: false,
|
||||||
@ -210,8 +214,18 @@ impl InputAggregator {
|
|||||||
|
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
pub fn init(&mut self) -> Result<()> {
|
pub fn init(&mut self) -> Result<()> {
|
||||||
let paths = std::fs::read_dir("/dev/input").context("Failed to read /dev/input")?;
|
let found_any = self.scan_input_devices(self.capture_remote_boot, true)?;
|
||||||
|
|
||||||
|
if !found_any {
|
||||||
|
bail!("No suitable keyboard/mouse devices found or none grabbed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn scan_input_devices(&mut self, remote_active: bool, fail_grab: bool) -> Result<bool> {
|
||||||
|
let paths = std::fs::read_dir("/dev/input").context("Failed to read /dev/input")?;
|
||||||
let mut found_any = false;
|
let mut found_any = false;
|
||||||
|
|
||||||
for entry in paths {
|
for entry in paths {
|
||||||
@ -224,6 +238,9 @@ impl InputAggregator {
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if self.known_input_paths.contains(&path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let mut dev = match Device::open(&path) {
|
let mut dev = match Device::open(&path) {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
@ -240,18 +257,25 @@ impl InputAggregator {
|
|||||||
DeviceKind::Keyboard => {
|
DeviceKind::Keyboard => {
|
||||||
if !matches_selected_input_device(&path, self.selected_keyboard_path.as_deref())
|
if !matches_selected_input_device(&path, self.selected_keyboard_path.as_deref())
|
||||||
{
|
{
|
||||||
|
self.known_input_paths.insert(path);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if self.capture_remote_boot {
|
if remote_active {
|
||||||
dev.grab()
|
if let Err(err) = dev.grab() {
|
||||||
.with_context(|| format!("grabbing keyboard {path:?}"))?;
|
if fail_grab {
|
||||||
|
return Err(err)
|
||||||
|
.with_context(|| format!("grabbing keyboard {path:?}"));
|
||||||
|
}
|
||||||
|
warn!("❌ grab keyboard {}: {err}", path.display());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
info!(
|
info!(
|
||||||
"🤏🖱️ Grabbed keyboard {:?}",
|
"🤏🖱️ Grabbed keyboard {:?}",
|
||||||
dev.name().unwrap_or("UNKNOWN")
|
dev.name().unwrap_or("UNKNOWN")
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
info!(
|
info!(
|
||||||
"⌨️ local-input boot mode; keyboard left ungrabbed {:?}",
|
"⌨️ local-input mode; keyboard staged ungrabbed {:?}",
|
||||||
dev.name().unwrap_or("UNKNOWN")
|
dev.name().unwrap_or("UNKNOWN")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -262,54 +286,57 @@ impl InputAggregator {
|
|||||||
self.kbd_tx.clone(),
|
self.kbd_tx.clone(),
|
||||||
self.paste_tx.clone(),
|
self.paste_tx.clone(),
|
||||||
);
|
);
|
||||||
kbd_agg.set_send(self.capture_remote_boot);
|
kbd_agg.set_send(remote_active);
|
||||||
if !self.capture_remote_boot {
|
if !remote_active {
|
||||||
kbd_agg.set_grab(false);
|
kbd_agg.set_grab(false);
|
||||||
}
|
}
|
||||||
|
self.known_input_paths.insert(path);
|
||||||
self.keyboards.push(kbd_agg);
|
self.keyboards.push(kbd_agg);
|
||||||
found_any = true;
|
found_any = true;
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
DeviceKind::Mouse => {
|
DeviceKind::Mouse => {
|
||||||
if !matches_selected_input_device(&path, self.selected_mouse_path.as_deref()) {
|
if !matches_selected_input_device(&path, self.selected_mouse_path.as_deref()) {
|
||||||
|
self.known_input_paths.insert(path);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if self.capture_remote_boot {
|
if remote_active {
|
||||||
dev.grab()
|
if let Err(err) = dev.grab() {
|
||||||
.with_context(|| format!("grabbing mouse {path:?}"))?;
|
if fail_grab {
|
||||||
|
return Err(err)
|
||||||
|
.with_context(|| format!("grabbing mouse {path:?}"));
|
||||||
|
}
|
||||||
|
warn!("❌ grab mouse {}: {err}", path.display());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
info!("🤏⌨️ Grabbed mouse {:?}", dev.name().unwrap_or("UNKNOWN"));
|
info!("🤏⌨️ Grabbed mouse {:?}", dev.name().unwrap_or("UNKNOWN"));
|
||||||
} else {
|
} else {
|
||||||
info!(
|
info!(
|
||||||
"🖱️ local-input boot mode; mouse left ungrabbed {:?}",
|
"🖱️ local-input mode; mouse staged ungrabbed {:?}",
|
||||||
dev.name().unwrap_or("UNKNOWN")
|
dev.name().unwrap_or("UNKNOWN")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut mouse_agg =
|
let mut mouse_agg =
|
||||||
MouseAggregator::new(dev, self.dev_mode, self.mou_tx.clone());
|
MouseAggregator::new(dev, self.dev_mode, self.mou_tx.clone());
|
||||||
mouse_agg.set_send(self.capture_remote_boot);
|
mouse_agg.set_send(remote_active);
|
||||||
if !self.capture_remote_boot {
|
if !remote_active {
|
||||||
mouse_agg.set_grab(false);
|
mouse_agg.set_grab(false);
|
||||||
}
|
}
|
||||||
|
self.known_input_paths.insert(path);
|
||||||
self.mice.push(mouse_agg);
|
self.mice.push(mouse_agg);
|
||||||
found_any = true;
|
found_any = true;
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
DeviceKind::Other => {
|
DeviceKind::Other => {
|
||||||
debug!(
|
debug!(
|
||||||
"Skipping non-kbd/mouse device: {:?}",
|
"Skipping non-kbd/mouse device: {:?}",
|
||||||
dev.name().unwrap_or("UNKNOWN")
|
dev.name().unwrap_or("UNKNOWN")
|
||||||
);
|
);
|
||||||
continue;
|
self.known_input_paths.insert(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !found_any {
|
Ok(found_any)
|
||||||
bail!("No suitable keyboard/mouse devices found or none grabbed.");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
@ -362,10 +389,21 @@ impl InputAggregator {
|
|||||||
// Example approach: poll each aggregator in a simple loop
|
// Example approach: poll each aggregator in a simple loop
|
||||||
let mut tick = interval(Duration::from_millis(10));
|
let mut tick = interval(Duration::from_millis(10));
|
||||||
let mut current = Layout::SideBySide;
|
let mut current = Layout::SideBySide;
|
||||||
|
let input_rescan_interval = input_rescan_interval_from_env();
|
||||||
|
let mut last_input_rescan_at = Instant::now();
|
||||||
self.publish_routing_state_if_changed();
|
self.publish_routing_state_if_changed();
|
||||||
loop {
|
loop {
|
||||||
let mut want_kill = false;
|
let mut want_kill = false;
|
||||||
self.process_keyboard_updates();
|
self.process_keyboard_updates();
|
||||||
|
if !input_rescan_interval.is_zero()
|
||||||
|
&& last_input_rescan_at.elapsed() >= input_rescan_interval
|
||||||
|
{
|
||||||
|
last_input_rescan_at = Instant::now();
|
||||||
|
let remote_active = self.remote_capture_active();
|
||||||
|
if let Err(err) = self.scan_input_devices(remote_active, false) {
|
||||||
|
warn!("⚠️ input device rescan failed: {err:#}");
|
||||||
|
}
|
||||||
|
}
|
||||||
for kbd in &self.keyboards {
|
for kbd in &self.keyboards {
|
||||||
want_kill |= kbd.magic_kill();
|
want_kill |= kbd.magic_kill();
|
||||||
}
|
}
|
||||||
@ -553,6 +591,13 @@ impl InputAggregator {
|
|||||||
.is_some_and(|started_at| started_at.elapsed() >= self.remote_failsafe_timeout)
|
.is_some_and(|started_at| started_at.elapsed() >= self.remote_failsafe_timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn remote_capture_active(&self) -> bool {
|
||||||
|
!self.released
|
||||||
|
&& !self.pending_release
|
||||||
|
&& !self.pending_kill
|
||||||
|
&& self.remote_capture_enabled.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
fn capture_pending_keys(&mut self) {
|
fn capture_pending_keys(&mut self) {
|
||||||
self.pending_keys.clear();
|
self.pending_keys.clear();
|
||||||
for k in &self.keyboards {
|
for k in &self.keyboards {
|
||||||
@ -979,6 +1024,19 @@ fn remote_failsafe_timeout_from_env() -> Duration {
|
|||||||
Duration::from_millis(millis)
|
Duration::from_millis(millis)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn input_rescan_interval_from_env() -> Duration {
|
||||||
|
let millis = std::env::var("LESAVKA_INPUT_RESCAN_MS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|raw| raw.parse::<u64>().ok())
|
||||||
|
.unwrap_or(1_000);
|
||||||
|
if millis == 0 {
|
||||||
|
Duration::ZERO
|
||||||
|
} else {
|
||||||
|
Duration::from_millis(millis.max(250))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
fn focus_launcher_on_local_if_enabled() {
|
fn focus_launcher_on_local_if_enabled() {
|
||||||
if std::env::var("LESAVKA_FOCUS_LAUNCHER_ON_LOCAL")
|
if std::env::var("LESAVKA_FOCUS_LAUNCHER_ON_LOCAL")
|
||||||
|
|||||||
@ -269,13 +269,9 @@ impl KeyboardAggregator {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If V is pressed with any paste modifier down, arm a possible paste chord.
|
// Only intercept the complete configured Lesavka paste chord. Plain
|
||||||
// This prevents leaking Ctrl+V / Alt+V while user is completing chord order.
|
// app shortcuts such as Ctrl+V must keep travelling to the remote HID.
|
||||||
if code == KeyCode::KEY_V
|
if code == KeyCode::KEY_V && value == 1 && self.paste_chord_active() {
|
||||||
&& value == 1
|
|
||||||
&& ((self.has_key(KeyCode::KEY_LEFTCTRL) || self.has_key(KeyCode::KEY_RIGHTCTRL))
|
|
||||||
|| (self.has_key(KeyCode::KEY_LEFTALT) || self.has_key(KeyCode::KEY_RIGHTALT)))
|
|
||||||
{
|
|
||||||
self.paste_chord_armed = true;
|
self.paste_chord_armed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1159,7 +1159,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
let message = if usb_audio_kernel_support_missing() {
|
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."
|
"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 {
|
} 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."
|
"Device staging refreshed. Newly attached devices are ready for local tests; all-keyboard/all-mouse relay sessions will pick up new input devices automatically."
|
||||||
};
|
};
|
||||||
widgets_handle.status_label.set_text(message);
|
widgets_handle.status_label.set_text(message);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -311,6 +311,7 @@ pub fn build_launcher_view(
|
|||||||
audio_check_detail.set_wrap(false);
|
audio_check_detail.set_wrap(false);
|
||||||
audio_check_detail.set_ellipsize(pango::EllipsizeMode::End);
|
audio_check_detail.set_ellipsize(pango::EllipsizeMode::End);
|
||||||
audio_check_detail.set_xalign(0.0);
|
audio_check_detail.set_xalign(0.0);
|
||||||
|
audio_check_detail.set_visible(false);
|
||||||
let audio_check_meter = gtk::ProgressBar::new();
|
let audio_check_meter = gtk::ProgressBar::new();
|
||||||
audio_check_meter.add_css_class("audio-check-meter");
|
audio_check_meter.add_css_class("audio-check-meter");
|
||||||
audio_check_meter.set_show_text(false);
|
audio_check_meter.set_show_text(false);
|
||||||
@ -371,24 +372,22 @@ pub fn build_launcher_view(
|
|||||||
playback_group.set_hexpand(false);
|
playback_group.set_hexpand(false);
|
||||||
playback_group.set_vexpand(false);
|
playback_group.set_vexpand(false);
|
||||||
playback_group.set_valign(gtk::Align::Start);
|
playback_group.set_valign(gtk::Align::Start);
|
||||||
playback_group.set_size_request(148, -1);
|
playback_group.set_size_request(72, -1);
|
||||||
let playback_body = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
let playback_body = gtk::Box::new(gtk::Orientation::Vertical, 6);
|
||||||
|
playback_body.set_halign(gtk::Align::Center);
|
||||||
playback_body.set_vexpand(false);
|
playback_body.set_vexpand(false);
|
||||||
let playback_controls = gtk::Box::new(gtk::Orientation::Vertical, 6);
|
let microphone_replay_button = gtk::Button::with_label("Replay");
|
||||||
playback_controls.set_hexpand(true);
|
stabilize_button(µphone_replay_button, 70);
|
||||||
let microphone_replay_button = gtk::Button::with_label("Replay Last 3s");
|
|
||||||
stabilize_button(µphone_replay_button, 124);
|
|
||||||
audio_check_meter.set_orientation(gtk::Orientation::Vertical);
|
audio_check_meter.set_orientation(gtk::Orientation::Vertical);
|
||||||
audio_check_meter.set_inverted(true);
|
audio_check_meter.set_inverted(true);
|
||||||
audio_check_meter.set_hexpand(false);
|
audio_check_meter.set_hexpand(false);
|
||||||
audio_check_meter.set_vexpand(false);
|
audio_check_meter.set_vexpand(false);
|
||||||
audio_check_meter.set_size_request(20, CAMERA_PREVIEW_VIEWPORT_HEIGHT - 38);
|
audio_check_meter.set_halign(gtk::Align::Center);
|
||||||
|
audio_check_meter.set_size_request(20, CAMERA_PREVIEW_VIEWPORT_HEIGHT - 40);
|
||||||
audio_check_meter.set_show_text(false);
|
audio_check_meter.set_show_text(false);
|
||||||
audio_check_meter.set_text(Some("Idle"));
|
audio_check_meter.set_text(Some("Idle"));
|
||||||
playback_controls.append(µphone_replay_button);
|
|
||||||
playback_controls.append(&audio_check_detail);
|
|
||||||
playback_body.append(&audio_check_meter);
|
playback_body.append(&audio_check_meter);
|
||||||
playback_body.append(&playback_controls);
|
playback_body.append(µphone_replay_button);
|
||||||
playback_group.append(&playback_body);
|
playback_group.append(&playback_body);
|
||||||
testing_row.append(&playback_group);
|
testing_row.append(&playback_group);
|
||||||
preview_body.append(&testing_row);
|
preview_body.append(&testing_row);
|
||||||
|
|||||||
@ -152,9 +152,9 @@ pub fn refresh_test_buttons(widgets: &LauncherWidgets, tests: &mut DeviceTestCon
|
|||||||
widgets
|
widgets
|
||||||
.microphone_replay_button
|
.microphone_replay_button
|
||||||
.set_label(if microphone_replay_running {
|
.set_label(if microphone_replay_running {
|
||||||
"Stop Replay"
|
"Stop"
|
||||||
} else {
|
} else {
|
||||||
"Replay Last 3s"
|
"Replay"
|
||||||
});
|
});
|
||||||
widgets
|
widgets
|
||||||
.microphone_replay_button
|
.microphone_replay_button
|
||||||
@ -586,7 +586,7 @@ fn local_test_detail(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if active.is_empty() {
|
if active.is_empty() {
|
||||||
"Local checks are idle. Use Start Preview, Monitor Mic, Replay Last 3s, or Play Tone before you launch."
|
"Local checks are idle. Use Start Preview, Monitor Mic, Replay, or Play Tone before you launch."
|
||||||
.to_string()
|
.to_string()
|
||||||
} else {
|
} else {
|
||||||
format!(
|
format!(
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.11.33"
|
version = "0.11.34"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn banner_includes_version() {
|
fn banner_includes_version() {
|
||||||
assert_eq!(banner("0.11.33"), "lesavka-common CLI (v0.11.33)");
|
assert_eq!(banner("0.11.34"), "lesavka-common CLI (v0.11.34)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ bench = false
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.11.33"
|
version = "0.11.34"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
autobins = false
|
autobins = false
|
||||||
|
|
||||||
|
|||||||
@ -381,34 +381,53 @@ impl Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn restart_uvc_helper() -> anyhow::Result<()> {
|
fn restart_uvc_helper() -> anyhow::Result<()> {
|
||||||
for args in [
|
if std::env::var("LESAVKA_GADGET_SYSFS_ROOT").is_ok()
|
||||||
["reset-failed", "lesavka-uvc.service"].as_slice(),
|
|| std::env::var("LESAVKA_GADGET_CONFIGFS_ROOT").is_ok()
|
||||||
["restart", "lesavka-uvc.service"].as_slice(),
|
{
|
||||||
] {
|
return Ok(());
|
||||||
let output = Command::new("systemctl")
|
|
||||||
.args(args)
|
|
||||||
.output()
|
|
||||||
.with_context(|| format!("running systemctl {}", args.join(" ")))?;
|
|
||||||
if !output.status.success() {
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
||||||
anyhow::bail!(
|
|
||||||
"systemctl {} failed: {}{}",
|
|
||||||
args.join(" "),
|
|
||||||
if stderr.is_empty() {
|
|
||||||
stdout.as_str()
|
|
||||||
} else {
|
|
||||||
stderr.as_str()
|
|
||||||
},
|
|
||||||
if stderr.is_empty() || stdout.is_empty() || stderr == stdout {
|
|
||||||
""
|
|
||||||
} else {
|
|
||||||
" / also see stdout"
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
|
run_systemctl(&["reset-failed", "lesavka-uvc.service"])?;
|
||||||
|
match run_systemctl(&["restart", "lesavka-uvc.service"]) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(err) if uvc_helper_restart_was_dependency_refused(&err.to_string()) => {
|
||||||
|
warn!(
|
||||||
|
"lesavka-uvc.service refused a direct restart because it is dependency-managed; USB reset already cycled the gadget"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(err) => Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_systemctl(args: &[&str]) -> anyhow::Result<()> {
|
||||||
|
let output = Command::new("systemctl")
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.with_context(|| format!("running systemctl {}", args.join(" ")))?;
|
||||||
|
if output.status.success() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
anyhow::bail!(
|
||||||
|
"systemctl {} failed: {}{}",
|
||||||
|
args.join(" "),
|
||||||
|
if stderr.is_empty() {
|
||||||
|
stdout.as_str()
|
||||||
|
} else {
|
||||||
|
stderr.as_str()
|
||||||
|
},
|
||||||
|
if stderr.is_empty() || stdout.is_empty() || stderr == stdout {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
" / also see stdout"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uvc_helper_restart_was_dependency_refused(message: &str) -> bool {
|
||||||
|
message.contains("Operation refused") || message.contains("may be requested by dependency only")
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EyeHub {
|
impl EyeHub {
|
||||||
|
|||||||
@ -240,6 +240,29 @@ mod keyboard_contract_extra {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn ctrl_v_reaches_remote_when_lesavka_paste_chord_requires_alt() {
|
||||||
|
let Some(dev) =
|
||||||
|
open_any_keyboard_device().or_else(|| build_keyboard("lesavka-include-kbd-ctrl-v-app"))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (mut agg, _) = new_aggregator(dev);
|
||||||
|
agg.paste_enabled = true;
|
||||||
|
agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL);
|
||||||
|
agg.pressed_keys.insert(evdev::KeyCode::KEY_V);
|
||||||
|
|
||||||
|
with_var("LESAVKA_CLIPBOARD_CHORD", Some("ctrl+alt+v"), || {
|
||||||
|
assert!(
|
||||||
|
!agg.try_handle_paste_event(evdev::KeyCode::KEY_V, 1),
|
||||||
|
"plain Ctrl+V should relay as an app shortcut, not trigger Lesavka paste"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
assert!(!agg.paste_chord_armed);
|
||||||
|
assert!(!agg.paste_chord_consumed);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn paste_via_rpc_returns_false_without_sender() {
|
fn paste_via_rpc_returns_false_without_sender() {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user