Compare commits

...

17 Commits

Author SHA1 Message Date
Ferdinand Schober
d68df35409 use proper css styling for the font colors 2026-06-14 22:59:26 +02:00
Jon Kinney
c2f6e172bb chore: cargo fmt for peer-version code 2026-06-14 22:59:26 +02:00
Jon Kinney
32b6683cda fix(version-exchange): also store peer commit on the listen side
Previously the Hello handler in `ListenTask` echoed our local commit
back but deliberately threw away the peer's, on the assumption that
the outgoing connect-side path (`connect.rs:278-279` →
`set_peer_commit`) would always populate the visible state for any
bidirectionally-configured peer.

That assumption breaks any time the *outgoing* TCP/DTLS direction is
broken even though the inbound direction is fine — happened just now
when the peer Mac's daemon stopped listening on 4242 (DHCP-renewed
IP, daemon crashed, asymmetric NAT, …). Mac was still happily
connecting in the other direction and sending events, including the
initial Hello, but Linux silently displayed "peer version unknown"
because the listen side dropped Mac's commit on the floor.

Add a `PeerHello { addr, commit }` EmulationEvent variant fired from
the listen-side Hello handler. The service maps `addr → ClientHandle`
via `client_manager.get_client(addr)` and calls `set_peer_commit` +
`broadcast_client` exactly like the connect path does. The connect
path remains the primary source for symmetric setups; this is the
defensive fallback so version visibility doesn't depend on outbound
reachability.

Skips silently when no outgoing client is configured for the peer's
addr (incoming-only setup) — there's no UI row to update in that
case anyway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-14 22:59:26 +02:00
Jon Kinney
62b22e1764 ui(client_row): sentence-case "Peer version" and "Ours" labels
These are user-visible labels in the version-status subtitle, so
sentence-case reads better than the lowercase first-pass. "matched"
stays lowercase since it's a status descriptor, not a label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-14 22:59:26 +02:00
Jon Kinney
72c86c0d83 feat: peer version exchange with soft-warn UI indicator
Adds a one-shot Hello message to the lan-mouse wire protocol so each
peer can display the other end's build commit hash and warn on
version mismatch. Soft-warn only — mismatched versions never refuse
traffic.

Wire change (lan-mouse-proto)
* `ProtoEvent::Hello { commit: [u8; 8] }` carries the 8-byte ASCII
  short commit from shadow_rs's `SHORT_COMMIT`. Encoded/decoded
  alongside the existing event variants.
* `EventType::Hello` is appended to the enum so existing IDs are
  untouched. Old peers receive the event, hit `InvalidEventId`, and
  silently skip it via the forward-compat handler in
  `connect.rs::receive_loop` — the connection is unaffected.

Daemon
* Connect side sends one Hello immediately after the DTLS handshake
  authenticates and before the ping_pong loop starts. Best-effort,
  fire-and-forget — `log::debug!` on send error.
* Listen side mirrors the peer's Hello with its own (same shape as
  the existing Ping → Pong reply), so the peer's connect-side
  receive_loop populates `ClientState::peer_commit` for that
  handle.
* The disconnect path clears `peer_commit` so a stale hash isn't
  shown after the connection drops.

IPC
* `ClientState::peer_commit: Option<[u8; 8]>`. `None` means the
  peer hasn't sent Hello yet — either fresh connection or older
  build that predates the event.

GTK
* `ClientObject` exposes `peer-commit` as an `Option<String>`
  property; `peer_commit_to_string` converts the wire `[u8; 8]` to
  the displayable hex.
* `lan_mouse_gtk::run` now takes the local commit and stashes it in
  a `OnceLock` so per-row UI can compare against each peer's hash.
* `ClientRow::refresh_version_status` re-renders the collapsed
  subtitle with Pango markup whenever the property changes:
   - matched → green   "peer version: <hex> · matched"
   - mismatch → orange "peer version: <hex> · ours: <hex>"
   - unknown → orange  "peer version: unknown · ours: <hex>"
* Window invokes `refresh_version_status` from
  `update_client_state` after writing the new property, and
  `bind` calls it once on row construction so the initial
  subtitle isn't blank.

Known limitation: state-change broadcasts from the network side
(set_alive / set_active_addr / set_peer_commit) don't currently
trigger a `FrontendEvent::State` directly; the UI picks up the
latest values on the next user-driven broadcast. Same pre-existing
behavior as the alive/active_addr fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-14 22:59:26 +02:00
Jon Kinney
82766cdc87 fix(proto): tolerate undecodable peer datagrams instead of disconnecting
The DTLS recv loops in src/listen.rs and src/connect.rs each read
one full datagram per call. A failed `try_into::<ProtoEvent>()`
means the datagram's leading EventType byte didn't match any
known variant — a misalignment is impossible because DTLS is
message-framed, not stream-framed.

Previously, src/listen.rs would `break` out of the loop on parse
failure (tearing down the connection) and src/connect.rs would
silently swallow the error with no log. Both are wrong as
forward-compat behavior: any future protocol addition (e.g. a
new event variant) would force every existing peer to disconnect
rather than gracefully ignoring the unknown event.

Skip-and-continue on both sides, with a debug-level log so the
behavior is observable. Pre-requisite for any future ProtoEvent
variant to land without forcing a coordinated upgrade across
every peer in a deployment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-14 22:59:26 +02:00
Ferdinand Schober
dbeaea03ad ignore metadata change on config file
This avoids parsing the config file a second time on change because
of the metadata change.
2026-06-14 17:19:07 +02:00
Ferdinand Schober
0d2190e787 chore: Release 2026-06-12 15:07:11 +02:00
Ty Smith
4b93be3228 docs(macos): clarify repeat-task cleanup releases the key
Address @feschber review feedback on PR #441. The repeat-task cleanup
already releases the key with the correct CGKeyCode via the existing
key_event call at the end of the closure — this commit just expands
the surrounding comment to make that explicit and to document why
update_modifiers is intentionally NOT called from this path (Mac
CGKeyCode vs Linux evdev scancode collision).

No behavioral change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-12 12:07:03 +02:00
Ty Smith
c32d695cd9 macos: stop corrupting modifier state in repeat-task cleanup
spawn_repeat_task() takes a Mac CGKeyCode, but the cleanup block was
passing that value to update_modifiers(), which expects a Linux evdev
scancode (it calls scancode::Linux::try_from(key)). The two codespaces
collide on several values, so cancelling the repeat task could
silently clear a still-held modifier:

  Mac LeftShift   = 56  == Linux KeyLeftAlt   = 56  -> clears Mod1Mask
  Mac Down arrow  = 125 == Linux KeyLeftMeta  = 125 -> clears Mod4Mask
  Mac Up arrow    = 126 == Linux KeyRightMeta = 126 -> clears Mod4Mask
  Mac Backslash   = 42  == Linux KeyLeftShift = 42  -> clears ShiftMask
  Mac "9"         = 29  == Linux KeyLeftCtrl  = 29  -> clears ControlMask

In practice this broke chords such as Shift+Option+X and Cmd+Down:
pressing Shift while holding Option cancels Option's repeat task and
runs the buggy cleanup, which then interprets Mac LeftShift's code
(56) as Linux KeyLeftAlt and removes Option from the modifier state.
The next key arrives with Shift only, so window-manager bindings on
the original Option chord never fire.

Remove the buggy update_modifiers() call. Modifier state is owned by
the main consume() loop, which already calls update_modifiers() with
the correct Linux scancode on the real release event.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-12 12:07:03 +02:00
Ty Smith
82d677f9c8 macos: post NumericPad and SecondaryFn flags for synthesized arrow keys
Hardware-generated arrow key events on macOS carry the NumericPad and
SecondaryFn flags in addition to any user-pressed modifiers. CGEventTap-
based hotkey matchers (tiling window managers, accessibility tools, etc.)
commonly check those flags to disambiguate navigation arrows from generic
chords, and reject events that lack them.

Before this change, synthesized Option+Arrow chords were silently
swallowed by the focused application instead of being captured by the
window manager, because the events arrived with only the Alternate flag
set. Hardware Option+Arrow chords on the local keyboard worked because
the OS itself set the missing flags.

Add NumericPad + SecondaryFn to the flags posted with arrow key events
(Mac key codes 0x7B-0x7E) so synthesized arrow chords match hardware
chords on the wire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-12 12:07:03 +02:00
Ferdinand Schober
7ef43418c9 fix output name 2026-06-11 17:27:04 +02:00
Ferdinand Schober
8f32b7fe96 fix short-sha 2026-06-11 16:55:33 +02:00
Ferdinand Schober
02ac0bf220 include commit hash in pre-release (#456)
this solves the "create new release" issue as well as tag conflicts with
branch name
2026-06-11 16:50:04 +02:00
Ferdinand Schober
1b53e58ba9 remaining feature flags (#444) 2026-06-08 14:38:24 +02:00
Ferdinand Schober
a9461ae830 move feature flags to build.rs (#439) 2026-05-19 11:26:06 +02:00
Ferdinand Schober
1fa3800d3c windows: fix clippy lints 2026-05-16 17:09:06 +02:00
36 changed files with 518 additions and 149 deletions

View File

@@ -174,13 +174,16 @@ jobs:
steps:
- name: Download build artifacts
uses: actions/download-artifact@v4
- name: Get short SHA
id: vars
run: echo "short_sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- name: Create Pre-Release
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
uses: softprops/action-gh-release@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ github.event.inputs.name || github.ref_name }}
name: ${{ github.event.inputs.name || github.ref_name }}
tag_name: ${{ format('{0}-{1}', github.event.inputs.name || github.ref_name, steps.vars.outputs.short_sha) }}
name: ${{ format('{0}-{1}', github.event.inputs.name || github.ref_name, steps.vars.outputs.short_sha) }}
prerelease: true
generate_release_notes: true
files: |

View File

@@ -28,7 +28,7 @@ Lan Mouse is an open-source Software KVM sharing mouse/keyboard input across loc
## Feature & cfg discipline
- Feature flags live in root `Cargo.toml`. Gate OS-specific modules with tight cfgs (e.g., `cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))`).
- Feature flags live in root `Cargo.toml`. Gate OS-specific modules with the configs exported in build.rs (e.g., `cfg(layer_shell)`).
- Prefer module-level gating over per-function cfgs to avoid empty stubs.
- New backends: add feature in `Cargo.toml`, create gated module, log backend selection.

16
Cargo.lock generated
View File

@@ -1647,7 +1647,7 @@ dependencies = [
[[package]]
name = "input-capture"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"ashpd",
"async-trait",
@@ -1677,7 +1677,7 @@ dependencies = [
[[package]]
name = "input-emulation"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"ashpd",
"async-trait",
@@ -1703,7 +1703,7 @@ dependencies = [
[[package]]
name = "input-event"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"futures-core",
"log",
@@ -1840,7 +1840,7 @@ dependencies = [
[[package]]
name = "lan-mouse"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"clap",
"env_logger",
@@ -1875,7 +1875,7 @@ dependencies = [
[[package]]
name = "lan-mouse-cli"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"clap",
"futures",
@@ -1886,7 +1886,7 @@ dependencies = [
[[package]]
name = "lan-mouse-gtk"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"async-channel",
"glib-build-tools",
@@ -1900,7 +1900,7 @@ dependencies = [
[[package]]
name = "lan-mouse-ipc"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"futures",
"log",
@@ -1913,7 +1913,7 @@ dependencies = [
[[package]]
name = "lan-mouse-proto"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"input-event",
"num_enum",

View File

@@ -12,7 +12,7 @@ members = [
[package]
name = "lan-mouse"
description = "Software KVM Switch / mouse & keyboard sharing software for Local Area Networks"
version = "0.10.0"
version = "0.11.0"
edition = "2021"
license = "GPL-3.0-or-later"
repository = "https://github.com/feschber/lan-mouse"
@@ -27,13 +27,13 @@ panic = "abort"
shadow-rs = "1.2.0"
[dependencies]
input-event = { path = "input-event", version = "0.3.0" }
input-emulation = { path = "input-emulation", version = "0.3.0", default-features = false }
input-capture = { path = "input-capture", version = "0.3.0", default-features = false }
lan-mouse-cli = { path = "lan-mouse-cli", version = "0.2.0" }
lan-mouse-gtk = { path = "lan-mouse-gtk", version = "0.2.0", optional = true }
lan-mouse-ipc = { path = "lan-mouse-ipc", version = "0.2.0" }
lan-mouse-proto = { path = "lan-mouse-proto", version = "0.2.0" }
input-event = { path = "input-event", version = "0.4.0" }
input-emulation = { path = "input-emulation", version = "0.4.0", default-features = false }
input-capture = { path = "input-capture", version = "0.4.0", default-features = false }
lan-mouse-cli = { path = "lan-mouse-cli", version = "0.3.0" }
lan-mouse-gtk = { path = "lan-mouse-gtk", version = "0.3.0", optional = true }
lan-mouse-ipc = { path = "lan-mouse-ipc", version = "0.3.0" }
lan-mouse-proto = { path = "lan-mouse-proto", version = "0.3.0" }
shadow-rs = { version = "1.2.0", features = ["metadata"] }
hickory-resolver = "0.25.2"

View File

@@ -5,4 +5,57 @@ fn main() {
.deny_const(Default::default())
.build()
.expect("shadow build");
let unix = cfg!(unix);
let macos = cfg!(target_os = "macos");
let layer_shell_capture = cfg!(feature = "layer_shell_capture");
let libei_capture = cfg!(feature = "libei_capture");
let x11_capture = cfg!(feature = "x11_capture");
let libei_emulation = cfg!(feature = "libei_emulation");
let x11_emulation = cfg!(feature = "x11_emulation");
let wlroots_emulation = cfg!(feature = "wlroots_emulation");
let rdp_emulation = cfg!(feature = "rdp_emulation");
let layer_shell_capture = unix && !macos && layer_shell_capture;
let libei_capture = unix && !macos && libei_capture;
let x11_capture = unix && !macos && x11_capture;
let libei_emulation = unix && !macos && libei_emulation;
let rdp_emulation = unix && !macos && rdp_emulation;
let wlroots_emulation = unix && !macos && wlroots_emulation;
let x11_emulation = unix && !macos && x11_emulation;
println!("cargo::rustc-check-cfg=cfg(layer_shell_capture)");
println!("cargo::rustc-check-cfg=cfg(libei_capture)");
println!("cargo::rustc-check-cfg=cfg(x11_capture)");
println!("cargo::rustc-check-cfg=cfg(libei_emulation)");
println!("cargo::rustc-check-cfg=cfg(rdp_emulation)");
println!("cargo::rustc-check-cfg=cfg(wlroots_emulation)");
println!("cargo::rustc-check-cfg=cfg(x11_emulation)");
if layer_shell_capture {
println!("cargo::rustc-cfg=layer_shell_capture");
}
if libei_capture {
println!("cargo::rustc-cfg=libei_capture");
}
if x11_capture {
println!("cargo::rustc-cfg=x11_capture");
}
if libei_emulation {
println!("cargo::rustc-cfg=libei_emulation");
}
if rdp_emulation {
println!("cargo::rustc-cfg=rdp_emulation");
}
if wlroots_emulation {
println!("cargo::rustc-cfg=wlroots_emulation");
}
if x11_emulation {
println!("cargo::rustc-cfg=x11_emulation");
}
}

View File

@@ -1,7 +1,7 @@
[package]
name = "input-capture"
description = "cross-platform input-capture library used by lan-mouse"
version = "0.3.0"
version = "0.4.0"
edition = "2021"
license = "GPL-3.0-or-later"
repository = "https://github.com/feschber/lan-mouse"
@@ -10,7 +10,7 @@ repository = "https://github.com/feschber/lan-mouse"
futures = "0.3.28"
futures-core = "0.3.30"
log = "0.4.22"
input-event = { path = "../input-event", version = "0.3.0" }
input-event = { path = "../input-event", version = "0.4.0" }
memmap = "0.7"
tempfile = "3.25.0"
thiserror = "2.0.0"

25
input-capture/build.rs Normal file
View File

@@ -0,0 +1,25 @@
fn main() {
let unix = cfg!(unix);
let layer_shell = cfg!(feature = "layer_shell");
let libei = cfg!(feature = "libei");
let x11 = cfg!(feature = "x11");
let macos = cfg!(target_os = "macos");
let libei = unix && !macos && libei;
let layer_shell = unix && !macos && layer_shell;
let x11 = unix && !macos && x11;
println!("cargo::rustc-check-cfg=cfg(layer_shell)");
println!("cargo::rustc-check-cfg=cfg(libei)");
println!("cargo::rustc-check-cfg=cfg(x11)");
if layer_shell {
println!("cargo::rustc-cfg=layer_shell");
}
if libei {
println!("cargo::rustc-cfg=libei");
}
if x11 {
println!("cargo::rustc-cfg=x11");
}
}

View File

@@ -8,16 +8,16 @@ pub enum InputCaptureError {
Capture(#[from] CaptureError),
}
#[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))]
#[cfg(layer_shell)]
use std::io;
#[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))]
#[cfg(layer_shell)]
use wayland_client::{
ConnectError, DispatchError,
backend::WaylandError,
globals::{BindError, GlobalError},
};
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
#[cfg(libei)]
use ashpd::desktop::ResponseError;
#[cfg(target_os = "macos")]
@@ -31,13 +31,13 @@ pub enum CaptureError {
EndOfStream,
#[error("io error: `{0}`")]
Io(#[from] std::io::Error),
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
#[cfg(libei)]
#[error("libei error: `{0}`")]
Reis(#[from] reis::Error),
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
#[cfg(libei)]
#[error(transparent)]
Portal(#[from] ashpd::Error),
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
#[cfg(libei)]
#[error("libei disconnected - reason: `{0}`")]
Disconnected(String),
#[cfg(target_os = "macos")]
@@ -61,13 +61,13 @@ pub enum CaptureError {
pub enum CaptureCreationError {
#[error("no backend available")]
NoAvailableBackend,
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
#[cfg(libei)]
#[error("error creating input-capture-portal backend: `{0}`")]
Libei(#[from] LibeiCaptureCreationError),
#[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))]
#[cfg(layer_shell)]
#[error("error creating layer-shell capture backend: `{0}`")]
LayerShell(#[from] LayerShellCaptureCreationError),
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
#[cfg(x11)]
#[error("error creating x11 capture backend: `{0}`")]
X11(#[from] X11InputCaptureCreationError),
#[cfg(windows)]
@@ -80,7 +80,7 @@ pub enum CaptureCreationError {
impl CaptureCreationError {
/// request was intentionally denied by the user
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
#[cfg(libei)]
pub(crate) fn cancelled_by_user(&self) -> bool {
matches!(
self,
@@ -89,20 +89,20 @@ impl CaptureCreationError {
)))
)
}
#[cfg(not(all(unix, feature = "libei", not(target_os = "macos"))))]
#[cfg(not(libei))]
pub(crate) fn cancelled_by_user(&self) -> bool {
false
}
}
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
#[cfg(libei)]
#[derive(Debug, Error)]
pub enum LibeiCaptureCreationError {
#[error("xdg-desktop-portal: `{0}`")]
Ashpd(#[from] ashpd::Error),
}
#[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))]
#[cfg(layer_shell)]
#[derive(Debug, Error)]
#[error("{protocol} protocol not supported: {inner}")]
pub struct WaylandBindError {
@@ -110,14 +110,14 @@ pub struct WaylandBindError {
protocol: &'static str,
}
#[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))]
#[cfg(layer_shell)]
impl WaylandBindError {
pub(crate) fn new(inner: BindError, protocol: &'static str) -> Self {
Self { inner, protocol }
}
}
#[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))]
#[cfg(layer_shell)]
#[derive(Debug, Error)]
pub enum LayerShellCaptureCreationError {
#[error(transparent)]
@@ -134,7 +134,7 @@ pub enum LayerShellCaptureCreationError {
Io(#[from] io::Error),
}
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
#[cfg(x11)]
#[derive(Debug, Error)]
pub enum X11InputCaptureCreationError {
#[error("X11 input capture is not yet implemented :(")]

View File

@@ -15,19 +15,19 @@ pub use error::{CaptureCreationError, CaptureError, InputCaptureError};
pub mod error;
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
#[cfg(libei)]
mod libei;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))]
#[cfg(layer_shell)]
mod layer_shell;
#[cfg(windows)]
mod windows;
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
#[cfg(x11)]
mod x11;
/// fallback input capture (does not produce events)
@@ -85,11 +85,11 @@ impl Display for Position {
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Backend {
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
#[cfg(libei)]
InputCapturePortal,
#[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))]
#[cfg(layer_shell)]
LayerShell,
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
#[cfg(x11)]
X11,
#[cfg(windows)]
Windows,
@@ -101,11 +101,11 @@ pub enum Backend {
impl Display for Backend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
#[cfg(libei)]
Backend::InputCapturePortal => write!(f, "input-capture-portal"),
#[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))]
#[cfg(layer_shell)]
Backend::LayerShell => write!(f, "layer-shell"),
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
#[cfg(x11)]
Backend::X11 => write!(f, "X11"),
#[cfg(windows)]
Backend::Windows => write!(f, "windows"),
@@ -298,11 +298,11 @@ async fn create_backend(
CaptureCreationError,
> {
match backend {
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
#[cfg(libei)]
Backend::InputCapturePortal => Ok(Box::new(libei::LibeiInputCapture::new().await?)),
#[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))]
#[cfg(layer_shell)]
Backend::LayerShell => Ok(Box::new(layer_shell::LayerShellInputCapture::new()?)),
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
#[cfg(x11)]
Backend::X11 => Ok(Box::new(x11::X11InputCapture::new()?)),
#[cfg(windows)]
Backend::Windows => Ok(Box::new(windows::WindowsInputCapture::new())),
@@ -327,11 +327,11 @@ async fn create(
}
for backend in [
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
#[cfg(libei)]
Backend::InputCapturePortal,
#[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))]
#[cfg(layer_shell)]
Backend::LayerShell,
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
#[cfg(x11)]
Backend::X11,
#[cfg(windows)]
Backend::Windows,

View File

@@ -222,11 +222,8 @@ fn start_routine(
}
/* run message loop */
loop {
while let Some(msg) = get_msg() {
// mouse / keybrd proc do not actually return a message
let Some(msg) = get_msg() else {
break;
};
if msg.hwnd.0.is_null() {
/* messages sent via PostThreadMessage */
match msg.wParam.0 {

View File

@@ -1,7 +1,7 @@
[package]
name = "input-emulation"
description = "cross-platform input emulation library used by lan-mouse"
version = "0.3.0"
version = "0.4.0"
edition = "2021"
license = "GPL-3.0-or-later"
repository = "https://github.com/feschber/lan-mouse"
@@ -10,7 +10,7 @@ repository = "https://github.com/feschber/lan-mouse"
async-trait = "0.1.80"
futures = "0.3.28"
log = "0.4.22"
input-event = { path = "../input-event", version = "0.3.0" }
input-event = { path = "../input-event", version = "0.4.0" }
thiserror = "2.0.0"
tokio = { version = "1.32.0", features = [
"io-util",

31
input-emulation/build.rs Normal file
View File

@@ -0,0 +1,31 @@
fn main() {
let unix = cfg!(unix);
let libei = cfg!(feature = "libei");
let x11 = cfg!(feature = "x11");
let macos = cfg!(target_os = "macos");
let wlroots = cfg!(feature = "wlroots");
let rdp = cfg!(feature = "remote_desktop_portal");
let libei = unix && !macos && libei;
let wlroots = unix && !macos && wlroots;
let x11 = unix && !macos && x11;
let rdp = unix && !macos && rdp;
println!("cargo::rustc-check-cfg=cfg(wlroots)");
println!("cargo::rustc-check-cfg=cfg(libei)");
println!("cargo::rustc-check-cfg=cfg(x11)");
println!("cargo::rustc-check-cfg=cfg(rdp)");
if libei {
println!("cargo::rustc-cfg=libei");
}
if x11 {
println!("cargo::rustc-cfg=x11");
}
if wlroots {
println!("cargo::rustc-cfg=wlroots");
}
if rdp {
println!("cargo::rustc-cfg=rdp");
}
}

View File

@@ -6,16 +6,12 @@ pub enum InputEmulationError {
Emulate(#[from] EmulationError),
}
#[cfg(all(
unix,
any(feature = "remote_desktop_portal", feature = "libei"),
not(target_os = "macos")
))]
#[cfg(any(libei, rdp))]
use ashpd::{Error::Response, desktop::ResponseError};
use std::io;
use thiserror::Error;
#[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))]
#[cfg(wlroots)]
use wayland_client::{
ConnectError, DispatchError,
backend::WaylandError,
@@ -26,17 +22,13 @@ use wayland_client::{
pub enum EmulationError {
#[error("event stream closed")]
EndOfStream,
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
#[cfg(libei)]
#[error("libei error: `{0}`")]
Libei(#[from] reis::Error),
#[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))]
#[cfg(wlroots)]
#[error("wayland error: `{0}`")]
Wayland(#[from] wayland_client::backend::WaylandError),
#[cfg(all(
unix,
any(feature = "remote_desktop_portal", feature = "libei"),
not(target_os = "macos")
))]
#[cfg(any(rdp, libei))]
#[error("xdg-desktop-portal: `{0}`")]
Ashpd(#[from] ashpd::Error),
#[error("io error: `{0}`")]
@@ -45,16 +37,16 @@ pub enum EmulationError {
#[derive(Debug, Error)]
pub enum EmulationCreationError {
#[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))]
#[cfg(wlroots)]
#[error("wlroots backend: `{0}`")]
Wlroots(#[from] WlrootsEmulationCreationError),
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
#[cfg(libei)]
#[error("libei backend: `{0}`")]
Libei(#[from] LibeiEmulationCreationError),
#[cfg(all(unix, feature = "remote_desktop_portal", not(target_os = "macos")))]
#[cfg(rdp)]
#[error("xdg-desktop-portal: `{0}`")]
Xdp(#[from] XdpEmulationCreationError),
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
#[cfg(x11)]
#[error("x11: `{0}`")]
X11(#[from] X11EmulationCreationError),
#[cfg(target_os = "macos")]
@@ -70,7 +62,7 @@ pub enum EmulationCreationError {
impl EmulationCreationError {
/// request was intentionally denied by the user
pub(crate) fn cancelled_by_user(&self) -> bool {
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
#[cfg(libei)]
if matches!(
self,
EmulationCreationError::Libei(LibeiEmulationCreationError::Ashpd(Response(
@@ -79,7 +71,7 @@ impl EmulationCreationError {
) {
return true;
}
#[cfg(all(unix, feature = "remote_desktop_portal", not(target_os = "macos")))]
#[cfg(rdp)]
if matches!(
self,
EmulationCreationError::Xdp(XdpEmulationCreationError::Ashpd(Response(
@@ -92,7 +84,7 @@ impl EmulationCreationError {
}
}
#[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))]
#[cfg(wlroots)]
#[derive(Debug, Error)]
pub enum WlrootsEmulationCreationError {
#[error(transparent)]
@@ -109,7 +101,7 @@ pub enum WlrootsEmulationCreationError {
Io(#[from] std::io::Error),
}
#[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))]
#[cfg(wlroots)]
#[derive(Debug, Error)]
#[error("wayland protocol \"{protocol}\" not supported: {inner}")]
pub struct WaylandBindError {
@@ -117,14 +109,14 @@ pub struct WaylandBindError {
protocol: &'static str,
}
#[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))]
#[cfg(wlroots)]
impl WaylandBindError {
pub(crate) fn new(inner: BindError, protocol: &'static str) -> Self {
Self { inner, protocol }
}
}
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
#[cfg(libei)]
#[derive(Debug, Error)]
pub enum LibeiEmulationCreationError {
#[error(transparent)]
@@ -135,14 +127,14 @@ pub enum LibeiEmulationCreationError {
Reis(#[from] reis::Error),
}
#[cfg(all(unix, feature = "remote_desktop_portal", not(target_os = "macos")))]
#[cfg(rdp)]
#[derive(Debug, Error)]
pub enum XdpEmulationCreationError {
#[error(transparent)]
Ashpd(#[from] ashpd::Error),
}
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
#[cfg(x11)]
#[derive(Debug, Error)]
pub enum X11EmulationCreationError {
#[error("could not open display")]

View File

@@ -11,16 +11,16 @@ pub use self::error::{EmulationCreationError, EmulationError, InputEmulationErro
#[cfg(windows)]
mod windows;
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
#[cfg(x11)]
mod x11;
#[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))]
#[cfg(wlroots)]
mod wlroots;
#[cfg(all(unix, feature = "remote_desktop_portal", not(target_os = "macos")))]
#[cfg(rdp)]
mod xdg_desktop_portal;
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
#[cfg(libei)]
mod libei;
#[cfg(target_os = "macos")]
@@ -34,13 +34,13 @@ pub type EmulationHandle = u64;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Backend {
#[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))]
#[cfg(wlroots)]
Wlroots,
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
#[cfg(libei)]
Libei,
#[cfg(all(unix, feature = "remote_desktop_portal", not(target_os = "macos")))]
#[cfg(rdp)]
Xdp,
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
#[cfg(x11)]
X11,
#[cfg(windows)]
Windows,
@@ -52,13 +52,13 @@ pub enum Backend {
impl Display for Backend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
#[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))]
#[cfg(wlroots)]
Backend::Wlroots => write!(f, "wlroots"),
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
#[cfg(libei)]
Backend::Libei => write!(f, "libei"),
#[cfg(all(unix, feature = "remote_desktop_portal", not(target_os = "macos")))]
#[cfg(rdp)]
Backend::Xdp => write!(f, "xdg-desktop-portal"),
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
#[cfg(x11)]
Backend::X11 => write!(f, "X11"),
#[cfg(windows)]
Backend::Windows => write!(f, "windows"),
@@ -78,13 +78,13 @@ pub struct InputEmulation {
impl InputEmulation {
async fn with_backend(backend: Backend) -> Result<InputEmulation, EmulationCreationError> {
let emulation: Box<dyn Emulation> = match backend {
#[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))]
#[cfg(wlroots)]
Backend::Wlroots => Box::new(wlroots::WlrootsEmulation::new()?),
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
#[cfg(libei)]
Backend::Libei => Box::new(libei::LibeiEmulation::new().await?),
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
#[cfg(x11)]
Backend::X11 => Box::new(x11::X11Emulation::new()?),
#[cfg(all(unix, feature = "remote_desktop_portal", not(target_os = "macos")))]
#[cfg(rdp)]
Backend::Xdp => Box::new(xdg_desktop_portal::DesktopPortalEmulation::new().await?),
#[cfg(windows)]
Backend::Windows => Box::new(windows::WindowsEmulation::new()?),
@@ -109,13 +109,13 @@ impl InputEmulation {
}
for backend in [
#[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))]
#[cfg(wlroots)]
Backend::Wlroots,
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
#[cfg(libei)]
Backend::Libei,
#[cfg(all(unix, feature = "remote_desktop_portal", not(target_os = "macos")))]
#[cfg(rdp)]
Backend::Xdp,
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
#[cfg(x11)]
Backend::X11,
#[cfg(windows)]
Backend::Windows,

View File

@@ -106,8 +106,19 @@ impl MacOSEmulation {
}
}
}
// release key when cancelled
update_modifiers(&modifiers, key as u32, 0);
// Always release the key with the correct CGKeyCode, regardless of
// whether the repeat loop ran. This matches @feschber's review
// request: "still release the key repeat task but with the correct
// code."
//
// Do NOT call update_modifiers here: `key` is a Mac CGKeyCode but
// update_modifiers expects a Linux evdev scancode, and the two
// codespaces collide (e.g. Mac LeftShift=56 == Linux KeyLeftAlt=56,
// Mac Down=125 == Linux KeyLeftMeta=125), corrupting modifier
// state for chords like Shift+Option+X or Cmd+Down. Modifier state
// is owned by the main consume() loop, which already calls
// update_modifiers with the correct Linux scancode on the real key
// release event from the client.
key_event(event_source.clone(), key, 0, modifiers.get());
});
self.repeat_task = Some(repeat_task);
@@ -157,6 +168,19 @@ extern "C" {
fn AXIsProcessTrusted() -> bool;
}
/// Mac virtual key codes for the four arrow keys.
const MAC_KEY_LEFT: u16 = 0x7B;
const MAC_KEY_RIGHT: u16 = 0x7C;
const MAC_KEY_DOWN: u16 = 0x7D;
const MAC_KEY_UP: u16 = 0x7E;
fn is_arrow_key(key: u16) -> bool {
matches!(
key,
MAC_KEY_LEFT | MAC_KEY_RIGHT | MAC_KEY_DOWN | MAC_KEY_UP
)
}
fn key_event(event_source: CGEventSource, key: u16, state: u8, modifiers: XMods) {
let event = match CGEvent::new_keyboard_event(event_source, key, state != 0) {
Ok(e) => e,
@@ -165,7 +189,15 @@ fn key_event(event_source: CGEventSource, key: u16, state: u8, modifiers: XMods)
return;
}
};
event.set_flags(to_cgevent_flags(modifiers));
let mut flags = to_cgevent_flags(modifiers);
// Hardware-generated arrow keys on macOS carry NumericPad + SecondaryFn.
// CGEventTap-based hotkey matchers (e.g. tiling window managers) check
// these flags to recognize navigation keys; without them synthesized
// arrow chords fall through to the focused app.
if is_arrow_key(key) {
flags |= CGEventFlags::CGEventFlagNumericPad | CGEventFlags::CGEventFlagSecondaryFn;
}
event.set_flags(flags);
event.post(CGEventTapLocation::HID);
log::trace!("key event: {key} {state}");
}

View File

@@ -1,7 +1,7 @@
[package]
name = "input-event"
description = "cross-platform input-event types for input-capture / input-emulation"
version = "0.3.0"
version = "0.4.0"
edition = "2021"
license = "GPL-3.0-or-later"
repository = "https://github.com/feschber/lan-mouse"

View File

@@ -1,14 +1,14 @@
[package]
name = "lan-mouse-cli"
description = "CLI Frontend for lan-mouse"
version = "0.2.0"
version = "0.3.0"
edition = "2021"
license = "GPL-3.0-or-later"
repository = "https://github.com/feschber/lan-mouse"
[dependencies]
futures = "0.3.30"
lan-mouse-ipc = { path = "../lan-mouse-ipc", version = "0.2.0" }
lan-mouse-ipc = { path = "../lan-mouse-ipc", version = "0.3.0" }
clap = { version = "4.4.11", features = ["derive"] }
thiserror = "2.0.0"
tokio = { version = "1.32.0", features = [

View File

@@ -1,7 +1,7 @@
[package]
name = "lan-mouse-gtk"
description = "GTK4 / Libadwaita Frontend for lan-mouse"
version = "0.2.0"
version = "0.3.0"
edition = "2021"
license = "GPL-3.0-or-later"
repository = "https://github.com/feschber/lan-mouse"
@@ -12,7 +12,7 @@ adw = { package = "libadwaita", version = "0.7.0", features = ["v1_1"] }
async-channel = { version = "2.1.1" }
hostname = "0.4.0"
log = "0.4.20"
lan-mouse-ipc = { path = "../lan-mouse-ipc", version = "0.2.0" }
lan-mouse-ipc = { path = "../lan-mouse-ipc", version = "0.3.0" }
thiserror = "2.0.0"
[build-dependencies]

View File

@@ -6,6 +6,7 @@
<file compressed="true" preprocess="xml-stripblanks">fingerprint_window.ui</file>
<file compressed="true" preprocess="xml-stripblanks">client_row.ui</file>
<file compressed="true" preprocess="xml-stripblanks">key_row.ui</file>
<file compressed="true">style.css</file>
</gresource>
<gresource prefix="/de/feschber/LanMouse/icons">
<file compressed="true" preprocess="xml-stripblanks">de.feschber.LanMouse.svg</file>

View File

@@ -0,0 +1,12 @@
.peer-match > box > list .subtitle {
color: @success_color;
}
.peer-mismatch > box > list .subtitle {
font-weight: bold;
color: @warning_color;
}
.peer-unknown > box > list .subtitle {
color: @warning_color;
}

View File

@@ -26,6 +26,7 @@ impl ClientObject {
.collect::<Vec<_>>(),
)
.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<String> {
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<String>,
pub peer_commit: Option<String>,
}

View File

@@ -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<String>, member = ips)]
#[property(name = "peer-commit", get, set, type = Option<String>, member = peer_commit)]
pub data: RefCell<ClientData>,
}

View File

@@ -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,40 @@ 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<String> = self
.imp()
.client_object
.borrow()
.as_ref()
.and_then(|co| co.property::<Option<String>>("peer-commit"));
let local = crate::local_commit_str();
let markup = match peer.as_deref() {
None => format!("Peer version: unknown · Ours: {local}"),
Some(p) if p == local.as_str() => {
format!("Peer version: {p} · matched")
}
Some(p) => {
format!("Peer version: {p} · Ours: {local}")
}
};
self.remove_css_class("peer-mismatch");
self.remove_css_class("peer-match");
self.remove_css_class("peer-unknown");
match peer.as_deref() {
Some(p) if p == local.as_str() => self.add_css_class("peer-match"),
Some(_) => self.add_css_class("peer-mismatch"),
None => self.add_css_class("peer-unknown"),
};
self.set_subtitle(&markup);
}
}

View File

@@ -10,10 +10,28 @@ mod macos_privacy;
mod macos_status_item;
mod window;
use std::{env, process, str};
use std::{env, process, str, sync::OnceLock};
use gtk::CssProvider;
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 +49,12 @@ 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
@@ -64,6 +86,7 @@ fn gtk_main() -> glib::ExitCode {
.build();
app.connect_startup(|app| {
load_css();
load_icons();
setup_actions(app);
setup_menu(app);
@@ -132,6 +155,16 @@ fn configure_macos_bundle_environment() {
);
}
fn load_css() {
let provider = CssProvider::default();
provider.load_from_resource("de/feschber/LanMouse/style.css");
gtk::style_context_add_provider_for_display(
&Display::default().expect("Could not connect to a display"),
&provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
}
fn load_icons() {
let display = &Display::default().expect("Could not connect to a display.");
let icon_theme = IconTheme::for_display(display);

View File

@@ -365,6 +365,13 @@ impl Window {
.map(|ip| ip.to_string())
.collect::<Vec<_>>();
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<ClientObject> {

View File

@@ -1,7 +1,7 @@
[package]
name = "lan-mouse-ipc"
description = "library for communication between lan-mouse service and frontends"
version = "0.2.0"
version = "0.3.0"
edition = "2021"
license = "GPL-3.0-or-later"
repository = "https://github.com/feschber/lan-mouse"

View File

@@ -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)]

View File

@@ -1,7 +1,7 @@
[package]
name = "lan-mouse-proto"
description = "network protocol for lan-mouse"
version = "0.2.0"
version = "0.3.0"
edition = "2021"
license = "GPL-3.0-or-later"
repository = "https://github.com/feschber/lan-mouse"
@@ -9,5 +9,5 @@ repository = "https://github.com/feschber/lan-mouse"
[dependencies]
num_enum = "0.7.2"
thiserror = "2.0.0"
input-event = { path = "../input-event", version = "0.3.0" }
input-event = { path = "../input-event", version = "0.4.0" }
paste = "1.0"

View File

@@ -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<ProtoEvent> 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)

View File

@@ -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<SocketAddr> {
self.clients
.borrow()

View File

@@ -1,6 +1,7 @@
use crate::capture_test::TestCaptureArgs;
use crate::emulation_test::TestEmulationArgs;
use clap::{Parser, Subcommand, ValueEnum};
use notify::event::ModifyKind;
use notify::{EventKind, RecommendedWatcher, Watcher};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -27,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";
@@ -118,13 +131,13 @@ pub enum Command {
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ValueEnum)]
pub enum CaptureBackend {
#[cfg(all(unix, feature = "libei_capture", not(target_os = "macos")))]
#[cfg(libei_capture)]
#[serde(rename = "input-capture-portal")]
InputCapturePortal,
#[cfg(all(unix, feature = "layer_shell_capture", not(target_os = "macos")))]
#[cfg(layer_shell_capture)]
#[serde(rename = "layer-shell")]
LayerShell,
#[cfg(all(unix, feature = "x11_capture", not(target_os = "macos")))]
#[cfg(x11_capture)]
#[serde(rename = "x11")]
X11,
#[cfg(windows)]
@@ -140,11 +153,11 @@ pub enum CaptureBackend {
impl Display for CaptureBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
#[cfg(all(unix, feature = "libei_capture", not(target_os = "macos")))]
#[cfg(libei_capture)]
CaptureBackend::InputCapturePortal => write!(f, "input-capture-portal"),
#[cfg(all(unix, feature = "layer_shell_capture", not(target_os = "macos")))]
#[cfg(layer_shell_capture)]
CaptureBackend::LayerShell => write!(f, "layer-shell"),
#[cfg(all(unix, feature = "x11_capture", not(target_os = "macos")))]
#[cfg(x11_capture)]
CaptureBackend::X11 => write!(f, "X11"),
#[cfg(windows)]
CaptureBackend::Windows => write!(f, "windows"),
@@ -158,11 +171,11 @@ impl Display for CaptureBackend {
impl From<CaptureBackend> for input_capture::Backend {
fn from(backend: CaptureBackend) -> Self {
match backend {
#[cfg(all(unix, feature = "libei_capture", not(target_os = "macos")))]
#[cfg(libei_capture)]
CaptureBackend::InputCapturePortal => Self::InputCapturePortal,
#[cfg(all(unix, feature = "layer_shell_capture", not(target_os = "macos")))]
#[cfg(layer_shell_capture)]
CaptureBackend::LayerShell => Self::LayerShell,
#[cfg(all(unix, feature = "x11_capture", not(target_os = "macos")))]
#[cfg(x11_capture)]
CaptureBackend::X11 => Self::X11,
#[cfg(windows)]
CaptureBackend::Windows => Self::Windows,
@@ -175,16 +188,16 @@ impl From<CaptureBackend> for input_capture::Backend {
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ValueEnum)]
pub enum EmulationBackend {
#[cfg(all(unix, feature = "wlroots_emulation", not(target_os = "macos")))]
#[cfg(wlroots_emulation)]
#[serde(rename = "wlroots")]
Wlroots,
#[cfg(all(unix, feature = "libei_emulation", not(target_os = "macos")))]
#[cfg(libei_emulation)]
#[serde(rename = "libei")]
Libei,
#[cfg(all(unix, feature = "rdp_emulation", not(target_os = "macos")))]
#[cfg(rdp_emulation)]
#[serde(rename = "xdp")]
Xdp,
#[cfg(all(unix, feature = "x11_emulation", not(target_os = "macos")))]
#[cfg(x11_emulation)]
#[serde(rename = "x11")]
X11,
#[cfg(windows)]
@@ -200,13 +213,13 @@ pub enum EmulationBackend {
impl From<EmulationBackend> for input_emulation::Backend {
fn from(backend: EmulationBackend) -> Self {
match backend {
#[cfg(all(unix, feature = "wlroots_emulation", not(target_os = "macos")))]
#[cfg(wlroots_emulation)]
EmulationBackend::Wlroots => Self::Wlroots,
#[cfg(all(unix, feature = "libei_emulation", not(target_os = "macos")))]
#[cfg(libei_emulation)]
EmulationBackend::Libei => Self::Libei,
#[cfg(all(unix, feature = "rdp_emulation", not(target_os = "macos")))]
#[cfg(rdp_emulation)]
EmulationBackend::Xdp => Self::Xdp,
#[cfg(all(unix, feature = "x11_emulation", not(target_os = "macos")))]
#[cfg(x11_emulation)]
EmulationBackend::X11 => Self::X11,
#[cfg(windows)]
EmulationBackend::Windows => Self::Windows,
@@ -220,13 +233,13 @@ impl From<EmulationBackend> for input_emulation::Backend {
impl Display for EmulationBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
#[cfg(all(unix, feature = "wlroots_emulation", not(target_os = "macos")))]
#[cfg(wlroots_emulation)]
EmulationBackend::Wlroots => write!(f, "wlroots"),
#[cfg(all(unix, feature = "libei_emulation", not(target_os = "macos")))]
#[cfg(libei_emulation)]
EmulationBackend::Libei => write!(f, "libei"),
#[cfg(all(unix, feature = "rdp_emulation", not(target_os = "macos")))]
#[cfg(rdp_emulation)]
EmulationBackend::Xdp => write!(f, "xdg-desktop-portal"),
#[cfg(all(unix, feature = "x11_emulation", not(target_os = "macos")))]
#[cfg(x11_emulation)]
EmulationBackend::X11 => write!(f, "X11"),
#[cfg(windows)]
EmulationBackend::Windows => write!(f, "windows"),
@@ -406,7 +419,9 @@ impl Config {
if event.paths.contains(&self.config_path)
&& matches!(
event.kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
EventKind::Create(_)
| EventKind::Modify(ModifyKind::Data(_))
| EventKind::Remove(_)
)
&& self.read_from_disk()?
{
@@ -524,6 +539,11 @@ impl Config {
}
Err(e) => log::warn!("{:?} {e}", self.config_path()),
};
if changed {
log::info!("config changed");
} else {
log::info!("config unchanged");
}
Ok(changed)
}

View File

@@ -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,19 @@ 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()));
@@ -255,16 +269,26 @@ async fn receive_loop(
) {
let mut buf = [0u8; MAX_EVENT_SIZE];
while conn.recv(&mut buf).await.is_ok() {
if let Ok(event) = buf.try_into() {
log::trace!("{addr} <==<==<== {event}");
match event {
ProtoEvent::Pong(b) => {
client_manager.set_active_addr(handle, Some(addr));
client_manager.set_alive(handle, b);
ping_response.borrow_mut().insert(addr);
match buf.try_into() {
Ok(event) => {
log::trace!("{addr} <==<==<== {event}");
match event {
ProtoEvent::Pong(b) => {
client_manager.set_active_addr(handle, Some(addr));
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"),
}
event => tx.send((handle, event)).expect("channel closed"),
}
// Skip undecodable datagrams without dropping the
// connection. Each DTLS recv is one framed message, so
// skipping is safe and keeps us forward-compatible with
// peers that send event types we don't yet know about.
Err(e) => log::debug!("ignoring undecodable event from {addr}: {e}"),
}
}
log::warn!("recv error");
@@ -280,6 +304,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<SocketAddr> = conns.lock().await.keys().copied().collect();
log::info!("active connections: {active:?}");
}

View File

@@ -1,3 +1,4 @@
use crate::config::local_commit;
use crate::listen::{LanMouseListener, ListenEvent, ListenerCreationError};
use futures::StreamExt;
use input_emulation::{EmulationHandle, InputEmulation, InputEmulationError};
@@ -52,6 +53,17 @@ pub(crate) enum EmulationEvent {
EmulationEnabled,
/// capture should be released
ReleaseNotify,
/// peer sent us a Hello with its build commit hash. Used to
/// populate `client_manager.peer_commit` from the listen side
/// too — without this, peer-version visibility silently fails
/// whenever the outgoing connection in the *other* direction is
/// broken (one-way setups, asymmetric NAT, peer's TCP listener
/// down). The connect-side path stays as the primary source;
/// this is the defensive fallback.
PeerHello {
addr: SocketAddr,
commit: [u8; 8],
},
}
enum EmulationRequest {
@@ -150,6 +162,21 @@ 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,
// Peer's version handshake. Echo our own
// commit back so the peer's connect-side
// receive_loop populates its `peer_commit`,
// AND publish a PeerHello upward so our
// service can populate ours from the listen
// side too — the connect side is the primary
// path, but if the outbound direction is
// broken (one-way setup, NAT, peer's TCP
// listener down) the version display would
// otherwise silently say "unknown" while
// the peer is in fact happily talking to us.
ProtoEvent::Hello { commit } => {
self.listener.reply(addr, ProtoEvent::Hello { commit: local_commit() }).await;
self.event_tx.send(EmulationEvent::PeerHello { addr, commit }).expect("channel closed");
}
_ => {}
}
}

View File

@@ -259,8 +259,16 @@ async fn read_loop(
.send(ListenEvent::Msg { event, addr })
.expect("channel closed"),
Err(e) => {
log::warn!("error receiving event: {e}");
break;
// Skip the malformed/unknown datagram and keep
// listening. Each DTLS recv returns one full
// datagram, so a parse error here can't desync a
// stream; the next call gets a fresh, framed
// message. This makes the protocol forward-
// compatible: a peer running a newer Lan Mouse
// version can introduce additional event types
// and old peers will simply ignore them rather
// than dropping the connection.
log::debug!("ignoring undecodable event from {addr}: {e}");
}
}
}

View File

@@ -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

View File

@@ -319,6 +319,17 @@ impl Service {
EmulationEvent::Connected { addr, fingerprint } => {
self.notify_frontend(FrontendEvent::DeviceConnected { addr, fingerprint });
}
EmulationEvent::PeerHello { addr, commit } => {
// Map the peer's source addr back to its client handle
// and stamp the commit. Skip if we don't have an
// outgoing client configured for this peer (incoming-
// only setup) — there's nowhere to display the version
// in that case anyway.
if let Some(handle) = self.client_manager.get_client(addr) {
self.client_manager.set_peer_commit(handle, Some(commit));
self.broadcast_client(handle);
}
}
}
}