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]
|
||||
name = "lesavka_client"
|
||||
version = "0.11.25"
|
||||
version = "0.11.26"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -21,20 +21,18 @@ impl MicrophoneCapture {
|
||||
pub fn new() -> Result<Self> {
|
||||
gst::init().ok(); // idempotent
|
||||
|
||||
/* pulsesrc (default mic) → AAC/ADTS → appsink -------------------*/
|
||||
// Optional override: LESAVKA_MIC_SOURCE=<pulse‑device‑name>
|
||||
// If not provided or not found, fall back to first non-monitor source.
|
||||
let device_arg = match std::env::var("LESAVKA_MIC_SOURCE") {
|
||||
Ok(s) if !s.is_empty() => match Self::pulse_source_by_substr(&s) {
|
||||
Some(full) => format!("device={}", escape(full.into())),
|
||||
/* preferred path: pipewiresrc; fallback: pulsesrc ----------------*/
|
||||
let source_desc = match std::env::var("LESAVKA_MIC_SOURCE") {
|
||||
Ok(s) if !s.is_empty() => match Self::resolve_source_desc(&s) {
|
||||
Some(desc) => desc,
|
||||
None => {
|
||||
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"]
|
||||
.into_iter()
|
||||
.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"
|
||||
};
|
||||
let desc = format!(
|
||||
"pulsesrc {device_arg} do-timestamp=true ! \
|
||||
"{source_desc} ! \
|
||||
audio/x-raw,format=S16LE,channels=2,rate=48000 ! \
|
||||
audioconvert ! audioresample ! {aac} bitrate=128000 ! \
|
||||
{parser} ! \
|
||||
@ -70,7 +68,7 @@ impl MicrophoneCapture {
|
||||
if s.current() == gst::State::Playing
|
||||
&& msg.src().map(|s| s.is::<gst::Pipeline>()).unwrap_or(false) =>
|
||||
{
|
||||
info!("🎤 mic pipeline ▶️ (source=pulsesrc)")
|
||||
info!("🎤 mic pipeline ▶️")
|
||||
}
|
||||
Error(e) => error!(
|
||||
"🎤💥 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> {
|
||||
use std::process::Command;
|
||||
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_arg() -> String {
|
||||
use std::process::Command;
|
||||
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()));
|
||||
}
|
||||
fn default_source_desc() -> String {
|
||||
if Self::pipewire_source_available() {
|
||||
return Self::pipewire_source_desc(None);
|
||||
}
|
||||
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 {
|
||||
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
|
||||
.map(gst_quote)
|
||||
.map(|value| format!(" device=\"{value}\""))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"pulsesrc device=\"{source}\" ! \
|
||||
"{source_element} ! \
|
||||
audioconvert ! audioresample ! \
|
||||
audio/x-raw,format=S16LE,rate={MIC_MONITOR_RATE},channels={MIC_MONITOR_CHANNELS} ! \
|
||||
tee name=t \
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use evdev::{AbsoluteAxisCode, Device, EventType, KeyCode, RelativeAxisCode};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct DeviceCatalog {
|
||||
@ -26,8 +27,8 @@ impl DeviceCatalog {
|
||||
|
||||
fn discover_with_camera_override(override_dir: Option<String>) -> Self {
|
||||
let cameras = discover_camera_devices(override_dir);
|
||||
let microphones = discover_pactl_devices("sources");
|
||||
let speakers = discover_pactl_devices("sinks");
|
||||
let microphones = discover_microphone_devices();
|
||||
let speakers = discover_speaker_devices();
|
||||
let keyboards = discover_input_devices(InputDeviceKind::Keyboard);
|
||||
let mice = discover_input_devices(InputDeviceKind::Mouse);
|
||||
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> {
|
||||
let dir = override_dir.unwrap_or_else(|| "/dev/v4l/by-id".to_string());
|
||||
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()
|
||||
}
|
||||
|
||||
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)]
|
||||
enum InputDeviceKind {
|
||||
Keyboard,
|
||||
@ -231,4 +301,48 @@ mod tests {
|
||||
catalog.speakers.push("sink-1".to_string());
|
||||
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_runtime::{
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
},
|
||||
crate::handshake::{HandshakeProbe, probe},
|
||||
crate::output::display::enumerate_monitors,
|
||||
@ -1455,7 +1455,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
{
|
||||
let widgets = widgets.clone();
|
||||
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
|
||||
.status_label
|
||||
.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(
|
||||
&app,
|
||||
&diagnostics_popout,
|
||||
&widgets.diagnostics_popout_label,
|
||||
&widgets.diagnostics_popout_scroll,
|
||||
&widgets.diagnostics_buffer,
|
||||
&widgets.diagnostics_rendered_text,
|
||||
);
|
||||
widgets
|
||||
.status_label
|
||||
|
||||
@ -52,8 +52,9 @@ pub struct PopoutWindowHandle {
|
||||
pub struct LauncherWidgets {
|
||||
pub status_label: gtk::Label,
|
||||
pub diagnostics_log: Rc<RefCell<DiagnosticsLog>>,
|
||||
pub diagnostics_buffer: gtk::TextBuffer,
|
||||
pub diagnostics_label: gtk::Label,
|
||||
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_rendered_text: Rc<RefCell<String>>,
|
||||
pub session_log_buffer: gtk::TextBuffer,
|
||||
@ -199,19 +200,16 @@ pub fn build_launcher_view(
|
||||
staging_row.set_vexpand(false);
|
||||
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");
|
||||
stabilize_button(&device_refresh_button, 132);
|
||||
device_refresh_button.set_tooltip_text(Some(
|
||||
"Re-scan webcams, microphones, speakers, keyboards, and mice without restarting Lesavka.",
|
||||
));
|
||||
devices_actions.append(&device_refresh_button);
|
||||
devices_body.append(&devices_actions);
|
||||
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_body.set_spacing(8);
|
||||
|
||||
let control_group = build_subgroup("Control Inputs");
|
||||
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_popout_button);
|
||||
let diagnostics_log = Rc::new(RefCell::new(DiagnosticsLog::new(16)));
|
||||
let diagnostics_buffer = gtk::TextBuffer::new(None);
|
||||
let diagnostics_view = gtk::TextView::with_buffer(&diagnostics_buffer);
|
||||
diagnostics_view.add_css_class("status-log");
|
||||
diagnostics_view.set_editable(false);
|
||||
diagnostics_view.set_cursor_visible(false);
|
||||
diagnostics_view.set_monospace(true);
|
||||
diagnostics_view.set_wrap_mode(gtk::WrapMode::None);
|
||||
let diagnostics_label = gtk::Label::new(None);
|
||||
diagnostics_label.add_css_class("status-log");
|
||||
diagnostics_label.set_selectable(true);
|
||||
diagnostics_label.set_xalign(0.0);
|
||||
diagnostics_label.set_yalign(0.0);
|
||||
diagnostics_label.set_wrap(false);
|
||||
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()
|
||||
.hexpand(true)
|
||||
.vexpand(false)
|
||||
.min_content_height(190)
|
||||
.child(&diagnostics_view)
|
||||
.child(&diagnostics_shell)
|
||||
.build();
|
||||
diagnostics_body.append(&diagnostics_toolbar);
|
||||
diagnostics_body.append(&diagnostics_scroll);
|
||||
@ -632,13 +636,15 @@ pub fn build_launcher_view(
|
||||
state.breakout_size_options(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 widgets = LauncherWidgets {
|
||||
status_label: status_label.clone(),
|
||||
diagnostics_log: diagnostics_log.clone(),
|
||||
diagnostics_buffer: diagnostics_buffer.clone(),
|
||||
diagnostics_label: diagnostics_label.clone(),
|
||||
diagnostics_scroll: diagnostics_scroll.clone(),
|
||||
diagnostics_popout_label: diagnostics_popout_label.clone(),
|
||||
diagnostics_popout_scroll: diagnostics_popout_scroll.clone(),
|
||||
diagnostics_rendered_text: Rc::new(RefCell::new(String::new())),
|
||||
session_log_buffer: session_log_buffer.clone(),
|
||||
@ -792,7 +798,8 @@ pub fn install_css(window: >k::ApplicationWindow) {
|
||||
label.status-line {
|
||||
opacity: 0.9;
|
||||
}
|
||||
textview.status-log {
|
||||
textview.status-log,
|
||||
label.status-log {
|
||||
font-family: monospace;
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
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) {
|
||||
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);
|
||||
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));
|
||||
heading.add_css_class("panel-title");
|
||||
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);
|
||||
panel.append(&body);
|
||||
|
||||
@ -937,9 +937,13 @@ pub fn copy_session_log(buffer: >k::TextBuffer) -> Result<()> {
|
||||
let text = buffer
|
||||
.text(&buffer.start_iter(), &buffer.end_iter(), false)
|
||||
.to_string();
|
||||
copy_plain_text(&text)
|
||||
}
|
||||
|
||||
pub fn copy_plain_text(text: &str) -> Result<()> {
|
||||
let display = gtk::gdk::Display::default()
|
||||
.ok_or_else(|| anyhow::anyhow!("no desktop clipboard is available in this session"))?;
|
||||
display.clipboard().set_text(&text);
|
||||
display.clipboard().set_text(text);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -993,14 +997,30 @@ pub fn refresh_diagnostics_report(
|
||||
}
|
||||
};
|
||||
*widgets.diagnostics_rendered_text.borrow_mut() = rendered.clone();
|
||||
widgets.diagnostics_buffer.set_text(&rendered);
|
||||
restore_adjustment(&diagnostics_adjustment, previous_value, was_at_bottom);
|
||||
if let Some((adjustment, previous_value, was_at_bottom)) = popout_state.as_ref() {
|
||||
let update_docked = was_at_bottom || widgets.diagnostics_label.text().is_empty();
|
||||
if update_docked {
|
||||
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);
|
||||
}
|
||||
glib::idle_add_local_once(move || {
|
||||
restore_adjustment(&diagnostics_adjustment, previous_value, was_at_bottom);
|
||||
if let Some((adjustment, previous_value, was_at_bottom)) = popout_state {
|
||||
if update_docked {
|
||||
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);
|
||||
}
|
||||
});
|
||||
@ -1025,18 +1045,82 @@ pub fn open_session_log_popout(
|
||||
pub fn open_diagnostics_popout(
|
||||
app: >k::Application,
|
||||
handle: &Rc<RefCell<Option<gtk::ApplicationWindow>>>,
|
||||
label_handle: &Rc<RefCell<Option<gtk::Label>>>,
|
||||
scroll_handle: &Rc<RefCell<Option<gtk::ScrolledWindow>>>,
|
||||
buffer: >k::TextBuffer,
|
||||
rendered_text: &Rc<RefCell<String>>,
|
||||
) {
|
||||
open_text_buffer_popout(
|
||||
app,
|
||||
handle,
|
||||
Some(scroll_handle),
|
||||
buffer,
|
||||
"Lesavka Diagnostics",
|
||||
"Copy Report",
|
||||
gtk::WrapMode::None,
|
||||
);
|
||||
if let Some(window) = handle.borrow().as_ref() {
|
||||
window.present();
|
||||
return;
|
||||
}
|
||||
|
||||
let window = gtk::ApplicationWindow::builder()
|
||||
.application(app)
|
||||
.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(
|
||||
@ -1165,6 +1249,7 @@ pub fn shutdown_launcher_runtime(
|
||||
}
|
||||
|
||||
if let Some(window) = diagnostics_popout.borrow_mut().take() {
|
||||
widgets.diagnostics_popout_label.borrow_mut().take();
|
||||
widgets.diagnostics_popout_scroll.borrow_mut().take();
|
||||
window.set_child(Option::<>k::Widget>::None);
|
||||
window.hide();
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.11.25"
|
||||
version = "0.11.26"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -17,6 +17,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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]
|
||||
name = "lesavka_server"
|
||||
version = "0.11.25"
|
||||
version = "0.11.26"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -323,6 +323,9 @@ impl Voice {
|
||||
.context("make alsasink")?;
|
||||
|
||||
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(&[
|
||||
appsrc.upcast_ref(),
|
||||
|
||||
@ -345,18 +345,36 @@ fn preferred_uac_device_candidates(preferred: &str) -> Vec<String> {
|
||||
"hw:Lesavka,0",
|
||||
];
|
||||
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 {
|
||||
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() {
|
||||
push_audio_candidate(&mut out, &mut seen, &detected);
|
||||
push_audio_candidate_family(&mut out, &mut seen, &detected);
|
||||
}
|
||||
}
|
||||
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))]
|
||||
fn push_audio_candidate(out: &mut Vec<String>, seen: &mut BTreeSet<String>, candidate: &str) {
|
||||
let trimmed = candidate.trim();
|
||||
@ -498,13 +516,18 @@ mod tests {
|
||||
#[test]
|
||||
fn preferred_uac_device_candidates_keeps_custom_override_only() {
|
||||
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]
|
||||
fn preferred_uac_device_candidates_expands_known_aliases() {
|
||||
let candidates = preferred_uac_device_candidates("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:Composite,0"));
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user