lesavka: add launcher diagnostics panel

This commit is contained in:
Brad Stein 2026-04-16 19:19:37 -03:00
parent 52510ac20d
commit 0155581f30
6 changed files with 411 additions and 8 deletions

1
.gitignore vendored
View File

@ -9,4 +9,3 @@ override.toml
**/*~
*.swp
*.swo
*.md

66
README.md Normal file
View File

@ -0,0 +1,66 @@
# Lesavka
<p align="center">
<img src="client/assets/icons/hicolor/1024x1024/apps/lesavka.png" alt="Lesavka icon" width="220" />
</p>
Lesavka is a remote-control and remote-presence client/server pair built to make a far-away desktop feel as direct and usable as possible. It combines live eye-feed previews, input routing, device staging, capture power control, clipboard send, and operator-focused observability in one launcher.
## What Lesavka Can Do
- Launch and control a live remote relay session from a local desktop app
- Preview the left and right remote eye feeds inline or in broken-out windows
- Stage the local camera, microphone, speaker, keyboard, and mouse before connecting
- Route keyboard and mouse ownership between local and remote on demand
- Send clipboard text into the remote session
- Control relay GPIO/capture power from the launcher
- Show local and remote build versions so we know which code is running on each side
- Install cleanly through idempotent client/server install scripts
## Current Capabilities
- KDE launcher integration for the local client install
- Session console with copy and breakout support
- Adjustable capture and breakout sizing for each eye feed
- Automatic redocking of broken-out eye windows when the relay disconnects
- Modifier-aware keyboard relay that now supports `Shift+a -> A`
- Server and client build identity visible in the launcher
## Install / Update
### Local Client
```bash
cd /home/brad/Development/lesavka
git pull --ff-only
sudo LESAVKA_REF=master ./scripts/install/client.sh
```
### Server (`theia`)
```bash
ssh theia 'cd /var/src/lesavka && git pull --ff-only && sudo LESAVKA_REF=master ./scripts/install/server.sh'
```
These install scripts are intended to be the trusted, repeatable delivery path. They pull the requested ref, ensure the environment is ready, build the correct binaries, install them into sensible system paths, and refresh the launched application or service.
## Operator Workflow
1. Install or update the client and server through the install scripts.
2. Launch `Lesavka` from the KDE application launcher or run `lesavka`.
3. Stage the local devices you want the next relay session to inherit.
4. Connect the relay and confirm the eye previews come online.
5. Route inputs to the remote when you are ready to drive the far-side machine.
6. Use the session console and diagnostics tools to understand what the session is doing.
## Roadmap
### Highest-Impact Next Steps
- Add a real diagnostics panel with breakout/copy support
- Show stream health metrics such as fps, dropped frames, RTT, jitter, and packet loss
- Improve client decoder selection so preview and breakout paths prefer hardware acceleration when possible
- Surface server adaptive-stream stats directly in the launcher
### After That
- Add adaptive bitrate and resolution controls
- Add synthetic motion/input test scenes for objective latency and smoothness measurement
- Add artifact-quality scoring for controlled test patterns
- Keep tightening the “feels local” experience for typing, motion, and conference-style usage
## Philosophy
Lesavka is meant to be practical. The goal is not just to establish a remote session, but to make that session reliable, measurable, and comfortable enough for important real-world work.

View File

@ -1,5 +1,6 @@
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::fmt::Write as _;
use super::state::{InputRouting, LauncherState, ViewMode};
@ -54,30 +55,99 @@ impl DiagnosticsLog {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotReport {
pub client_version: String,
pub server_version: Option<String>,
pub server_available: bool,
pub routing: InputRouting,
pub view_mode: ViewMode,
pub remote_active: bool,
pub power_state: String,
pub preview_source: String,
pub breakout_display: String,
pub left_surface: String,
pub left_capture_profile: String,
pub left_breakout_profile: String,
pub right_surface: String,
pub right_capture_profile: String,
pub right_breakout_profile: String,
pub selected_camera: Option<String>,
pub selected_microphone: Option<String>,
pub selected_speaker: Option<String>,
pub selected_keyboard: Option<String>,
pub selected_mouse: Option<String>,
pub status: String,
pub recent_samples: Vec<PerformanceSample>,
pub notes: Vec<String>,
pub recommendations: Vec<String>,
pub probe_command: String,
}
impl SnapshotReport {
pub fn from_state(state: &LauncherState, log: &DiagnosticsLog, probe_command: String) -> Self {
let left_capture = state.capture_size_choice(0);
let right_capture = state.capture_size_choice(1);
let left_breakout = state.breakout_size_choice(0);
let right_breakout = state.breakout_size_choice(1);
Self {
client_version: crate::VERSION.to_string(),
server_version: state.server_version.clone(),
server_available: state.server_available,
routing: state.routing,
view_mode: state.view_mode,
remote_active: state.remote_active,
power_state: format!(
"{} | {} | leases {}",
state.capture_power.mode,
state.capture_power.detail,
state.capture_power.active_leases
),
preview_source: format!(
"{}x{} @ {} fps",
state.preview_source.width, state.preview_source.height, state.preview_source.fps
),
breakout_display: format!(
"{}x{}",
state.breakout_display.width, state.breakout_display.height
),
left_surface: state.display_surface(0).label().to_string(),
left_capture_profile: format!(
"{} | {}x{} | {} fps | {} kbit",
left_capture.preset.as_id(),
left_capture.width,
left_capture.height,
left_capture.fps,
left_capture.max_bitrate_kbit
),
left_breakout_profile: format!(
"{} | {}x{}",
left_breakout.preset.as_id(),
left_breakout.width,
left_breakout.height
),
right_surface: state.display_surface(1).label().to_string(),
right_capture_profile: format!(
"{} | {}x{} | {} fps | {} kbit",
right_capture.preset.as_id(),
right_capture.width,
right_capture.height,
right_capture.fps,
right_capture.max_bitrate_kbit
),
right_breakout_profile: format!(
"{} | {}x{}",
right_breakout.preset.as_id(),
right_breakout.width,
right_breakout.height
),
selected_camera: state.devices.camera.clone(),
selected_microphone: state.devices.microphone.clone(),
selected_speaker: state.devices.speaker.clone(),
selected_keyboard: state.devices.keyboard.clone(),
selected_mouse: state.devices.mouse.clone(),
status: state.status_line(),
recent_samples: log.iter().cloned().collect(),
notes: state.notes.clone(),
recommendations: recommendations_for(state, log),
probe_command,
}
}
@ -85,12 +155,148 @@ impl SnapshotReport {
pub fn to_pretty_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
pub fn to_pretty_text(&self) -> String {
let mut text = String::new();
let server_version = self.server_version.as_deref().unwrap_or("unknown");
let server_state = if self.server_available {
"reachable"
} else {
"unreachable"
};
let _ = writeln!(text, "Lesavka Diagnostics");
let _ = writeln!(text, "client: v{}", self.client_version);
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
);
let _ = writeln!(text, "source feed: {}", self.preview_source);
let _ = writeln!(text, "breakout display: {}", self.breakout_display);
let _ = writeln!(text);
let _ = writeln!(text, "left eye");
let _ = writeln!(text, " surface: {}", self.left_surface);
let _ = writeln!(text, " capture: {}", self.left_capture_profile);
let _ = writeln!(text, " breakout: {}", self.left_breakout_profile);
let _ = writeln!(text, "right eye");
let _ = writeln!(text, " surface: {}", self.right_surface);
let _ = writeln!(text, " capture: {}", self.right_capture_profile);
let _ = writeln!(text, " breakout: {}", self.right_breakout_profile);
let _ = writeln!(text);
let _ = writeln!(text, "device staging");
let _ = writeln!(
text,
" camera: {}",
self.selected_camera.as_deref().unwrap_or("auto")
);
let _ = writeln!(
text,
" microphone: {}",
self.selected_microphone.as_deref().unwrap_or("auto")
);
let _ = writeln!(
text,
" speaker: {}",
self.selected_speaker.as_deref().unwrap_or("auto")
);
let _ = writeln!(
text,
" keyboard: {}",
self.selected_keyboard.as_deref().unwrap_or("all")
);
let _ = writeln!(
text,
" mouse: {}",
self.selected_mouse.as_deref().unwrap_or("all")
);
let _ = writeln!(text);
let _ = writeln!(text, "launcher status");
let _ = writeln!(text, " {}", self.status);
let _ = writeln!(text);
let _ = writeln!(text, "recent samples");
if self.recent_samples.is_empty() {
let _ = writeln!(
text,
" no live RTT/jitter/loss samples yet; this report is currently a launcher state snapshot."
);
} else {
for sample in &self.recent_samples {
let _ = writeln!(
text,
" rtt={:.1}ms input={:.1}ms left={:.1}fps right={:.1}fps dropped={} queue={}",
sample.rtt_ms,
sample.input_latency_ms,
sample.left_fps,
sample.right_fps,
sample.dropped_frames,
sample.queue_depth
);
}
}
let _ = writeln!(text);
let _ = writeln!(text, "recommendations");
for item in &self.recommendations {
let _ = writeln!(text, " - {item}");
}
if !self.notes.is_empty() {
let _ = writeln!(text);
let _ = writeln!(text, "notes");
for item in &self.notes {
let _ = writeln!(text, " - {item}");
}
}
let _ = writeln!(text);
let _ = writeln!(text, "quality probe");
let _ = writeln!(text, " {}", self.probe_command);
text
}
}
pub fn quality_probe_command() -> &'static str {
"scripts/ci/hygiene_gate.sh && scripts/ci/quality_gate.sh"
}
fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec<String> {
let mut items = Vec::new();
if !state.server_available {
items.push(
"The server is not reachable from this launcher yet, so stream-quality results would not be meaningful."
.to_string(),
);
}
if log.is_empty() {
items.push(
"Live RTT, jitter, packet-loss, and queue samples are the next diagnostics tranche; this panel currently reports launcher/session state."
.to_string(),
);
}
let heavy_capture = state.capture_sizes.iter().any(|preset| {
matches!(
preset,
super::state::CaptureSizePreset::Source
| super::state::CaptureSizePreset::P1080
| super::state::CaptureSizePreset::P1440
)
});
if heavy_capture {
items.push(
"If motion artifacting spikes, try a 900p or 720p capture profile before shrinking the breakout window; that usually lowers WAN pressure faster."
.to_string(),
);
}
if state.breakout_count() == 2 {
items.push(
"Both eye feeds are broken out right now. If the client starts struggling, compare in-launcher preview smoothness against full-window decode."
.to_string(),
);
}
if items.is_empty() {
items.push("Session state looks stable. Collect a few real samples before changing capture settings.".to_string());
}
items
}
#[cfg(test)]
mod tests {
use super::*;
@ -150,9 +356,16 @@ mod tests {
Some("alsa_input.usb")
);
assert_eq!(report.selected_speaker.as_deref(), Some("alsa_output.usb"));
assert_eq!(
report.selected_keyboard.as_deref(),
Some("/dev/input/event10")
);
assert_eq!(report.selected_mouse.as_deref(), Some("/dev/input/event11"));
assert_eq!(report.recent_samples.len(), 1);
assert_eq!(report.notes, vec!["first note".to_string()]);
assert!(report.status.contains("mode=remote"));
assert!(report.client_version.starts_with("0."));
assert!(report.left_capture_profile.contains("fps"));
}
#[test]
@ -168,6 +381,20 @@ mod tests {
assert!(json.contains("view_mode"));
}
#[test]
fn snapshot_text_mentions_versions_profiles_and_recommendations() {
let report = SnapshotReport::from_state(
&LauncherState::new(),
&DiagnosticsLog::new(1),
quality_probe_command().to_string(),
);
let text = report.to_pretty_text();
assert!(text.contains("Lesavka Diagnostics"));
assert!(text.contains("client: v"));
assert!(text.contains("left eye"));
assert!(text.contains("recommendations"));
}
#[test]
fn quality_probe_command_mentions_both_gates() {
let cmd = quality_probe_command();

View File

@ -17,11 +17,12 @@ use {
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_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, spawn_client_process, stop_child_process,
toggle_key_label, update_test_action_result, write_input_routing_request,
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, spawn_client_process,
stop_child_process, toggle_key_label, update_test_action_result,
write_input_routing_request,
},
crate::handshake::{PeerCaps, negotiate},
crate::output::display::enumerate_monitors,
@ -354,6 +355,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let widgets = view.widgets.clone();
let preview = view.preview.clone();
let popouts = Rc::clone(&view.popouts);
let diagnostics_popout = Rc::clone(&view.diagnostics_popout);
let log_popout = Rc::clone(&view.log_popout);
{
@ -892,6 +894,33 @@ 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) {
widgets
.status_label
.set_text(&format!("Could not copy the diagnostics report: {err}"));
} else {
widgets
.status_label
.set_text("Diagnostics report copied to the local clipboard.");
}
});
}
{
let app = app.clone();
let widgets = widgets.clone();
let diagnostics_popout = Rc::clone(&diagnostics_popout);
widgets.diagnostics_popout_button.connect_clicked(move |_| {
open_diagnostics_popout(&app, &diagnostics_popout, &widgets.diagnostics_buffer);
widgets
.status_label
.set_text("Diagnostics report moved into its own window.");
});
}
{
let widgets = widgets.clone();
widgets.console_copy_button.connect_clicked(move |_| {

View File

@ -5,6 +5,7 @@ use gtk::{pango, prelude::*};
use super::{
devices::DeviceCatalog,
diagnostics::DiagnosticsLog,
preview::{LauncherPreview, PreviewBinding, PreviewSurface},
state::{
BreakoutSizeChoice, BreakoutSizePreset, CaptureSizeChoice, CaptureSizePreset, LauncherState,
@ -46,6 +47,8 @@ pub struct PopoutWindowHandle {
#[derive(Clone)]
pub struct LauncherWidgets {
pub status_label: gtk::Label,
pub diagnostics_log: Rc<RefCell<DiagnosticsLog>>,
pub diagnostics_buffer: gtk::TextBuffer,
pub session_log_buffer: gtk::TextBuffer,
pub session_log_view: gtk::TextView,
pub summary: SummaryWidgets,
@ -65,6 +68,8 @@ pub struct LauncherWidgets {
pub microphone_test_button: gtk::Button,
pub microphone_replay_button: gtk::Button,
pub speaker_test_button: gtk::Button,
pub diagnostics_copy_button: gtk::Button,
pub diagnostics_popout_button: gtk::Button,
pub console_copy_button: gtk::Button,
pub console_popout_button: gtk::Button,
}
@ -87,6 +92,7 @@ pub struct LauncherView {
pub widgets: LauncherWidgets,
pub preview: Option<Rc<LauncherPreview>>,
pub popouts: Rc<RefCell<[Option<PopoutWindowHandle>; 2]>>,
pub diagnostics_popout: Rc<RefCell<Option<gtk::ApplicationWindow>>>,
pub log_popout: Rc<RefCell<Option<gtk::ApplicationWindow>>>,
}
@ -433,6 +439,33 @@ pub fn build_launcher_view(
connection_body.append(&routing_row);
operations.append(&connection_panel);
let (diagnostics_panel, diagnostics_body) = build_panel("Diagnostics");
let diagnostics_toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 8);
diagnostics_toolbar.set_homogeneous(true);
let diagnostics_copy_button = gtk::Button::with_label("Copy Report");
stabilize_button(&diagnostics_copy_button, 112);
let diagnostics_popout_button = gtk::Button::with_label("Break Out");
stabilize_button(&diagnostics_popout_button, 112);
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::WordChar);
let diagnostics_scroll = gtk::ScrolledWindow::builder()
.hexpand(true)
.vexpand(false)
.min_content_height(190)
.child(&diagnostics_view)
.build();
diagnostics_body.append(&diagnostics_toolbar);
diagnostics_body.append(&diagnostics_scroll);
operations.append(&diagnostics_panel);
let (console_panel, console_body) = build_panel("Session Console");
console_panel.set_vexpand(true);
let console_toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 8);
@ -532,6 +565,8 @@ pub fn build_launcher_view(
let widgets = LauncherWidgets {
status_label: status_label.clone(),
diagnostics_log: diagnostics_log.clone(),
diagnostics_buffer: diagnostics_buffer.clone(),
session_log_buffer: session_log_buffer.clone(),
session_log_view: session_log_view.clone(),
summary: SummaryWidgets {
@ -559,12 +594,17 @@ pub fn build_launcher_view(
microphone_test_button: microphone_test_button.clone(),
microphone_replay_button: microphone_replay_button.clone(),
speaker_test_button: speaker_test_button.clone(),
diagnostics_copy_button: diagnostics_copy_button.clone(),
diagnostics_popout_button: diagnostics_popout_button.clone(),
console_copy_button: console_copy_button.clone(),
console_popout_button: console_popout_button.clone(),
};
let popouts = Rc::new(RefCell::new([None, None]));
let diagnostics_popout = Rc::new(RefCell::new(None));
let log_popout = Rc::new(RefCell::new(None));
super::ui_runtime::refresh_diagnostics_report(&widgets, state, false);
window.set_child(Some(&root));
LauncherView {
@ -582,6 +622,7 @@ pub fn build_launcher_view(
widgets,
preview,
popouts,
diagnostics_popout,
log_popout,
}
}

View File

@ -12,6 +12,7 @@ use std::{
use super::{
LAUNCHER_CLIPBOARD_CONTROL_ENV, LAUNCHER_FOCUS_SIGNAL_ENV,
device_test::{DeviceTestController, DeviceTestKind},
diagnostics::{SnapshotReport, quality_probe_command},
launcher_clipboard_control_path, launcher_focus_signal_path,
preview::{LauncherPreview, PreviewSurface},
runtime_env_vars,
@ -117,6 +118,7 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
state.display_surface(monitor_id),
);
}
refresh_diagnostics_report(widgets, state, child_running);
}
pub fn refresh_test_buttons(widgets: &LauncherWidgets, tests: &mut DeviceTestController) {
@ -870,10 +872,49 @@ pub fn copy_session_log(buffer: &gtk::TextBuffer) -> Result<()> {
Ok(())
}
pub fn refresh_diagnostics_report(
widgets: &LauncherWidgets,
state: &LauncherState,
child_running: bool,
) {
let mut snapshot = SnapshotReport::from_state(
state,
&widgets.diagnostics_log.borrow(),
quality_probe_command().to_string(),
);
if child_running && !snapshot.remote_active {
snapshot.recommendations.insert(
0,
"The relay child is still alive while launcher state says inactive; give it a moment or reconnect before trusting throughput feel.".to_string(),
);
}
widgets
.diagnostics_buffer
.set_text(&snapshot.to_pretty_text());
}
pub fn open_session_log_popout(
app: &gtk::Application,
handle: &Rc<RefCell<Option<gtk::ApplicationWindow>>>,
buffer: &gtk::TextBuffer,
) {
open_text_buffer_popout(app, handle, buffer, "Lesavka Log", "Copy Log");
}
pub fn open_diagnostics_popout(
app: &gtk::Application,
handle: &Rc<RefCell<Option<gtk::ApplicationWindow>>>,
buffer: &gtk::TextBuffer,
) {
open_text_buffer_popout(app, handle, buffer, "Lesavka Diagnostics", "Copy Report");
}
fn open_text_buffer_popout(
app: &gtk::Application,
handle: &Rc<RefCell<Option<gtk::ApplicationWindow>>>,
buffer: &gtk::TextBuffer,
title: &str,
copy_button_label: &str,
) {
if let Some(window) = handle.borrow().as_ref() {
window.present();
@ -882,7 +923,7 @@ pub fn open_session_log_popout(
let window = gtk::ApplicationWindow::builder()
.application(app)
.title("Lesavka Log")
.title(title)
.default_width(980)
.default_height(680)
.build();
@ -896,7 +937,7 @@ pub fn open_session_log_popout(
root.set_margin_bottom(14);
let toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 8);
let copy_button = gtk::Button::with_label("Copy Log");
let copy_button = gtk::Button::with_label(copy_button_label);
toolbar.append(&copy_button);
root.append(&toolbar);