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]
|
||||
name = "lesavka_client"
|
||||
version = "0.11.28"
|
||||
version = "0.11.29"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -47,10 +47,16 @@ pub struct InputAggregator {
|
||||
quick_toggle_down: bool,
|
||||
quick_toggle_debounce: Duration,
|
||||
last_quick_toggle_at: Option<Instant>,
|
||||
pending_release_started_at: Option<Instant>,
|
||||
pending_release_timeout: Duration,
|
||||
#[cfg(not(coverage))]
|
||||
routing_control_path: Option<PathBuf>,
|
||||
#[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))]
|
||||
clipboard_control_path: Option<PathBuf>,
|
||||
#[cfg(not(coverage))]
|
||||
@ -85,6 +91,9 @@ impl InputAggregator {
|
||||
#[cfg(not(coverage))]
|
||||
let routing_state_path = launcher_routing_path_from_env("LESAVKA_LAUNCHER_INPUT_STATE");
|
||||
#[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 =
|
||||
launcher_routing_path_from_env("LESAVKA_LAUNCHER_CLIPBOARD_CONTROL");
|
||||
Self {
|
||||
@ -107,14 +116,21 @@ impl InputAggregator {
|
||||
quick_toggle_down: false,
|
||||
quick_toggle_debounce: quick_toggle_debounce_from_env(),
|
||||
last_quick_toggle_at: None,
|
||||
pending_release_started_at: None,
|
||||
pending_release_timeout: pending_release_timeout_from_env(),
|
||||
#[cfg(not(coverage))]
|
||||
routing_control_marker: routing_control_path
|
||||
last_routing_request_raw: routing_control_path
|
||||
.as_deref()
|
||||
.map(path_marker)
|
||||
.unwrap_or_default(),
|
||||
.and_then(read_launcher_control_snapshot),
|
||||
#[cfg(not(coverage))]
|
||||
routing_control_path,
|
||||
#[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
|
||||
.as_deref()
|
||||
.map(path_marker)
|
||||
@ -350,6 +366,7 @@ impl InputAggregator {
|
||||
want_kill |= kbd.magic_kill();
|
||||
}
|
||||
self.poll_launcher_routing_request();
|
||||
self.poll_launcher_quick_toggle_request();
|
||||
self.poll_launcher_clipboard_request();
|
||||
let quick_toggle_now = self.quick_toggle_active();
|
||||
self.observe_quick_toggle(quick_toggle_now);
|
||||
@ -381,6 +398,7 @@ impl InputAggregator {
|
||||
}
|
||||
self.pending_kill = true;
|
||||
self.capture_pending_keys();
|
||||
self.pending_release_started_at = Some(Instant::now());
|
||||
}
|
||||
|
||||
if self.pending_release || self.pending_kill {
|
||||
@ -394,25 +412,17 @@ impl InputAggregator {
|
||||
.iter()
|
||||
.all(|key| !self.keyboards.iter().any(|k| k.has_key(*key)))
|
||||
};
|
||||
if chord_released {
|
||||
for k in &mut self.keyboards {
|
||||
k.set_grab(false);
|
||||
k.reset_state();
|
||||
let timed_out = self.pending_release_timed_out();
|
||||
if chord_released || timed_out {
|
||||
if timed_out {
|
||||
warn!(
|
||||
"⌛ local release timed out waiting for key-up events; forcing the handoff"
|
||||
);
|
||||
}
|
||||
for m in &mut self.mice {
|
||||
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();
|
||||
self.finish_local_release(!self.pending_kill);
|
||||
if self.pending_kill {
|
||||
return Ok(());
|
||||
}
|
||||
self.pending_release = false;
|
||||
self.pending_keys.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@ -457,10 +467,17 @@ impl InputAggregator {
|
||||
}
|
||||
self.released = false;
|
||||
self.pending_release = false;
|
||||
self.pending_release_started_at = None;
|
||||
self.pending_keys.clear();
|
||||
self.last_keyboard_report = [0; 8];
|
||||
}
|
||||
|
||||
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);
|
||||
for k in &mut self.keyboards {
|
||||
k.send_empty_report();
|
||||
@ -471,10 +488,39 @@ impl InputAggregator {
|
||||
m.set_send(false);
|
||||
}
|
||||
self.pending_release = true;
|
||||
self.pending_release_started_at = Some(Instant::now());
|
||||
self.last_keyboard_report = [0; 8];
|
||||
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) {
|
||||
self.pending_keys.clear();
|
||||
for k in &self.keyboards {
|
||||
@ -560,27 +606,60 @@ impl InputAggregator {
|
||||
let Some(path) = self.routing_control_path.as_deref() else {
|
||||
return;
|
||||
};
|
||||
let marker = path_marker(path);
|
||||
if marker <= self.routing_control_marker {
|
||||
return;
|
||||
}
|
||||
self.routing_control_marker = marker;
|
||||
let Some(remote_capture) = read_launcher_routing_request(path) else {
|
||||
let Some(raw) = read_launcher_control_snapshot(path) else {
|
||||
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;
|
||||
}
|
||||
if remote_capture {
|
||||
if !self.released && !self.pending_release {
|
||||
return;
|
||||
}
|
||||
info!("🎛️ launcher requested remote input capture");
|
||||
self.enable_remote_capture();
|
||||
self.publish_routing_state_if_changed();
|
||||
} else {
|
||||
if self.released && !self.pending_release {
|
||||
return;
|
||||
}
|
||||
info!("🎛️ launcher requested local input capture");
|
||||
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))]
|
||||
fn poll_launcher_clipboard_request(&mut self) {
|
||||
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))
|
||||
}
|
||||
|
||||
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))]
|
||||
fn focus_launcher_on_local_if_enabled() {
|
||||
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))]
|
||||
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()?;
|
||||
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),
|
||||
"local" => Some(false),
|
||||
_ => None,
|
||||
|
||||
@ -17,12 +17,13 @@ use {
|
||||
super::ui_runtime::{
|
||||
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,
|
||||
dock_display_to_preview, input_control_path, input_state_path, next_input_routing,
|
||||
open_diagnostics_popout, open_popout_window, open_session_log_popout, path_marker,
|
||||
present_popout_windows, read_input_routing_state, reap_exited_child, refresh_launcher_ui,
|
||||
refresh_test_buttons, routing_name, selected_combo_value, selected_server_addr,
|
||||
shutdown_launcher_runtime, spawn_client_process, stop_child_process, toggle_key_label,
|
||||
update_test_action_result, write_input_routing_request,
|
||||
dock_display_to_preview, input_control_path, input_state_path, input_toggle_control_path,
|
||||
next_input_routing, open_diagnostics_popout, open_popout_window, open_session_log_popout,
|
||||
path_marker, present_popout_windows, read_input_routing_state, reap_exited_child,
|
||||
refresh_launcher_ui, refresh_test_buttons, routing_name, selected_combo_value,
|
||||
selected_server_addr, shutdown_launcher_runtime, spawn_client_process, stop_child_process,
|
||||
toggle_key_label, update_test_action_result, write_input_routing_request,
|
||||
write_input_toggle_key_request,
|
||||
},
|
||||
crate::handshake::{HandshakeProbe, probe},
|
||||
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 input_control_path = Rc::new(input_control_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(clipboard_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_toggle_control_path.as_path());
|
||||
|
||||
{
|
||||
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 input_control_path = Rc::clone(&input_control_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);
|
||||
app.connect_shutdown(move |_| {
|
||||
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(input_control_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 input_control_path = Rc::clone(&input_control_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| {
|
||||
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 input_control_path = Rc::clone(&input_control_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 preview = preview.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_state_path.as_path());
|
||||
let _ = std::fs::remove_file(input_toggle_control_path.as_path());
|
||||
let launch_state = state.borrow().clone();
|
||||
let input_toggle_key = launch_state.swap_key.clone();
|
||||
let input_control_path = input_control_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);
|
||||
widgets_handle.status_label.set_text(&format!(
|
||||
"Connecting relay with {} as the swap key...",
|
||||
@ -1267,6 +1276,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
&input_toggle_key,
|
||||
input_control_path.as_path(),
|
||||
input_state_path.as_path(),
|
||||
input_toggle_control_path.as_path(),
|
||||
)
|
||||
.map_err(|err| err.to_string());
|
||||
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 child_proc = Rc::clone(&child_proc);
|
||||
let widgets = widgets.clone();
|
||||
let input_toggle_control_path = Rc::clone(&input_toggle_control_path);
|
||||
let key_controller = gtk::EventControllerKey::new();
|
||||
key_controller.connect_key_pressed(move |_, key, _, _| {
|
||||
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());
|
||||
}
|
||||
let status_message = if relay_live {
|
||||
format!(
|
||||
"Swap key set to {}. Disconnect and reconnect the relay to use it live.",
|
||||
toggle_key_label(&swap_key)
|
||||
)
|
||||
match write_input_toggle_key_request(
|
||||
input_toggle_control_path.as_path(),
|
||||
&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 {
|
||||
format!(
|
||||
"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 last_focus_marker =
|
||||
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 relay_request_in_flight = Rc::clone(&relay_request_in_flight);
|
||||
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());
|
||||
let mut last_state = last_state_marker.borrow_mut();
|
||||
if next_state_marker > *last_state {
|
||||
*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);
|
||||
if matches!(routing, InputRouting::Remote) {
|
||||
present_popout_windows(&popouts);
|
||||
} else {
|
||||
window.present();
|
||||
}
|
||||
if child_running
|
||||
&& let Some(routing) = read_input_routing_state(input_state_path.as_path())
|
||||
&& routing != state.borrow().routing
|
||||
{
|
||||
state.borrow_mut().set_routing(routing);
|
||||
refresh_launcher_ui(&widgets, &state.borrow(), child_running);
|
||||
if matches!(routing, InputRouting::Remote) {
|
||||
present_popout_windows(&popouts);
|
||||
} else {
|
||||
window.present();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -197,7 +197,8 @@ pub fn build_launcher_view(
|
||||
|
||||
let staging_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
staging_row.set_hexpand(true);
|
||||
staging_row.set_vexpand(false);
|
||||
staging_row.set_vexpand(true);
|
||||
staging_row.set_homogeneous(true);
|
||||
workspace.append(&staging_row);
|
||||
|
||||
let device_refresh_button = gtk::Button::with_label("Refresh Devices");
|
||||
@ -208,8 +209,9 @@ pub fn build_launcher_view(
|
||||
let (devices_panel, devices_body) =
|
||||
build_panel_with_action("Device Staging", Some(device_refresh_button.upcast_ref()));
|
||||
devices_panel.set_hexpand(true);
|
||||
devices_panel.set_vexpand(false);
|
||||
devices_panel.set_vexpand(true);
|
||||
devices_body.set_spacing(8);
|
||||
devices_body.set_vexpand(true);
|
||||
|
||||
let control_group = build_subgroup("Control Inputs");
|
||||
let control_stack = gtk::Box::new(gtk::Orientation::Vertical, 10);
|
||||
@ -315,10 +317,11 @@ pub fn build_launcher_view(
|
||||
devices_body.append(&media_group);
|
||||
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_vexpand(false);
|
||||
preview_body.set_spacing(6);
|
||||
preview_panel.set_vexpand(true);
|
||||
preview_body.set_spacing(8);
|
||||
preview_body.set_vexpand(true);
|
||||
let camera_preview = gtk::Picture::new();
|
||||
camera_preview.set_can_shrink(false);
|
||||
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_child(Some(&camera_preview));
|
||||
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_body = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
||||
@ -359,6 +366,7 @@ pub fn build_launcher_view(
|
||||
playback_row.append(&audio_preview_heading);
|
||||
playback_body.append(&playback_row);
|
||||
playback_body.append(&audio_check_meter);
|
||||
playback_body.append(&audio_check_detail);
|
||||
playback_group.append(&playback_body);
|
||||
preview_body.append(&playback_group);
|
||||
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_row_spacing(8);
|
||||
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 breakout_row = build_inline_combo_row("Display", &breakout_combo, 7);
|
||||
feed_row.set_hexpand(true);
|
||||
|
||||
@ -7,6 +7,7 @@ use std::{
|
||||
process::{Child, Command, Stdio},
|
||||
rc::Rc,
|
||||
sync::mpsc::Sender,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use super::{
|
||||
@ -22,8 +23,10 @@ use super::{
|
||||
|
||||
pub const INPUT_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_INPUT_CONTROL";
|
||||
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_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;
|
||||
|
||||
@ -745,20 +748,49 @@ pub fn input_state_path() -> PathBuf {
|
||||
.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<()> {
|
||||
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(())
|
||||
}
|
||||
|
||||
pub fn read_input_routing_state(path: &Path) -> Option<InputRouting> {
|
||||
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),
|
||||
"remote" => Some(InputRouting::Remote),
|
||||
_ => 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 {
|
||||
match routing {
|
||||
InputRouting::Local => "local",
|
||||
@ -866,6 +898,7 @@ pub fn spawn_client_process(
|
||||
input_toggle_key: &str,
|
||||
input_control_path: &Path,
|
||||
input_state_path: &Path,
|
||||
input_toggle_control_path: &Path,
|
||||
) -> Result<RelayChild> {
|
||||
let exe = std::env::current_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_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_CLIPBOARD_PASTE", "1");
|
||||
for (key, value) in runtime_env_vars(state) {
|
||||
|
||||
@ -143,18 +143,8 @@ fn pick_sink_element() -> Result<String> {
|
||||
return Ok(sink);
|
||||
}
|
||||
let sinks = list_pw_sinks();
|
||||
for (n, st) in &sinks {
|
||||
if *st == "RUNNING" {
|
||||
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);
|
||||
if let Some((n, st)) = sinks.first() {
|
||||
info!("🔈 using PipeWire sink '{}' ({st})", n);
|
||||
return Ok(pulsesink_device_element(n));
|
||||
}
|
||||
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)> {
|
||||
if let Ok(info) = std::process::Command::new("pactl")
|
||||
let default_sink = std::process::Command::new("pactl")
|
||||
.args(["info"])
|
||||
.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:")) {
|
||||
let def = line["Default Sink:".len()..].trim();
|
||||
return vec![(def.to_string(), "UNKNOWN".to_string())];
|
||||
}
|
||||
return parse_pactl_short_sinks(
|
||||
&String::from_utf8_lossy(&output.stdout),
|
||||
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]
|
||||
name = "lesavka_common"
|
||||
version = "0.11.28"
|
||||
version = "0.11.29"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -17,6 +17,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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]
|
||||
name = "lesavka_server"
|
||||
version = "0.11.28"
|
||||
version = "0.11.29"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ use anyhow::Context;
|
||||
use futures_util::{Stream, StreamExt};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::os::unix::fs::FileTypeExt;
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::{backtrace::Backtrace, panic, pin::Pin, sync::Arc, time::Duration};
|
||||
@ -118,7 +118,11 @@ impl Handler {
|
||||
fn detected_capture_devices_from_symlinks() -> u32 {
|
||||
["/dev/lesavka_l_eye", "/dev/lesavka_r_eye"]
|
||||
.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
|
||||
}
|
||||
|
||||
|
||||
@ -388,10 +388,29 @@ fn push_audio_candidate(out: &mut Vec<String>, seen: &mut BTreeSet<String>, cand
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
fn detect_uac_card_candidates() -> Vec<String> {
|
||||
let Ok(cards) = fs::read_to_string("/proc/asound/cards") else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = 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
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
@ -411,6 +430,52 @@ fn detect_uac_card_candidates() -> Vec<String> {
|
||||
.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.
|
||||
///
|
||||
/// Inputs: none.
|
||||
@ -477,9 +542,11 @@ pub async fn write_hid_report(
|
||||
mod tests {
|
||||
use super::{
|
||||
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,
|
||||
};
|
||||
use serial_test::serial;
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
use temp_env::with_var;
|
||||
use tempfile::NamedTempFile;
|
||||
@ -538,6 +605,37 @@ mod tests {
|
||||
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]
|
||||
#[serial]
|
||||
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 {
|
||||
"x264enc(testsrc)".to_string()
|
||||
} else {
|
||||
"source-pass-through".to_string()
|
||||
"source-pass-through(auto-caps)".to_string()
|
||||
};
|
||||
let server_process_cpu_tenths = server_process_cpu_metric();
|
||||
if !use_test_src {
|
||||
@ -444,12 +444,11 @@ pub async fn eye_ball_with_request(
|
||||
} else {
|
||||
format!(
|
||||
"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 ! \
|
||||
h264parse disable-passthrough=true config-interval=-1 ! \
|
||||
video/x-h264,stream-format=byte-stream,alignment=au ! \
|
||||
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