diff --git a/client/src/app_support.rs b/client/src/app_support.rs index defbf95..e9da9d5 100644 --- a/client/src/app_support.rs +++ b/client/src/app_support.rs @@ -97,6 +97,7 @@ mod tests { let mut caps = PeerCaps { camera: true, microphone: false, + server_version: None, camera_output: Some(String::from("uvc")), camera_codec: Some(String::from("mjpeg")), camera_width: Some(1280), diff --git a/client/src/handshake.rs b/client/src/handshake.rs index 159f154..fb6e984 100644 --- a/client/src/handshake.rs +++ b/client/src/handshake.rs @@ -12,6 +12,7 @@ use tracing::{info, warn}; pub struct PeerCaps { pub camera: bool, pub microphone: bool, + pub server_version: Option, pub camera_output: Option, pub camera_codec: Option, pub camera_width: Option, @@ -64,6 +65,8 @@ pub async fn negotiate(uri: &str) -> PeerCaps { PeerCaps { camera: rsp.camera, 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_codec: (!rsp.camera_codec.is_empty()).then_some(rsp.camera_codec.clone()), 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 { camera: rsp.camera, 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() { None } else { diff --git a/client/src/launcher/state.rs b/client/src/launcher/state.rs index b3fb141..2c3e744 100644 --- a/client/src/launcher/state.rs +++ b/client/src/launcher/state.rs @@ -188,6 +188,7 @@ pub struct DeviceSelection { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LauncherState { pub server_available: bool, + pub server_version: Option, pub routing: InputRouting, pub view_mode: ViewMode, pub displays: [DisplaySurface; 2], @@ -209,6 +210,7 @@ impl Default for LauncherState { fn default() -> Self { Self { server_available: false, + server_version: None, routing: InputRouting::Remote, view_mode: ViewMode::Unified, displays: [DisplaySurface::Preview, DisplaySurface::Preview], @@ -241,6 +243,17 @@ impl LauncherState { self.server_available = available; } + pub fn set_server_version(&mut self, version: Option) { + 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) { self.view_mode = view_mode; self.displays = match view_mode { diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index 498a4d0..2db8fdb 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -487,7 +487,11 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { server_entry.connect_changed(move |_| { let server_addr = 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() { 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() { match message { CapsMessage::Refresh(caps) => { + { + let mut state = state.borrow_mut(); + state.set_server_version(caps.server_version.clone()); + } if let (Some(width), Some(height)) = (caps.eye_width, caps.eye_height) { diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index 479e0f9..be5a322 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -125,21 +125,29 @@ pub fn build_launcher_view( hero.set_hexpand(true); 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")); heading.add_css_class("title-2"); 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); let chips = gtk::Box::new(gtk::Orientation::Horizontal, 6); chips.set_halign(gtk::Align::End); 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) = build_status_chip_with_light("Inputs", "Local"); 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"); - stabilize_chip(&relay_chip, 84); + stabilize_chip(&relay_chip, 104); stabilize_chip(&routing_chip, 84); stabilize_chip(&gpio_chip, 84); stabilize_chip(&shortcut_chip, 88); @@ -610,6 +618,11 @@ pub fn install_css(window: >k::ApplicationWindow) { font-weight: 700; opacity: 0.92; } + label.version-tag { + font-size: 0.76rem; + opacity: 0.72; + margin-bottom: 3px; + } box.status-chip { background: rgba(91, 179, 162, 0.12); border: 1px solid rgba(91, 179, 162, 0.25); diff --git a/client/src/launcher/ui_runtime.rs b/client/src/launcher/ui_runtime.rs index 412054b..8b4c1a3 100644 --- a/client/src/launcher/ui_runtime.rs +++ b/client/src/launcher/ui_runtime.rs @@ -29,14 +29,22 @@ pub type RelayChild = Child; pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, child_running: bool) { let relay_live = child_running || state.remote_active; set_status_light(&widgets.summary.relay_light, state.server_available); - widgets - .summary - .relay_value - .set_text(if state.server_available { - "Online" - } else { - "Offline" - }); + widgets.summary.relay_value.set_text( + state + .server_version + .as_deref() + .map(|version| version.trim()) + .filter(|version| !version.is_empty()) + .map(|version| { + if version.starts_with('v') { + version.to_string() + } else { + format!("v{version}") + } + }) + .as_deref() + .unwrap_or(""), + ); set_status_light( &widgets.summary.routing_light, matches!(state.routing, InputRouting::Remote), diff --git a/client/src/lib.rs b/client/src/lib.rs index 15bb775..b06c3ea 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -2,6 +2,8 @@ #![forbid(unsafe_code)] +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + pub mod app; mod app_support; pub mod handshake; diff --git a/common/proto/lesavka.proto b/common/proto/lesavka.proto index bd02ade..519186b 100644 --- a/common/proto/lesavka.proto +++ b/common/proto/lesavka.proto @@ -53,6 +53,7 @@ message HandshakeSet { uint32 eye_width = 8; uint32 eye_height = 9; uint32 eye_fps = 10; + string server_version = 11; } message Empty {} diff --git a/server/src/handshake.rs b/server/src/handshake.rs index bd3ef52..d9345ca 100644 --- a/server/src/handshake.rs +++ b/server/src/handshake.rs @@ -33,6 +33,7 @@ impl Handshake for HandshakeSvc { eye_width, eye_height, eye_fps, + server_version: crate::VERSION.to_string(), })) } } diff --git a/server/src/lib.rs b/server/src/lib.rs index 9ec9960..1f461a2 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -1,5 +1,7 @@ // server/src/lib.rs +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + pub mod audio; pub mod camera; pub mod camera_runtime; diff --git a/testing/tests/client_app_include_contract.rs b/testing/tests/client_app_include_contract.rs index ce06698..c8568c7 100644 --- a/testing/tests/client_app_include_contract.rs +++ b/testing/tests/client_app_include_contract.rs @@ -7,16 +7,19 @@ //! devices. mod handshake { + #[allow(dead_code)] #[derive(Default, Clone, Debug)] pub struct PeerCaps { pub camera: bool, pub microphone: bool, + pub server_version: Option, } pub async fn negotiate(_uri: &str) -> PeerCaps { PeerCaps { camera: std::env::var("LESAVKA_TEST_CAP_CAMERA").is_ok(), microphone: std::env::var("LESAVKA_TEST_CAP_MIC").is_ok(), + server_version: None, } } } diff --git a/testing/tests/handshake_camera_contract.rs b/testing/tests/handshake_camera_contract.rs index 7eaaf3f..71acb57 100644 --- a/testing/tests/handshake_camera_contract.rs +++ b/testing/tests/handshake_camera_contract.rs @@ -54,6 +54,7 @@ async fn negotiate_against_local_server() -> PeerCaps { fn assert_default_caps(caps: &PeerCaps) { assert!(!caps.camera); assert!(!caps.microphone); + assert_eq!(caps.server_version, None); assert_eq!(caps.camera_output, None); assert_eq!(caps.camera_codec, None); assert_eq!(caps.camera_width, None); @@ -99,6 +100,7 @@ impl Handshake for SparseHandshakeSvc { Ok(Response::new(HandshakeSet { camera: true, microphone: false, + server_version: String::new(), camera_output: String::new(), camera_codec: String::new(), 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()); assert!(caps.camera); 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_codec, Some(String::from("mjpeg"))); assert_eq!(caps.camera_width, Some(1024));