fix(relay): stabilize routing and media recovery
This commit is contained in:
parent
48503c7c22
commit
3526bf7b6d
@ -4,7 +4,7 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.11.28"
|
version = "0.11.29"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -47,10 +47,16 @@ pub struct InputAggregator {
|
|||||||
quick_toggle_down: bool,
|
quick_toggle_down: bool,
|
||||||
quick_toggle_debounce: Duration,
|
quick_toggle_debounce: Duration,
|
||||||
last_quick_toggle_at: Option<Instant>,
|
last_quick_toggle_at: Option<Instant>,
|
||||||
|
pending_release_started_at: Option<Instant>,
|
||||||
|
pending_release_timeout: Duration,
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
routing_control_path: Option<PathBuf>,
|
routing_control_path: Option<PathBuf>,
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
routing_control_marker: u128,
|
last_routing_request_raw: Option<String>,
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
quick_toggle_control_path: Option<PathBuf>,
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
last_quick_toggle_request_raw: Option<String>,
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
clipboard_control_path: Option<PathBuf>,
|
clipboard_control_path: Option<PathBuf>,
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
@ -85,6 +91,9 @@ impl InputAggregator {
|
|||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
let routing_state_path = launcher_routing_path_from_env("LESAVKA_LAUNCHER_INPUT_STATE");
|
let routing_state_path = launcher_routing_path_from_env("LESAVKA_LAUNCHER_INPUT_STATE");
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
|
let quick_toggle_control_path =
|
||||||
|
launcher_routing_path_from_env("LESAVKA_LAUNCHER_TOGGLE_KEY_CONTROL");
|
||||||
|
#[cfg(not(coverage))]
|
||||||
let clipboard_control_path =
|
let clipboard_control_path =
|
||||||
launcher_routing_path_from_env("LESAVKA_LAUNCHER_CLIPBOARD_CONTROL");
|
launcher_routing_path_from_env("LESAVKA_LAUNCHER_CLIPBOARD_CONTROL");
|
||||||
Self {
|
Self {
|
||||||
@ -107,14 +116,21 @@ impl InputAggregator {
|
|||||||
quick_toggle_down: false,
|
quick_toggle_down: false,
|
||||||
quick_toggle_debounce: quick_toggle_debounce_from_env(),
|
quick_toggle_debounce: quick_toggle_debounce_from_env(),
|
||||||
last_quick_toggle_at: None,
|
last_quick_toggle_at: None,
|
||||||
|
pending_release_started_at: None,
|
||||||
|
pending_release_timeout: pending_release_timeout_from_env(),
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
routing_control_marker: routing_control_path
|
last_routing_request_raw: routing_control_path
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(path_marker)
|
.and_then(read_launcher_control_snapshot),
|
||||||
.unwrap_or_default(),
|
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
routing_control_path,
|
routing_control_path,
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
|
last_quick_toggle_request_raw: quick_toggle_control_path
|
||||||
|
.as_deref()
|
||||||
|
.and_then(read_launcher_control_snapshot),
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
quick_toggle_control_path,
|
||||||
|
#[cfg(not(coverage))]
|
||||||
clipboard_control_marker: clipboard_control_path
|
clipboard_control_marker: clipboard_control_path
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(path_marker)
|
.map(path_marker)
|
||||||
@ -350,6 +366,7 @@ impl InputAggregator {
|
|||||||
want_kill |= kbd.magic_kill();
|
want_kill |= kbd.magic_kill();
|
||||||
}
|
}
|
||||||
self.poll_launcher_routing_request();
|
self.poll_launcher_routing_request();
|
||||||
|
self.poll_launcher_quick_toggle_request();
|
||||||
self.poll_launcher_clipboard_request();
|
self.poll_launcher_clipboard_request();
|
||||||
let quick_toggle_now = self.quick_toggle_active();
|
let quick_toggle_now = self.quick_toggle_active();
|
||||||
self.observe_quick_toggle(quick_toggle_now);
|
self.observe_quick_toggle(quick_toggle_now);
|
||||||
@ -381,6 +398,7 @@ impl InputAggregator {
|
|||||||
}
|
}
|
||||||
self.pending_kill = true;
|
self.pending_kill = true;
|
||||||
self.capture_pending_keys();
|
self.capture_pending_keys();
|
||||||
|
self.pending_release_started_at = Some(Instant::now());
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.pending_release || self.pending_kill {
|
if self.pending_release || self.pending_kill {
|
||||||
@ -394,25 +412,17 @@ impl InputAggregator {
|
|||||||
.iter()
|
.iter()
|
||||||
.all(|key| !self.keyboards.iter().any(|k| k.has_key(*key)))
|
.all(|key| !self.keyboards.iter().any(|k| k.has_key(*key)))
|
||||||
};
|
};
|
||||||
if chord_released {
|
let timed_out = self.pending_release_timed_out();
|
||||||
for k in &mut self.keyboards {
|
if chord_released || timed_out {
|
||||||
k.set_grab(false);
|
if timed_out {
|
||||||
k.reset_state();
|
warn!(
|
||||||
|
"⌛ local release timed out waiting for key-up events; forcing the handoff"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for m in &mut self.mice {
|
self.finish_local_release(!self.pending_kill);
|
||||||
m.set_grab(false);
|
|
||||||
m.reset_state();
|
|
||||||
}
|
|
||||||
self.released = true;
|
|
||||||
if !self.pending_kill {
|
|
||||||
focus_launcher_on_local_if_enabled();
|
|
||||||
}
|
|
||||||
self.publish_routing_state_if_changed();
|
|
||||||
if self.pending_kill {
|
if self.pending_kill {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
self.pending_release = false;
|
|
||||||
self.pending_keys.clear();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -457,10 +467,17 @@ impl InputAggregator {
|
|||||||
}
|
}
|
||||||
self.released = false;
|
self.released = false;
|
||||||
self.pending_release = false;
|
self.pending_release = false;
|
||||||
|
self.pending_release_started_at = None;
|
||||||
|
self.pending_keys.clear();
|
||||||
self.last_keyboard_report = [0; 8];
|
self.last_keyboard_report = [0; 8];
|
||||||
}
|
}
|
||||||
|
|
||||||
fn begin_local_release(&mut self) {
|
fn begin_local_release(&mut self) {
|
||||||
|
if self.released && !self.pending_release {
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
self.publish_routing_state_if_changed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
self.remote_capture_enabled.store(false, Ordering::Relaxed);
|
self.remote_capture_enabled.store(false, Ordering::Relaxed);
|
||||||
for k in &mut self.keyboards {
|
for k in &mut self.keyboards {
|
||||||
k.send_empty_report();
|
k.send_empty_report();
|
||||||
@ -471,10 +488,39 @@ impl InputAggregator {
|
|||||||
m.set_send(false);
|
m.set_send(false);
|
||||||
}
|
}
|
||||||
self.pending_release = true;
|
self.pending_release = true;
|
||||||
|
self.pending_release_started_at = Some(Instant::now());
|
||||||
self.last_keyboard_report = [0; 8];
|
self.last_keyboard_report = [0; 8];
|
||||||
self.capture_pending_keys();
|
self.capture_pending_keys();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn finish_local_release(&mut self, focus_launcher: bool) {
|
||||||
|
for k in &mut self.keyboards {
|
||||||
|
k.set_grab(false);
|
||||||
|
k.reset_state();
|
||||||
|
}
|
||||||
|
for m in &mut self.mice {
|
||||||
|
m.set_grab(false);
|
||||||
|
m.reset_state();
|
||||||
|
}
|
||||||
|
self.released = true;
|
||||||
|
self.pending_release = false;
|
||||||
|
self.pending_release_started_at = None;
|
||||||
|
self.pending_keys.clear();
|
||||||
|
if focus_launcher {
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
focus_launcher_on_local_if_enabled();
|
||||||
|
}
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
self.publish_routing_state_if_changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pending_release_timed_out(&self) -> bool {
|
||||||
|
(self.pending_release || self.pending_kill)
|
||||||
|
&& self
|
||||||
|
.pending_release_started_at
|
||||||
|
.is_some_and(|started_at| started_at.elapsed() >= self.pending_release_timeout)
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
||||||
@ -560,27 +606,60 @@ impl InputAggregator {
|
|||||||
let Some(path) = self.routing_control_path.as_deref() else {
|
let Some(path) = self.routing_control_path.as_deref() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let marker = path_marker(path);
|
let Some(raw) = read_launcher_control_snapshot(path) else {
|
||||||
if marker <= self.routing_control_marker {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.routing_control_marker = marker;
|
|
||||||
let Some(remote_capture) = read_launcher_routing_request(path) else {
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if self.pending_release || self.pending_kill || remote_capture == !self.released {
|
if self.last_routing_request_raw.as_deref() == Some(raw.as_str()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.last_routing_request_raw = Some(raw.clone());
|
||||||
|
let Some(remote_capture) = parse_launcher_routing_request(&raw) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if self.pending_kill {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if remote_capture {
|
if remote_capture {
|
||||||
|
if !self.released && !self.pending_release {
|
||||||
|
return;
|
||||||
|
}
|
||||||
info!("🎛️ launcher requested remote input capture");
|
info!("🎛️ launcher requested remote input capture");
|
||||||
self.enable_remote_capture();
|
self.enable_remote_capture();
|
||||||
self.publish_routing_state_if_changed();
|
self.publish_routing_state_if_changed();
|
||||||
} else {
|
} else {
|
||||||
|
if self.released && !self.pending_release {
|
||||||
|
return;
|
||||||
|
}
|
||||||
info!("🎛️ launcher requested local input capture");
|
info!("🎛️ launcher requested local input capture");
|
||||||
self.begin_local_release();
|
self.begin_local_release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn poll_launcher_quick_toggle_request(&mut self) {
|
||||||
|
let Some(path) = self.quick_toggle_control_path.as_deref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(raw) = read_launcher_control_snapshot(path) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if self.last_quick_toggle_request_raw.as_deref() == Some(raw.as_str()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.last_quick_toggle_request_raw = Some(raw.clone());
|
||||||
|
let next_key = raw
|
||||||
|
.split_ascii_whitespace()
|
||||||
|
.next()
|
||||||
|
.and_then(parse_quick_toggle_key);
|
||||||
|
self.quick_toggle_key = next_key;
|
||||||
|
self.quick_toggle_down = false;
|
||||||
|
self.last_quick_toggle_at = None;
|
||||||
|
match next_key {
|
||||||
|
Some(key) => info!("🎛️ launcher updated the live swap key to {:?}", key),
|
||||||
|
None => info!("🎛️ launcher disabled the live swap key"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
fn poll_launcher_clipboard_request(&mut self) {
|
fn poll_launcher_clipboard_request(&mut self) {
|
||||||
let Some(path) = self.clipboard_control_path.as_deref() else {
|
let Some(path) = self.clipboard_control_path.as_deref() else {
|
||||||
@ -852,6 +931,14 @@ fn quick_toggle_debounce_from_env() -> Duration {
|
|||||||
Duration::from_millis(millis.max(50))
|
Duration::from_millis(millis.max(50))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn pending_release_timeout_from_env() -> Duration {
|
||||||
|
let millis = std::env::var("LESAVKA_INPUT_RELEASE_TIMEOUT_MS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|raw| raw.parse::<u64>().ok())
|
||||||
|
.unwrap_or(750);
|
||||||
|
Duration::from_millis(millis.max(100))
|
||||||
|
}
|
||||||
|
|
||||||
#[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")
|
||||||
@ -899,9 +986,20 @@ fn matches_selected_input_device(path: &std::path::Path, selected: Option<&str>)
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
fn read_launcher_routing_request(path: &Path) -> Option<bool> {
|
fn read_launcher_control_snapshot(path: &Path) -> Option<String> {
|
||||||
let raw = std::fs::read_to_string(path).ok()?;
|
let raw = std::fs::read_to_string(path).ok()?;
|
||||||
match raw.trim().to_ascii_lowercase().as_str() {
|
let trimmed = raw.trim();
|
||||||
|
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn parse_launcher_routing_request(raw: &str) -> Option<bool> {
|
||||||
|
match raw
|
||||||
|
.split_ascii_whitespace()
|
||||||
|
.next()?
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
"remote" => Some(true),
|
"remote" => Some(true),
|
||||||
"local" => Some(false),
|
"local" => Some(false),
|
||||||
_ => None,
|
_ => None,
|
||||||
|
|||||||
@ -17,12 +17,13 @@ use {
|
|||||||
super::ui_runtime::{
|
super::ui_runtime::{
|
||||||
RelayChild, append_session_log, apply_popout_window_size, attach_child_log_streams,
|
RelayChild, append_session_log, apply_popout_window_size, attach_child_log_streams,
|
||||||
capture_swap_key, copy_plain_text, copy_session_log, dock_all_displays_to_preview,
|
capture_swap_key, copy_plain_text, copy_session_log, dock_all_displays_to_preview,
|
||||||
dock_display_to_preview, input_control_path, input_state_path, next_input_routing,
|
dock_display_to_preview, input_control_path, input_state_path, input_toggle_control_path,
|
||||||
open_diagnostics_popout, open_popout_window, open_session_log_popout, path_marker,
|
next_input_routing, open_diagnostics_popout, open_popout_window, open_session_log_popout,
|
||||||
present_popout_windows, read_input_routing_state, reap_exited_child, refresh_launcher_ui,
|
path_marker, present_popout_windows, read_input_routing_state, reap_exited_child,
|
||||||
refresh_test_buttons, routing_name, selected_combo_value, selected_server_addr,
|
refresh_launcher_ui, refresh_test_buttons, routing_name, selected_combo_value,
|
||||||
shutdown_launcher_runtime, spawn_client_process, stop_child_process, toggle_key_label,
|
selected_server_addr, shutdown_launcher_runtime, spawn_client_process, stop_child_process,
|
||||||
update_test_action_result, write_input_routing_request,
|
toggle_key_label, update_test_action_result, write_input_routing_request,
|
||||||
|
write_input_toggle_key_request,
|
||||||
},
|
},
|
||||||
crate::handshake::{HandshakeProbe, probe},
|
crate::handshake::{HandshakeProbe, probe},
|
||||||
crate::output::display::enumerate_monitors,
|
crate::output::display::enumerate_monitors,
|
||||||
@ -621,10 +622,12 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
let clipboard_control_path = Rc::new(launcher_clipboard_control_path());
|
let clipboard_control_path = Rc::new(launcher_clipboard_control_path());
|
||||||
let input_control_path = Rc::new(input_control_path());
|
let input_control_path = Rc::new(input_control_path());
|
||||||
let input_state_path = Rc::new(input_state_path());
|
let input_state_path = Rc::new(input_state_path());
|
||||||
|
let input_toggle_control_path = Rc::new(input_toggle_control_path());
|
||||||
let _ = std::fs::remove_file(focus_signal_path.as_path());
|
let _ = std::fs::remove_file(focus_signal_path.as_path());
|
||||||
let _ = std::fs::remove_file(clipboard_control_path.as_path());
|
let _ = std::fs::remove_file(clipboard_control_path.as_path());
|
||||||
let _ = std::fs::remove_file(input_control_path.as_path());
|
let _ = std::fs::remove_file(input_control_path.as_path());
|
||||||
let _ = std::fs::remove_file(input_state_path.as_path());
|
let _ = std::fs::remove_file(input_state_path.as_path());
|
||||||
|
let _ = std::fs::remove_file(input_toggle_control_path.as_path());
|
||||||
|
|
||||||
{
|
{
|
||||||
let child_proc = Rc::clone(&child_proc);
|
let child_proc = Rc::clone(&child_proc);
|
||||||
@ -632,6 +635,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
let clipboard_control_path = Rc::clone(&clipboard_control_path);
|
let clipboard_control_path = Rc::clone(&clipboard_control_path);
|
||||||
let input_control_path = Rc::clone(&input_control_path);
|
let input_control_path = Rc::clone(&input_control_path);
|
||||||
let input_state_path = Rc::clone(&input_state_path);
|
let input_state_path = Rc::clone(&input_state_path);
|
||||||
|
let input_toggle_control_path = Rc::clone(&input_toggle_control_path);
|
||||||
let tests = Rc::clone(&tests);
|
let tests = Rc::clone(&tests);
|
||||||
app.connect_shutdown(move |_| {
|
app.connect_shutdown(move |_| {
|
||||||
stop_child_process(&child_proc);
|
stop_child_process(&child_proc);
|
||||||
@ -640,6 +644,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
let _ = std::fs::remove_file(clipboard_control_path.as_path());
|
let _ = std::fs::remove_file(clipboard_control_path.as_path());
|
||||||
let _ = std::fs::remove_file(input_control_path.as_path());
|
let _ = std::fs::remove_file(input_control_path.as_path());
|
||||||
let _ = std::fs::remove_file(input_state_path.as_path());
|
let _ = std::fs::remove_file(input_state_path.as_path());
|
||||||
|
let _ = std::fs::remove_file(input_toggle_control_path.as_path());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -652,6 +657,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
let focus_signal_path = Rc::clone(&focus_signal_path);
|
let focus_signal_path = Rc::clone(&focus_signal_path);
|
||||||
let input_control_path = Rc::clone(&input_control_path);
|
let input_control_path = Rc::clone(&input_control_path);
|
||||||
let input_state_path = Rc::clone(&input_state_path);
|
let input_state_path = Rc::clone(&input_state_path);
|
||||||
|
let input_toggle_control_path = Rc::clone(&input_toggle_control_path);
|
||||||
|
|
||||||
app.connect_activate(move |app| {
|
app.connect_activate(move |app| {
|
||||||
let (display_width, display_height) = largest_monitor_size();
|
let (display_width, display_height) = largest_monitor_size();
|
||||||
@ -1175,6 +1181,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
let speaker_combo = speaker_combo.clone();
|
let speaker_combo = speaker_combo.clone();
|
||||||
let input_control_path = Rc::clone(&input_control_path);
|
let input_control_path = Rc::clone(&input_control_path);
|
||||||
let input_state_path = Rc::clone(&input_state_path);
|
let input_state_path = Rc::clone(&input_state_path);
|
||||||
|
let input_toggle_control_path = Rc::clone(&input_toggle_control_path);
|
||||||
let server_addr_fallback = Rc::clone(&server_addr);
|
let server_addr_fallback = Rc::clone(&server_addr);
|
||||||
let preview = preview.clone();
|
let preview = preview.clone();
|
||||||
let power_tx = power_tx.clone();
|
let power_tx = power_tx.clone();
|
||||||
@ -1245,10 +1252,12 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
}
|
}
|
||||||
let _ = std::fs::remove_file(input_control_path.as_path());
|
let _ = std::fs::remove_file(input_control_path.as_path());
|
||||||
let _ = std::fs::remove_file(input_state_path.as_path());
|
let _ = std::fs::remove_file(input_state_path.as_path());
|
||||||
|
let _ = std::fs::remove_file(input_toggle_control_path.as_path());
|
||||||
let launch_state = state.borrow().clone();
|
let launch_state = state.borrow().clone();
|
||||||
let input_toggle_key = launch_state.swap_key.clone();
|
let input_toggle_key = launch_state.swap_key.clone();
|
||||||
let input_control_path = input_control_path.as_ref().clone();
|
let input_control_path = input_control_path.as_ref().clone();
|
||||||
let input_state_path = input_state_path.as_ref().clone();
|
let input_state_path = input_state_path.as_ref().clone();
|
||||||
|
let input_toggle_control_path = input_toggle_control_path.as_ref().clone();
|
||||||
relay_request_in_flight.set(true);
|
relay_request_in_flight.set(true);
|
||||||
widgets_handle.status_label.set_text(&format!(
|
widgets_handle.status_label.set_text(&format!(
|
||||||
"Connecting relay with {} as the swap key...",
|
"Connecting relay with {} as the swap key...",
|
||||||
@ -1267,6 +1276,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
&input_toggle_key,
|
&input_toggle_key,
|
||||||
input_control_path.as_path(),
|
input_control_path.as_path(),
|
||||||
input_state_path.as_path(),
|
input_state_path.as_path(),
|
||||||
|
input_toggle_control_path.as_path(),
|
||||||
)
|
)
|
||||||
.map_err(|err| err.to_string());
|
.map_err(|err| err.to_string());
|
||||||
let _ = relay_tx.send(RelayMessage::Spawned(result));
|
let _ = relay_tx.send(RelayMessage::Spawned(result));
|
||||||
@ -1739,6 +1749,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
let state = Rc::clone(&state);
|
let state = Rc::clone(&state);
|
||||||
let child_proc = Rc::clone(&child_proc);
|
let child_proc = Rc::clone(&child_proc);
|
||||||
let widgets = widgets.clone();
|
let widgets = widgets.clone();
|
||||||
|
let input_toggle_control_path = Rc::clone(&input_toggle_control_path);
|
||||||
let key_controller = gtk::EventControllerKey::new();
|
let key_controller = gtk::EventControllerKey::new();
|
||||||
key_controller.connect_key_pressed(move |_, key, _, _| {
|
key_controller.connect_key_pressed(move |_, key, _, _| {
|
||||||
if !state.borrow().swap_key_binding {
|
if !state.borrow().swap_key_binding {
|
||||||
@ -1763,10 +1774,19 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
state.complete_swap_key_binding(swap_key.clone());
|
state.complete_swap_key_binding(swap_key.clone());
|
||||||
}
|
}
|
||||||
let status_message = if relay_live {
|
let status_message = if relay_live {
|
||||||
format!(
|
match write_input_toggle_key_request(
|
||||||
"Swap key set to {}. Disconnect and reconnect the relay to use it live.",
|
input_toggle_control_path.as_path(),
|
||||||
toggle_key_label(&swap_key)
|
&swap_key,
|
||||||
)
|
) {
|
||||||
|
Ok(()) => format!(
|
||||||
|
"Swap key set to {} and applied to the live relay.",
|
||||||
|
toggle_key_label(&swap_key)
|
||||||
|
),
|
||||||
|
Err(err) => format!(
|
||||||
|
"Swap key set to {}, but Lesavka could not push it live: {err}",
|
||||||
|
toggle_key_label(&swap_key)
|
||||||
|
),
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
format!(
|
format!(
|
||||||
"Swap key set to {}. The next relay launch will use it.",
|
"Swap key set to {}. The next relay launch will use it.",
|
||||||
@ -1792,8 +1812,6 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
let server_addr_fallback = Rc::clone(&server_addr);
|
let server_addr_fallback = Rc::clone(&server_addr);
|
||||||
let last_focus_marker =
|
let last_focus_marker =
|
||||||
Rc::new(RefCell::new(path_marker(focus_signal_path.as_path())));
|
Rc::new(RefCell::new(path_marker(focus_signal_path.as_path())));
|
||||||
let last_state_marker =
|
|
||||||
Rc::new(RefCell::new(path_marker(input_state_path.as_path())));
|
|
||||||
let power_request_in_flight = Rc::clone(&power_request_in_flight);
|
let power_request_in_flight = Rc::clone(&power_request_in_flight);
|
||||||
let relay_request_in_flight = Rc::clone(&relay_request_in_flight);
|
let relay_request_in_flight = Rc::clone(&relay_request_in_flight);
|
||||||
let preview = preview.clone();
|
let preview = preview.clone();
|
||||||
@ -1846,19 +1864,16 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let next_state_marker = path_marker(input_state_path.as_path());
|
if child_running
|
||||||
let mut last_state = last_state_marker.borrow_mut();
|
&& let Some(routing) = read_input_routing_state(input_state_path.as_path())
|
||||||
if next_state_marker > *last_state {
|
&& routing != state.borrow().routing
|
||||||
*last_state = next_state_marker;
|
{
|
||||||
if let Some(routing) = read_input_routing_state(input_state_path.as_path())
|
state.borrow_mut().set_routing(routing);
|
||||||
{
|
refresh_launcher_ui(&widgets, &state.borrow(), child_running);
|
||||||
state.borrow_mut().set_routing(routing);
|
if matches!(routing, InputRouting::Remote) {
|
||||||
refresh_launcher_ui(&widgets, &state.borrow(), child_running);
|
present_popout_windows(&popouts);
|
||||||
if matches!(routing, InputRouting::Remote) {
|
} else {
|
||||||
present_popout_windows(&popouts);
|
window.present();
|
||||||
} else {
|
|
||||||
window.present();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -197,7 +197,8 @@ pub fn build_launcher_view(
|
|||||||
|
|
||||||
let staging_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
let staging_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||||
staging_row.set_hexpand(true);
|
staging_row.set_hexpand(true);
|
||||||
staging_row.set_vexpand(false);
|
staging_row.set_vexpand(true);
|
||||||
|
staging_row.set_homogeneous(true);
|
||||||
workspace.append(&staging_row);
|
workspace.append(&staging_row);
|
||||||
|
|
||||||
let device_refresh_button = gtk::Button::with_label("Refresh Devices");
|
let device_refresh_button = gtk::Button::with_label("Refresh Devices");
|
||||||
@ -208,8 +209,9 @@ pub fn build_launcher_view(
|
|||||||
let (devices_panel, devices_body) =
|
let (devices_panel, devices_body) =
|
||||||
build_panel_with_action("Device Staging", Some(device_refresh_button.upcast_ref()));
|
build_panel_with_action("Device Staging", Some(device_refresh_button.upcast_ref()));
|
||||||
devices_panel.set_hexpand(true);
|
devices_panel.set_hexpand(true);
|
||||||
devices_panel.set_vexpand(false);
|
devices_panel.set_vexpand(true);
|
||||||
devices_body.set_spacing(8);
|
devices_body.set_spacing(8);
|
||||||
|
devices_body.set_vexpand(true);
|
||||||
|
|
||||||
let control_group = build_subgroup("Control Inputs");
|
let control_group = build_subgroup("Control Inputs");
|
||||||
let control_stack = gtk::Box::new(gtk::Orientation::Vertical, 10);
|
let control_stack = gtk::Box::new(gtk::Orientation::Vertical, 10);
|
||||||
@ -315,10 +317,11 @@ pub fn build_launcher_view(
|
|||||||
devices_body.append(&media_group);
|
devices_body.append(&media_group);
|
||||||
staging_row.append(&devices_panel);
|
staging_row.append(&devices_panel);
|
||||||
|
|
||||||
let (preview_panel, preview_body) = build_panel("Webcam Test");
|
let (preview_panel, preview_body) = build_panel("Device Testing");
|
||||||
preview_panel.set_hexpand(true);
|
preview_panel.set_hexpand(true);
|
||||||
preview_panel.set_vexpand(false);
|
preview_panel.set_vexpand(true);
|
||||||
preview_body.set_spacing(6);
|
preview_body.set_spacing(8);
|
||||||
|
preview_body.set_vexpand(true);
|
||||||
let camera_preview = gtk::Picture::new();
|
let camera_preview = gtk::Picture::new();
|
||||||
camera_preview.set_can_shrink(false);
|
camera_preview.set_can_shrink(false);
|
||||||
camera_preview.set_hexpand(true);
|
camera_preview.set_hexpand(true);
|
||||||
@ -343,7 +346,11 @@ pub fn build_launcher_view(
|
|||||||
camera_preview_frame.set_size_request(-1, CAMERA_PREVIEW_VIEWPORT_HEIGHT);
|
camera_preview_frame.set_size_request(-1, CAMERA_PREVIEW_VIEWPORT_HEIGHT);
|
||||||
camera_preview_frame.set_child(Some(&camera_preview));
|
camera_preview_frame.set_child(Some(&camera_preview));
|
||||||
camera_preview_shell.append(&camera_preview_frame);
|
camera_preview_shell.append(&camera_preview_frame);
|
||||||
preview_body.append(&camera_preview_shell);
|
let webcam_group = build_subgroup("Webcam Preview");
|
||||||
|
webcam_group.set_vexpand(true);
|
||||||
|
webcam_group.append(&camera_preview_shell);
|
||||||
|
webcam_group.append(&camera_status);
|
||||||
|
preview_body.append(&webcam_group);
|
||||||
|
|
||||||
let playback_group = build_subgroup("Mic Playback");
|
let playback_group = build_subgroup("Mic Playback");
|
||||||
let playback_body = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
let playback_body = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
||||||
@ -359,6 +366,7 @@ pub fn build_launcher_view(
|
|||||||
playback_row.append(&audio_preview_heading);
|
playback_row.append(&audio_preview_heading);
|
||||||
playback_body.append(&playback_row);
|
playback_body.append(&playback_row);
|
||||||
playback_body.append(&audio_check_meter);
|
playback_body.append(&audio_check_meter);
|
||||||
|
playback_body.append(&audio_check_detail);
|
||||||
playback_group.append(&playback_body);
|
playback_group.append(&playback_body);
|
||||||
preview_body.append(&playback_group);
|
preview_body.append(&playback_group);
|
||||||
staging_row.append(&preview_panel);
|
staging_row.append(&preview_panel);
|
||||||
@ -1217,7 +1225,7 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
|
|||||||
controls_grid.set_column_spacing(8);
|
controls_grid.set_column_spacing(8);
|
||||||
controls_grid.set_row_spacing(8);
|
controls_grid.set_row_spacing(8);
|
||||||
controls_grid.set_hexpand(true);
|
controls_grid.set_hexpand(true);
|
||||||
let feed_row = build_inline_combo_row("Feed", &feed_source_combo, 4);
|
let feed_row = build_inline_combo_row("Feed", &feed_source_combo, 7);
|
||||||
let capture_row = build_inline_combo_row("Capture", &capture_resolution_combo, 7);
|
let capture_row = build_inline_combo_row("Capture", &capture_resolution_combo, 7);
|
||||||
let breakout_row = build_inline_combo_row("Display", &breakout_combo, 7);
|
let breakout_row = build_inline_combo_row("Display", &breakout_combo, 7);
|
||||||
feed_row.set_hexpand(true);
|
feed_row.set_hexpand(true);
|
||||||
|
|||||||
@ -7,6 +7,7 @@ use std::{
|
|||||||
process::{Child, Command, Stdio},
|
process::{Child, Command, Stdio},
|
||||||
rc::Rc,
|
rc::Rc,
|
||||||
sync::mpsc::Sender,
|
sync::mpsc::Sender,
|
||||||
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
@ -22,8 +23,10 @@ use super::{
|
|||||||
|
|
||||||
pub const INPUT_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_INPUT_CONTROL";
|
pub const INPUT_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_INPUT_CONTROL";
|
||||||
pub const INPUT_STATE_ENV: &str = "LESAVKA_LAUNCHER_INPUT_STATE";
|
pub const INPUT_STATE_ENV: &str = "LESAVKA_LAUNCHER_INPUT_STATE";
|
||||||
|
pub const TOGGLE_KEY_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_TOGGLE_KEY_CONTROL";
|
||||||
pub const DEFAULT_INPUT_CONTROL_PATH: &str = "/tmp/lesavka-launcher-input.control";
|
pub const DEFAULT_INPUT_CONTROL_PATH: &str = "/tmp/lesavka-launcher-input.control";
|
||||||
pub const DEFAULT_INPUT_STATE_PATH: &str = "/tmp/lesavka-launcher-input.state";
|
pub const DEFAULT_INPUT_STATE_PATH: &str = "/tmp/lesavka-launcher-input.state";
|
||||||
|
pub const DEFAULT_TOGGLE_KEY_CONTROL_PATH: &str = "/tmp/lesavka-launcher-toggle-key.control";
|
||||||
|
|
||||||
pub type RelayChild = Child;
|
pub type RelayChild = Child;
|
||||||
|
|
||||||
@ -745,20 +748,49 @@ pub fn input_state_path() -> PathBuf {
|
|||||||
.unwrap_or_else(|_| PathBuf::from(DEFAULT_INPUT_STATE_PATH))
|
.unwrap_or_else(|_| PathBuf::from(DEFAULT_INPUT_STATE_PATH))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn input_toggle_control_path() -> PathBuf {
|
||||||
|
std::env::var(TOGGLE_KEY_CONTROL_ENV)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| PathBuf::from(DEFAULT_TOGGLE_KEY_CONTROL_PATH))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn write_input_routing_request(path: &Path, routing: InputRouting) -> Result<()> {
|
pub fn write_input_routing_request(path: &Path, routing: InputRouting) -> Result<()> {
|
||||||
std::fs::write(path, format!("{}\n", routing_name(routing)))?;
|
std::fs::write(
|
||||||
|
path,
|
||||||
|
format!("{} {}\n", routing_name(routing), control_request_nonce()),
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_input_toggle_key_request(path: &Path, swap_key: &str) -> Result<()> {
|
||||||
|
std::fs::write(
|
||||||
|
path,
|
||||||
|
format!("{} {}\n", swap_key.trim(), control_request_nonce()),
|
||||||
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_input_routing_state(path: &Path) -> Option<InputRouting> {
|
pub fn read_input_routing_state(path: &Path) -> Option<InputRouting> {
|
||||||
let raw = std::fs::read_to_string(path).ok()?;
|
let raw = std::fs::read_to_string(path).ok()?;
|
||||||
match raw.trim().to_ascii_lowercase().as_str() {
|
match raw
|
||||||
|
.split_ascii_whitespace()
|
||||||
|
.next()?
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
"local" => Some(InputRouting::Local),
|
"local" => Some(InputRouting::Local),
|
||||||
"remote" => Some(InputRouting::Remote),
|
"remote" => Some(InputRouting::Remote),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn control_request_nonce() -> u128 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_nanos())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn routing_name(routing: InputRouting) -> &'static str {
|
pub fn routing_name(routing: InputRouting) -> &'static str {
|
||||||
match routing {
|
match routing {
|
||||||
InputRouting::Local => "local",
|
InputRouting::Local => "local",
|
||||||
@ -866,6 +898,7 @@ pub fn spawn_client_process(
|
|||||||
input_toggle_key: &str,
|
input_toggle_key: &str,
|
||||||
input_control_path: &Path,
|
input_control_path: &Path,
|
||||||
input_state_path: &Path,
|
input_state_path: &Path,
|
||||||
|
input_toggle_control_path: &Path,
|
||||||
) -> Result<RelayChild> {
|
) -> Result<RelayChild> {
|
||||||
let exe = std::env::current_exe()?;
|
let exe = std::env::current_exe()?;
|
||||||
let mut command = Command::new(exe);
|
let mut command = Command::new(exe);
|
||||||
@ -884,6 +917,7 @@ pub fn spawn_client_process(
|
|||||||
);
|
);
|
||||||
command.env(INPUT_CONTROL_ENV, input_control_path);
|
command.env(INPUT_CONTROL_ENV, input_control_path);
|
||||||
command.env(INPUT_STATE_ENV, input_state_path);
|
command.env(INPUT_STATE_ENV, input_state_path);
|
||||||
|
command.env(TOGGLE_KEY_CONTROL_ENV, input_toggle_control_path);
|
||||||
command.env("LESAVKA_DISABLE_VIDEO_RENDER", "1");
|
command.env("LESAVKA_DISABLE_VIDEO_RENDER", "1");
|
||||||
command.env("LESAVKA_CLIPBOARD_PASTE", "1");
|
command.env("LESAVKA_CLIPBOARD_PASTE", "1");
|
||||||
for (key, value) in runtime_env_vars(state) {
|
for (key, value) in runtime_env_vars(state) {
|
||||||
|
|||||||
@ -143,18 +143,8 @@ fn pick_sink_element() -> Result<String> {
|
|||||||
return Ok(sink);
|
return Ok(sink);
|
||||||
}
|
}
|
||||||
let sinks = list_pw_sinks();
|
let sinks = list_pw_sinks();
|
||||||
for (n, st) in &sinks {
|
if let Some((n, st)) = sinks.first() {
|
||||||
if *st == "RUNNING" {
|
info!("🔈 using PipeWire sink '{}' ({st})", n);
|
||||||
info!("🔈 using default RUNNING sink '{}'", n);
|
|
||||||
return Ok(pulsesink_device_element(n));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some((n, _)) = sinks.iter().find(|(_, st)| *st == "RUNNING") {
|
|
||||||
warn!("🏃 picking first RUNNING sink '{}'", n);
|
|
||||||
return Ok(pulsesink_device_element(n));
|
|
||||||
}
|
|
||||||
if let Some((n, _)) = sinks.first() {
|
|
||||||
warn!("🎲 picking first sink '{}'", n);
|
|
||||||
return Ok(pulsesink_device_element(n));
|
return Ok(pulsesink_device_element(n));
|
||||||
}
|
}
|
||||||
warn!("🫣 no PipeWire sinks readable - falling back to autoaudiosink");
|
warn!("🫣 no PipeWire sinks readable - falling back to autoaudiosink");
|
||||||
@ -194,15 +184,81 @@ fn pulsesink_device_element(device: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn list_pw_sinks() -> Vec<(String, String)> {
|
fn list_pw_sinks() -> Vec<(String, String)> {
|
||||||
if let Ok(info) = std::process::Command::new("pactl")
|
let default_sink = std::process::Command::new("pactl")
|
||||||
.args(["info"])
|
.args(["info"])
|
||||||
.output()
|
.output()
|
||||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
|
.ok()
|
||||||
|
.filter(|output| output.status.success())
|
||||||
|
.and_then(|output| parse_pactl_default_sink(&String::from_utf8_lossy(&output.stdout)));
|
||||||
|
|
||||||
|
if let Ok(output) = std::process::Command::new("pactl")
|
||||||
|
.args(["list", "short", "sinks"])
|
||||||
|
.output()
|
||||||
|
&& output.status.success()
|
||||||
{
|
{
|
||||||
if let Some(line) = info.lines().find(|l| l.starts_with("Default Sink:")) {
|
return parse_pactl_short_sinks(
|
||||||
let def = line["Default Sink:".len()..].trim();
|
&String::from_utf8_lossy(&output.stdout),
|
||||||
return vec![(def.to_string(), "UNKNOWN".to_string())];
|
default_sink.as_deref(),
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
default_sink
|
||||||
|
.map(|sink| vec![(sink, "DEFAULT".to_string())])
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_pactl_default_sink(stdout: &str) -> Option<String> {
|
||||||
|
stdout
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.strip_prefix("Default Sink:"))
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|sink| !sink.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_pactl_short_sinks(stdout: &str, default_sink: Option<&str>) -> Vec<(String, String)> {
|
||||||
|
let mut sinks = Vec::new();
|
||||||
|
for line in stdout.lines() {
|
||||||
|
let columns: Vec<_> = line.split_whitespace().collect();
|
||||||
|
if columns.len() < 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = columns[1].to_string();
|
||||||
|
let state = columns
|
||||||
|
.last()
|
||||||
|
.copied()
|
||||||
|
.unwrap_or("UNKNOWN")
|
||||||
|
.to_ascii_uppercase();
|
||||||
|
sinks.push((name, state));
|
||||||
|
}
|
||||||
|
|
||||||
|
sinks.sort_by_key(|(name, state)| {
|
||||||
|
(
|
||||||
|
sink_state_rank(state),
|
||||||
|
if Some(name.as_str()) == default_sink {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
},
|
||||||
|
name.clone(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
sinks.dedup_by(|left, right| left.0 == right.0);
|
||||||
|
|
||||||
|
if let Some(default_sink) = default_sink
|
||||||
|
&& sinks.iter().all(|(name, _)| name != default_sink)
|
||||||
|
{
|
||||||
|
sinks.insert(0, (default_sink.to_string(), "DEFAULT".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
sinks
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sink_state_rank(state: &str) -> u8 {
|
||||||
|
match state {
|
||||||
|
"RUNNING" => 0,
|
||||||
|
"IDLE" => 1,
|
||||||
|
"SUSPENDED" => 2,
|
||||||
|
_ => 3,
|
||||||
}
|
}
|
||||||
Vec::new()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.11.28"
|
version = "0.11.29"
|
||||||
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.28"), "lesavka-common CLI (v0.11.28)");
|
assert_eq!(banner("0.11.29"), "lesavka-common CLI (v0.11.29)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ bench = false
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.11.28"
|
version = "0.11.29"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
autobins = false
|
autobins = false
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ use anyhow::Context;
|
|||||||
use futures_util::{Stream, StreamExt};
|
use futures_util::{Stream, StreamExt};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::path::Path;
|
use std::os::unix::fs::FileTypeExt;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||||
use std::{backtrace::Backtrace, panic, pin::Pin, sync::Arc, time::Duration};
|
use std::{backtrace::Backtrace, panic, pin::Pin, sync::Arc, time::Duration};
|
||||||
@ -118,7 +118,11 @@ impl Handler {
|
|||||||
fn detected_capture_devices_from_symlinks() -> u32 {
|
fn detected_capture_devices_from_symlinks() -> u32 {
|
||||||
["/dev/lesavka_l_eye", "/dev/lesavka_r_eye"]
|
["/dev/lesavka_l_eye", "/dev/lesavka_r_eye"]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|path| Path::new(path).exists())
|
.filter(|path| {
|
||||||
|
std::fs::metadata(path)
|
||||||
|
.ok()
|
||||||
|
.is_some_and(|metadata| metadata.file_type().is_char_device())
|
||||||
|
})
|
||||||
.count() as u32
|
.count() as u32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -388,10 +388,29 @@ fn push_audio_candidate(out: &mut Vec<String>, seen: &mut BTreeSet<String>, cand
|
|||||||
|
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
fn detect_uac_card_candidates() -> Vec<String> {
|
fn detect_uac_card_candidates() -> Vec<String> {
|
||||||
let Ok(cards) = fs::read_to_string("/proc/asound/cards") else {
|
let mut out = Vec::new();
|
||||||
return Vec::new();
|
let mut seen = BTreeSet::new();
|
||||||
};
|
let card_data = fs::read_to_string("/proc/asound/cards").ok();
|
||||||
|
let numeric_card_ids = card_data
|
||||||
|
.as_deref()
|
||||||
|
.map(parse_uac_numeric_card_ids)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if let Some(cards) = card_data.as_deref() {
|
||||||
|
for candidate in parse_uac_named_card_candidates(cards) {
|
||||||
|
push_audio_candidate(&mut out, &mut seen, &candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(pcm) = fs::read_to_string("/proc/asound/pcm") {
|
||||||
|
for candidate in parse_uac_pcm_candidates(&pcm, &numeric_card_ids) {
|
||||||
|
push_audio_candidate(&mut out, &mut seen, &candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn parse_uac_named_card_candidates(cards: &str) -> Vec<String> {
|
||||||
cards
|
cards
|
||||||
.lines()
|
.lines()
|
||||||
.filter_map(|line| {
|
.filter_map(|line| {
|
||||||
@ -411,6 +430,52 @@ fn detect_uac_card_candidates() -> Vec<String> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn parse_uac_numeric_card_ids(cards: &str) -> BTreeSet<String> {
|
||||||
|
cards
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let lower = line.to_ascii_lowercase();
|
||||||
|
if !(lower.contains("uac2")
|
||||||
|
|| lower.contains("gadget")
|
||||||
|
|| lower.contains("composite")
|
||||||
|
|| lower.contains("lesavka"))
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
line.split_whitespace()
|
||||||
|
.next()
|
||||||
|
.filter(|candidate| candidate.chars().all(|ch| ch.is_ascii_digit()))
|
||||||
|
.map(|candidate| candidate.to_string())
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn parse_uac_pcm_candidates(pcm: &str, numeric_card_ids: &BTreeSet<String>) -> Vec<String> {
|
||||||
|
pcm.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let (prefix, _) = line.split_once(':')?;
|
||||||
|
let (card_id, device_id) = prefix.split_once('-')?;
|
||||||
|
let normalized_card = card_id.trim_start_matches('0');
|
||||||
|
let normalized_card = if normalized_card.is_empty() {
|
||||||
|
"0"
|
||||||
|
} else {
|
||||||
|
normalized_card
|
||||||
|
};
|
||||||
|
let normalized_device = device_id.trim_start_matches('0');
|
||||||
|
let normalized_device = if normalized_device.is_empty() {
|
||||||
|
"0"
|
||||||
|
} else {
|
||||||
|
normalized_device
|
||||||
|
};
|
||||||
|
numeric_card_ids
|
||||||
|
.contains(normalized_card)
|
||||||
|
.then(|| format!("hw:{normalized_card},{normalized_device}"))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Allocate a stream identifier for logging and correlation.
|
/// Allocate a stream identifier for logging and correlation.
|
||||||
///
|
///
|
||||||
/// Inputs: none.
|
/// Inputs: none.
|
||||||
@ -477,9 +542,11 @@ pub async fn write_hid_report(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
allow_gadget_cycle, detect_uac_card_candidates, next_stream_id, open_with_retry,
|
allow_gadget_cycle, detect_uac_card_candidates, next_stream_id, open_with_retry,
|
||||||
|
parse_uac_named_card_candidates, parse_uac_numeric_card_ids, parse_uac_pcm_candidates,
|
||||||
preferred_uac_device_candidates, should_recover_hid_error, write_hid_report,
|
preferred_uac_device_candidates, should_recover_hid_error, write_hid_report,
|
||||||
};
|
};
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use temp_env::with_var;
|
use temp_env::with_var;
|
||||||
use tempfile::NamedTempFile;
|
use tempfile::NamedTempFile;
|
||||||
@ -538,6 +605,37 @@ mod tests {
|
|||||||
assert!(live.iter().all(|value| value.starts_with("hw:")));
|
assert!(live.iter().all(|value| value.starts_with("hw:")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_uac_card_helpers_collect_named_and_numeric_candidates() {
|
||||||
|
let cards = "\
|
||||||
|
0 [PCH ]: HDA-Intel - HDA Intel PCH\n\
|
||||||
|
2 [UAC2Gadget ]: USB-Audio - UAC2Gadget\n\
|
||||||
|
Lesavka USB Audio\n";
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
parse_uac_named_card_candidates(cards),
|
||||||
|
vec!["hw:UAC2Gadget,0"]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
parse_uac_numeric_card_ids(cards).contains("2"),
|
||||||
|
"expected numeric card index for the gadget card"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_uac_pcm_candidates_expands_all_matching_device_indexes() {
|
||||||
|
let pcm = "\
|
||||||
|
00-00: PCH device : playback 1 : capture 1\n\
|
||||||
|
02-00: USB Audio : USB Audio : playback 1 : capture 1\n\
|
||||||
|
02-01: USB Audio #1 : USB Audio #1 : playback 1 : capture 1\n";
|
||||||
|
let ids = BTreeSet::from(["2".to_string()]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
parse_uac_pcm_candidates(pcm, &ids),
|
||||||
|
vec!["hw:2,0", "hw:2,1"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn open_with_retry_opens_existing_file() {
|
async fn open_with_retry_opens_existing_file() {
|
||||||
|
|||||||
@ -422,7 +422,7 @@ pub async fn eye_ball_with_request(
|
|||||||
let server_encoder_label = if use_test_src {
|
let server_encoder_label = if use_test_src {
|
||||||
"x264enc(testsrc)".to_string()
|
"x264enc(testsrc)".to_string()
|
||||||
} else {
|
} else {
|
||||||
"source-pass-through".to_string()
|
"source-pass-through(auto-caps)".to_string()
|
||||||
};
|
};
|
||||||
let server_process_cpu_tenths = server_process_cpu_metric();
|
let server_process_cpu_tenths = server_process_cpu_metric();
|
||||||
if !use_test_src {
|
if !use_test_src {
|
||||||
@ -444,12 +444,11 @@ pub async fn eye_ball_with_request(
|
|||||||
} else {
|
} else {
|
||||||
format!(
|
format!(
|
||||||
"v4l2src name=cam_{eye} device=\"{dev}\" io-mode=mmap do-timestamp=true ! \
|
"v4l2src name=cam_{eye} device=\"{dev}\" io-mode=mmap do-timestamp=true ! \
|
||||||
video/x-h264,width={},height={},framerate={}/1 ! \
|
video/x-h264 ! \
|
||||||
queue max-size-buffers={queue_buffers} max-size-time=0 max-size-bytes=0 leaky=downstream ! \
|
queue max-size-buffers={queue_buffers} max-size-time=0 max-size-bytes=0 leaky=downstream ! \
|
||||||
h264parse disable-passthrough=true config-interval=-1 ! \
|
h264parse disable-passthrough=true config-interval=-1 ! \
|
||||||
video/x-h264,stream-format=byte-stream,alignment=au ! \
|
video/x-h264,stream-format=byte-stream,alignment=au ! \
|
||||||
appsink name=sink emit-signals=true max-buffers={appsink_buffers} drop=true",
|
appsink name=sink emit-signals=true max-buffers={appsink_buffers} drop=true",
|
||||||
request.width, request.height, request.fps,
|
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user