lesavka: show client and server versions

This commit is contained in:
Brad Stein 2026-04-16 15:59:42 -03:00
parent a3e84c5c15
commit 29944a5fd7
12 changed files with 78 additions and 12 deletions

View File

@ -97,6 +97,7 @@ mod tests {
let mut caps = PeerCaps { let mut caps = PeerCaps {
camera: true, camera: true,
microphone: false, microphone: false,
server_version: None,
camera_output: Some(String::from("uvc")), camera_output: Some(String::from("uvc")),
camera_codec: Some(String::from("mjpeg")), camera_codec: Some(String::from("mjpeg")),
camera_width: Some(1280), camera_width: Some(1280),

View File

@ -12,6 +12,7 @@ use tracing::{info, warn};
pub struct PeerCaps { pub struct PeerCaps {
pub camera: bool, pub camera: bool,
pub microphone: bool, pub microphone: bool,
pub server_version: Option<String>,
pub camera_output: Option<String>, pub camera_output: Option<String>,
pub camera_codec: Option<String>, pub camera_codec: Option<String>,
pub camera_width: Option<u32>, pub camera_width: Option<u32>,
@ -64,6 +65,8 @@ pub async fn negotiate(uri: &str) -> PeerCaps {
PeerCaps { PeerCaps {
camera: rsp.camera, camera: rsp.camera,
microphone: rsp.microphone, microphone: rsp.microphone,
server_version: (!rsp.server_version.is_empty())
.then_some(rsp.server_version.clone()),
camera_output: (!rsp.camera_output.is_empty()).then_some(rsp.camera_output.clone()), camera_output: (!rsp.camera_output.is_empty()).then_some(rsp.camera_output.clone()),
camera_codec: (!rsp.camera_codec.is_empty()).then_some(rsp.camera_codec.clone()), camera_codec: (!rsp.camera_codec.is_empty()).then_some(rsp.camera_codec.clone()),
camera_width: (rsp.camera_width != 0).then_some(rsp.camera_width), camera_width: (rsp.camera_width != 0).then_some(rsp.camera_width),
@ -125,6 +128,11 @@ pub async fn negotiate(uri: &str) -> PeerCaps {
let caps = PeerCaps { let caps = PeerCaps {
camera: rsp.camera, camera: rsp.camera,
microphone: rsp.microphone, microphone: rsp.microphone,
server_version: if rsp.server_version.is_empty() {
None
} else {
Some(rsp.server_version.clone())
},
camera_output: if rsp.camera_output.is_empty() { camera_output: if rsp.camera_output.is_empty() {
None None
} else { } else {

View File

@ -188,6 +188,7 @@ pub struct DeviceSelection {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LauncherState { pub struct LauncherState {
pub server_available: bool, pub server_available: bool,
pub server_version: Option<String>,
pub routing: InputRouting, pub routing: InputRouting,
pub view_mode: ViewMode, pub view_mode: ViewMode,
pub displays: [DisplaySurface; 2], pub displays: [DisplaySurface; 2],
@ -209,6 +210,7 @@ impl Default for LauncherState {
fn default() -> Self { fn default() -> Self {
Self { Self {
server_available: false, server_available: false,
server_version: None,
routing: InputRouting::Remote, routing: InputRouting::Remote,
view_mode: ViewMode::Unified, view_mode: ViewMode::Unified,
displays: [DisplaySurface::Preview, DisplaySurface::Preview], displays: [DisplaySurface::Preview, DisplaySurface::Preview],
@ -241,6 +243,17 @@ impl LauncherState {
self.server_available = available; self.server_available = available;
} }
pub fn set_server_version(&mut self, version: Option<String>) {
self.server_version = version.and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
});
}
pub fn set_view_mode(&mut self, view_mode: ViewMode) { pub fn set_view_mode(&mut self, view_mode: ViewMode) {
self.view_mode = view_mode; self.view_mode = view_mode;
self.displays = match view_mode { self.displays = match view_mode {

View File

@ -487,7 +487,11 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
server_entry.connect_changed(move |_| { server_entry.connect_changed(move |_| {
let server_addr = let server_addr =
selected_server_addr(&server_entry_read, server_addr_fallback.as_ref()); selected_server_addr(&server_entry_read, server_addr_fallback.as_ref());
state.borrow_mut().set_server_available(false); {
let mut state = state.borrow_mut();
state.set_server_available(false);
state.set_server_version(None);
}
if let Some(preview) = preview.as_ref() { if let Some(preview) = preview.as_ref() {
preview.set_server_addr(server_addr.clone()); preview.set_server_addr(server_addr.clone());
} }
@ -1404,6 +1408,10 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
while let Ok(message) = caps_rx.try_recv() { while let Ok(message) = caps_rx.try_recv() {
match message { match message {
CapsMessage::Refresh(caps) => { CapsMessage::Refresh(caps) => {
{
let mut state = state.borrow_mut();
state.set_server_version(caps.server_version.clone());
}
if let (Some(width), Some(height)) = if let (Some(width), Some(height)) =
(caps.eye_width, caps.eye_height) (caps.eye_width, caps.eye_height)
{ {

View File

@ -125,21 +125,29 @@ pub fn build_launcher_view(
hero.set_hexpand(true); hero.set_hexpand(true);
let brand_box = gtk::Box::new(gtk::Orientation::Vertical, 0); let brand_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
let brand_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
brand_row.set_halign(gtk::Align::Start);
let heading = gtk::Label::new(Some("Lesavka")); let heading = gtk::Label::new(Some("Lesavka"));
heading.add_css_class("title-2"); heading.add_css_class("title-2");
heading.set_halign(gtk::Align::Start); heading.set_halign(gtk::Align::Start);
brand_box.append(&heading); 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);
brand_row.append(&heading);
brand_row.append(&version_tag);
brand_box.append(&brand_row);
hero.append(&brand_box); hero.append(&brand_box);
let chips = gtk::Box::new(gtk::Orientation::Horizontal, 6); let chips = gtk::Box::new(gtk::Orientation::Horizontal, 6);
chips.set_halign(gtk::Align::End); chips.set_halign(gtk::Align::End);
chips.set_hexpand(true); chips.set_hexpand(true);
let (relay_chip, relay_light, relay_value) = build_status_chip_with_light("Server", "Offline"); let (relay_chip, relay_light, relay_value) = build_status_chip_with_light("Server", "");
let (routing_chip, routing_light, routing_value) = let (routing_chip, routing_light, routing_value) =
build_status_chip_with_light("Inputs", "Local"); build_status_chip_with_light("Inputs", "Local");
let (gpio_chip, gpio_light, gpio_value) = build_status_chip_with_light("GPIO", "Unknown"); let (gpio_chip, gpio_light, gpio_value) = build_status_chip_with_light("GPIO", "Unknown");
let (shortcut_chip, shortcut_value) = build_status_chip("Swap Key", "Pause"); let (shortcut_chip, shortcut_value) = build_status_chip("Swap Key", "Pause");
stabilize_chip(&relay_chip, 84); stabilize_chip(&relay_chip, 104);
stabilize_chip(&routing_chip, 84); stabilize_chip(&routing_chip, 84);
stabilize_chip(&gpio_chip, 84); stabilize_chip(&gpio_chip, 84);
stabilize_chip(&shortcut_chip, 88); stabilize_chip(&shortcut_chip, 88);
@ -610,6 +618,11 @@ pub fn install_css(window: &gtk::ApplicationWindow) {
font-weight: 700; font-weight: 700;
opacity: 0.92; opacity: 0.92;
} }
label.version-tag {
font-size: 0.76rem;
opacity: 0.72;
margin-bottom: 3px;
}
box.status-chip { box.status-chip {
background: rgba(91, 179, 162, 0.12); background: rgba(91, 179, 162, 0.12);
border: 1px solid rgba(91, 179, 162, 0.25); border: 1px solid rgba(91, 179, 162, 0.25);

View File

@ -29,14 +29,22 @@ pub type RelayChild = Child;
pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, child_running: bool) { pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, child_running: bool) {
let relay_live = child_running || state.remote_active; let relay_live = child_running || state.remote_active;
set_status_light(&widgets.summary.relay_light, state.server_available); set_status_light(&widgets.summary.relay_light, state.server_available);
widgets widgets.summary.relay_value.set_text(
.summary state
.relay_value .server_version
.set_text(if state.server_available { .as_deref()
"Online" .map(|version| version.trim())
} else { .filter(|version| !version.is_empty())
"Offline" .map(|version| {
}); if version.starts_with('v') {
version.to_string()
} else {
format!("v{version}")
}
})
.as_deref()
.unwrap_or(""),
);
set_status_light( set_status_light(
&widgets.summary.routing_light, &widgets.summary.routing_light,
matches!(state.routing, InputRouting::Remote), matches!(state.routing, InputRouting::Remote),

View File

@ -2,6 +2,8 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub mod app; pub mod app;
mod app_support; mod app_support;
pub mod handshake; pub mod handshake;

View File

@ -53,6 +53,7 @@ message HandshakeSet {
uint32 eye_width = 8; uint32 eye_width = 8;
uint32 eye_height = 9; uint32 eye_height = 9;
uint32 eye_fps = 10; uint32 eye_fps = 10;
string server_version = 11;
} }
message Empty {} message Empty {}

View File

@ -33,6 +33,7 @@ impl Handshake for HandshakeSvc {
eye_width, eye_width,
eye_height, eye_height,
eye_fps, eye_fps,
server_version: crate::VERSION.to_string(),
})) }))
} }
} }

View File

@ -1,5 +1,7 @@
// server/src/lib.rs // server/src/lib.rs
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub mod audio; pub mod audio;
pub mod camera; pub mod camera;
pub mod camera_runtime; pub mod camera_runtime;

View File

@ -7,16 +7,19 @@
//! devices. //! devices.
mod handshake { mod handshake {
#[allow(dead_code)]
#[derive(Default, Clone, Debug)] #[derive(Default, Clone, Debug)]
pub struct PeerCaps { pub struct PeerCaps {
pub camera: bool, pub camera: bool,
pub microphone: bool, pub microphone: bool,
pub server_version: Option<String>,
} }
pub async fn negotiate(_uri: &str) -> PeerCaps { pub async fn negotiate(_uri: &str) -> PeerCaps {
PeerCaps { PeerCaps {
camera: std::env::var("LESAVKA_TEST_CAP_CAMERA").is_ok(), camera: std::env::var("LESAVKA_TEST_CAP_CAMERA").is_ok(),
microphone: std::env::var("LESAVKA_TEST_CAP_MIC").is_ok(), microphone: std::env::var("LESAVKA_TEST_CAP_MIC").is_ok(),
server_version: None,
} }
} }
} }

View File

@ -54,6 +54,7 @@ async fn negotiate_against_local_server() -> PeerCaps {
fn assert_default_caps(caps: &PeerCaps) { fn assert_default_caps(caps: &PeerCaps) {
assert!(!caps.camera); assert!(!caps.camera);
assert!(!caps.microphone); assert!(!caps.microphone);
assert_eq!(caps.server_version, None);
assert_eq!(caps.camera_output, None); assert_eq!(caps.camera_output, None);
assert_eq!(caps.camera_codec, None); assert_eq!(caps.camera_codec, None);
assert_eq!(caps.camera_width, None); assert_eq!(caps.camera_width, None);
@ -99,6 +100,7 @@ impl Handshake for SparseHandshakeSvc {
Ok(Response::new(HandshakeSet { Ok(Response::new(HandshakeSet {
camera: true, camera: true,
microphone: false, microphone: false,
server_version: String::new(),
camera_output: String::new(), camera_output: String::new(),
camera_codec: String::new(), camera_codec: String::new(),
camera_width: 0, camera_width: 0,
@ -135,6 +137,10 @@ fn handshake_returns_uvc_caps_with_explicit_dimensions_and_fps() {
let caps = rt.block_on(negotiate_against_local_server()); let caps = rt.block_on(negotiate_against_local_server());
assert!(caps.camera); assert!(caps.camera);
assert!(caps.microphone); assert!(caps.microphone);
assert_eq!(
caps.server_version,
Some(lesavka_server::VERSION.to_string())
);
assert_eq!(caps.camera_output, Some(String::from("uvc"))); assert_eq!(caps.camera_output, Some(String::from("uvc")));
assert_eq!(caps.camera_codec, Some(String::from("mjpeg"))); assert_eq!(caps.camera_codec, Some(String::from("mjpeg")));
assert_eq!(caps.camera_width, Some(1024)); assert_eq!(caps.camera_width, Some(1024));