lesavka: steady diagnostics and broaden uplink staging
This commit is contained in:
parent
ba443371e6
commit
74893c97ae
@ -4,7 +4,7 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.11.25"
|
version = "0.11.26"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -21,20 +21,18 @@ impl MicrophoneCapture {
|
|||||||
pub fn new() -> Result<Self> {
|
pub fn new() -> Result<Self> {
|
||||||
gst::init().ok(); // idempotent
|
gst::init().ok(); // idempotent
|
||||||
|
|
||||||
/* pulsesrc (default mic) → AAC/ADTS → appsink -------------------*/
|
/* preferred path: pipewiresrc; fallback: pulsesrc ----------------*/
|
||||||
// Optional override: LESAVKA_MIC_SOURCE=<pulse‑device‑name>
|
let source_desc = match std::env::var("LESAVKA_MIC_SOURCE") {
|
||||||
// If not provided or not found, fall back to first non-monitor source.
|
Ok(s) if !s.is_empty() => match Self::resolve_source_desc(&s) {
|
||||||
let device_arg = match std::env::var("LESAVKA_MIC_SOURCE") {
|
Some(desc) => desc,
|
||||||
Ok(s) if !s.is_empty() => match Self::pulse_source_by_substr(&s) {
|
|
||||||
Some(full) => format!("device={}", escape(full.into())),
|
|
||||||
None => {
|
None => {
|
||||||
warn!("🎤 requested mic '{s}' not found; using default");
|
warn!("🎤 requested mic '{s}' not found; using default");
|
||||||
Self::default_source_arg()
|
Self::default_source_desc()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
_ => Self::default_source_arg(),
|
_ => Self::default_source_desc(),
|
||||||
};
|
};
|
||||||
debug!("🎤 device: {device_arg}");
|
debug!("🎤 source: {source_desc}");
|
||||||
let aac = ["avenc_aac", "fdkaacenc", "faac", "opusenc"]
|
let aac = ["avenc_aac", "fdkaacenc", "faac", "opusenc"]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|e| gst::ElementFactory::find(e).is_some())
|
.find(|e| gst::ElementFactory::find(e).is_some())
|
||||||
@ -47,7 +45,7 @@ impl MicrophoneCapture {
|
|||||||
"aacparse ! capsfilter caps=audio/mpeg,stream-format=adts,rate=48000,channels=2"
|
"aacparse ! capsfilter caps=audio/mpeg,stream-format=adts,rate=48000,channels=2"
|
||||||
};
|
};
|
||||||
let desc = format!(
|
let desc = format!(
|
||||||
"pulsesrc {device_arg} do-timestamp=true ! \
|
"{source_desc} ! \
|
||||||
audio/x-raw,format=S16LE,channels=2,rate=48000 ! \
|
audio/x-raw,format=S16LE,channels=2,rate=48000 ! \
|
||||||
audioconvert ! audioresample ! {aac} bitrate=128000 ! \
|
audioconvert ! audioresample ! {aac} bitrate=128000 ! \
|
||||||
{parser} ! \
|
{parser} ! \
|
||||||
@ -70,7 +68,7 @@ impl MicrophoneCapture {
|
|||||||
if s.current() == gst::State::Playing
|
if s.current() == gst::State::Playing
|
||||||
&& msg.src().map(|s| s.is::<gst::Pipeline>()).unwrap_or(false) =>
|
&& msg.src().map(|s| s.is::<gst::Pipeline>()).unwrap_or(false) =>
|
||||||
{
|
{
|
||||||
info!("🎤 mic pipeline ▶️ (source=pulsesrc)")
|
info!("🎤 mic pipeline ▶️")
|
||||||
}
|
}
|
||||||
Error(e) => error!(
|
Error(e) => error!(
|
||||||
"🎤💥 mic: {} ({})",
|
"🎤💥 mic: {} ({})",
|
||||||
@ -120,6 +118,64 @@ impl MicrophoneCapture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_source_desc(fragment: &str) -> Option<String> {
|
||||||
|
if Self::pipewire_source_available()
|
||||||
|
&& let Some(full) = Self::pipewire_source_by_substr(fragment)
|
||||||
|
{
|
||||||
|
return Some(Self::pipewire_source_desc(Some(&full)));
|
||||||
|
}
|
||||||
|
Self::pulse_source_by_substr(fragment).map(|full| Self::pulse_source_desc(Some(&full)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pipewire_source_available() -> bool {
|
||||||
|
gst::ElementFactory::find("pipewiresrc").is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pipewire_source_desc(source: Option<&str>) -> String {
|
||||||
|
match source {
|
||||||
|
Some(source) if !source.trim().is_empty() => {
|
||||||
|
format!(
|
||||||
|
"pipewiresrc target-object={} do-timestamp=true",
|
||||||
|
escape(source.to_string().into())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => "pipewiresrc do-timestamp=true".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pulse_source_desc(source: Option<&str>) -> String {
|
||||||
|
match source {
|
||||||
|
Some(source) if !source.trim().is_empty() => {
|
||||||
|
format!(
|
||||||
|
"pulsesrc device={} do-timestamp=true",
|
||||||
|
escape(source.to_string().into())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => "pulsesrc do-timestamp=true".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pipewire_source_by_substr(fragment: &str) -> Option<String> {
|
||||||
|
let out = std::process::Command::new("pw-dump").output().ok()?;
|
||||||
|
let list = serde_json::from_slice::<serde_json::Value>(&out.stdout).ok()?;
|
||||||
|
let objects = list.as_array()?;
|
||||||
|
objects.iter().find_map(|object| {
|
||||||
|
let props = object.get("info")?.get("props")?.as_object()?;
|
||||||
|
if props.get("media.class")?.as_str()? != "Audio/Source" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let name = props
|
||||||
|
.get("node.name")
|
||||||
|
.or_else(|| props.get("node.nick"))?
|
||||||
|
.as_str()?;
|
||||||
|
if name.contains(fragment) && !name.ends_with(".monitor") {
|
||||||
|
Some(name.to_owned())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn pulse_source_by_substr(fragment: &str) -> Option<String> {
|
fn pulse_source_by_substr(fragment: &str) -> Option<String> {
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
let out = Command::new("pactl")
|
let out = Command::new("pactl")
|
||||||
@ -139,23 +195,11 @@ impl MicrophoneCapture {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pick the first non-monitor Pulse source if available; otherwise empty.
|
fn default_source_desc() -> String {
|
||||||
fn default_source_arg() -> String {
|
if Self::pipewire_source_available() {
|
||||||
use std::process::Command;
|
return Self::pipewire_source_desc(None);
|
||||||
let out = Command::new("pactl")
|
|
||||||
.args(["list", "short", "sources"])
|
|
||||||
.output();
|
|
||||||
if let Ok(out) = out {
|
|
||||||
let list = String::from_utf8_lossy(&out.stdout);
|
|
||||||
if let Some(name) = list
|
|
||||||
.lines()
|
|
||||||
.filter_map(|ln| ln.split_whitespace().nth(1))
|
|
||||||
.find(|name| !name.ends_with(".monitor"))
|
|
||||||
{
|
|
||||||
return format!("device={}", escape(name.into()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
String::new()
|
Self::pulse_source_desc(None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -621,13 +621,19 @@ fn camera_preview_pipeline_desc(device: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn microphone_monitor_pipeline_desc(source: &str, sink: Option<&str>) -> String {
|
fn microphone_monitor_pipeline_desc(source: &str, sink: Option<&str>) -> String {
|
||||||
let source = gst_quote(source);
|
let source_element = if gst::ElementFactory::find("pipewiresrc").is_some() {
|
||||||
|
let source = gst_quote(source);
|
||||||
|
format!("pipewiresrc target-object=\"{source}\" do-timestamp=true")
|
||||||
|
} else {
|
||||||
|
let source = gst_quote(source);
|
||||||
|
format!("pulsesrc device=\"{source}\" do-timestamp=true")
|
||||||
|
};
|
||||||
let sink_prop = sink
|
let sink_prop = sink
|
||||||
.map(gst_quote)
|
.map(gst_quote)
|
||||||
.map(|value| format!(" device=\"{value}\""))
|
.map(|value| format!(" device=\"{value}\""))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
format!(
|
format!(
|
||||||
"pulsesrc device=\"{source}\" ! \
|
"{source_element} ! \
|
||||||
audioconvert ! audioresample ! \
|
audioconvert ! audioresample ! \
|
||||||
audio/x-raw,format=S16LE,rate={MIC_MONITOR_RATE},channels={MIC_MONITOR_CHANNELS} ! \
|
audio/x-raw,format=S16LE,rate={MIC_MONITOR_RATE},channels={MIC_MONITOR_CHANNELS} ! \
|
||||||
tee name=t \
|
tee name=t \
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
use evdev::{AbsoluteAxisCode, Device, EventType, KeyCode, RelativeAxisCode};
|
use evdev::{AbsoluteAxisCode, Device, EventType, KeyCode, RelativeAxisCode};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
pub struct DeviceCatalog {
|
pub struct DeviceCatalog {
|
||||||
@ -26,8 +27,8 @@ impl DeviceCatalog {
|
|||||||
|
|
||||||
fn discover_with_camera_override(override_dir: Option<String>) -> Self {
|
fn discover_with_camera_override(override_dir: Option<String>) -> Self {
|
||||||
let cameras = discover_camera_devices(override_dir);
|
let cameras = discover_camera_devices(override_dir);
|
||||||
let microphones = discover_pactl_devices("sources");
|
let microphones = discover_microphone_devices();
|
||||||
let speakers = discover_pactl_devices("sinks");
|
let speakers = discover_speaker_devices();
|
||||||
let keyboards = discover_input_devices(InputDeviceKind::Keyboard);
|
let keyboards = discover_input_devices(InputDeviceKind::Keyboard);
|
||||||
let mice = discover_input_devices(InputDeviceKind::Mouse);
|
let mice = discover_input_devices(InputDeviceKind::Mouse);
|
||||||
Self {
|
Self {
|
||||||
@ -40,6 +41,32 @@ impl DeviceCatalog {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn discover_microphone_devices() -> Vec<String> {
|
||||||
|
let mut set = BTreeSet::new();
|
||||||
|
for source in discover_pactl_devices("sources") {
|
||||||
|
if !source.ends_with(".monitor") {
|
||||||
|
set.insert(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for source in discover_pipewire_audio_nodes("Audio/Source") {
|
||||||
|
if !source.ends_with(".monitor") {
|
||||||
|
set.insert(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set.into_iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discover_speaker_devices() -> Vec<String> {
|
||||||
|
let mut set = BTreeSet::new();
|
||||||
|
for sink in discover_pactl_devices("sinks") {
|
||||||
|
set.insert(sink);
|
||||||
|
}
|
||||||
|
for sink in discover_pipewire_audio_nodes("Audio/Sink") {
|
||||||
|
set.insert(sink);
|
||||||
|
}
|
||||||
|
set.into_iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn discover_camera_devices(override_dir: Option<String>) -> Vec<String> {
|
fn discover_camera_devices(override_dir: Option<String>) -> Vec<String> {
|
||||||
let dir = override_dir.unwrap_or_else(|| "/dev/v4l/by-id".to_string());
|
let dir = override_dir.unwrap_or_else(|| "/dev/v4l/by-id".to_string());
|
||||||
let Ok(iter) = std::fs::read_dir(dir) else {
|
let Ok(iter) = std::fs::read_dir(dir) else {
|
||||||
@ -84,6 +111,49 @@ pub fn parse_pactl_short(stdout: &str) -> Vec<String> {
|
|||||||
set.into_iter().collect()
|
set.into_iter().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn discover_pipewire_audio_nodes(media_class: &str) -> Vec<String> {
|
||||||
|
let output = std::process::Command::new("pw-dump").output();
|
||||||
|
let Ok(output) = output else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
if !output.status.success() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
parse_pipewire_audio_nodes(&output.stdout, media_class)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_pipewire_audio_nodes(stdout: &[u8], media_class: &str) -> Vec<String> {
|
||||||
|
let Ok(Value::Array(objects)) = serde_json::from_slice::<Value>(stdout) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut set = BTreeSet::new();
|
||||||
|
for object in objects {
|
||||||
|
let Some(props) = object
|
||||||
|
.get("info")
|
||||||
|
.and_then(|info| info.get("props"))
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if props.get("media.class").and_then(Value::as_str) != Some(media_class) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(name) = props
|
||||||
|
.get("node.name")
|
||||||
|
.or_else(|| props.get("node.nick"))
|
||||||
|
.or_else(|| props.get("device.name"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !name.trim().is_empty() {
|
||||||
|
set.insert(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set.into_iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum InputDeviceKind {
|
enum InputDeviceKind {
|
||||||
Keyboard,
|
Keyboard,
|
||||||
@ -231,4 +301,48 @@ mod tests {
|
|||||||
catalog.speakers.push("sink-1".to_string());
|
catalog.speakers.push("sink-1".to_string());
|
||||||
assert!(!catalog.is_empty());
|
assert!(!catalog.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_pipewire_audio_nodes_collects_named_audio_nodes() {
|
||||||
|
let sample = br#"
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"info": {
|
||||||
|
"props": {
|
||||||
|
"media.class": "Audio/Source",
|
||||||
|
"node.name": "alsa_input.usb-TestMic-00.mono-fallback"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"info": {
|
||||||
|
"props": {
|
||||||
|
"media.class": "Audio/Source",
|
||||||
|
"node.name": "bluez_input.80:C3:BA:76:26:AB"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"info": {
|
||||||
|
"props": {
|
||||||
|
"media.class": "Audio/Sink",
|
||||||
|
"node.name": "alsa_output.pci-0000_00_1f.3.analog-stereo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
"#;
|
||||||
|
assert_eq!(
|
||||||
|
parse_pipewire_audio_nodes(sample, "Audio/Source"),
|
||||||
|
vec![
|
||||||
|
"alsa_input.usb-TestMic-00.mono-fallback".to_string(),
|
||||||
|
"bluez_input.80:C3:BA:76:26:AB".to_string(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_pipewire_audio_nodes_ignores_invalid_payloads() {
|
||||||
|
assert!(parse_pipewire_audio_nodes(b"not json", "Audio/Source").is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,13 +16,13 @@ use {
|
|||||||
super::ui_components::{build_launcher_view, sync_input_device_combo, sync_stage_device_combo},
|
super::ui_components::{build_launcher_view, sync_input_device_combo, sync_stage_device_combo},
|
||||||
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_session_log, dock_all_displays_to_preview, dock_display_to_preview,
|
capture_swap_key, copy_plain_text, copy_session_log, dock_all_displays_to_preview,
|
||||||
input_control_path, input_state_path, next_input_routing, open_diagnostics_popout,
|
dock_display_to_preview, input_control_path, input_state_path, next_input_routing,
|
||||||
open_popout_window, open_session_log_popout, path_marker, present_popout_windows,
|
open_diagnostics_popout, open_popout_window, open_session_log_popout, path_marker,
|
||||||
read_input_routing_state, reap_exited_child, refresh_launcher_ui, refresh_test_buttons,
|
present_popout_windows, read_input_routing_state, reap_exited_child, refresh_launcher_ui,
|
||||||
routing_name, selected_combo_value, selected_server_addr, shutdown_launcher_runtime,
|
refresh_test_buttons, routing_name, selected_combo_value, selected_server_addr,
|
||||||
spawn_client_process, stop_child_process, toggle_key_label, update_test_action_result,
|
shutdown_launcher_runtime, spawn_client_process, stop_child_process, toggle_key_label,
|
||||||
write_input_routing_request,
|
update_test_action_result, write_input_routing_request,
|
||||||
},
|
},
|
||||||
crate::handshake::{HandshakeProbe, probe},
|
crate::handshake::{HandshakeProbe, probe},
|
||||||
crate::output::display::enumerate_monitors,
|
crate::output::display::enumerate_monitors,
|
||||||
@ -1455,7 +1455,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
{
|
{
|
||||||
let widgets = widgets.clone();
|
let widgets = widgets.clone();
|
||||||
widgets.diagnostics_copy_button.connect_clicked(move |_| {
|
widgets.diagnostics_copy_button.connect_clicked(move |_| {
|
||||||
if let Err(err) = copy_session_log(&widgets.diagnostics_buffer) {
|
if let Err(err) = copy_plain_text(&widgets.diagnostics_rendered_text.borrow()) {
|
||||||
widgets
|
widgets
|
||||||
.status_label
|
.status_label
|
||||||
.set_text(&format!("Could not copy the diagnostics report: {err}"));
|
.set_text(&format!("Could not copy the diagnostics report: {err}"));
|
||||||
@ -1475,8 +1475,9 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
open_diagnostics_popout(
|
open_diagnostics_popout(
|
||||||
&app,
|
&app,
|
||||||
&diagnostics_popout,
|
&diagnostics_popout,
|
||||||
|
&widgets.diagnostics_popout_label,
|
||||||
&widgets.diagnostics_popout_scroll,
|
&widgets.diagnostics_popout_scroll,
|
||||||
&widgets.diagnostics_buffer,
|
&widgets.diagnostics_rendered_text,
|
||||||
);
|
);
|
||||||
widgets
|
widgets
|
||||||
.status_label
|
.status_label
|
||||||
|
|||||||
@ -52,8 +52,9 @@ pub struct PopoutWindowHandle {
|
|||||||
pub struct LauncherWidgets {
|
pub struct LauncherWidgets {
|
||||||
pub status_label: gtk::Label,
|
pub status_label: gtk::Label,
|
||||||
pub diagnostics_log: Rc<RefCell<DiagnosticsLog>>,
|
pub diagnostics_log: Rc<RefCell<DiagnosticsLog>>,
|
||||||
pub diagnostics_buffer: gtk::TextBuffer,
|
pub diagnostics_label: gtk::Label,
|
||||||
pub diagnostics_scroll: gtk::ScrolledWindow,
|
pub diagnostics_scroll: gtk::ScrolledWindow,
|
||||||
|
pub diagnostics_popout_label: Rc<RefCell<Option<gtk::Label>>>,
|
||||||
pub diagnostics_popout_scroll: Rc<RefCell<Option<gtk::ScrolledWindow>>>,
|
pub diagnostics_popout_scroll: Rc<RefCell<Option<gtk::ScrolledWindow>>>,
|
||||||
pub diagnostics_rendered_text: Rc<RefCell<String>>,
|
pub diagnostics_rendered_text: Rc<RefCell<String>>,
|
||||||
pub session_log_buffer: gtk::TextBuffer,
|
pub session_log_buffer: gtk::TextBuffer,
|
||||||
@ -199,19 +200,16 @@ pub fn build_launcher_view(
|
|||||||
staging_row.set_vexpand(false);
|
staging_row.set_vexpand(false);
|
||||||
workspace.append(&staging_row);
|
workspace.append(&staging_row);
|
||||||
|
|
||||||
let (devices_panel, devices_body) = build_panel("Device Staging");
|
|
||||||
devices_panel.set_hexpand(true);
|
|
||||||
devices_panel.set_vexpand(false);
|
|
||||||
devices_body.set_spacing(8);
|
|
||||||
let devices_actions = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
|
||||||
devices_actions.set_halign(gtk::Align::End);
|
|
||||||
let device_refresh_button = gtk::Button::with_label("Refresh Devices");
|
let device_refresh_button = gtk::Button::with_label("Refresh Devices");
|
||||||
stabilize_button(&device_refresh_button, 132);
|
stabilize_button(&device_refresh_button, 132);
|
||||||
device_refresh_button.set_tooltip_text(Some(
|
device_refresh_button.set_tooltip_text(Some(
|
||||||
"Re-scan webcams, microphones, speakers, keyboards, and mice without restarting Lesavka.",
|
"Re-scan webcams, microphones, speakers, keyboards, and mice without restarting Lesavka.",
|
||||||
));
|
));
|
||||||
devices_actions.append(&device_refresh_button);
|
let (devices_panel, devices_body) =
|
||||||
devices_body.append(&devices_actions);
|
build_panel_with_action("Device Staging", Some(device_refresh_button.upcast_ref()));
|
||||||
|
devices_panel.set_hexpand(true);
|
||||||
|
devices_panel.set_vexpand(false);
|
||||||
|
devices_body.set_spacing(8);
|
||||||
|
|
||||||
let control_group = build_subgroup("Control Inputs");
|
let control_group = build_subgroup("Control Inputs");
|
||||||
let control_row = gtk::Box::new(gtk::Orientation::Horizontal, 12);
|
let control_row = gtk::Box::new(gtk::Orientation::Horizontal, 12);
|
||||||
@ -469,18 +467,24 @@ pub fn build_launcher_view(
|
|||||||
diagnostics_toolbar.append(&diagnostics_copy_button);
|
diagnostics_toolbar.append(&diagnostics_copy_button);
|
||||||
diagnostics_toolbar.append(&diagnostics_popout_button);
|
diagnostics_toolbar.append(&diagnostics_popout_button);
|
||||||
let diagnostics_log = Rc::new(RefCell::new(DiagnosticsLog::new(16)));
|
let diagnostics_log = Rc::new(RefCell::new(DiagnosticsLog::new(16)));
|
||||||
let diagnostics_buffer = gtk::TextBuffer::new(None);
|
let diagnostics_label = gtk::Label::new(None);
|
||||||
let diagnostics_view = gtk::TextView::with_buffer(&diagnostics_buffer);
|
diagnostics_label.add_css_class("status-log");
|
||||||
diagnostics_view.add_css_class("status-log");
|
diagnostics_label.set_selectable(true);
|
||||||
diagnostics_view.set_editable(false);
|
diagnostics_label.set_xalign(0.0);
|
||||||
diagnostics_view.set_cursor_visible(false);
|
diagnostics_label.set_yalign(0.0);
|
||||||
diagnostics_view.set_monospace(true);
|
diagnostics_label.set_wrap(false);
|
||||||
diagnostics_view.set_wrap_mode(gtk::WrapMode::None);
|
diagnostics_label.set_halign(gtk::Align::Start);
|
||||||
|
diagnostics_label.set_valign(gtk::Align::Start);
|
||||||
|
diagnostics_label.set_hexpand(true);
|
||||||
|
let diagnostics_shell = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||||
|
diagnostics_shell.set_hexpand(true);
|
||||||
|
diagnostics_shell.set_vexpand(false);
|
||||||
|
diagnostics_shell.append(&diagnostics_label);
|
||||||
let diagnostics_scroll = gtk::ScrolledWindow::builder()
|
let diagnostics_scroll = gtk::ScrolledWindow::builder()
|
||||||
.hexpand(true)
|
.hexpand(true)
|
||||||
.vexpand(false)
|
.vexpand(false)
|
||||||
.min_content_height(190)
|
.min_content_height(190)
|
||||||
.child(&diagnostics_view)
|
.child(&diagnostics_shell)
|
||||||
.build();
|
.build();
|
||||||
diagnostics_body.append(&diagnostics_toolbar);
|
diagnostics_body.append(&diagnostics_toolbar);
|
||||||
diagnostics_body.append(&diagnostics_scroll);
|
diagnostics_body.append(&diagnostics_scroll);
|
||||||
@ -632,13 +636,15 @@ pub fn build_launcher_view(
|
|||||||
state.breakout_size_options(1),
|
state.breakout_size_options(1),
|
||||||
state.breakout_size_preset(1),
|
state.breakout_size_preset(1),
|
||||||
);
|
);
|
||||||
|
let diagnostics_popout_label = Rc::new(RefCell::new(None));
|
||||||
let diagnostics_popout_scroll = Rc::new(RefCell::new(None));
|
let diagnostics_popout_scroll = Rc::new(RefCell::new(None));
|
||||||
|
|
||||||
let widgets = LauncherWidgets {
|
let widgets = LauncherWidgets {
|
||||||
status_label: status_label.clone(),
|
status_label: status_label.clone(),
|
||||||
diagnostics_log: diagnostics_log.clone(),
|
diagnostics_log: diagnostics_log.clone(),
|
||||||
diagnostics_buffer: diagnostics_buffer.clone(),
|
diagnostics_label: diagnostics_label.clone(),
|
||||||
diagnostics_scroll: diagnostics_scroll.clone(),
|
diagnostics_scroll: diagnostics_scroll.clone(),
|
||||||
|
diagnostics_popout_label: diagnostics_popout_label.clone(),
|
||||||
diagnostics_popout_scroll: diagnostics_popout_scroll.clone(),
|
diagnostics_popout_scroll: diagnostics_popout_scroll.clone(),
|
||||||
diagnostics_rendered_text: Rc::new(RefCell::new(String::new())),
|
diagnostics_rendered_text: Rc::new(RefCell::new(String::new())),
|
||||||
session_log_buffer: session_log_buffer.clone(),
|
session_log_buffer: session_log_buffer.clone(),
|
||||||
@ -792,7 +798,8 @@ pub fn install_css(window: >k::ApplicationWindow) {
|
|||||||
label.status-line {
|
label.status-line {
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
textview.status-log {
|
textview.status-log,
|
||||||
|
label.status-log {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
background: rgba(0, 0, 0, 0.22);
|
background: rgba(0, 0, 0, 0.22);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
@ -841,13 +848,25 @@ pub fn install_window_icon(window: &impl IsA<gtk::Window>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_panel(title: &str) -> (gtk::Box, gtk::Box) {
|
fn build_panel(title: &str) -> (gtk::Box, gtk::Box) {
|
||||||
|
build_panel_with_action(title, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_panel_with_action(title: &str, action: Option<>k::Widget>) -> (gtk::Box, gtk::Box) {
|
||||||
let panel = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
let panel = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
||||||
panel.add_css_class("panel");
|
panel.add_css_class("panel");
|
||||||
|
|
||||||
|
let header = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||||
|
header.set_hexpand(true);
|
||||||
|
header.set_halign(gtk::Align::Fill);
|
||||||
let heading = gtk::Label::new(Some(title));
|
let heading = gtk::Label::new(Some(title));
|
||||||
heading.add_css_class("panel-title");
|
heading.add_css_class("panel-title");
|
||||||
heading.set_halign(gtk::Align::Start);
|
heading.set_halign(gtk::Align::Start);
|
||||||
panel.append(&heading);
|
heading.set_hexpand(true);
|
||||||
|
header.append(&heading);
|
||||||
|
if let Some(action) = action {
|
||||||
|
header.append(action);
|
||||||
|
}
|
||||||
|
panel.append(&header);
|
||||||
|
|
||||||
let body = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
let body = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
||||||
panel.append(&body);
|
panel.append(&body);
|
||||||
|
|||||||
@ -937,9 +937,13 @@ pub fn copy_session_log(buffer: >k::TextBuffer) -> Result<()> {
|
|||||||
let text = buffer
|
let text = buffer
|
||||||
.text(&buffer.start_iter(), &buffer.end_iter(), false)
|
.text(&buffer.start_iter(), &buffer.end_iter(), false)
|
||||||
.to_string();
|
.to_string();
|
||||||
|
copy_plain_text(&text)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn copy_plain_text(text: &str) -> Result<()> {
|
||||||
let display = gtk::gdk::Display::default()
|
let display = gtk::gdk::Display::default()
|
||||||
.ok_or_else(|| anyhow::anyhow!("no desktop clipboard is available in this session"))?;
|
.ok_or_else(|| anyhow::anyhow!("no desktop clipboard is available in this session"))?;
|
||||||
display.clipboard().set_text(&text);
|
display.clipboard().set_text(text);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -993,14 +997,30 @@ pub fn refresh_diagnostics_report(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
*widgets.diagnostics_rendered_text.borrow_mut() = rendered.clone();
|
*widgets.diagnostics_rendered_text.borrow_mut() = rendered.clone();
|
||||||
widgets.diagnostics_buffer.set_text(&rendered);
|
let update_docked = was_at_bottom || widgets.diagnostics_label.text().is_empty();
|
||||||
restore_adjustment(&diagnostics_adjustment, previous_value, was_at_bottom);
|
if update_docked {
|
||||||
if let Some((adjustment, previous_value, was_at_bottom)) = popout_state.as_ref() {
|
widgets.diagnostics_label.set_text(&rendered);
|
||||||
|
restore_adjustment(&diagnostics_adjustment, previous_value, was_at_bottom);
|
||||||
|
}
|
||||||
|
let update_popout = popout_state
|
||||||
|
.as_ref()
|
||||||
|
.map(|(_, _, was_at_bottom)| *was_at_bottom)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if let Some(label) = widgets.diagnostics_popout_label.borrow().as_ref()
|
||||||
|
&& (update_popout || label.text().is_empty())
|
||||||
|
{
|
||||||
|
label.set_text(&rendered);
|
||||||
|
}
|
||||||
|
if update_popout
|
||||||
|
&& let Some((adjustment, previous_value, was_at_bottom)) = popout_state.as_ref()
|
||||||
|
{
|
||||||
restore_adjustment(adjustment, *previous_value, *was_at_bottom);
|
restore_adjustment(adjustment, *previous_value, *was_at_bottom);
|
||||||
}
|
}
|
||||||
glib::idle_add_local_once(move || {
|
glib::idle_add_local_once(move || {
|
||||||
restore_adjustment(&diagnostics_adjustment, previous_value, was_at_bottom);
|
if update_docked {
|
||||||
if let Some((adjustment, previous_value, was_at_bottom)) = popout_state {
|
restore_adjustment(&diagnostics_adjustment, previous_value, was_at_bottom);
|
||||||
|
}
|
||||||
|
if update_popout && let Some((adjustment, previous_value, was_at_bottom)) = popout_state {
|
||||||
restore_adjustment(&adjustment, previous_value, was_at_bottom);
|
restore_adjustment(&adjustment, previous_value, was_at_bottom);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -1025,18 +1045,82 @@ pub fn open_session_log_popout(
|
|||||||
pub fn open_diagnostics_popout(
|
pub fn open_diagnostics_popout(
|
||||||
app: >k::Application,
|
app: >k::Application,
|
||||||
handle: &Rc<RefCell<Option<gtk::ApplicationWindow>>>,
|
handle: &Rc<RefCell<Option<gtk::ApplicationWindow>>>,
|
||||||
|
label_handle: &Rc<RefCell<Option<gtk::Label>>>,
|
||||||
scroll_handle: &Rc<RefCell<Option<gtk::ScrolledWindow>>>,
|
scroll_handle: &Rc<RefCell<Option<gtk::ScrolledWindow>>>,
|
||||||
buffer: >k::TextBuffer,
|
rendered_text: &Rc<RefCell<String>>,
|
||||||
) {
|
) {
|
||||||
open_text_buffer_popout(
|
if let Some(window) = handle.borrow().as_ref() {
|
||||||
app,
|
window.present();
|
||||||
handle,
|
return;
|
||||||
Some(scroll_handle),
|
}
|
||||||
buffer,
|
|
||||||
"Lesavka Diagnostics",
|
let window = gtk::ApplicationWindow::builder()
|
||||||
"Copy Report",
|
.application(app)
|
||||||
gtk::WrapMode::None,
|
.title("Lesavka Diagnostics")
|
||||||
);
|
.default_width(980)
|
||||||
|
.default_height(680)
|
||||||
|
.build();
|
||||||
|
super::ui_components::install_css(&window);
|
||||||
|
super::ui_components::install_window_icon(&window);
|
||||||
|
|
||||||
|
let root = gtk::Box::new(gtk::Orientation::Vertical, 10);
|
||||||
|
root.set_margin_start(14);
|
||||||
|
root.set_margin_end(14);
|
||||||
|
root.set_margin_top(14);
|
||||||
|
root.set_margin_bottom(14);
|
||||||
|
|
||||||
|
let toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||||
|
let copy_button = gtk::Button::with_label("Copy Report");
|
||||||
|
toolbar.append(©_button);
|
||||||
|
root.append(&toolbar);
|
||||||
|
|
||||||
|
let current_text = rendered_text.borrow().clone();
|
||||||
|
let label = gtk::Label::new(Some(¤t_text));
|
||||||
|
label.add_css_class("status-log");
|
||||||
|
label.set_selectable(true);
|
||||||
|
label.set_xalign(0.0);
|
||||||
|
label.set_yalign(0.0);
|
||||||
|
label.set_wrap(false);
|
||||||
|
label.set_halign(gtk::Align::Start);
|
||||||
|
label.set_valign(gtk::Align::Start);
|
||||||
|
label.set_hexpand(true);
|
||||||
|
let shell = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||||
|
shell.set_hexpand(true);
|
||||||
|
shell.set_vexpand(false);
|
||||||
|
shell.append(&label);
|
||||||
|
let scroll = gtk::ScrolledWindow::builder()
|
||||||
|
.hexpand(true)
|
||||||
|
.vexpand(true)
|
||||||
|
.child(&shell)
|
||||||
|
.build();
|
||||||
|
*label_handle.borrow_mut() = Some(label.clone());
|
||||||
|
*scroll_handle.borrow_mut() = Some(scroll.clone());
|
||||||
|
root.append(&scroll);
|
||||||
|
window.set_child(Some(&root));
|
||||||
|
window.maximize();
|
||||||
|
|
||||||
|
{
|
||||||
|
let rendered_text = Rc::clone(rendered_text);
|
||||||
|
copy_button.connect_clicked(move |_| {
|
||||||
|
let current_text = rendered_text.borrow().clone();
|
||||||
|
let _ = copy_plain_text(¤t_text);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let handle = Rc::clone(handle);
|
||||||
|
let label_handle = Rc::clone(label_handle);
|
||||||
|
let scroll_handle = Rc::clone(scroll_handle);
|
||||||
|
window.connect_close_request(move |_| {
|
||||||
|
handle.borrow_mut().take();
|
||||||
|
label_handle.borrow_mut().take();
|
||||||
|
scroll_handle.borrow_mut().take();
|
||||||
|
glib::Propagation::Proceed
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
*handle.borrow_mut() = Some(window.clone());
|
||||||
|
window.present();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_text_buffer_popout(
|
fn open_text_buffer_popout(
|
||||||
@ -1165,6 +1249,7 @@ pub fn shutdown_launcher_runtime(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(window) = diagnostics_popout.borrow_mut().take() {
|
if let Some(window) = diagnostics_popout.borrow_mut().take() {
|
||||||
|
widgets.diagnostics_popout_label.borrow_mut().take();
|
||||||
widgets.diagnostics_popout_scroll.borrow_mut().take();
|
widgets.diagnostics_popout_scroll.borrow_mut().take();
|
||||||
window.set_child(Option::<>k::Widget>::None);
|
window.set_child(Option::<>k::Widget>::None);
|
||||||
window.hide();
|
window.hide();
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.11.25"
|
version = "0.11.26"
|
||||||
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.25"), "lesavka-common CLI (v0.11.25)");
|
assert_eq!(banner("0.11.26"), "lesavka-common CLI (v0.11.26)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ bench = false
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.11.25"
|
version = "0.11.26"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
autobins = false
|
autobins = false
|
||||||
|
|
||||||
|
|||||||
@ -323,6 +323,9 @@ impl Voice {
|
|||||||
.context("make alsasink")?;
|
.context("make alsasink")?;
|
||||||
|
|
||||||
alsa_sink.set_property("device", &alsa_dev);
|
alsa_sink.set_property("device", &alsa_dev);
|
||||||
|
alsa_sink.set_property("sync", false);
|
||||||
|
alsa_sink.set_property("async", false);
|
||||||
|
alsa_sink.set_property("enable-last-sample", false);
|
||||||
|
|
||||||
pipeline.add_many(&[
|
pipeline.add_many(&[
|
||||||
appsrc.upcast_ref(),
|
appsrc.upcast_ref(),
|
||||||
|
|||||||
@ -345,18 +345,36 @@ fn preferred_uac_device_candidates(preferred: &str) -> Vec<String> {
|
|||||||
"hw:Lesavka,0",
|
"hw:Lesavka,0",
|
||||||
];
|
];
|
||||||
let allow_aliases = auto_family.contains(&preferred);
|
let allow_aliases = auto_family.contains(&preferred);
|
||||||
push_audio_candidate(&mut out, &mut seen, preferred);
|
push_audio_candidate_family(&mut out, &mut seen, preferred);
|
||||||
if allow_aliases {
|
if allow_aliases {
|
||||||
for alias in auto_family {
|
for alias in auto_family {
|
||||||
push_audio_candidate(&mut out, &mut seen, alias);
|
push_audio_candidate_family(&mut out, &mut seen, alias);
|
||||||
}
|
}
|
||||||
for detected in detect_uac_card_candidates() {
|
for detected in detect_uac_card_candidates() {
|
||||||
push_audio_candidate(&mut out, &mut seen, &detected);
|
push_audio_candidate_family(&mut out, &mut seen, &detected);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn push_audio_candidate_family(
|
||||||
|
out: &mut Vec<String>,
|
||||||
|
seen: &mut BTreeSet<String>,
|
||||||
|
candidate: &str,
|
||||||
|
) {
|
||||||
|
let trimmed = candidate.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
push_audio_candidate(out, seen, trimmed);
|
||||||
|
if let Some(rest) = trimmed.strip_prefix("hw:") {
|
||||||
|
push_audio_candidate(out, seen, &format!("plughw:{rest}"));
|
||||||
|
} else if let Some(rest) = trimmed.strip_prefix("plughw:") {
|
||||||
|
push_audio_candidate(out, seen, &format!("hw:{rest}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
fn push_audio_candidate(out: &mut Vec<String>, seen: &mut BTreeSet<String>, candidate: &str) {
|
fn push_audio_candidate(out: &mut Vec<String>, seen: &mut BTreeSet<String>, candidate: &str) {
|
||||||
let trimmed = candidate.trim();
|
let trimmed = candidate.trim();
|
||||||
@ -498,13 +516,18 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn preferred_uac_device_candidates_keeps_custom_override_only() {
|
fn preferred_uac_device_candidates_keeps_custom_override_only() {
|
||||||
let candidates = preferred_uac_device_candidates("hw:7,0");
|
let candidates = preferred_uac_device_candidates("hw:7,0");
|
||||||
assert_eq!(candidates, vec!["hw:7,0"]);
|
assert_eq!(candidates, vec!["hw:7,0", "plughw:7,0"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn preferred_uac_device_candidates_expands_known_aliases() {
|
fn preferred_uac_device_candidates_expands_known_aliases() {
|
||||||
let candidates = preferred_uac_device_candidates("hw:UAC2Gadget,0");
|
let candidates = preferred_uac_device_candidates("hw:UAC2Gadget,0");
|
||||||
assert!(candidates.iter().any(|value| value == "hw:UAC2Gadget,0"));
|
assert!(candidates.iter().any(|value| value == "hw:UAC2Gadget,0"));
|
||||||
|
assert!(
|
||||||
|
candidates
|
||||||
|
.iter()
|
||||||
|
.any(|value| value == "plughw:UAC2Gadget,0")
|
||||||
|
);
|
||||||
assert!(candidates.iter().any(|value| value == "hw:UAC2_Gadget,0"));
|
assert!(candidates.iter().any(|value| value == "hw:UAC2_Gadget,0"));
|
||||||
assert!(candidates.iter().any(|value| value == "hw:Composite,0"));
|
assert!(candidates.iter().any(|value| value == "hw:Composite,0"));
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user