diff --git a/lan-mouse-gtk/src/client_object.rs b/lan-mouse-gtk/src/client_object.rs index 9f148fd..0e34fda 100644 --- a/lan-mouse-gtk/src/client_object.rs +++ b/lan-mouse-gtk/src/client_object.rs @@ -26,6 +26,7 @@ impl ClientObject { .collect::>(), ) .property("resolving", state.resolving) + .property("peer-commit", peer_commit_to_string(state.peer_commit)) .build() } @@ -34,6 +35,14 @@ impl ClientObject { } } +/// Render the 8-byte ASCII commit hash carried in +/// [`lan_mouse_ipc::ClientState::peer_commit`] as a `String`. `None` +/// in → `None` out (peer hasn't sent a Hello yet, or speaks an older +/// proto). +pub fn peer_commit_to_string(commit: Option<[u8; 8]>) -> Option { + commit.and_then(|c| std::str::from_utf8(&c).ok().map(str::to_string)) +} + #[derive(Default, Clone)] pub struct ClientData { pub handle: ClientHandle, @@ -43,4 +52,5 @@ pub struct ClientData { pub position: String, pub resolving: bool, pub ips: Vec, + pub peer_commit: Option, } diff --git a/lan-mouse-gtk/src/client_object/imp.rs b/lan-mouse-gtk/src/client_object/imp.rs index 016ae8c..2096584 100644 --- a/lan-mouse-gtk/src/client_object/imp.rs +++ b/lan-mouse-gtk/src/client_object/imp.rs @@ -19,6 +19,7 @@ pub struct ClientObject { #[property(name = "position", get, set, type = String, member = position)] #[property(name = "resolving", get, set, type = bool, member = resolving)] #[property(name = "ips", get, set, type = Vec, member = ips)] + #[property(name = "peer-commit", get, set, type = Option, member = peer_commit)] pub data: RefCell, } diff --git a/lan-mouse-gtk/src/client_row.rs b/lan-mouse-gtk/src/client_row.rs index cd3d02b..a26c63b 100644 --- a/lan-mouse-gtk/src/client_row.rs +++ b/lan-mouse-gtk/src/client_row.rs @@ -123,6 +123,12 @@ impl ClientRow { bindings.push(position_binding); bindings.push(resolve_binding); bindings.push(ip_binding); + + // Render the initial collapsed subtitle from whatever + // peer_commit the ClientObject was created with. Subsequent + // changes are pushed by `Window::update_client_state` calling + // `refresh_version_status` after writing the new property. + self.refresh_version_status(); } pub fn unbind(&self) { @@ -150,4 +156,34 @@ impl ClientRow { pub fn set_dns_state(&self, resolved: bool) { self.imp().set_dns_state(resolved); } + + /// Recompute the collapsed subtitle (Pango markup) based on the + /// current `peer-commit` property and the local build's commit. + /// Soft-warn semantics: a missing or mismatched peer commit + /// surfaces as orange text but never blocks traffic. Called by + /// the window after `update_client_state` writes the new + /// `peer-commit`. The dns-status icon is left to its existing + /// `set_dns_state` handler so the two indicators don't fight + /// for the same CSS class. + pub fn refresh_version_status(&self) { + let peer: Option = self + .imp() + .client_object + .borrow() + .as_ref() + .and_then(|co| co.property::>("peer-commit")); + let local = crate::local_commit_str(); + let markup = match peer.as_deref() { + None => format!( + r##"peer version: unknown · ours: {local}"## + ), + Some(p) if p == local.as_str() => format!( + r##"peer version: {p} · matched"## + ), + Some(p) => format!( + r##"peer version: {p} · ours: {local}"## + ), + }; + self.set_subtitle(&markup); + } } diff --git a/lan-mouse-gtk/src/lib.rs b/lan-mouse-gtk/src/lib.rs index ecdd708..7809487 100644 --- a/lan-mouse-gtk/src/lib.rs +++ b/lan-mouse-gtk/src/lib.rs @@ -10,10 +10,27 @@ mod macos_privacy; mod macos_status_item; mod window; -use std::{env, process, str}; +use std::{env, process, str, sync::OnceLock}; use window::Window; +/// Local build's commit hash, set once by [`run`] before the GTK +/// main loop starts. Read by per-row UI to compare against each +/// peer's [`lan_mouse_ipc::ClientState::peer_commit`] for the +/// soft-warn version-mismatch indicator. +pub(crate) static LOCAL_COMMIT: OnceLock<[u8; 8]> = OnceLock::new(); + +/// Convenience: returns the local commit as an 8-char ASCII string, +/// or a placeholder if unset (which would indicate a programmer +/// error since [`run`] always sets it). +pub(crate) fn local_commit_str() -> String { + LOCAL_COMMIT + .get() + .and_then(|c| std::str::from_utf8(c).ok()) + .unwrap_or("????????") + .to_string() +} + use lan_mouse_ipc::FrontendEvent; use adw::Application; @@ -31,8 +48,13 @@ pub enum GtkError { NonZeroExitCode(i32), } -pub fn run() -> Result<(), GtkError> { +pub fn run(local_commit: [u8; 8]) -> Result<(), GtkError> { log::debug!("running gtk frontend"); + LOCAL_COMMIT + .set(local_commit) + .expect("local_commit set once"); + + #[cfg(windows)] let ret = std::thread::Builder::new() .stack_size(8 * 1024 * 1024) // https://gitlab.gnome.org/GNOME/gtk/-/commit/52dbb3f372b2c3ea339e879689c1de535ba2c2c3 -> caused crash on windows diff --git a/lan-mouse-gtk/src/window.rs b/lan-mouse-gtk/src/window.rs index f650157..f498f05 100644 --- a/lan-mouse-gtk/src/window.rs +++ b/lan-mouse-gtk/src/window.rs @@ -365,6 +365,13 @@ impl Window { .map(|ip| ip.to_string()) .collect::>(); client_object.set_ips(ips); + + /* peer build version (drives the version-match indicator) */ + client_object.set_property( + "peer-commit", + crate::client_object::peer_commit_to_string(state.peer_commit), + ); + row.refresh_version_status(); } fn client_object_for_handle(&self, handle: ClientHandle) -> Option { diff --git a/lan-mouse-ipc/src/lib.rs b/lan-mouse-ipc/src/lib.rs index a2c7112..17e9799 100644 --- a/lan-mouse-ipc/src/lib.rs +++ b/lan-mouse-ipc/src/lib.rs @@ -176,6 +176,12 @@ pub struct ClientState { pub has_pressed_keys: bool, /// dns resolving in progress pub resolving: bool, + /// Peer's build short commit hash from the [`Hello`] proto + /// event. `None` means we haven't received a Hello yet — either + /// the connection is fresh, or the peer is on an older build + /// that predates the Hello event. The frontend uses this to + /// soft-warn on version mismatch. + pub peer_commit: Option<[u8; 8]>, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/lan-mouse-proto/src/lib.rs b/lan-mouse-proto/src/lib.rs index 41f2efb..bff42db 100644 --- a/lan-mouse-proto/src/lib.rs +++ b/lan-mouse-proto/src/lib.rs @@ -63,6 +63,15 @@ pub enum ProtoEvent { Ping, /// Response to [`ProtoEvent::Ping`], true if emulation is enabled / available Pong(bool), + /// Build identification for the sending peer. Sent by the + /// connect side once after the connection authenticates, and + /// echoed back by the listen side in reply, so each end can + /// display the peer's build hash and warn (soft) on mismatch. + /// `commit` is the 8-byte ASCII short commit hash from + /// `shadow_rs`'s `SHORT_COMMIT`. Old peers that don't + /// recognize the event type silently skip it per the + /// forward-compat handling in the receive loop. + Hello { commit: [u8; 8] }, } impl Display for ProtoEvent { @@ -80,6 +89,10 @@ impl Display for ProtoEvent { if *alive { "alive" } else { "not available" } ) } + ProtoEvent::Hello { commit } => { + let s = std::str::from_utf8(commit).unwrap_or("????????"); + write!(f, "Hello({s})") + } } } } @@ -98,6 +111,7 @@ pub enum EventType { Enter, Leave, Ack, + Hello, } impl ProtoEvent { @@ -120,6 +134,7 @@ impl ProtoEvent { ProtoEvent::Enter(_) => EventType::Enter, ProtoEvent::Leave(_) => EventType::Leave, ProtoEvent::Ack(_) => EventType::Ack, + ProtoEvent::Hello { .. } => EventType::Hello, } } } @@ -174,6 +189,13 @@ impl TryFrom<[u8; MAX_EVENT_SIZE]> for ProtoEvent { EventType::Enter => Ok(Self::Enter(decode_u8(&mut buf)?.try_into()?)), EventType::Leave => Ok(Self::Leave(decode_u32(&mut buf)?)), EventType::Ack => Ok(Self::Ack(decode_u32(&mut buf)?)), + EventType::Hello => { + let mut commit = [0u8; 8]; + for b in commit.iter_mut() { + *b = decode_u8(&mut buf)?; + } + Ok(Self::Hello { commit }) + } } } } @@ -238,6 +260,11 @@ impl From for ([u8; MAX_EVENT_SIZE], usize) { ProtoEvent::Enter(pos) => encode_u8(buf, len, pos as u8), ProtoEvent::Leave(serial) => encode_u32(buf, len, serial), ProtoEvent::Ack(serial) => encode_u32(buf, len, serial), + ProtoEvent::Hello { commit } => { + for b in commit.iter() { + encode_u8(buf, len, *b); + } + } } } (buf, len) diff --git a/src/client.rs b/src/client.rs index 3229c8c..faaa13f 100644 --- a/src/client.rs +++ b/src/client.rs @@ -282,6 +282,12 @@ impl ClientManager { } } + pub(crate) fn set_peer_commit(&self, handle: ClientHandle, commit: Option<[u8; 8]>) { + if let Some((_, s)) = self.clients.borrow_mut().get_mut(handle as usize) { + s.peer_commit = commit; + } + } + pub(crate) fn active_addr(&self, handle: ClientHandle) -> Option { self.clients .borrow() diff --git a/src/config.rs b/src/config.rs index cc6baf9..4a5e917 100644 --- a/src/config.rs +++ b/src/config.rs @@ -28,6 +28,18 @@ use shadow_rs::shadow; shadow!(build); +/// Local build's 8-byte ASCII short commit hash, suitable for use +/// in [`lan_mouse_proto::ProtoEvent::Hello`]. Pads with `'?'` if +/// shadow_rs returns an unexpected length so the field is always +/// well-formed on the wire. +pub fn local_commit() -> [u8; 8] { + let bytes = build::SHORT_COMMIT.as_bytes(); + let mut out = [b'?'; 8]; + let n = bytes.len().min(8); + out[..n].copy_from_slice(&bytes[..n]); + out +} + const CONFIG_FILE_NAME: &str = "config.toml"; const CERT_FILE_NAME: &str = "lan-mouse.pem"; diff --git a/src/connect.rs b/src/connect.rs index af17461..721f049 100644 --- a/src/connect.rs +++ b/src/connect.rs @@ -1,4 +1,5 @@ use crate::client::ClientManager; +use crate::config::local_commit; use lan_mouse_ipc::{ClientHandle, DEFAULT_PORT}; use lan_mouse_proto::{MAX_EVENT_SIZE, ProtoEvent}; use local_channel::mpsc::{Receiver, Sender, channel}; @@ -197,6 +198,16 @@ async fn connect_to_handle( conns.lock().await.insert(addr, conn.clone()); connecting.lock().await.remove(&handle); + // Best-effort version handshake. Send our commit hash once + // immediately after the DTLS handshake; the listen side + // mirrors a Hello back so the receive loop can populate + // `peer_commit`. Old peers will silently skip this event + // per the forward-compat handler in [`receive_loop`]. + let (buf, len) = ProtoEvent::Hello { commit: local_commit() }.into(); + if let Err(e) = conn.send(&buf[..len]).await { + log::debug!("hello send to {addr} failed: {e}"); + } + // poll connection for active spawn_local(ping_pong(addr, conn.clone(), ping_response.clone())); @@ -264,6 +275,9 @@ async fn receive_loop( client_manager.set_alive(handle, b); ping_response.borrow_mut().insert(addr); } + ProtoEvent::Hello { commit } => { + client_manager.set_peer_commit(handle, Some(commit)); + } event => tx.send((handle, event)).expect("channel closed"), } } @@ -287,6 +301,7 @@ async fn disconnect( log::warn!("client ({handle}) @ {addr} connection closed"); conns.lock().await.remove(&addr); client_manager.set_active_addr(handle, None); + client_manager.set_peer_commit(handle, None); let active: Vec = conns.lock().await.keys().copied().collect(); log::info!("active connections: {active:?}"); } diff --git a/src/emulation.rs b/src/emulation.rs index 853267c..d2ff60f 100644 --- a/src/emulation.rs +++ b/src/emulation.rs @@ -1,3 +1,4 @@ +use crate::config::local_commit; use crate::listen::{LanMouseListener, ListenEvent, ListenerCreationError}; use futures::StreamExt; use input_emulation::{EmulationHandle, InputEmulation, InputEmulationError}; @@ -150,6 +151,16 @@ impl ListenTask { } ProtoEvent::Input(event) => self.emulation_proxy.consume(event, addr), ProtoEvent::Ping => self.listener.reply(addr, ProtoEvent::Pong(self.emulation_proxy.emulation_active.get())).await, + // Mirror the peer's version handshake. The + // outgoing connect side initiated this with + // its own Hello; we echo ours back so the + // peer's connect-side receive_loop can + // populate `peer_commit`. We don't store + // the peer's commit on the listen side — + // the user-visible state lives on outgoing + // connections, where the same peer is also + // configured as a `[[clients]]` entry. + ProtoEvent::Hello { .. } => self.listener.reply(addr, ProtoEvent::Hello { commit: local_commit() }).await, _ => {} } } diff --git a/src/main.rs b/src/main.rs index 0ca54c9..93ae193 100644 --- a/src/main.rs +++ b/src/main.rs @@ -74,7 +74,7 @@ fn run() -> Result<(), LanMouseError> { #[cfg(feature = "gtk")] { let mut service = start_service()?; - let res = lan_mouse_gtk::run(); + let res = lan_mouse_gtk::run(config::local_commit()); #[cfg(unix)] { // on unix we give the service a chance to terminate gracefully