ui: polish launcher logging and diagnostics

This commit is contained in:
Brad Stein 2026-04-23 00:40:37 -03:00
parent 3b112996dd
commit 64ded7839d
9 changed files with 418 additions and 141 deletions

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package]
name = "lesavka_client"
version = "0.12.0"
version = "0.12.1"
edition = "2024"
[dependencies]

View File

@ -2,7 +2,10 @@ use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::fmt::Write as _;
use super::state::{CaptureSizeChoice, FeedSourcePreset, InputRouting, LauncherState, ViewMode};
use super::{
devices::CameraMode,
state::{CaptureSizeChoice, FeedSourcePreset, InputRouting, LauncherState, ViewMode},
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PerformanceSample {
@ -88,6 +91,13 @@ impl DiagnosticsLog {
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct MediaChannelState {
pub camera: bool,
pub microphone: bool,
pub audio: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotReport {
pub client_version: String,
@ -138,9 +148,12 @@ pub struct SnapshotReport {
pub right_decoded_caps_label: String,
pub right_rendered_caps_label: String,
pub selected_camera: Option<String>,
pub camera_quality_label: String,
pub selected_microphone: Option<String>,
pub selected_speaker: Option<String>,
pub media_channels: MediaChannelState,
pub audio_gain_label: String,
pub mic_gain_label: String,
pub selected_keyboard: Option<String>,
pub selected_mouse: Option<String>,
pub status: String,
@ -353,9 +366,19 @@ impl SnapshotReport {
})
.unwrap_or_else(|| "pending".to_string()),
selected_camera: state.devices.camera.clone(),
camera_quality_label: state
.camera_quality
.map(CameraMode::short_label)
.unwrap_or_else(|| "default".to_string()),
selected_microphone: state.devices.microphone.clone(),
selected_speaker: state.devices.speaker.clone(),
media_channels: MediaChannelState {
camera: state.channels.camera,
microphone: state.channels.microphone,
audio: state.channels.audio,
},
audio_gain_label: state.audio_gain_label(),
mic_gain_label: state.mic_gain_label(),
selected_keyboard: state.devices.keyboard.clone(),
selected_mouse: state.devices.mouse.clone(),
status: state.status_line(),
@ -383,16 +406,19 @@ impl SnapshotReport {
let _ = writeln!(text, "server: {server_version} ({server_state})");
let _ = writeln!(
text,
"session: routing={:?} view={:?} relay_active={} power={}",
self.routing, self.view_mode, self.remote_active, self.power_state
"session: routing={:?} view={:?} relay={} capture_power={}",
self.routing,
self.view_mode,
if self.remote_active { "active" } else { "idle" },
self.power_state
);
let _ = writeln!(
text,
"runtime pressure: client={:.1}% server={:.1}%",
"runtime: client CPU {:.1}% | server CPU {:.1}%",
self.client_process_cpu_pct, self.server_process_cpu_pct
);
let _ = writeln!(text, "source feed: {}", self.preview_source);
let _ = writeln!(text, "client display limit: {}", self.client_display_limit);
let _ = writeln!(text, "display limit: {}", self.client_display_limit);
let _ = writeln!(text);
let _ = writeln!(text, "left eye");
let _ = writeln!(text, " surface: {}", self.left_surface);
@ -451,23 +477,28 @@ impl SnapshotReport {
self.right_server_queue_peak
);
let _ = writeln!(text);
let _ = writeln!(text, "device staging");
let _ = writeln!(text, "media staging");
let _ = writeln!(
text,
" camera: {}",
self.selected_camera.as_deref().unwrap_or("auto")
" camera: {} | quality={} | enabled={}",
self.selected_camera.as_deref().unwrap_or("auto"),
self.camera_quality_label,
self.media_channels.camera
);
let _ = writeln!(
text,
" microphone: {}",
self.selected_microphone.as_deref().unwrap_or("auto")
" speaker: {} | volume={} | enabled={}",
self.selected_speaker.as_deref().unwrap_or("auto"),
self.audio_gain_label,
self.media_channels.audio
);
let _ = writeln!(
text,
" speaker: {}",
self.selected_speaker.as_deref().unwrap_or("auto")
" microphone: {} | gain={} | enabled={}",
self.selected_microphone.as_deref().unwrap_or("auto"),
self.mic_gain_label,
self.media_channels.microphone
);
let _ = writeln!(text, " audio gain: {}", self.audio_gain_label);
let _ = writeln!(
text,
" keyboard: {}",
@ -479,7 +510,7 @@ impl SnapshotReport {
self.selected_mouse.as_deref().unwrap_or("all")
);
let _ = writeln!(text);
let _ = writeln!(text, "launcher status");
let _ = writeln!(text, "current UI state");
let _ = writeln!(text, " {}", self.status);
let _ = writeln!(text);
let _ = writeln!(text, "recent samples");
@ -931,9 +962,38 @@ mod tests {
assert!(text.contains("stream caps:"));
assert!(text.contains("decoded caps:"));
assert!(text.contains("rendered caps:"));
assert!(text.contains("media staging"));
assert!(text.contains("current UI state"));
assert!(text.contains("recommendations"));
}
#[test]
#[doc = "Verifies diagnostics text follows live media settings."]
fn snapshot_text_reflects_live_media_control_changes() {
let mut state = LauncherState::new();
state.select_camera(Some("/dev/video9".to_string()));
state.select_camera_quality(Some(crate::launcher::devices::CameraMode::new(
1920, 1080, 30,
)));
state.select_microphone(Some("alsa_input.usb".to_string()));
state.select_speaker(Some("alsa_output.usb".to_string()));
state.set_audio_gain_percent(250);
state.set_mic_gain_percent(125);
state.set_camera_channel_enabled(false);
state.set_microphone_channel_enabled(true);
let report = SnapshotReport::from_state(
&state,
&DiagnosticsLog::new(1),
quality_probe_command().to_string(),
);
let text = report.to_pretty_text();
assert!(text.contains("camera: /dev/video9 | quality=1080p@30 | enabled=false"));
assert!(text.contains("speaker: alsa_output.usb | volume=250% | enabled=true"));
assert!(text.contains("microphone: alsa_input.usb | gain=125% | enabled=true"));
}
#[test]
fn snapshot_report_uses_effective_mirrored_capture_profile() {
let mut state = LauncherState::new();

View File

@ -15,21 +15,21 @@ use {
MAX_MIC_GAIN_PERCENT,
},
super::ui_components::{
build_launcher_view, sync_camera_quality_combo, sync_input_device_combo,
ConsoleLogLevel, build_launcher_view, sync_camera_quality_combo, sync_input_device_combo,
sync_stage_device_combo,
},
super::ui_runtime::{
RelayChild, append_session_log, apply_popout_window_size, attach_child_log_streams,
audio_gain_control_path, capture_swap_key, copy_plain_text, copy_session_log,
dock_all_displays_to_preview, dock_display_to_preview, input_control_path,
input_state_path, input_toggle_control_path, mic_gain_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, uplink_camera_preview_path, uplink_mic_level_path,
write_audio_gain_request, write_input_routing_request, write_input_toggle_key_request,
write_mic_gain_request,
RelayChild, append_session_log_for_level, apply_popout_window_size,
attach_child_log_streams, audio_gain_control_path, capture_swap_key, copy_plain_text,
copy_session_log, dock_all_displays_to_preview, dock_display_to_preview,
input_control_path, input_state_path, input_toggle_control_path, mic_gain_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, uplink_camera_preview_path,
uplink_mic_level_path, write_audio_gain_request, write_input_routing_request,
write_input_toggle_key_request, write_mic_gain_request,
},
crate::handshake::{HandshakeProbe, probe},
crate::output::display::enumerate_monitors,
@ -1790,6 +1790,22 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
});
}
{
let widgets = widgets.clone();
widgets.console_level_combo.connect_changed(move |combo| {
let level = combo
.active_id()
.as_deref()
.and_then(ConsoleLogLevel::from_id)
.unwrap_or_default();
*widgets.session_log_level.borrow_mut() = level;
widgets.status_label.set_text(&format!(
"Console now shows {} relay logs and higher.",
level.label()
));
});
}
{
let widgets = widgets.clone();
widgets.console_copy_button.connect_clicked(move |_| {
@ -2212,16 +2228,13 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let power_mode = state.borrow().capture_power.mode.clone();
let message = match power_mode.as_str() {
"forced-off" => format!(
"Relay connected with inputs routed to {}, but capture is forced off. Return capture to Auto or Force On when you want remote video.",
routing
"Relay connected with inputs routed to {routing}, but capture is forced off. Return capture to Auto or Force On when you want remote video."
),
"forced-on" => format!(
"Relay connected with inputs routed to {}. Capture is being held awake and the eye previews are coming online.",
routing
"Relay connected with inputs routed to {routing}. Capture is being held awake and the eye previews are coming online."
),
_ => format!(
"Relay connected with inputs routed to {}. The eye previews will come up with the live session.",
routing
"Relay connected with inputs routed to {routing}. The eye previews will come up with the live session."
),
};
widgets.status_label.set_text(&message);
@ -2252,11 +2265,14 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
}
while let Ok(line) = log_rx.try_recv() {
append_session_log(&widgets.session_log_buffer, &line);
let mut end = widgets.session_log_buffer.end_iter();
widgets
.session_log_view
.scroll_to_iter(&mut end, 0.0, false, 0.0, 1.0);
let level = *widgets.session_log_level.borrow();
if append_session_log_for_level(&widgets.session_log_buffer, &line, level)
{
let mut end = widgets.session_log_buffer.end_iter();
widgets
.session_log_view
.scroll_to_iter(&mut end, 0.0, false, 0.0, 1.0);
}
}
while let Ok(message) = power_rx.try_recv() {

View File

@ -48,6 +48,76 @@ pub struct PopoutWindowHandle {
pub binding: PreviewBinding,
}
/// Minimum severity for relay log lines shown in the session console.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ConsoleLogLevel {
Error,
#[default]
Warn,
Info,
Debug,
Trace,
}
impl ConsoleLogLevel {
pub const ALL: [Self; 5] = [
Self::Error,
Self::Warn,
Self::Info,
Self::Debug,
Self::Trace,
];
/// Stable value stored on the GTK combo row.
#[must_use]
pub const fn id(self) -> &'static str {
match self {
Self::Error => "error",
Self::Warn => "warn",
Self::Info => "info",
Self::Debug => "debug",
Self::Trace => "trace",
}
}
/// Short label displayed in the launcher dropdown.
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Error => "Error",
Self::Warn => "Warn",
Self::Info => "Info",
Self::Debug => "Debug",
Self::Trace => "Trace",
}
}
#[must_use]
#[doc = "Parses a GTK combo id back into a log level."]
pub fn from_id(raw: &str) -> Option<Self> {
match raw {
"error" => Some(Self::Error),
"warn" => Some(Self::Warn),
"info" => Some(Self::Info),
"debug" => Some(Self::Debug),
"trace" => Some(Self::Trace),
_ => None,
}
}
/// Numeric ordering where lower values are more important.
#[must_use]
pub const fn rank(self) -> u8 {
match self {
Self::Error => 0,
Self::Warn => 1,
Self::Info => 2,
Self::Debug => 3,
Self::Trace => 4,
}
}
}
#[derive(Clone)]
pub struct LauncherWidgets {
pub status_label: gtk::Label,
@ -96,6 +166,8 @@ pub struct LauncherWidgets {
pub diagnostics_popout_button: gtk::Button,
pub console_copy_button: gtk::Button,
pub console_popout_button: gtk::Button,
pub console_level_combo: gtk::ComboBoxText,
pub session_log_level: Rc<RefCell<ConsoleLogLevel>>,
pub _device_body_height_group: gtk::SizeGroup,
}
@ -161,15 +233,23 @@ pub fn build_launcher_view(
hero.set_hexpand(true);
let brand_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
brand_box.set_valign(gtk::Align::Center);
let brand_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
brand_row.set_halign(gtk::Align::Start);
brand_row.set_valign(gtk::Align::Center);
let brand_icon = gtk::Image::from_icon_name(LESAVKA_ICON_NAME);
brand_icon.add_css_class("app-logo");
brand_icon.set_pixel_size(28);
brand_icon.set_valign(gtk::Align::Center);
let heading = gtk::Label::new(Some("Lesavka"));
heading.add_css_class("title-2");
heading.set_halign(gtk::Align::Start);
heading.set_valign(gtk::Align::Center);
let version_tag = gtk::Label::new(Some(&format!("v{}", crate::VERSION)));
version_tag.add_css_class("version-tag");
version_tag.set_halign(gtk::Align::Start);
version_tag.set_valign(gtk::Align::End);
version_tag.set_valign(gtk::Align::Center);
brand_row.append(&brand_icon);
brand_row.append(&heading);
brand_row.append(&version_tag);
brand_box.append(&brand_row);
@ -226,9 +306,7 @@ pub fn build_launcher_view(
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.",
));
device_refresh_button.set_tooltip_text(Some("Re-scan connected devices."));
let (devices_panel, devices_body) =
build_panel_with_action("Device Staging", Some(device_refresh_button.upcast_ref()));
devices_panel.set_hexpand(true);
@ -253,14 +331,10 @@ pub fn build_launcher_view(
state.selected_camera_quality(catalog),
);
camera_quality_combo.set_size_request(88, -1);
camera_quality_combo.set_tooltip_text(Some(
"Choose the webcam quality Lesavka should capture and send to the relay host.",
));
camera_quality_combo.set_tooltip_text(Some("Webcam uplink quality."));
let camera_test_button = gtk::Button::with_label("Start Preview");
stabilize_button(&camera_test_button, 118);
camera_test_button.set_tooltip_text(Some(
"Open a local preview for the selected webcam so you can confirm the right source.",
));
camera_test_button.set_tooltip_text(Some("Preview selected webcam locally."));
let speaker_combo = gtk::ComboBoxText::new();
sync_stage_device_combo(
@ -270,9 +344,7 @@ pub fn build_launcher_view(
);
let speaker_test_button = gtk::Button::with_label("Play Tone");
stabilize_button(&speaker_test_button, 118);
speaker_test_button.set_tooltip_text(Some(
"Play a short continuous tone through the selected speaker until you stop the test.",
));
speaker_test_button.set_tooltip_text(Some("Play a local test tone."));
let keyboard_combo = gtk::ComboBoxText::new();
keyboard_combo.append(Some("all"), "all keyboards");
@ -280,9 +352,7 @@ pub fn build_launcher_view(
append_input_choice(&keyboard_combo, keyboard);
}
super::ui_runtime::set_combo_active_text(&keyboard_combo, state.devices.keyboard.as_deref());
keyboard_combo.set_tooltip_text(Some(
"Leave this on all keyboards to relay every keyboard, or pick one specific device.",
));
keyboard_combo.set_tooltip_text(Some("Keyboard source for relay input."));
let keyboard_row = build_inline_selector_row("Keyboard", &keyboard_combo);
control_stack.append(&keyboard_row);
@ -292,9 +362,7 @@ pub fn build_launcher_view(
append_input_choice(&mouse_combo, mouse);
}
super::ui_runtime::set_combo_active_text(&mouse_combo, state.devices.mouse.as_deref());
mouse_combo.set_tooltip_text(Some(
"Leave this on all mice to relay every pointer, or pick one specific device.",
));
mouse_combo.set_tooltip_text(Some("Pointer source for relay input."));
let mouse_row = build_inline_selector_row("Mouse", &mouse_combo);
control_stack.append(&mouse_row);
devices_body.append(&control_group);
@ -306,24 +374,18 @@ pub fn build_launcher_view(
media_group.append(&media_grid);
let camera_channel_toggle = gtk::CheckButton::with_label("Camera");
camera_channel_toggle.set_active(state.channels.camera);
camera_channel_toggle.set_tooltip_text(Some(
"Include the local webcam uplink in the next relay session.",
));
camera_channel_toggle.set_tooltip_text(Some("Send webcam during relay."));
let audio_channel_toggle = gtk::CheckButton::with_label("Speaker");
audio_channel_toggle.set_active(state.channels.audio);
audio_channel_toggle.set_tooltip_text(Some(
"Play remote audio on this client during the next relay session.",
));
audio_channel_toggle.set_tooltip_text(Some("Play remote audio here."));
let microphone_channel_toggle = gtk::CheckButton::with_label("Mic");
microphone_channel_toggle.set_active(state.channels.microphone);
microphone_channel_toggle.set_tooltip_text(Some(
"Include the local microphone uplink in the next relay session.",
));
microphone_channel_toggle.set_tooltip_text(Some("Send mic during relay."));
let audio_gain_adjustment = gtk::Adjustment::new(
state.audio_gain_percent as f64,
f64::from(state.audio_gain_percent),
0.0,
super::state::MAX_AUDIO_GAIN_PERCENT as f64,
f64::from(super::state::MAX_AUDIO_GAIN_PERCENT),
25.0,
100.0,
0.0,
@ -333,16 +395,18 @@ pub fn build_launcher_view(
audio_gain_scale.set_draw_value(false);
audio_gain_scale.set_hexpand(false);
audio_gain_scale.set_size_request(96, -1);
audio_gain_scale.set_tooltip_text(Some(
"Boost or lower remote speaker playback on this client. Changes apply live while the relay is connected.",
));
audio_gain_scale.set_tooltip_text(Some("Speaker volume. Double-click resets to 200%."));
attach_scale_reset_gesture(
&audio_gain_scale,
f64::from(super::state::DEFAULT_AUDIO_GAIN_PERCENT),
);
let audio_gain_value = gtk::Label::new(Some(&state.audio_gain_label()));
audio_gain_value.set_visible(false);
let mic_gain_adjustment = gtk::Adjustment::new(
state.mic_gain_percent as f64,
f64::from(state.mic_gain_percent),
0.0,
super::state::MAX_MIC_GAIN_PERCENT as f64,
f64::from(super::state::MAX_MIC_GAIN_PERCENT),
25.0,
100.0,
0.0,
@ -351,9 +415,11 @@ pub fn build_launcher_view(
mic_gain_scale.set_draw_value(false);
mic_gain_scale.set_hexpand(false);
mic_gain_scale.set_size_request(96, -1);
mic_gain_scale.set_tooltip_text(Some(
"Boost or lower local microphone uplink gain. Changes apply live while microphone uplink is running.",
));
mic_gain_scale.set_tooltip_text(Some("Mic gain. Double-click resets to 100%."));
attach_scale_reset_gesture(
&mic_gain_scale,
f64::from(super::state::DEFAULT_MIC_GAIN_PERCENT),
);
let mic_gain_value = gtk::Label::new(Some(&state.mic_gain_label()));
mic_gain_value.set_visible(false);
@ -391,9 +457,7 @@ pub fn build_launcher_view(
);
let microphone_test_button = gtk::Button::with_label("Monitor Mic");
stabilize_button(&microphone_test_button, 118);
microphone_test_button.set_tooltip_text(Some(
"Monitor the selected microphone through the selected speaker until you stop the test.",
));
microphone_test_button.set_tooltip_text(Some("Monitor mic through speaker."));
microphone_combo.set_size_request(0, -1);
let microphone_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6);
microphone_combo.set_hexpand(true);
@ -509,9 +573,7 @@ pub fn build_launcher_view(
server_entry.set_hexpand(true);
server_entry.set_width_chars(18);
server_entry.set_text(server_addr);
server_entry.set_tooltip_text(Some(
"Relay host address for previews, power control, and the live session.",
));
server_entry.set_tooltip_text(Some("Relay host address."));
let relay_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
relay_row.set_halign(gtk::Align::Fill);
relay_row.set_hexpand(true);
@ -528,21 +590,15 @@ pub fn build_launcher_view(
let clipboard_button = gtk::Button::with_label("Send Clipboard");
clipboard_button.set_hexpand(true);
stabilize_button(&clipboard_button, 108);
clipboard_button.set_tooltip_text(Some(
"Type the current local clipboard into the remote target. This stays launcher-only.",
));
clipboard_button.set_tooltip_text(Some("Type clipboard remotely."));
let probe_button = gtk::Button::with_label("Copy Gate Probe");
probe_button.set_hexpand(true);
stabilize_button(&probe_button, 108);
probe_button.set_tooltip_text(Some(
"Copy the hygiene/quality probe command into the local clipboard.",
));
probe_button.set_tooltip_text(Some("Copy quality probe."));
let usb_recover_button = gtk::Button::with_label("Recover USB");
usb_recover_button.set_hexpand(true);
stabilize_button(&usb_recover_button, 108);
usb_recover_button.set_tooltip_text(Some(
"Force the remote USB gadget to re-enumerate when keyboard, mouse, webcam, or audio stop showing up on the host.",
));
usb_recover_button.set_tooltip_text(Some("Re-enumerate remote USB."));
live_actions_row.append(&clipboard_button);
live_actions_row.append(&probe_button);
live_actions_row.append(&usb_recover_button);
@ -599,9 +655,7 @@ pub fn build_launcher_view(
let input_toggle_button = gtk::Button::with_label("Route");
input_toggle_button.set_hexpand(true);
stabilize_button(&input_toggle_button, 106);
input_toggle_button.set_tooltip_text(Some(
"Change live keyboard and mouse ownership between this machine and the remote target.",
));
input_toggle_button.set_tooltip_text(Some("Swap input ownership."));
let swap_key_button = gtk::Button::with_label("Set Swap Key");
swap_key_button.set_hexpand(true);
stabilize_button(&swap_key_button, 106);
@ -652,11 +706,21 @@ pub fn build_launcher_view(
console_panel.set_valign(gtk::Align::Fill);
console_body.set_vexpand(true);
let console_toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 8);
console_toolbar.set_homogeneous(true);
let console_copy_button = gtk::Button::with_label("Copy Log");
stabilize_button(&console_copy_button, 104);
let console_popout_button = gtk::Button::with_label("Break Out Log");
stabilize_button(&console_popout_button, 104);
let session_log_level = Rc::new(RefCell::new(ConsoleLogLevel::default()));
let console_level_combo = gtk::ComboBoxText::new();
for level in ConsoleLogLevel::ALL {
console_level_combo.append(Some(level.id()), level.label());
}
console_level_combo.set_active_id(Some(ConsoleLogLevel::default().id()));
console_level_combo.set_size_request(78, 36);
console_level_combo.set_tooltip_text(Some("Show relay logs at this level or higher."));
let console_copy_button = gtk::Button::with_label("Copy");
stabilize_button(&console_copy_button, 66);
console_copy_button.set_tooltip_text(Some("Copy visible log."));
let console_popout_button = gtk::Button::with_label("Pop Out");
stabilize_button(&console_popout_button, 78);
console_popout_button.set_tooltip_text(Some("Open log window."));
console_toolbar.append(&console_level_combo);
console_toolbar.append(&console_copy_button);
console_toolbar.append(&console_popout_button);
let status_label = gtk::Label::new(Some("Session log ready."));
@ -853,6 +917,8 @@ pub fn build_launcher_view(
diagnostics_popout_button: diagnostics_popout_button.clone(),
console_copy_button: console_copy_button.clone(),
console_popout_button: console_popout_button.clone(),
console_level_combo: console_level_combo.clone(),
session_log_level: session_log_level.clone(),
_device_body_height_group: device_body_height_group,
};
let popouts = Rc::new(RefCell::new([None, None]));
@ -887,7 +953,7 @@ pub fn build_launcher_view(
pub fn install_css(window: &gtk::ApplicationWindow) {
let provider = gtk::CssProvider::new();
provider.load_from_data(
r#"
r"
window.lesavka {
background: #101319;
color: #eef2f7;
@ -919,7 +985,9 @@ pub fn install_css(window: &gtk::ApplicationWindow) {
label.version-tag {
font-size: 0.76rem;
opacity: 0.72;
margin-bottom: 3px;
}
image.app-logo {
opacity: 0.96;
}
box.status-chip {
background: rgba(91, 179, 162, 0.12);
@ -975,7 +1043,12 @@ pub fn install_css(window: &gtk::ApplicationWindow) {
}
label.eye-inline-status {
font-size: 0.86rem;
opacity: 0.82;
font-weight: 600;
background: rgba(91, 179, 162, 0.10);
border: 1px solid rgba(91, 179, 162, 0.22);
border-radius: 999px;
padding: 5px 8px;
opacity: 0.9;
}
textview.status-log,
label.status-log {
@ -1009,7 +1082,7 @@ pub fn install_css(window: &gtk::ApplicationWindow) {
border-color: rgba(91, 179, 162, 0.45);
font-weight: 700;
}
"#,
",
);
if let Some(display) = gtk::gdk::Display::default() {
gtk::style_context_add_provider_for_display(
@ -1444,27 +1517,21 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
root.append(&stack);
let feed_source_combo = gtk::ComboBoxText::new();
feed_source_combo.set_tooltip_text(Some(
"Choose which physical eye feed appears in this pane. Off disables the pane; the opposite-eye option mirrors the other physical feed while preserving a separate stream load for realistic validation.",
));
feed_source_combo.set_tooltip_text(Some("Eye source for this pane."));
feed_source_combo.set_hexpand(true);
feed_source_combo.set_size_request(0, -1);
let capture_resolution_combo = gtk::ComboBoxText::new();
capture_resolution_combo.set_tooltip_text(Some(
"Choose the eye-stream source mode for this feed. Source keeps the HDMI device's own H.264 stream; cheaper source-device modes will appear here once the hardware proves it supports them.",
));
capture_resolution_combo.set_tooltip_text(Some("Eye capture mode."));
capture_resolution_combo.set_size_request(0, -1);
capture_resolution_combo.set_hexpand(true);
let breakout_combo = gtk::ComboBoxText::new();
breakout_combo.set_tooltip_text(Some(
"Choose the client-side breakout window size for this eye feed. Source Size preserves the feed's own dimensions; Display Size fills the effective monitor size.",
));
breakout_combo.set_tooltip_text(Some("Breakout window size."));
breakout_combo.set_size_request(0, -1);
breakout_combo.set_hexpand(true);
let action_button = gtk::Button::with_label("Break Out");
stabilize_button(&action_button, 104);
action_button.set_halign(gtk::Align::End);
let stream_status = gtk::Label::new(Some("Connect relay to preview."));
let stream_status = gtk::Label::new(Some("Preview pending"));
stream_status.add_css_class("status-line");
stream_status.add_css_class("eye-inline-status");
stream_status.set_halign(gtk::Align::Fill);
@ -1474,7 +1541,7 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
stream_status.set_single_line_mode(true);
stream_status.set_width_chars(12);
stream_status.set_max_width_chars(18);
stream_status.set_tooltip_text(Some("Connect relay to preview."));
stream_status.set_tooltip_text(Some("Eye stream status."));
let footer_shell = gtk::Box::new(gtk::Orientation::Vertical, 6);
let controls_grid = gtk::Grid::new();
controls_grid.set_column_spacing(8);
@ -1513,3 +1580,16 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
fn stabilize_button(button: &gtk::Button, width: i32) {
button.set_size_request(width, 36);
}
/// Resets one slider to its default value on double-click.
fn attach_scale_reset_gesture(scale: &gtk::Scale, default_value: f64) {
let gesture = gtk::GestureClick::new();
gesture.set_button(0);
let scale_for_click = scale.clone();
gesture.connect_released(move |_, n_press, _, _| {
if n_press == 2 && (scale_for_click.value() - default_value).abs() > f64::EPSILON {
scale_for_click.set_value(default_value);
}
});
scale.add_controller(gesture);
}

View File

@ -18,7 +18,7 @@ use super::{
preview::{LauncherPreview, PreviewSurface},
runtime_env_vars,
state::{BreakoutSizeChoice, CapturePowerStatus, DisplaySurface, InputRouting, LauncherState},
ui_components::{DisplayPaneWidgets, LauncherWidgets, PopoutWindowHandle},
ui_components::{ConsoleLogLevel, DisplayPaneWidgets, LauncherWidgets, PopoutWindowHandle},
};
pub const INPUT_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_INPUT_CONTROL";
@ -117,9 +117,9 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
widgets.start_button.set_sensitive(true);
widgets.server_entry.set_sensitive(!relay_live);
widgets.start_button.set_tooltip_text(Some(if relay_live {
"Disconnect from the relay host, stop the live session, and let capture fall back to grace/standby."
"Disconnect the relay session."
} else {
"Connect to the relay host, start the live session, and bring the eye previews online."
"Start relay and previews."
}));
widgets.clipboard_button.set_sensitive(relay_live);
widgets.probe_button.set_sensitive(true);
@ -169,19 +169,17 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
widgets
.input_toggle_button
.set_tooltip_text(Some(match state.routing {
InputRouting::Remote => {
"Inputs are currently going to the remote session. Click to bring them back local."
}
InputRouting::Local => {
"Inputs are currently staying local. Click to hand them to the remote session."
}
InputRouting::Remote => "Route inputs back local.",
InputRouting::Local => "Route inputs to remote.",
}));
widgets.swap_key_button.set_label("Set Swap Key");
widgets.swap_key_button.set_tooltip_text(Some(if state.swap_key_binding {
"Waiting for the next key press. The top chip still shows the current live shortcut."
} else {
"Capture the next key you press and make it the swap shortcut. The current shortcut is shown in the top chip."
}));
widgets
.swap_key_button
.set_tooltip_text(Some(if state.swap_key_binding {
"Press the new swap key."
} else {
"Set the swap shortcut."
}));
let power_available = state.capture_power.available;
widgets
.power_auto_button
@ -1089,6 +1087,33 @@ pub fn append_session_log(buffer: &gtk::TextBuffer, message: &str) {
if trimmed.is_empty() {
return;
}
append_clean_session_log(buffer, trimmed);
}
/// Appends a session log line only when it passes the selected severity filter.
pub fn append_session_log_for_level(
buffer: &gtk::TextBuffer,
message: &str,
level: ConsoleLogLevel,
) -> bool {
let cleaned = strip_ansi_sequences(message);
let trimmed = cleaned.trim();
if trimmed.is_empty() || !should_show_clean_session_log_line(trimmed, level) {
return false;
}
append_clean_session_log(buffer, trimmed);
true
}
#[cfg(test)]
fn should_show_session_log_line(message: &str, level: ConsoleLogLevel) -> bool {
let cleaned = strip_ansi_sequences(message);
let trimmed = cleaned.trim();
!trimmed.is_empty() && should_show_clean_session_log_line(trimmed, level)
}
/// Writes a cleaned line with the GTK text tags that match its source/severity.
fn append_clean_session_log(buffer: &gtk::TextBuffer, trimmed: &str) {
let mut end = buffer.end_iter();
let tags = classify_log_tags(trimmed);
if tags.is_empty() {
@ -1202,7 +1227,7 @@ pub fn open_session_log_popout(
None,
buffer,
"Lesavka Log",
"Copy Log",
"Copy",
gtk::WrapMode::WordChar,
);
}
@ -1454,6 +1479,71 @@ fn set_status_light(light: &gtk::Box, state: StatusLightState) {
light.add_css_class(state.css_class());
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SessionLogSeverity {
Error,
Warn,
Info,
Debug,
Trace,
Other,
}
impl SessionLogSeverity {
const fn rank(self) -> Option<u8> {
match self {
Self::Error => Some(0),
Self::Warn => Some(1),
Self::Info => Some(2),
Self::Debug => Some(3),
Self::Trace => Some(4),
Self::Other => None,
}
}
}
/// Returns whether a cleaned log line should be visible at the selected threshold.
fn should_show_clean_session_log_line(message: &str, level: ConsoleLogLevel) -> bool {
let severity = session_log_severity(message);
if severity == SessionLogSeverity::Error {
return true;
}
if message.starts_with("[launcher]") || message.starts_with("[preview:") {
return true;
}
match severity.rank() {
Some(rank) => rank <= level.rank(),
None => true,
}
}
/// Infers severity from the structured relay prefix and human-readable text.
fn session_log_severity(message: &str) -> SessionLogSeverity {
let uppercase = message.to_ascii_uppercase();
if message.starts_with("[relay:stderr]")
|| message.contains('❌')
|| uppercase.contains(" ERROR ")
|| uppercase.contains("FAILED")
|| uppercase.contains("PANIC")
|| uppercase.contains(" RPC FAILED")
{
SessionLogSeverity::Error
} else if uppercase.contains(" WARN ")
|| uppercase.contains("UNAVAILABLE")
|| uppercase.contains("WAITING FOR CAPTURE PIPELINE")
{
SessionLogSeverity::Warn
} else if uppercase.contains(" INFO ") {
SessionLogSeverity::Info
} else if uppercase.contains(" DEBUG ") {
SessionLogSeverity::Debug
} else if uppercase.contains(" TRACE ") {
SessionLogSeverity::Trace
} else {
SessionLogSeverity::Other
}
}
fn classify_log_tags(message: &str) -> Vec<&'static str> {
let mut tags = Vec::new();
if message.starts_with("[launcher]") {
@ -1469,7 +1559,9 @@ fn classify_log_tags(message: &str) -> Vec<&'static str> {
}
let uppercase = message.to_ascii_uppercase();
if uppercase.contains(" ERROR ")
if message.starts_with("[relay:stderr]")
|| message.contains('❌')
|| uppercase.contains(" ERROR ")
|| uppercase.contains("FAILED")
|| uppercase.contains("PANIC")
|| uppercase.contains(" RPC FAILED")
@ -1585,6 +1677,35 @@ mod tests {
assert!(tags.contains(&"log-error") || tags.contains(&"log-warn"));
}
#[test]
#[doc = "Verifies the default console filter hides relay INFO noise."]
fn session_log_filter_hides_noisy_info_by_default_but_keeps_errors() {
assert!(!should_show_session_log_line(
"[relay] 2026-04-22T23:20:17Z INFO ThreadId(01) audio packet received packet=3000",
ConsoleLogLevel::Warn
));
assert!(!should_show_session_log_line(
"[relay] 2026-04-22T23:20:17Z INFO ThreadId(04) decoded audio level rms=-32",
ConsoleLogLevel::Warn
));
assert!(should_show_session_log_line(
"[relay] 2026-04-22T23:20:17Z WARN pipeline is recovering",
ConsoleLogLevel::Warn
));
assert!(should_show_session_log_line(
"[relay] 2026-04-22T23:20:17Z INFO ❌ connect failed",
ConsoleLogLevel::Error
));
assert!(should_show_session_log_line(
"[launcher] Relay connected with inputs routed to remote.",
ConsoleLogLevel::Error
));
assert!(should_show_session_log_line(
"[relay] 2026-04-22T23:20:17Z INFO audio packet received",
ConsoleLogLevel::Info
));
}
#[test]
fn write_audio_gain_request_formats_live_control_file() {
let dir = tempfile::tempdir().expect("tempdir");

View File

@ -1,6 +1,6 @@
[package]
name = "lesavka_common"
version = "0.12.0"
version = "0.12.1"
edition = "2024"
build = "build.rs"

View File

@ -73,7 +73,7 @@
"client/src/launcher/diagnostics.rs": {
"clippy_warnings": 92,
"doc_debt": 12,
"loc": 1021
"loc": 1081
},
"client/src/launcher/mod.rs": {
"clippy_warnings": 8,
@ -96,19 +96,19 @@
"loc": 1562
},
"client/src/launcher/ui.rs": {
"clippy_warnings": 70,
"clippy_warnings": 64,
"doc_debt": 23,
"loc": 2619
"loc": 2635
},
"client/src/launcher/ui_components.rs": {
"clippy_warnings": 22,
"clippy_warnings": 12,
"doc_debt": 18,
"loc": 1515
"loc": 1595
},
"client/src/launcher/ui_runtime.rs": {
"clippy_warnings": 74,
"doc_debt": 44,
"loc": 1802
"loc": 1923
},
"client/src/layout.rs": {
"clippy_warnings": 6,

View File

@ -49,8 +49,8 @@
"loc": 564
},
"client/src/launcher/diagnostics.rs": {
"line_percent": 84.3,
"loc": 1021
"line_percent": 84.98,
"loc": 1081
},
"client/src/launcher/mod.rs": {
"line_percent": 84.85,
@ -62,7 +62,7 @@
},
"client/src/launcher/ui.rs": {
"line_percent": 100.0,
"loc": 2619
"loc": 2635
},
"client/src/layout.rs": {
"line_percent": 97.73,

View File

@ -10,7 +10,7 @@ bench = false
[package]
name = "lesavka_server"
version = "0.12.0"
version = "0.12.1"
edition = "2024"
autobins = false