Compare commits

..

1 Commits

Author SHA1 Message Date
Ferdinand Schober
83ec01a640 automatically update config when changed 2026-04-09 11:33:23 +02:00
59 changed files with 242 additions and 1941 deletions

View File

@@ -174,16 +174,13 @@ jobs:
steps: steps:
- name: Download build artifacts - name: Download build artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
- name: Get short SHA
id: vars
run: echo "short_sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- name: Create Pre-Release - name: Create Pre-Release
if: ${{ !startsWith(github.ref, 'refs/tags/') }} if: ${{ !startsWith(github.ref, 'refs/tags/') }}
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ format('{0}-{1}', github.event.inputs.name || github.ref_name, steps.vars.outputs.short_sha) }} tag_name: ${{ github.event.inputs.name || github.ref_name }}
name: ${{ format('{0}-{1}', github.event.inputs.name || github.ref_name, steps.vars.outputs.short_sha) }} name: ${{ github.event.inputs.name || github.ref_name }}
prerelease: true prerelease: true
generate_release_notes: true generate_release_notes: true
files: | 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 & cfg discipline
- Feature flags live in root `Cargo.toml`. Gate OS-specific modules with the configs exported in build.rs (e.g., `cfg(layer_shell)`). - 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")))`).
- Prefer module-level gating over per-function cfgs to avoid empty stubs. - 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. - New backends: add feature in `Cargo.toml`, create gated module, log backend selection.

18
Cargo.lock generated
View File

@@ -1647,7 +1647,7 @@ dependencies = [
[[package]] [[package]]
name = "input-capture" name = "input-capture"
version = "0.4.0" version = "0.3.0"
dependencies = [ dependencies = [
"ashpd", "ashpd",
"async-trait", "async-trait",
@@ -1677,13 +1677,11 @@ dependencies = [
[[package]] [[package]]
name = "input-emulation" name = "input-emulation"
version = "0.4.0" version = "0.3.0"
dependencies = [ dependencies = [
"ashpd", "ashpd",
"async-trait", "async-trait",
"bitflags 2.11.0", "bitflags 2.11.0",
"core-foundation",
"core-foundation-sys",
"core-graphics", "core-graphics",
"futures", "futures",
"input-event", "input-event",
@@ -1703,7 +1701,7 @@ dependencies = [
[[package]] [[package]]
name = "input-event" name = "input-event"
version = "0.4.0" version = "0.3.0"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"log", "log",
@@ -1840,7 +1838,7 @@ dependencies = [
[[package]] [[package]]
name = "lan-mouse" name = "lan-mouse"
version = "0.11.0" version = "0.10.0"
dependencies = [ dependencies = [
"clap", "clap",
"env_logger", "env_logger",
@@ -1875,7 +1873,7 @@ dependencies = [
[[package]] [[package]]
name = "lan-mouse-cli" name = "lan-mouse-cli"
version = "0.3.0" version = "0.2.0"
dependencies = [ dependencies = [
"clap", "clap",
"futures", "futures",
@@ -1886,7 +1884,7 @@ dependencies = [
[[package]] [[package]]
name = "lan-mouse-gtk" name = "lan-mouse-gtk"
version = "0.3.0" version = "0.2.0"
dependencies = [ dependencies = [
"async-channel", "async-channel",
"glib-build-tools", "glib-build-tools",
@@ -1900,7 +1898,7 @@ dependencies = [
[[package]] [[package]]
name = "lan-mouse-ipc" name = "lan-mouse-ipc"
version = "0.3.0" version = "0.2.0"
dependencies = [ dependencies = [
"futures", "futures",
"log", "log",
@@ -1913,7 +1911,7 @@ dependencies = [
[[package]] [[package]]
name = "lan-mouse-proto" name = "lan-mouse-proto"
version = "0.3.0" version = "0.2.0"
dependencies = [ dependencies = [
"input-event", "input-event",
"num_enum", "num_enum",

View File

@@ -12,7 +12,7 @@ members = [
[package] [package]
name = "lan-mouse" name = "lan-mouse"
description = "Software KVM Switch / mouse & keyboard sharing software for Local Area Networks" description = "Software KVM Switch / mouse & keyboard sharing software for Local Area Networks"
version = "0.11.0" version = "0.10.0"
edition = "2021" edition = "2021"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
repository = "https://github.com/feschber/lan-mouse" repository = "https://github.com/feschber/lan-mouse"
@@ -27,13 +27,13 @@ panic = "abort"
shadow-rs = "1.2.0" shadow-rs = "1.2.0"
[dependencies] [dependencies]
input-event = { path = "input-event", version = "0.4.0" } input-event = { path = "input-event", version = "0.3.0" }
input-emulation = { path = "input-emulation", version = "0.4.0", default-features = false } input-emulation = { path = "input-emulation", version = "0.3.0", default-features = false }
input-capture = { path = "input-capture", version = "0.4.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.3.0" } lan-mouse-cli = { path = "lan-mouse-cli", version = "0.2.0" }
lan-mouse-gtk = { path = "lan-mouse-gtk", version = "0.3.0", optional = true } lan-mouse-gtk = { path = "lan-mouse-gtk", version = "0.2.0", optional = true }
lan-mouse-ipc = { path = "lan-mouse-ipc", version = "0.3.0" } lan-mouse-ipc = { path = "lan-mouse-ipc", version = "0.2.0" }
lan-mouse-proto = { path = "lan-mouse-proto", version = "0.3.0" } lan-mouse-proto = { path = "lan-mouse-proto", version = "0.2.0" }
shadow-rs = { version = "1.2.0", features = ["metadata"] } shadow-rs = { version = "1.2.0", features = ["metadata"] }
hickory-resolver = "0.25.2" hickory-resolver = "0.25.2"
@@ -96,5 +96,3 @@ rdp_emulation = ["input-emulation/remote_desktop_portal"]
name = "Lan Mouse" name = "Lan Mouse"
icon = ["target/icon.icns"] icon = ["target/icon.icns"]
identifier = "de.feschber.LanMouse" identifier = "de.feschber.LanMouse"
osx_info_plist_exts = ["build-aux/macos-lsui-element.plist"]
resources = ["target/menubar-template.png"]

View File

@@ -105,7 +105,6 @@ dnf install lan-mouse
- Unzip it - Unzip it
- Remove the quarantine with `xattr -rd com.apple.quarantine "Lan Mouse.app"` - Remove the quarantine with `xattr -rd com.apple.quarantine "Lan Mouse.app"`
- Launch the app - Launch the app
- Use the menu bar item to open the settings window or quit Lan Mouse. Bundled macOS builds run as a menu bar app and do not keep a Dock icon visible.
- Grant accessibility permissions in System Preferences - Grant accessibility permissions in System Preferences
</details> </details>

View File

@@ -1,8 +0,0 @@
<key>LSUIElement</key>
<true/>
<key>NSAppSleepDisabled</key>
<true/>
<key>NSInputMonitoringUsageDescription</key>
<string>Lan Mouse needs Input Monitoring access to capture keyboard and mouse input and forward it to remote machines on your network.</string>
<key>NSAppleEventsUsageDescription</key>
<string>Lan Mouse uses Apple Events to deliver synthesized keyboard and mouse events to the system.</string>

View File

@@ -5,57 +5,4 @@ fn main() {
.deny_const(Default::default()) .deny_const(Default::default())
.build() .build()
.expect("shadow 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] [package]
name = "input-capture" name = "input-capture"
description = "cross-platform input-capture library used by lan-mouse" description = "cross-platform input-capture library used by lan-mouse"
version = "0.4.0" version = "0.3.0"
edition = "2021" edition = "2021"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
repository = "https://github.com/feschber/lan-mouse" repository = "https://github.com/feschber/lan-mouse"
@@ -10,7 +10,7 @@ repository = "https://github.com/feschber/lan-mouse"
futures = "0.3.28" futures = "0.3.28"
futures-core = "0.3.30" futures-core = "0.3.30"
log = "0.4.22" log = "0.4.22"
input-event = { path = "../input-event", version = "0.4.0" } input-event = { path = "../input-event", version = "0.3.0" }
memmap = "0.7" memmap = "0.7"
tempfile = "3.25.0" tempfile = "3.25.0"
thiserror = "2.0.0" thiserror = "2.0.0"

View File

@@ -1,25 +0,0 @@
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), Capture(#[from] CaptureError),
} }
#[cfg(layer_shell)] #[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))]
use std::io; use std::io;
#[cfg(layer_shell)] #[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))]
use wayland_client::{ use wayland_client::{
ConnectError, DispatchError, ConnectError, DispatchError,
backend::WaylandError, backend::WaylandError,
globals::{BindError, GlobalError}, globals::{BindError, GlobalError},
}; };
#[cfg(libei)] #[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
use ashpd::desktop::ResponseError; use ashpd::desktop::ResponseError;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -31,13 +31,13 @@ pub enum CaptureError {
EndOfStream, EndOfStream,
#[error("io error: `{0}`")] #[error("io error: `{0}`")]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
#[cfg(libei)] #[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
#[error("libei error: `{0}`")] #[error("libei error: `{0}`")]
Reis(#[from] reis::Error), Reis(#[from] reis::Error),
#[cfg(libei)] #[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
#[error(transparent)] #[error(transparent)]
Portal(#[from] ashpd::Error), Portal(#[from] ashpd::Error),
#[cfg(libei)] #[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
#[error("libei disconnected - reason: `{0}`")] #[error("libei disconnected - reason: `{0}`")]
Disconnected(String), Disconnected(String),
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -61,13 +61,13 @@ pub enum CaptureError {
pub enum CaptureCreationError { pub enum CaptureCreationError {
#[error("no backend available")] #[error("no backend available")]
NoAvailableBackend, NoAvailableBackend,
#[cfg(libei)] #[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
#[error("error creating input-capture-portal backend: `{0}`")] #[error("error creating input-capture-portal backend: `{0}`")]
Libei(#[from] LibeiCaptureCreationError), Libei(#[from] LibeiCaptureCreationError),
#[cfg(layer_shell)] #[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))]
#[error("error creating layer-shell capture backend: `{0}`")] #[error("error creating layer-shell capture backend: `{0}`")]
LayerShell(#[from] LayerShellCaptureCreationError), LayerShell(#[from] LayerShellCaptureCreationError),
#[cfg(x11)] #[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
#[error("error creating x11 capture backend: `{0}`")] #[error("error creating x11 capture backend: `{0}`")]
X11(#[from] X11InputCaptureCreationError), X11(#[from] X11InputCaptureCreationError),
#[cfg(windows)] #[cfg(windows)]
@@ -80,7 +80,7 @@ pub enum CaptureCreationError {
impl CaptureCreationError { impl CaptureCreationError {
/// request was intentionally denied by the user /// request was intentionally denied by the user
#[cfg(libei)] #[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
pub(crate) fn cancelled_by_user(&self) -> bool { pub(crate) fn cancelled_by_user(&self) -> bool {
matches!( matches!(
self, self,
@@ -89,20 +89,20 @@ impl CaptureCreationError {
))) )))
) )
} }
#[cfg(not(libei))] #[cfg(not(all(unix, feature = "libei", not(target_os = "macos"))))]
pub(crate) fn cancelled_by_user(&self) -> bool { pub(crate) fn cancelled_by_user(&self) -> bool {
false false
} }
} }
#[cfg(libei)] #[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum LibeiCaptureCreationError { pub enum LibeiCaptureCreationError {
#[error("xdg-desktop-portal: `{0}`")] #[error("xdg-desktop-portal: `{0}`")]
Ashpd(#[from] ashpd::Error), Ashpd(#[from] ashpd::Error),
} }
#[cfg(layer_shell)] #[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))]
#[derive(Debug, Error)] #[derive(Debug, Error)]
#[error("{protocol} protocol not supported: {inner}")] #[error("{protocol} protocol not supported: {inner}")]
pub struct WaylandBindError { pub struct WaylandBindError {
@@ -110,14 +110,14 @@ pub struct WaylandBindError {
protocol: &'static str, protocol: &'static str,
} }
#[cfg(layer_shell)] #[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))]
impl WaylandBindError { impl WaylandBindError {
pub(crate) fn new(inner: BindError, protocol: &'static str) -> Self { pub(crate) fn new(inner: BindError, protocol: &'static str) -> Self {
Self { inner, protocol } Self { inner, protocol }
} }
} }
#[cfg(layer_shell)] #[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))]
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum LayerShellCaptureCreationError { pub enum LayerShellCaptureCreationError {
#[error(transparent)] #[error(transparent)]
@@ -134,7 +134,7 @@ pub enum LayerShellCaptureCreationError {
Io(#[from] io::Error), Io(#[from] io::Error),
} }
#[cfg(x11)] #[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum X11InputCaptureCreationError { pub enum X11InputCaptureCreationError {
#[error("X11 input capture is not yet implemented :(")] #[error("X11 input capture is not yet implemented :(")]
@@ -149,10 +149,6 @@ pub enum MacosCaptureCreationError {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
#[error("event tap creation failed")] #[error("event tap creation failed")]
EventTapCreation, EventTapCreation,
#[error("accessibility permission is required")]
AccessibilityPermission,
#[error("input monitoring permission is required")]
InputMonitoringPermission,
#[error("failed to set CG Cursor property")] #[error("failed to set CG Cursor property")]
CGCursorProperty, CGCursorProperty,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]

View File

@@ -15,19 +15,19 @@ pub use error::{CaptureCreationError, CaptureError, InputCaptureError};
pub mod error; pub mod error;
#[cfg(libei)] #[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
mod libei; mod libei;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
mod macos; mod macos;
#[cfg(layer_shell)] #[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))]
mod layer_shell; mod layer_shell;
#[cfg(windows)] #[cfg(windows)]
mod windows; mod windows;
#[cfg(x11)] #[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
mod x11; mod x11;
/// fallback input capture (does not produce events) /// fallback input capture (does not produce events)
@@ -85,11 +85,11 @@ impl Display for Position {
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Backend { pub enum Backend {
#[cfg(libei)] #[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
InputCapturePortal, InputCapturePortal,
#[cfg(layer_shell)] #[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))]
LayerShell, LayerShell,
#[cfg(x11)] #[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
X11, X11,
#[cfg(windows)] #[cfg(windows)]
Windows, Windows,
@@ -101,11 +101,11 @@ pub enum Backend {
impl Display for Backend { impl Display for Backend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
#[cfg(libei)] #[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
Backend::InputCapturePortal => write!(f, "input-capture-portal"), Backend::InputCapturePortal => write!(f, "input-capture-portal"),
#[cfg(layer_shell)] #[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))]
Backend::LayerShell => write!(f, "layer-shell"), Backend::LayerShell => write!(f, "layer-shell"),
#[cfg(x11)] #[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
Backend::X11 => write!(f, "X11"), Backend::X11 => write!(f, "X11"),
#[cfg(windows)] #[cfg(windows)]
Backend::Windows => write!(f, "windows"), Backend::Windows => write!(f, "windows"),
@@ -171,19 +171,6 @@ impl InputCapture {
self.capture.release().await self.capture.release().await
} }
/// Drain and return every key the capture has forwarded as
/// down-but-not-up. The caller is expected to synthesize key-up
/// events to the remote peer for each — otherwise the peer
/// retains phantom-held keys after capture is released. The
/// canonical case is the release-bind chord
/// (Ctrl+Shift+Alt+Meta): the down events were sent while
/// capture was active, but the matching up events arrive after
/// the local tap has flipped to passthrough and never reach
/// the peer.
pub fn take_pressed_keys(&mut self) -> HashSet<scancode::Linux> {
std::mem::take(&mut self.pressed_keys)
}
/// destroy the input capture /// destroy the input capture
pub async fn terminate(&mut self) -> Result<(), CaptureError> { pub async fn terminate(&mut self) -> Result<(), CaptureError> {
self.capture.terminate().await self.capture.terminate().await
@@ -298,11 +285,11 @@ async fn create_backend(
CaptureCreationError, CaptureCreationError,
> { > {
match backend { match backend {
#[cfg(libei)] #[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
Backend::InputCapturePortal => Ok(Box::new(libei::LibeiInputCapture::new().await?)), Backend::InputCapturePortal => Ok(Box::new(libei::LibeiInputCapture::new().await?)),
#[cfg(layer_shell)] #[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))]
Backend::LayerShell => Ok(Box::new(layer_shell::LayerShellInputCapture::new()?)), Backend::LayerShell => Ok(Box::new(layer_shell::LayerShellInputCapture::new()?)),
#[cfg(x11)] #[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
Backend::X11 => Ok(Box::new(x11::X11InputCapture::new()?)), Backend::X11 => Ok(Box::new(x11::X11InputCapture::new()?)),
#[cfg(windows)] #[cfg(windows)]
Backend::Windows => Ok(Box::new(windows::WindowsInputCapture::new())), Backend::Windows => Ok(Box::new(windows::WindowsInputCapture::new())),
@@ -327,11 +314,11 @@ async fn create(
} }
for backend in [ for backend in [
#[cfg(libei)] #[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
Backend::InputCapturePortal, Backend::InputCapturePortal,
#[cfg(layer_shell)] #[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))]
Backend::LayerShell, Backend::LayerShell,
#[cfg(x11)] #[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
Backend::X11, Backend::X11,
#[cfg(windows)] #[cfg(windows)]
Backend::Windows, Backend::Windows,

View File

@@ -2,7 +2,7 @@ use super::{Capture, CaptureError, CaptureEvent, Position, error::MacosCaptureCr
use async_trait::async_trait; use async_trait::async_trait;
use bitflags::bitflags; use bitflags::bitflags;
use core_foundation::{ use core_foundation::{
base::{CFRelease, TCFType, kCFAllocatorDefault}, base::{CFRelease, kCFAllocatorDefault},
date::CFTimeInterval, date::CFTimeInterval,
number::{CFBooleanRef, kCFBooleanTrue}, number::{CFBooleanRef, kCFBooleanTrue},
runloop::{CFRunLoop, CFRunLoopSource, kCFRunLoopCommonModes}, runloop::{CFRunLoop, CFRunLoopSource, kCFRunLoopCommonModes},
@@ -28,7 +28,7 @@ use std::{
collections::HashSet, collections::HashSet,
ffi::{CString, c_char}, ffi::{CString, c_char},
pin::Pin, pin::Pin,
sync::{Arc, OnceLock}, sync::Arc,
task::{Context, Poll, ready}, task::{Context, Poll, ready},
thread::{self}, thread::{self},
}; };
@@ -67,7 +67,6 @@ enum ProducerEvent {
Destroy(Position), Destroy(Position),
Grab(Position), Grab(Position),
EventTapDisabled, EventTapDisabled,
DisplayReconfigured,
} }
impl InputCaptureState { impl InputCaptureState {
@@ -177,31 +176,7 @@ impl InputCaptureState {
} }
self.active_clients.remove(&p); self.active_clients.remove(&p);
} }
ProducerEvent::EventTapDisabled => { ProducerEvent::EventTapDisabled => return Err(CaptureError::EventTapDisabled),
// Tap death can happen mid-capture (TCC Accessibility
// revoked, tap-timeout, etc). Release state so we
// don't leave the cursor hidden even if the outer
// task only logs this error rather than propagating.
if self.current_pos.is_some() {
self.show_cursor()?;
self.current_pos = None;
}
return Err(CaptureError::EventTapDisabled);
}
ProducerEvent::DisplayReconfigured => {
// The macOS display configuration changed — a monitor
// was plugged in/out, the resolution changed, the
// arrangement was rearranged, etc. Re-fetch the
// active-display bounds so barrier crossings and the
// cursor-warp on capture-start use the current
// geometry instead of whatever was true at process
// start.
if let Err(e) = self.update_bounds() {
log::warn!("failed to refresh display bounds: {e}");
} else {
log::info!("display reconfigured: {:?}", self.bounds);
}
}
}; };
Ok(()) Ok(())
} }
@@ -410,14 +385,6 @@ fn create_event_tap<'a>(
notify_tx: Sender<ProducerEvent>, notify_tx: Sender<ProducerEvent>,
event_tx: Sender<(Position, CaptureEvent)>, event_tx: Sender<(Position, CaptureEvent)>,
) -> Result<CGEventTap<'a>, MacosCaptureCreationError> { ) -> Result<CGEventTap<'a>, MacosCaptureCreationError> {
// Shared slot for the tap's mach port pointer. Stored as `usize`
// because raw pointers aren't `Send`, but the integer
// representation is — and CGEventTapEnable is documented as
// thread-safe. Set immediately after CGEventTap::new returns;
// read by the callback to recover from a TapDisabledByTimeout.
let tap_mach_port: Arc<OnceLock<usize>> = Arc::new(OnceLock::new());
let tap_mach_port_cb = Arc::clone(&tap_mach_port);
let cg_events_of_interest: Vec<CGEventType> = vec![ let cg_events_of_interest: Vec<CGEventType> = vec![
CGEventType::LeftMouseDown, CGEventType::LeftMouseDown,
CGEventType::LeftMouseUp, CGEventType::LeftMouseUp,
@@ -435,114 +402,76 @@ fn create_event_tap<'a>(
CGEventType::FlagsChanged, CGEventType::FlagsChanged,
]; ];
let event_tap_callback = move |_proxy: CGEventTapProxy, let event_tap_callback =
event_type: CGEventType, move |_proxy: CGEventTapProxy, event_type: CGEventType, cg_ev: &CGEvent| {
cg_ev: &CGEvent| { log::trace!("Got event from tap: {event_type:?}");
log::trace!("Got event from tap: {event_type:?}"); let mut state = client_state.blocking_lock();
let mut state = client_state.blocking_lock(); let mut capture_position = None;
let mut capture_position = None; let mut res_events = vec![];
let mut res_events = vec![];
if matches!(event_type, CGEventType::TapDisabledByTimeout) {
// The kernel disables the tap when our callback runs
// longer than ~1s on a single event — typical causes
// are heavy load, scheduler contention, or this
// process being briefly suspended (e.g. App Nap on a
// long idle). It is NOT a fatal condition: Apple's
// documented recovery is to call CGEventTapEnable
// and resume processing. Re-enable in place and KEEP
// existing capture state so the user doesn't see the
// cursor pop back to the local screen mid-session.
if let Some(&port) = tap_mach_port_cb.get() {
log::warn!("CGEventTap disabled by timeout — re-enabling");
unsafe {
CGEventTapEnable(port as *mut c_void, true);
}
} else {
log::error!(
"CGEventTap disabled by timeout, but mach port not yet stored — cannot re-enable"
);
}
return CallbackResult::Keep;
}
if matches!(event_type, CGEventType::TapDisabledByUserInput) {
// Deliberate kill — secure-input mode (e.g. password
// field), TCC Accessibility revoked mid-session, or
// the user disabling event-monitoring. We can't
// recover from this; drop captured state synchronously
// and return Keep on this event. Otherwise the
// `current_pos.is_some()` branch below would drop this
// event (and any racing callback still in flight) back
// into `CallbackResult::Drop`, silently eating the
// user's clicks and keypresses while the tap winds
// down. Clear state + show the cursor here, then
// notify the producer loop so the service can tear
// down cleanly.
log::error!("CGEventTap disabled by user input, releasing capture state");
if state.current_pos.is_some() {
let _ = CGDisplay::show_cursor(&CGDisplay::main());
state.current_pos = None;
}
notify_tx
.blocking_send(ProducerEvent::EventTapDisabled)
.unwrap_or_else(|e| {
log::error!("Failed to send notification: {e}");
});
return CallbackResult::Keep;
}
// Are we in a client?
if let Some(current_pos) = state.current_pos {
capture_position = Some(current_pos);
get_events(
&event_type,
cg_ev,
&mut res_events,
&mut state.modifier_state,
)
.unwrap_or_else(|e| {
log::error!("Failed to get events: {e}");
});
// Keep (hidden) cursor at the edge of the screen
if matches!( if matches!(
event_type, event_type,
CGEventType::MouseMoved CGEventType::TapDisabledByTimeout | CGEventType::TapDisabledByUserInput
| CGEventType::LeftMouseDragged
| CGEventType::RightMouseDragged
| CGEventType::OtherMouseDragged
) { ) {
state.reset_cursor().unwrap_or_else(|e| log::warn!("{e}")); log::error!("CGEventTap disabled");
}
} else if matches!(event_type, CGEventType::MouseMoved) {
// Did we cross a barrier?
if let Some(new_pos) = state.crossed(cg_ev) {
capture_position = Some(new_pos);
state
.start_capture(cg_ev, new_pos)
.unwrap_or_else(|e| log::warn!("{e}"));
res_events.push(CaptureEvent::Begin);
notify_tx notify_tx
.blocking_send(ProducerEvent::Grab(new_pos)) .blocking_send(ProducerEvent::EventTapDisabled)
.expect("Failed to send notification"); .unwrap_or_else(|e| {
log::error!("Failed to send notification: {e}");
});
} }
}
if let Some(pos) = capture_position { // Are we in a client?
res_events.iter().for_each(|e| { if let Some(current_pos) = state.current_pos {
// error must be ignored, since the event channel capture_position = Some(current_pos);
// may already be closed when the InputCapture instance is dropped. get_events(
let _ = event_tx.blocking_send((pos, *e)); &event_type,
}); cg_ev,
// Returning Drop should stop the event from being processed &mut res_events,
// but core fundation still returns the event &mut state.modifier_state,
cg_ev.set_type(CGEventType::Null); )
CallbackResult::Drop .unwrap_or_else(|e| {
} else { log::error!("Failed to get events: {e}");
CallbackResult::Keep });
}
}; // Keep (hidden) cursor at the edge of the screen
if matches!(
event_type,
CGEventType::MouseMoved
| CGEventType::LeftMouseDragged
| CGEventType::RightMouseDragged
| CGEventType::OtherMouseDragged
) {
state.reset_cursor().unwrap_or_else(|e| log::warn!("{e}"));
}
} else if matches!(event_type, CGEventType::MouseMoved) {
// Did we cross a barrier?
if let Some(new_pos) = state.crossed(cg_ev) {
capture_position = Some(new_pos);
state
.start_capture(cg_ev, new_pos)
.unwrap_or_else(|e| log::warn!("{e}"));
res_events.push(CaptureEvent::Begin);
notify_tx
.blocking_send(ProducerEvent::Grab(new_pos))
.expect("Failed to send notification");
}
}
if let Some(pos) = capture_position {
res_events.iter().for_each(|e| {
// error must be ignored, since the event channel
// may already be closed when the InputCapture instance is dropped.
let _ = event_tx.blocking_send((pos, *e));
});
// Returning Drop should stop the event from being processed
// but core fundation still returns the event
cg_ev.set_type(CGEventType::Null);
CallbackResult::Drop
} else {
CallbackResult::Keep
}
};
let tap = CGEventTap::new( let tap = CGEventTap::new(
CGEventTapLocation::Session, CGEventTapLocation::Session,
@@ -553,13 +482,6 @@ fn create_event_tap<'a>(
) )
.map_err(|_| MacosCaptureCreationError::EventTapCreation)?; .map_err(|_| MacosCaptureCreationError::EventTapCreation)?;
// Hand the mach port pointer to the callback so it can re-enable
// the tap on TapDisabledByTimeout. The pointer is valid for the
// lifetime of `tap` (which lives on the event-tap thread until
// the run loop exits).
let port_ptr = tap.mach_port().as_concrete_TypeRef() as usize;
let _ = tap_mach_port.set(port_ptr);
let tap_source: CFRunLoopSource = tap let tap_source: CFRunLoopSource = tap
.mach_port() .mach_port()
.create_runloop_source(0) .create_runloop_source(0)
@@ -579,9 +501,6 @@ fn event_tap_thread(
ready: std::sync::mpsc::Sender<Result<CFRunLoop, MacosCaptureCreationError>>, ready: std::sync::mpsc::Sender<Result<CFRunLoop, MacosCaptureCreationError>>,
exit: oneshot::Sender<()>, exit: oneshot::Sender<()>,
) { ) {
// Clone now: create_event_tap consumes notify_tx into its closure.
let display_notify_tx = notify_tx.clone();
let _tap = match create_event_tap(client_state, notify_tx, event_tx) { let _tap = match create_event_tap(client_state, notify_tx, event_tx) {
Err(e) => { Err(e) => {
ready.send(Err(e)).expect("channel closed"); ready.send(Err(e)).expect("channel closed");
@@ -593,62 +512,13 @@ fn event_tap_thread(
tap tap
} }
}; };
// Register a Quartz display-reconfiguration callback so the
// capture state's bounds get refreshed when the user plugs in a
// monitor, changes resolution, or rearranges displays. The
// callback runs on this thread's CFRunLoop. Box-leak the sender
// so the C side has a stable user_info pointer; reclaim it after
// the run loop exits.
let display_user_info = Box::into_raw(Box::new(display_notify_tx)) as *mut c_void;
unsafe {
CGDisplayRegisterReconfigurationCallback(
display_reconfiguration_callback,
display_user_info,
);
}
log::debug!("running CFRunLoop..."); log::debug!("running CFRunLoop...");
CFRunLoop::run_current(); CFRunLoop::run_current();
log::debug!("event tap thread exiting!..."); log::debug!("event tap thread exiting!...");
unsafe {
CGDisplayRemoveReconfigurationCallback(display_reconfiguration_callback, display_user_info);
// Reclaim the leaked sender Box so we don't leak a tokio
// channel sender on every capture create/destroy cycle.
drop(Box::from_raw(
display_user_info as *mut Sender<ProducerEvent>,
));
}
let _ = exit.send(()); let _ = exit.send(());
} }
/// Quartz display-reconfiguration callback. Fires twice per change:
/// once with `kCGDisplayBeginConfigurationFlag` set (BEFORE the
/// change is applied — the bounds are still stale at this point),
/// then again afterwards with the actual change flags (Add, Remove,
/// Mode, DesktopShapeChanged, etc.). Skip the begin phase; on the
/// real notification, kick the producer task to refresh bounds.
extern "C" fn display_reconfiguration_callback(_display: u32, flags: u32, user_info: *mut c_void) {
const K_CG_DISPLAY_BEGIN_CONFIGURATION_FLAG: u32 = 1 << 0;
if flags & K_CG_DISPLAY_BEGIN_CONFIGURATION_FLAG != 0 {
return;
}
if user_info.is_null() {
return;
}
// SAFETY: user_info is a Box::into_raw of Sender<ProducerEvent>
// owned by `event_tap_thread`. It's valid for the lifetime of
// that thread; the registration is removed before the box is
// freed. The callback only fires while the run loop is running
// on that thread, so we know the box is live here.
let sender = unsafe { &*(user_info as *const Sender<ProducerEvent>) };
if let Err(e) = sender.blocking_send(ProducerEvent::DisplayReconfigured) {
log::warn!("failed to notify display reconfiguration: {e}");
}
}
pub struct MacOSInputCapture { pub struct MacOSInputCapture {
event_rx: Receiver<(Position, CaptureEvent)>, event_rx: Receiver<(Position, CaptureEvent)>,
notify_tx: Sender<ProducerEvent>, notify_tx: Sender<ProducerEvent>,
@@ -657,8 +527,6 @@ pub struct MacOSInputCapture {
impl MacOSInputCapture { impl MacOSInputCapture {
pub async fn new() -> Result<Self, MacosCaptureCreationError> { pub async fn new() -> Result<Self, MacosCaptureCreationError> {
request_macos_capture_permissions()?;
let state = Arc::new(Mutex::new(InputCaptureState::new()?)); let state = Arc::new(Mutex::new(InputCaptureState::new()?));
let (event_tx, event_rx) = mpsc::channel(32); let (event_tx, event_rx) = mpsc::channel(32);
let (notify_tx, mut notify_rx) = mpsc::channel(32); let (notify_tx, mut notify_rx) = mpsc::channel(32);
@@ -712,38 +580,6 @@ impl MacOSInputCapture {
} }
} }
fn request_macos_capture_permissions() -> Result<(), MacosCaptureCreationError> {
// Call both request functions unconditionally so macOS surfaces both
// TCC prompts on the very first launch. TCC always returns `false` the
// first time a permission is requested (the grant only becomes visible
// on the next process launch), so returning early on the first failure
// would skip the second prompt and force the user through an extra
// relaunch just to see it.
let accessibility = request_accessibility_permission();
let input_monitoring = request_input_monitoring_permission();
if !accessibility {
return Err(MacosCaptureCreationError::AccessibilityPermission);
}
if !input_monitoring {
return Err(MacosCaptureCreationError::InputMonitoringPermission);
}
Ok(())
}
fn request_accessibility_permission() -> bool {
// Silent check. The GUI owns the one-time user-visible prompt at
// startup (see lan_mouse_gtk::macos_privacy) so retries triggered by
// clicking the "Reenable" button don't pop a fresh Accessibility
// alert every time.
unsafe { AXIsProcessTrusted() }
}
fn request_input_monitoring_permission() -> bool {
// Silent check, same reasoning as above.
unsafe { CGPreflightListenEventAccess() }
}
impl Drop for MacOSInputCapture { impl Drop for MacOSInputCapture {
fn drop(&mut self) { fn drop(&mut self) {
self.run_loop.stop(); self.run_loop.stop();
@@ -815,30 +651,6 @@ extern "C" {
event_source: CGEventSource, event_source: CGEventSource,
seconds: CFTimeInterval, seconds: CFTimeInterval,
); );
fn CGPreflightListenEventAccess() -> bool;
/// Re-enable an event tap that was disabled by a
/// `kCGEventTapDisabledByTimeout` event. The Apple-documented
/// recovery path: see Quartz Event Services Reference. The `tap`
/// argument is a `CFMachPortRef`; we pass the raw pointer so we
/// can store it as `usize` for cross-thread sharing.
fn CGEventTapEnable(tap: *mut c_void, enable: bool);
/// Register a callback invoked when the display configuration
/// changes (monitor add/remove, resolution change, mirror,
/// rearrange, etc). See Quartz Display Services Reference.
fn CGDisplayRegisterReconfigurationCallback(
callback: extern "C" fn(u32, u32, *mut c_void),
user_info: *mut c_void,
) -> CGError;
fn CGDisplayRemoveReconfigurationCallback(
callback: extern "C" fn(u32, u32, *mut c_void),
user_info: *mut c_void,
) -> CGError;
}
#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
fn AXIsProcessTrusted() -> bool;
} }
unsafe fn configure_cf_settings() -> Result<(), MacosCaptureCreationError> { unsafe fn configure_cf_settings() -> Result<(), MacosCaptureCreationError> {

View File

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

View File

@@ -1,7 +1,7 @@
[package] [package]
name = "input-emulation" name = "input-emulation"
description = "cross-platform input emulation library used by lan-mouse" description = "cross-platform input emulation library used by lan-mouse"
version = "0.4.0" version = "0.3.0"
edition = "2021" edition = "2021"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
repository = "https://github.com/feschber/lan-mouse" repository = "https://github.com/feschber/lan-mouse"
@@ -10,7 +10,7 @@ repository = "https://github.com/feschber/lan-mouse"
async-trait = "0.1.80" async-trait = "0.1.80"
futures = "0.3.28" futures = "0.3.28"
log = "0.4.22" log = "0.4.22"
input-event = { path = "../input-event", version = "0.4.0" } input-event = { path = "../input-event", version = "0.3.0" }
thiserror = "2.0.0" thiserror = "2.0.0"
tokio = { version = "1.32.0", features = [ tokio = { version = "1.32.0", features = [
"io-util", "io-util",
@@ -49,8 +49,6 @@ reis = { version = "0.5.0", features = ["tokio"], optional = true }
[target.'cfg(target_os="macos")'.dependencies] [target.'cfg(target_os="macos")'.dependencies]
bitflags = "2.6.0" bitflags = "2.6.0"
core-foundation = "0.10.0"
core-foundation-sys = "0.8.6"
core-graphics = { version = "0.25.0", features = ["highsierra"] } core-graphics = { version = "0.25.0", features = ["highsierra"] }
keycode = "1.0.0" keycode = "1.0.0"

View File

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

View File

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

View File

@@ -61,8 +61,6 @@ unsafe impl Send for MacOSEmulation {}
impl MacOSEmulation { impl MacOSEmulation {
pub(crate) fn new() -> Result<Self, MacOSEmulationCreationError> { pub(crate) fn new() -> Result<Self, MacOSEmulationCreationError> {
request_macos_emulation_permissions()?;
let event_source = CGEventSource::new(CGEventSourceStateID::CombinedSessionState) let event_source = CGEventSource::new(CGEventSourceStateID::CombinedSessionState)
.map_err(|_| MacOSEmulationCreationError::EventSourceCreation)?; .map_err(|_| MacOSEmulationCreationError::EventSourceCreation)?;
Ok(Self { Ok(Self {
@@ -106,19 +104,8 @@ impl MacOSEmulation {
} }
} }
} }
// Always release the key with the correct CGKeyCode, regardless of // release key when cancelled
// whether the repeat loop ran. This matches @feschber's review update_modifiers(&modifiers, key as u32, 0);
// 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()); key_event(event_source.clone(), key, 0, modifiers.get());
}); });
self.repeat_task = Some(repeat_task); self.repeat_task = Some(repeat_task);
@@ -132,55 +119,6 @@ impl MacOSEmulation {
} }
} }
fn request_macos_emulation_permissions() -> Result<(), MacOSEmulationCreationError> {
// Request both permissions up front so the user sees both TCC prompts
// on the first launch. See the matching comment in input-capture/src/
// macos.rs::request_macos_capture_permissions for the rationale.
let accessibility = request_accessibility_permission();
let input_control = request_input_control_permission();
if !accessibility {
return Err(MacOSEmulationCreationError::AccessibilityPermission);
}
if !input_control {
return Err(MacOSEmulationCreationError::InputControlPermission);
}
Ok(())
}
fn request_accessibility_permission() -> bool {
// Silent check. The GUI owns the one-time user-visible prompt at
// startup (see lan_mouse_gtk::macos_privacy).
unsafe { AXIsProcessTrusted() }
}
fn request_input_control_permission() -> bool {
unsafe { CGPreflightPostEventAccess() }
}
#[link(name = "CoreGraphics", kind = "framework")]
extern "C" {
fn CGPreflightPostEventAccess() -> bool;
}
#[link(name = "ApplicationServices", kind = "framework")]
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) { fn key_event(event_source: CGEventSource, key: u16, state: u8, modifiers: XMods) {
let event = match CGEvent::new_keyboard_event(event_source, key, state != 0) { let event = match CGEvent::new_keyboard_event(event_source, key, state != 0) {
Ok(e) => e, Ok(e) => e,
@@ -189,15 +127,7 @@ fn key_event(event_source: CGEventSource, key: u16, state: u8, modifiers: XMods)
return; return;
} }
}; };
let mut flags = to_cgevent_flags(modifiers); event.set_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); event.post(CGEventTapLocation::HID);
log::trace!("key event: {key} {state}"); log::trace!("key event: {key} {state}");
} }

View File

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

View File

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

View File

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

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 0 3 c 0 -1.644531 1.355469 -3 3 -3 h 5 c 1.644531 0 3 1.355469 3 3 c 0 0.550781 -0.449219 1 -1 1 s -1 -0.449219 -1 -1 c 0 -0.570312 -0.429688 -1 -1 -1 h -5 c -0.570312 0 -1 0.429688 -1 1 v 5 c 0 0.570312 0.429688 1 1 1 c 0.550781 0 1 0.449219 1 1 s -0.449219 1 -1 1 c -1.644531 0 -3 -1.355469 -3 -3 z m 5 5 c 0 -1.644531 1.355469 -3 3 -3 h 5 c 1.644531 0 3 1.355469 3 3 v 5 c 0 1.644531 -1.355469 3 -3 3 h -5 c -1.644531 0 -3 -1.355469 -3 -3 z m 2 0 v 5 c 0 0.570312 0.429688 1 1 1 h 5 c 0.570312 0 1 -0.429688 1 -1 v -5 c 0 -0.570312 -0.429688 -1 -1 -1 h -5 c -0.570312 0 -1 0.429688 -1 1 z m 0 0" fill="#2e3436"/>
</svg>

Before

Width:  |  Height:  |  Size: 765 B

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 8 0 c -4.410156 0 -8 3.589844 -8 8 s 3.589844 8 8 8 s 8 -3.589844 8 -8 s -3.589844 -8 -8 -8 z m 0 2 c 3.332031 0 6 2.667969 6 6 s -2.667969 6 -6 6 s -6 -2.667969 -6 -6 s 2.667969 -6 6 -6 z m -2.03125 2.96875 c -0.265625 0 -0.519531 0.105469 -0.707031 0.292969 c -0.390625 0.390625 -0.390625 1.023437 0 1.414062 l 1.292969 1.292969 l -1.292969 1.292969 c -0.390625 0.390625 -0.390625 1.023437 0 1.414062 s 1.023437 0.390625 1.414062 0 l 1.292969 -1.292969 l 1.292969 1.292969 c 0.390625 0.390625 1.023437 0.390625 1.414062 0 s 0.390625 -1.023437 0 -1.414062 l -1.292969 -1.292969 l 1.292969 -1.292969 c 0.390625 -0.390625 0.390625 -1.023437 0 -1.414062 c -0.1875 -0.1875 -0.441406 -0.292969 -0.707031 -0.292969 s -0.519531 0.105469 -0.707031 0.292969 l -1.292969 1.292969 l -1.292969 -1.292969 c -0.1875 -0.1875 -0.441406 -0.292969 -0.707031 -0.292969 z m 0 0" fill="#2e3436"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 13.753906 4.660156 c 0.175782 -0.199218 0.261719 -0.460937 0.246094 -0.726562 c -0.019531 -0.265625 -0.140625 -0.511719 -0.339844 -0.6875 c -0.199218 -0.175782 -0.460937 -0.261719 -0.726562 -0.246094 c -0.265625 0.019531 -0.511719 0.140625 -0.6875 0.339844 l -6.296875 7.195312 l -2.242188 -2.242187 c -0.390625 -0.390625 -1.023437 -0.390625 -1.414062 0 c -0.1875 0.1875 -0.292969 0.441406 -0.292969 0.707031 s 0.105469 0.519531 0.292969 0.707031 l 3 3 c 0.195312 0.195313 0.464843 0.304688 0.738281 0.292969 c 0.277344 -0.007812 0.539062 -0.132812 0.722656 -0.339844 z m 0 0" fill="#2e3436"/>
</svg>

Before

Width:  |  Height:  |  Size: 743 B

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 7 1 v 6 h -6 v 2 h 6 v 6 h 2 v -6 h 6 v -2 h -6 v -6 z m 0 0" fill="#2e3436"/>
</svg>

Before

Width:  |  Height:  |  Size: 228 B

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 7.085938 2 c 0.574218 0.007812 1.152343 0.085938 1.726562 0.238281 c 3.054688 0.820313 5.1875 3.597657 5.1875 6.761719 h 2 v 1 h -0.007812 c 0.003906 0.265625 -0.101563 0.519531 -0.285157 0.707031 l -2 2 c -0.390625 0.390625 -1.023437 0.390625 -1.414062 0 l -2 -2 c -0.1875 -0.1875 -0.289063 -0.441406 -0.289063 -0.707031 h -0.003906 v -1 h 2 c 0 -2.269531 -1.515625 -4.242188 -3.707031 -4.832031 c -2.1875 -0.585938 -4.488281 0.367187 -5.625 2.332031 c -1.132813 1.964844 -0.808594 4.429688 0.796875 6.035156 c 0.390625 0.390625 0.390625 1.023438 0 1.414063 s -1.023438 0.390625 -1.414063 0 c -2.238281 -2.238281 -2.695312 -5.710938 -1.113281 -8.449219 c 1.1875 -2.054688 3.304688 -3.324219 5.578125 -3.480469 c 0.1875 -0.015625 0.378906 -0.023437 0.570313 -0.019531 z m 0 0" fill="#2e3436"/>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 13.753906 4.660156 c 0.175782 -0.199218 0.261719 -0.460937 0.246094 -0.726562 c -0.019531 -0.265625 -0.140625 -0.511719 -0.339844 -0.6875 c -0.199218 -0.175782 -0.460937 -0.261719 -0.726562 -0.246094 c -0.265625 0.019531 -0.511719 0.140625 -0.6875 0.339844 l -6.296875 7.195312 l -2.242188 -2.242187 c -0.390625 -0.390625 -1.023437 -0.390625 -1.414062 0 c -0.1875 0.1875 -0.292969 0.441406 -0.292969 0.707031 s 0.105469 0.519531 0.292969 0.707031 l 3 3 c 0.195312 0.195313 0.464843 0.304688 0.738281 0.292969 c 0.277344 -0.007812 0.539062 -0.132812 0.722656 -0.339844 z m 0 0" fill="#2e3436"/>
</svg>

Before

Width:  |  Height:  |  Size: 743 B

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<g fill="#2e3436">
<path d="m 1 2 h 14 v 2 h -14 z m 0 0"/>
<path d="m 1 7 h 14 v 2 h -14 z m 0 0"/>
<path d="m 1 12 h 14 v 2 h -14 z m 0 0"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 314 B

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 3 2 c -0.265625 0 -0.519531 0.105469 -0.707031 0.292969 c -0.390625 0.390625 -0.390625 1.023437 0 1.414062 l 4.292969 4.292969 l -4.292969 4.292969 c -0.390625 0.390625 -0.390625 1.023437 0 1.414062 s 1.023437 0.390625 1.414062 0 l 4.292969 -4.292969 l 4.292969 4.292969 c 0.390625 0.390625 1.023437 0.390625 1.414062 0 s 0.390625 -1.023437 0 -1.414062 l -4.292969 -4.292969 l 4.292969 -4.292969 c 0.390625 -0.390625 0.390625 -1.023437 0 -1.414062 c -0.1875 -0.1875 -0.441406 -0.292969 -0.707031 -0.292969 s -0.519531 0.105469 -0.707031 0.292969 l -4.292969 4.292969 l -4.292969 -4.292969 c -0.1875 -0.1875 -0.441406 -0.292969 -0.707031 -0.292969 z m 0 0" fill="#2e3436"/>
</svg>

Before

Width:  |  Height:  |  Size: 822 B

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 8.074219 0 c -1.203125 -0.0117188 -2.40625 0.285156 -3.492188 0.890625 c -0.480469 0.269531 -0.652343 0.878906 -0.382812 1.359375 c 0.269531 0.484375 0.878906 0.65625 1.359375 0.386719 c 1.550781 -0.867188 3.4375 -0.847657 4.972656 0.050781 c 1.53125 0.898438 2.46875 2.535156 2.46875 4.3125 v 1 c 0 0.550781 0.449219 1 1 1 s 1 -0.449219 1 -1 v -1 c 0 -0.019531 0 -0.039062 -0.003906 -0.054688 c -0.019532 -2.460937 -1.332032 -4.738281 -3.457032 -5.984374 c -1.070312 -0.628907 -2.265624 -0.9492192 -3.46875 -0.960938 z m -5.199219 2.832031 c -0.066406 0 -0.132812 0.007813 -0.195312 0.023438 c -0.257813 0.058593 -0.484376 0.21875 -0.625 0.445312 c -0.6875 1.109375 -1.054688 2.390625 -1.054688 3.699219 v 5.0625 c 0 0.550781 0.449219 1 1 1 s 1 -0.449219 1 -1 v -5.0625 c 0 -0.933594 0.261719 -1.851562 0.753906 -2.644531 c 0.292969 -0.46875 0.148438 -1.082031 -0.320312 -1.375 c -0.167969 -0.105469 -0.363282 -0.15625 -0.558594 -0.148438 z m 5.125 0.167969 c -2.199219 0 -4 1.800781 -4 4 v 1 c 0 0.550781 0.449219 1 1 1 s 1 -0.449219 1 -1 v -1 c 0 -1.117188 0.882812 -2 2 -2 s 2 0.882812 2 2 v 5 s 0.007812 0.441406 0.175781 0.941406 s 0.5 1.148438 1.117188 1.765625 c 0.390625 0.390625 1.023437 0.390625 1.414062 0 s 0.390625 -1.023437 0 -1.414062 c -0.382812 -0.382813 -0.550781 -0.734375 -0.632812 -0.984375 s -0.074219 -0.308594 -0.074219 -0.308594 v -5 c 0 -2.199219 -1.800781 -4 -4 -4 z m 0 3 c -0.550781 0 -1 0.449219 -1 1 v 5 s 0 0.59375 0.144531 1.320312 c 0.144531 0.726563 0.414063 1.652344 1.148438 2.386719 c 0.390625 0.390625 1.023437 0.390625 1.414062 0 s 0.390625 -1.023437 0 -1.414062 c -0.265625 -0.265625 -0.496093 -0.839844 -0.601562 -1.363281 c -0.105469 -0.523438 -0.105469 -0.929688 -0.105469 -0.929688 v -5 c 0 -0.550781 -0.449219 -1 -1 -1 z m -3 4 c -0.550781 0 -1 0.449219 -1 1 v 3 c 0 0.550781 0.449219 1 1 1 s 1 -0.449219 1 -1 v -3 c 0 -0.550781 -0.449219 -1 -1 -1 z m 9 0 c -0.550781 0 -1 0.449219 -1 1 s 0.449219 1 1 1 s 1 -0.449219 1 -1 s -0.449219 -1 -1 -1 z m 0 0" fill="#2e3434"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 6 0.015625 c -0.554688 0 -1 0.445313 -1 1 v 3 c 0 0.554687 0.445312 1 1 1 h 1 v 2 h -7 v 2 h 2 v 2 h -1 c -0.554688 0 -1 0.445313 -1 1 v 3 c 0 0.554687 0.445312 1 1 1 h 4 c 0.554688 0 1 -0.445313 1 -1 v -3 c 0 -0.554687 -0.445312 -1 -1 -1 h -1 v -2 h 8 v 2 h -1 c -0.554688 0 -1 0.445313 -1 1 v 3 c 0 0.554687 0.445312 1 1 1 h 4 c 0.554688 0 1 -0.445313 1 -1 v -3 c 0 -0.554687 -0.445312 -1 -1 -1 h -1 v -2 h 2 v -2 h -7 v -2 h 1 c 0.554688 0 1 -0.445313 1 -1 v -3 c 0 -0.554687 -0.445312 -1 -1 -1 z m 0 0" fill="#2e3436"/>
</svg>

Before

Width:  |  Height:  |  Size: 673 B

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<g fill="#2e3436">
<path d="m 6.5 0 c -1.378906 0 -2.5 1.121094 -2.5 2.5 v 0.5 h -3 c -0.550781 0 -1 0.449219 -1 1 s 0.449219 1 1 1 h 1 v 8 c 0 1.65625 1.34375 3 3 3 h 6 c 1.65625 0 3 -1.34375 3 -3 v -8 h 1 c 0.550781 0 1 -0.449219 1 -1 s -0.449219 -1 -1 -1 h -3.023438 v -0.5 c 0 -1.378906 -1.117187 -2.5 -2.5 -2.5 z m 0 2 h 2.976562 c 0.289063 0 0.5 0.210938 0.5 0.5 v 0.5 h -3.976562 v -0.5 c 0 -0.289062 0.210938 -0.5 0.5 -0.5 z m -2.5 3 h 8 v 8 c 0 0.5625 -0.4375 1 -1 1 h -6 c -0.5625 0 -1 -0.4375 -1 -1 z m 0 0"/>
<path d="m 7 7 v 5 c 0 0.277344 -0.222656 0.5 -0.5 0.5 s -0.5 -0.222656 -0.5 -0.5 v -5 c 0 -0.277344 0.222656 -0.5 0.5 -0.5 s 0.5 0.222656 0.5 0.5 z m 0 0"/>
<path d="m 10 7 v 5 c 0 0.277344 -0.222656 0.5 -0.5 0.5 s -0.5 -0.222656 -0.5 -0.5 v -5 c 0 -0.277344 0.222656 -0.5 0.5 -0.5 s 0.5 0.222656 0.5 0.5 z m 0 0"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1009 B

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 7.90625 0.09375 c -0.527344 -0.0273438 -1.039062 0.28125 -1.4375 0.96875 l -6.25 11.59375 c -0.535156 0.964844 0.046875 2.34375 1.09375 2.34375 h 13.15625 c 0.980469 0 1.902344 -1.160156 1.21875 -2.34375 l -6.3125 -11.53125 c -0.398438 -0.644531 -0.941406 -1.003906 -1.46875 -1.03125 z m 1.09375 3.90625 v 5 c 0.007812 0.527344 -0.472656 1 -1 1 s -1.007812 -0.472656 -1 -1 v -5 z m -1 7 c 0.550781 0 1 0.449219 1 1 s -0.449219 1 -1 1 s -1 -0.449219 -1 -1 s 0.449219 -1 1 -1 z m 0 0" fill="#2e3436"/>
</svg>

Before

Width:  |  Height:  |  Size: 649 B

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<g fill="#2e3436">
<path d="m 15 10 c 0.265625 0 0.519531 0.105469 0.707031 0.292969 c 0.390625 0.390625 0.390625 1.023437 0 1.414062 l -1.292969 1.292969 l 1.292969 1.292969 c 0.390625 0.390625 0.390625 1.023437 0 1.414062 s -1.023437 0.390625 -1.414062 0 l -1.292969 -1.292969 l -1.292969 1.292969 c -0.390625 0.390625 -1.023437 0.390625 -1.414062 0 s -0.390625 -1.023437 0 -1.414062 l 1.292969 -1.292969 l -1.292969 -1.292969 c -0.390625 -0.390625 -0.390625 -1.023437 0 -1.414062 c 0.1875 -0.1875 0.441406 -0.292969 0.707031 -0.292969 s 0.519531 0.105469 0.707031 0.292969 l 1.292969 1.292969 l 1.292969 -1.292969 c 0.1875 -0.1875 0.441406 -0.292969 0.707031 -0.292969 z m 0 0"/>
<path d="m 6 0 c -0.554688 0 -1 0.445312 -1 1 v 3 c 0 0.554688 0.445312 1 1 1 h 1 v 2 h -7 v 2 h 2 v 2 h -1 c -0.554688 0 -1 0.445312 -1 1 v 3 c 0 0.554688 0.445312 1 1 1 h 4 c 0.554688 0 1 -0.445312 1 -1 v -3 c 0 -0.554688 -0.445312 -1 -1 -1 h -1 v -2 h 12 v -2 h -7 v -2 h 1 c 0.554688 0 1 -0.445312 1 -1 v -3 c 0 -0.554688 -0.445312 -1 -1 -1 z m 0 0" fill-opacity="0.34902"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -6,27 +6,8 @@
<file compressed="true" preprocess="xml-stripblanks">fingerprint_window.ui</file> <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">client_row.ui</file>
<file compressed="true" preprocess="xml-stripblanks">key_row.ui</file> <file compressed="true" preprocess="xml-stripblanks">key_row.ui</file>
<file compressed="true">style.css</file>
</gresource> </gresource>
<gresource prefix="/de/feschber/LanMouse/icons"> <gresource prefix="/de/feschber/LanMouse/icons">
<file compressed="true" preprocess="xml-stripblanks">de.feschber.LanMouse.svg</file> <file compressed="true" preprocess="xml-stripblanks">de.feschber.LanMouse.svg</file>
<!--
Bundled Adwaita symbolic icons so the GTK frontend has a complete icon set
on platforms (notably macOS) where the Adwaita icon theme is not installed.
Registered via IconTheme::add_resource_path("/de/feschber/LanMouse/icons").
-->
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/actions/edit-copy-symbolic.svg">icons/scalable/actions/edit-copy-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/actions/edit-delete-symbolic.svg">icons/scalable/actions/edit-delete-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/actions/emblem-ok-symbolic.svg">icons/scalable/actions/emblem-ok-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/actions/list-add-symbolic.svg">icons/scalable/actions/list-add-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/actions/object-rotate-right-symbolic.svg">icons/scalable/actions/object-rotate-right-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/actions/object-select-symbolic.svg">icons/scalable/actions/object-select-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/actions/open-menu-symbolic.svg">icons/scalable/actions/open-menu-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/actions/process-stop-symbolic.svg">icons/scalable/actions/process-stop-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/devices/auth-fingerprint-symbolic.svg">icons/scalable/devices/auth-fingerprint-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/devices/network-wired-symbolic.svg">icons/scalable/devices/network-wired-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/places/user-trash-symbolic.svg">icons/scalable/places/user-trash-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/status/dialog-warning-symbolic.svg">icons/scalable/status/dialog-warning-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/status/network-wired-disconnected-symbolic.svg">icons/scalable/status/network-wired-disconnected-symbolic.svg</file>
</gresource> </gresource>
</gresources> </gresources>

View File

@@ -1,12 +0,0 @@
.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

@@ -63,7 +63,7 @@
<signal name="clicked" handler="handle_capture" swapped="true"/> <signal name="clicked" handler="handle_capture" swapped="true"/>
<property name="valign">center</property> <property name="valign">center</property>
<style> <style>
<class name="pill"/> <class name="circular"/>
<class name="flat"/> <class name="flat"/>
</style> </style>
</object> </object>
@@ -89,7 +89,7 @@
<property name="valign">center</property> <property name="valign">center</property>
<signal name="clicked" handler="handle_emulation" swapped="true"/> <signal name="clicked" handler="handle_emulation" swapped="true"/>
<style> <style>
<class name="pill"/> <class name="circular"/>
<class name="flat"/> <class name="flat"/>
</style> </style>
</object> </object>

View File

@@ -26,7 +26,6 @@ impl ClientObject {
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
) )
.property("resolving", state.resolving) .property("resolving", state.resolving)
.property("peer-commit", peer_commit_to_string(state.peer_commit))
.build() .build()
} }
@@ -35,14 +34,6 @@ 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)] #[derive(Default, Clone)]
pub struct ClientData { pub struct ClientData {
pub handle: ClientHandle, pub handle: ClientHandle,
@@ -52,5 +43,4 @@ pub struct ClientData {
pub position: String, pub position: String,
pub resolving: bool, pub resolving: bool,
pub ips: Vec<String>, pub ips: Vec<String>,
pub peer_commit: Option<String>,
} }

View File

@@ -19,7 +19,6 @@ pub struct ClientObject {
#[property(name = "position", get, set, type = String, member = position)] #[property(name = "position", get, set, type = String, member = position)]
#[property(name = "resolving", get, set, type = bool, member = resolving)] #[property(name = "resolving", get, set, type = bool, member = resolving)]
#[property(name = "ips", get, set, type = Vec<String>, member = ips)] #[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>, pub data: RefCell<ClientData>,
} }

View File

@@ -123,12 +123,6 @@ impl ClientRow {
bindings.push(position_binding); bindings.push(position_binding);
bindings.push(resolve_binding); bindings.push(resolve_binding);
bindings.push(ip_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) { pub fn unbind(&self) {
@@ -156,40 +150,4 @@ impl ClientRow {
pub fn set_dns_state(&self, resolved: bool) { pub fn set_dns_state(&self, resolved: bool) {
self.imp().set_dns_state(resolved); 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

@@ -4,34 +4,12 @@ mod client_row;
mod fingerprint_window; mod fingerprint_window;
mod key_object; mod key_object;
mod key_row; mod key_row;
#[cfg(target_os = "macos")]
mod macos_privacy;
#[cfg(target_os = "macos")]
mod macos_status_item;
mod window; mod window;
use std::{env, process, str, sync::OnceLock}; use std::{env, process, str};
use gtk::CssProvider;
use window::Window; 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 lan_mouse_ipc::FrontendEvent;
use adw::Application; use adw::Application;
@@ -49,12 +27,8 @@ pub enum GtkError {
NonZeroExitCode(i32), NonZeroExitCode(i32),
} }
pub fn run(local_commit: [u8; 8]) -> Result<(), GtkError> { pub fn run() -> Result<(), GtkError> {
log::debug!("running gtk frontend"); log::debug!("running gtk frontend");
LOCAL_COMMIT
.set(local_commit)
.expect("local_commit set once");
#[cfg(windows)] #[cfg(windows)]
let ret = std::thread::Builder::new() let ret = std::thread::Builder::new()
.stack_size(8 * 1024 * 1024) // https://gitlab.gnome.org/GNOME/gtk/-/commit/52dbb3f372b2c3ea339e879689c1de535ba2c2c3 -> caused crash on windows .stack_size(8 * 1024 * 1024) // https://gitlab.gnome.org/GNOME/gtk/-/commit/52dbb3f372b2c3ea339e879689c1de535ba2c2c3 -> caused crash on windows
@@ -73,12 +47,6 @@ pub fn run(local_commit: [u8; 8]) -> Result<(), GtkError> {
} }
fn gtk_main() -> glib::ExitCode { fn gtk_main() -> glib::ExitCode {
#[cfg(target_os = "macos")]
{
configure_macos_bundle_environment();
install_macos_gtk_log_filter();
}
gio::resources_register_include!("lan-mouse.gresource").expect("Failed to register resources."); gio::resources_register_include!("lan-mouse.gresource").expect("Failed to register resources.");
let app = Application::builder() let app = Application::builder()
@@ -86,7 +54,6 @@ fn gtk_main() -> glib::ExitCode {
.build(); .build();
app.connect_startup(|app| { app.connect_startup(|app| {
load_css();
load_icons(); load_icons();
setup_actions(app); setup_actions(app);
setup_menu(app); setup_menu(app);
@@ -97,74 +64,6 @@ fn gtk_main() -> glib::ExitCode {
app.run_with_args(&args) app.run_with_args(&args)
} }
#[cfg(target_os = "macos")]
fn install_macos_gtk_log_filter() {
glib::log_set_writer_func(|level, fields| {
if level == glib::LogLevel::Warning && is_gtk_theme_parser_warning(fields) {
return glib::LogWriterOutput::Handled;
}
glib::log_writer_default(level, fields)
});
}
#[cfg(target_os = "macos")]
fn is_gtk_theme_parser_warning(fields: &[glib::LogField<'_>]) -> bool {
let mut domain = None;
let mut message = None;
for field in fields {
match field.key() {
"GLIB_DOMAIN" => domain = field.value_str(),
"MESSAGE" => message = field.value_str(),
_ => {}
}
}
domain == Some("Gtk")
&& message.is_some_and(|message| message.starts_with("Theme parser warning: gtk.css:"))
}
#[cfg(target_os = "macos")]
fn configure_macos_bundle_environment() {
let Ok(exe) = env::current_exe() else {
return;
};
let Some(contents) = exe
.parent()
.and_then(|dir| dir.parent())
.map(std::path::Path::to_owned)
else {
return;
};
let share = contents.join("Resources").join("share");
if !share.exists() {
return;
}
let schemas = share.join("glib-2.0").join("schemas");
if schemas.exists() {
env::set_var("GSETTINGS_SCHEMA_DIR", schemas);
}
env::set_var("XDG_DATA_DIRS", &share);
env::set_var(
"GTK_DATA_PREFIX",
contents.join("Resources").to_string_lossy().as_ref(),
);
}
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() { fn load_icons() {
let display = &Display::default().expect("Could not connect to a display."); let display = &Display::default().expect("Could not connect to a display.");
let icon_theme = IconTheme::for_display(display); let icon_theme = IconTheme::for_display(display);
@@ -224,41 +123,6 @@ fn build_ui(app: &Application) {
}); });
let window = Window::new(app, frontend_tx); let window = Window::new(app, frontend_tx);
#[cfg(target_os = "macos")]
{
window.connect_close_request(|window| {
window.set_visible(false);
glib::Propagation::Stop
});
macos_status_item::setup(app, &window);
// First-launch TCC prompts. No-op when already granted.
macos_privacy::fire_initial_prompts();
// Watch the Accessibility grant continuously for the lifetime
// of the process. On a grant, swap the warning row into its
// "relaunch required" state (the daemon subprocess already
// bailed and can't recover without a restart). On a REVOKE,
// quit immediately — an active CGEventTap at
// HeadInsertEventTap can wedge system input if the process
// lingers after losing AX, and forcing the process to exit is
// the only bulletproof way to guarantee the kernel tears the
// tap down.
let window_weak = window.downgrade();
let app_weak = app.downgrade();
macos_privacy::watch_accessibility_state(move |change| match change {
macos_privacy::AccessibilityChange::Granted => {
if let Some(window) = window_weak.upgrade() {
window.present();
window.refresh_capture_emulation_status();
}
}
macos_privacy::AccessibilityChange::Revoked => {
log::warn!("Accessibility revoked — quitting to avoid wedging system input");
if let Some(app) = app_weak.upgrade() {
app.quit();
}
}
});
}
glib::spawn_future_local(clone!( glib::spawn_future_local(clone!(
#[weak] #[weak]
@@ -307,18 +171,5 @@ fn build_ui(app: &Application) {
} }
)); ));
#[cfg(not(target_os = "macos"))]
window.present(); window.present();
// On macOS, default to presenting the main window on every launch
// so the user gets a visible confirmation that the app is running
// — including the post-grant relaunch and normal Dock/Finder/`open`
// launches. Opt out by setting `LAN_MOUSE_HIDDEN=1` in the
// environment (useful for a LaunchAgent / login-item configuration
// where the user wants the app to come up quietly into the menu
// bar only, with no window on boot).
#[cfg(target_os = "macos")]
if env::var_os("LAN_MOUSE_HIDDEN").is_none() {
window.present();
}
} }

View File

@@ -1,256 +0,0 @@
//! Tiny macOS Privacy-pane helpers used by the GUI.
//!
//! On macOS 13+, the Accessibility grant transitively confers the
//! listen-only event-tap privilege that Input Monitoring gates and the
//! synthesize-event privilege that Post Event gates, and the bundle
//! typically isn't even listed in those separate panes. So the single
//! user-facing action for any missing-capture or missing-emulation
//! scenario is "re-toggle Accessibility" — we don't route elsewhere.
use std::ffi::{c_uchar, c_void};
use std::process::Command;
use std::sync::Once;
use gtk::glib;
// Apple declares `AXIsProcessTrusted` as returning `Boolean` (`unsigned char`),
// NOT C's `bool`. Rust's `bool` has a strict bit pattern (0 or 1) so binding
// a `Boolean`-returning function as `-> bool` is technically UB if Apple ever
// returns a non-canonical true value. Keep these as `c_uchar` and normalize.
#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
fn AXIsProcessTrusted() -> c_uchar;
fn AXIsProcessTrustedWithOptions(options: *const c_void) -> c_uchar;
}
#[link(name = "CoreFoundation", kind = "framework")]
extern "C" {
static kCFAllocatorDefault: *const c_void;
static kCFTypeDictionaryKeyCallBacks: *const c_void;
static kCFTypeDictionaryValueCallBacks: *const c_void;
static kCFBooleanTrue: *const c_void;
fn CFDictionaryCreate(
allocator: *const c_void,
keys: *const *const c_void,
values: *const *const c_void,
num: isize,
key_callbacks: *const c_void,
value_callbacks: *const c_void,
) -> *const c_void;
fn CFRelease(cf: *const c_void);
}
// kAXTrustedCheckOptionPrompt is a CFStringRef exported from ApplicationServices.
#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
static kAXTrustedCheckOptionPrompt: *const c_void;
}
#[link(name = "CoreGraphics", kind = "framework")]
extern "C" {
fn CGRequestListenEventAccess() -> c_uchar;
fn CGRequestPostEventAccess() -> c_uchar;
// CFMachPortRef CGEventTapCreate(
// CGEventTapLocation tap, CGEventTapPlacement place,
// CGEventTapOptions options, CGEventMask eventsOfInterest,
// CGEventTapCallBack callback, void *userInfo);
fn CGEventTapCreate(
tap: u32,
place: u32,
options: u32,
events_of_interest: u64,
callback: *const c_void,
user_info: *const c_void,
) -> *const c_void;
}
pub fn accessibility_granted() -> bool {
let raw = unsafe { AXIsProcessTrusted() };
log::debug!("AXIsProcessTrusted() = {raw}");
raw != 0
}
pub enum AccessibilityChange {
/// AX was missing at startup and the user has now granted it.
/// Capture/emulation still need a relaunch to take effect, since
/// the daemon subprocess already bailed.
Granted,
/// AX was granted and the user has now revoked it. Quit immediately
/// — leaving the process alive with an active CGEventTap at
/// HeadInsertEventTap can wedge system input (clicks/keys silently
/// consumed) until the process dies. See
/// macos-cgeventtap-drop-fallthrough-tcc-revoke skill for the
/// underlying event-tap-disable footgun.
Revoked,
}
/// Poll for Accessibility grant/revoke transitions. Starts a 1-second
/// GLib timer that fires `on_change` every time `AXIsProcessTrusted()`
/// flips, and keeps running for the lifetime of the process.
///
/// We rely on polling rather than AXObserver because the AX notification
/// API requires a trusted process to subscribe — the precondition we
/// can't assume. This runs on the GTK main thread (via
/// `timeout_add_seconds_local`).
pub fn watch_accessibility_state<F>(mut on_change: F)
where
F: FnMut(AccessibilityChange) + 'static,
{
let mut last = accessibility_granted();
log::info!("watching Accessibility state (initial = {last})");
glib::timeout_add_seconds_local(1, move || {
let current = accessibility_granted();
if current != last {
log::info!("Accessibility state flip: {last} -> {current}");
on_change(if current {
AccessibilityChange::Granted
} else {
AccessibilityChange::Revoked
});
last = current;
}
glib::ControlFlow::Continue
});
}
pub fn open_accessibility_settings() {
open_url("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility");
}
/// Spawn a fresh instance of the current `.app` bundle via Launch Services
/// after a 1-second delay, so the new instance starts *after* the current
/// process has exited — otherwise Launch Services reactivates the existing
/// process instead of launching a fresh one, and the stale IPC socket
/// would block the new daemon subprocess. The caller is responsible for
/// quitting the current process (e.g. `Application::quit()`) after this.
pub fn relaunch_bundle() {
// Resolve the .app bundle path from the current executable: it lives
// at <bundle>/Contents/MacOS/lan-mouse, so three parents up is the
// bundle root we hand to `open`.
let Ok(exe) = std::env::current_exe() else {
return;
};
let Some(bundle) = exe
.parent()
.and_then(std::path::Path::parent)
.and_then(std::path::Path::parent)
else {
return;
};
// Trailing `&` backgrounds the sleep+open so our shell call returns
// immediately; the spawned shell is adopted by launchd once we exit.
let cmd = format!("(sleep 1 && open {bundle:?}) &");
let _ = Command::new("sh").arg("-c").arg(cmd).spawn();
}
/// Make sure the app appears in System Settings → Privacy → Input Monitoring.
///
/// `CGRequestListenEventAccess()` is *supposed* to register the app in the
/// list (and prompt) on first call, but in practice — particularly after a
/// `tccutil reset ListenEvent <bundle>` — it often silently no-ops and the
/// app never gets added. The reliable way to force registration is to
/// attempt a protected action: create a `CGEventTap`. If permission is
/// missing the call returns null, but the attempt itself causes TCC to add
/// the bundle to the Input Monitoring pane so the user can toggle it on.
/// If permission already exists the tap is created successfully, and we
/// tear it down immediately so it doesn't intercept events.
unsafe fn ensure_listed_in_input_monitoring() {
let req = CGRequestListenEventAccess();
log::debug!("CGRequestListenEventAccess() = {req}");
let cb = input_monitoring_noop_tap_callback as *const c_void;
// Use kCGSessionEventTap (1), NOT kCGHIDEventTap (0). The HID tap sits
// below window-server input and requires Accessibility in addition to
// Input Monitoring, so attempting it when Accessibility isn't granted
// surfaces an Accessibility prompt as a side effect — which is confusing
// on top of the real Accessibility prompt we already fire explicitly.
// The session tap requires only Input Monitoring, so its failure is a
// clean "Input Monitoring missing" signal that TCC uses to list the
// bundle under the Input Monitoring pane.
// kCGHeadInsertEventTap = 0, kCGEventTapOptionListenOnly = 1,
// mask kCGEventKeyDown = 1 << 10.
let tap = CGEventTapCreate(1, 0, 1, 1 << 10, cb, std::ptr::null());
log::debug!("CGEventTapCreate(kCGSessionEventTap) -> {tap:?}");
if !tap.is_null() {
CFRelease(tap);
}
}
extern "C" fn input_monitoring_noop_tap_callback(
_proxy: *const c_void,
_ty: u32,
event: *const c_void,
_refcon: *const c_void,
) -> *const c_void {
// Pass through unchanged. This tap is never added to a run loop, so
// in practice the callback never fires — it exists only so the tap
// can be created (and the attempt is what forces TCC registration).
event
}
fn open_url(url: &str) {
if let Err(e) = Command::new("open").arg(url).spawn() {
log::warn!("failed to open {url}: {e}");
}
}
/// One-shot, at GUI startup: if a permission is missing, fire the system
/// prompt. This is where the familiar first-launch "Lan Mouse.app would
/// like to control this computer" alert comes from. Subsequent clicks on
/// the Reenable button use URL-scheme navigation instead, so we never
/// double up alerts on retries.
///
/// Guarded with a `Once` because GApplication::activate can fire more
/// than once in a process (reactivation, window presentation) and we
/// must not re-pop the TCC alert on each activation — that looks like a
/// bug to the user.
pub fn fire_initial_prompts() {
static FIRED: Once = Once::new();
FIRED.call_once(fire_initial_prompts_inner);
}
fn fire_initial_prompts_inner() {
if !accessibility_granted() {
// When Accessibility isn't granted yet, ONLY fire the Accessibility
// prompt. Do NOT also try to register Input Monitoring or Post Event
// — those paths have been observed to surface a second Accessibility
// dialog on top of the one we fire explicitly (Post Event is part of
// the Accessibility category on modern macOS, and CGEventTap attempts
// can bail on Accessibility before they reach the Input Monitoring
// check). Once the user grants Accessibility and relaunches, this
// branch is skipped and we register the other grants cleanly below.
log::info!("firing first-launch Accessibility prompt");
unsafe {
let key = kAXTrustedCheckOptionPrompt;
let value = kCFBooleanTrue;
let options = CFDictionaryCreate(
kCFAllocatorDefault,
&key as *const _,
&value as *const _,
1,
kCFTypeDictionaryKeyCallBacks,
kCFTypeDictionaryValueCallBacks,
);
AXIsProcessTrustedWithOptions(options);
CFRelease(options);
}
return;
}
// Accessibility is granted. Attempt Input Monitoring registration
// unconditionally — even if preflight returns true — so the bundle gets
// listed in System Settings under its own identity (otherwise launches
// from a parent process that already has Input Monitoring, e.g. Terminal,
// inherit the grant but the bundle is never listed for the user to
// toggle persistently).
log::info!("ensuring Lan Mouse is listed under Input Monitoring");
unsafe {
ensure_listed_in_input_monitoring();
}
// Same for Post Event: now that Accessibility is present, this call is
// safe — it won't surface the generic Accessibility prompt.
log::info!("ensuring Lan Mouse is listed under Accessibility > Post Event");
unsafe {
CGRequestPostEventAccess();
}
}

View File

@@ -1,346 +0,0 @@
#![allow(clashing_extern_declarations)]
use std::{
cell::RefCell,
ffi::{CStr, CString, c_char, c_double, c_uint, c_void},
sync::OnceLock,
};
use adw::prelude::*;
use gtk::{gio, glib};
use crate::window::Window;
type Id = *mut c_void;
type Class = *mut c_void;
type Sel = *mut c_void;
type Bool = i8;
struct StatusItem {
app: glib::WeakRef<adw::Application>,
window: glib::WeakRef<Window>,
_hold: gio::ApplicationHoldGuard,
_delegate: Id,
_status_item: Id,
}
thread_local! {
static STATUS_ITEM: RefCell<Option<StatusItem>> = const { RefCell::new(None) };
}
pub fn setup(app: &adw::Application, window: &Window) {
log::debug!("macos_status_item::setup entered");
STATUS_ITEM.with(|item| {
let already_initialized = item.borrow().is_some();
if already_initialized {
let mut cell = item.borrow_mut();
if let Some(existing) = cell.as_mut() {
existing.app.set(Some(app));
existing.window.set(Some(window));
}
return;
}
unsafe {
let hold = app.hold();
let ns_app = msg_send_id(class(c"NSApplication"), sel(c"sharedApplication"));
assert!(
!ns_app.is_null(),
"NSApplication sharedApplication returned null"
);
msg_send_bool_usize(ns_app, sel(c"setActivationPolicy:"), 1);
let delegate = new_delegate();
let menu = menu(&[
menu_item(c"Open Lan Mouse", c"showLanMouse:"),
separator_item(),
menu_item(c"Quit Lan Mouse", c"quitLanMouse:"),
]);
let status_bar = msg_send_id(class(c"NSStatusBar"), sel(c"systemStatusBar"));
assert!(
!status_bar.is_null(),
"NSStatusBar systemStatusBar returned null"
);
let status_item = msg_send_id_f64(status_bar, sel(c"statusItemWithLength:"), -1.0);
assert!(!status_item.is_null(), "statusItemWithLength returned null");
// Retain so the status item survives autorelease pool drain.
let status_item = msg_send_id(status_item, sel(c"retain"));
let button = msg_send_id(status_item, sel(c"button"));
assert!(!button.is_null(), "NSStatusItem.button was null");
set_button_image(button);
msg_send_void_id(button, sel(c"setToolTip:"), nsstring(c"Lan Mouse"));
msg_send_void_id(status_item, sel(c"setMenu:"), menu);
for item in menu_items(menu) {
msg_send_void_id(item, sel(c"setTarget:"), delegate);
}
install_reopen_handler(delegate);
log::debug!("macos_status_item ready at {status_item:p}");
item.replace(Some(StatusItem {
app: app.downgrade(),
window: window.downgrade(),
_hold: hold,
_delegate: delegate,
_status_item: status_item,
}));
}
});
}
// Prefer a pre-rendered template PNG (black silhouette with alpha) so macOS
// auto-tints the glyph to match the menu bar in light and dark modes.
// Falls back to the full-color icns, then to "LM" text.
unsafe fn set_button_image(button: Id) {
if let Some(image) = load_menubar_template() {
msg_send_void_bool(image, sel(c"setTemplate:"), 1);
msg_send_void_id(button, sel(c"setImage:"), image);
return;
}
if let Some(image) = load_app_icon() {
msg_send_void_id(button, sel(c"setImage:"), image);
return;
}
log::warn!("no menu bar image available; falling back to text title");
msg_send_void_id(button, sel(c"setTitle:"), nsstring(c"LM"));
}
unsafe fn load_menubar_template() -> Option<Id> {
load_resource_image(c"menubar-template", c"png", MENUBAR_ICON_SIZE)
}
unsafe fn load_app_icon() -> Option<Id> {
load_resource_image(c"icon", c"icns", MENUBAR_ICON_SIZE)
}
unsafe fn load_resource_image(name: &CStr, ext: &CStr, size_pt: c_double) -> Option<Id> {
let bundle = msg_send_id(class(c"NSBundle"), sel(c"mainBundle"));
if bundle.is_null() {
return None;
}
let path = msg_send_id_id_id(
bundle,
sel(c"pathForResource:ofType:"),
nsstring(name),
nsstring(ext),
);
if path.is_null() {
return None;
}
let image = msg_send_id_id(
msg_send_id(class(c"NSImage"), sel(c"alloc")),
sel(c"initWithContentsOfFile:"),
path,
);
if image.is_null() {
return None;
}
// Render at menu bar height; 22pt is the full status bar icon height.
msg_send_void_size(image, sel(c"setSize:"), size_pt, size_pt);
Some(image)
}
const MENUBAR_ICON_SIZE: c_double = 22.0;
unsafe fn menu(items: &[Id]) -> Id {
let menu = msg_send_id(msg_send_id(class(c"NSMenu"), sel(c"alloc")), sel(c"init"));
for item in items {
msg_send_void_id(menu, sel(c"addItem:"), *item);
}
menu
}
unsafe fn menu_item(title: &CStr, action: &CStr) -> Id {
msg_send_id_id_sel_id(
msg_send_id(class(c"NSMenuItem"), sel(c"alloc")),
sel(c"initWithTitle:action:keyEquivalent:"),
nsstring(title),
sel(action),
nsstring(c""),
)
}
unsafe fn separator_item() -> Id {
msg_send_id(class(c"NSMenuItem"), sel(c"separatorItem"))
}
unsafe fn menu_items(menu: Id) -> Vec<Id> {
let count = msg_send_usize(menu, sel(c"numberOfItems"));
(0..count)
.map(|idx| msg_send_id_usize(menu, sel(c"itemAtIndex:"), idx))
.collect()
}
unsafe fn new_delegate() -> Id {
let class = delegate_class();
msg_send_id(msg_send_id(class, sel(c"alloc")), sel(c"init"))
}
fn delegate_class() -> Class {
static CLASS: OnceLock<usize> = OnceLock::new();
*CLASS.get_or_init(|| unsafe {
let superclass = class(c"NSObject");
let class_name = CString::new("LanMouseStatusItemDelegate").unwrap();
let class = objc_allocateClassPair(superclass, class_name.as_ptr(), 0);
assert!(!class.is_null(), "failed to allocate status item delegate");
class_addMethod(
class,
sel(c"showLanMouse:"),
show_lan_mouse as *const c_void,
c"v@:@".as_ptr(),
);
class_addMethod(
class,
sel(c"quitLanMouse:"),
quit_lan_mouse as *const c_void,
c"v@:@".as_ptr(),
);
// kAEReopenApplication handler — fires when the user re-launches
// the .app while it's already running (Finder, `open`, Dock).
class_addMethod(
class,
sel(c"handleReopenEvent:withReplyEvent:"),
handle_reopen_event as *const c_void,
c"v@:@@".as_ptr(),
);
objc_registerClassPair(class);
class as usize
}) as Class
}
extern "C" fn show_lan_mouse(_this: Id, _cmd: Sel, _sender: Id) {
present_window();
}
extern "C" fn handle_reopen_event(_this: Id, _cmd: Sel, _event: Id, _reply: Id) {
log::debug!("kAEReopenApplication received — presenting main window");
present_window();
}
fn present_window() {
STATUS_ITEM.with(|item| {
let item = item.borrow();
let Some(item) = item.as_ref() else {
return;
};
if let Some(window) = item.window.upgrade() {
window.present();
}
unsafe {
let ns_app = msg_send_id(class(c"NSApplication"), sel(c"sharedApplication"));
msg_send_void_bool(ns_app, sel(c"activateIgnoringOtherApps:"), 1);
}
});
}
// Register the status-item delegate as the handler for the
// kAEReopenApplication Apple Event ('aevt'/'rapp'). NSApplication
// installs a default handler at -finishLaunching that just delegates to
// applicationShouldHandleReopen:hasVisibleWindows: — which is a no-op
// here because GApplication owns NSApp's delegate. Replacing it lets us
// re-present the window when the user double-clicks the .app while
// we're already running.
unsafe fn install_reopen_handler(delegate: Id) {
const K_CORE_EVENT_CLASS: c_uint = 0x6165_7674; // 'aevt'
const K_AE_REOPEN_APPLICATION: c_uint = 0x7261_7070; // 'rapp'
let manager = msg_send_id(
class(c"NSAppleEventManager"),
sel(c"sharedAppleEventManager"),
);
if manager.is_null() {
log::warn!("NSAppleEventManager unavailable; re-launch will not re-open window");
return;
}
msg_send_void_id_sel_u32_u32(
manager,
sel(c"setEventHandler:andSelector:forEventClass:andEventID:"),
delegate,
sel(c"handleReopenEvent:withReplyEvent:"),
K_CORE_EVENT_CLASS,
K_AE_REOPEN_APPLICATION,
);
}
extern "C" fn quit_lan_mouse(_this: Id, _cmd: Sel, _sender: Id) {
STATUS_ITEM.with(|item| {
if let Some(app) = item.borrow().as_ref().and_then(|item| item.app.upgrade()) {
app.quit();
}
});
}
unsafe fn class(name: &CStr) -> Class {
let class = objc_getClass(name.as_ptr());
assert!(!class.is_null(), "missing Objective-C class {name:?}");
class
}
unsafe fn sel(name: &CStr) -> Sel {
sel_registerName(name.as_ptr())
}
unsafe fn nsstring(value: &CStr) -> Id {
msg_send_id_ptr(
class(c"NSString"),
sel(c"stringWithUTF8String:"),
value.as_ptr(),
)
}
#[link(name = "objc")]
extern "C" {
fn objc_allocateClassPair(superclass: Class, name: *const c_char, extra_bytes: usize) -> Class;
fn objc_getClass(name: *const c_char) -> Class;
fn objc_registerClassPair(class: Class);
fn sel_registerName(name: *const c_char) -> Sel;
fn class_addMethod(class: Class, name: Sel, imp: *const c_void, types: *const c_char) -> Bool;
}
#[link(name = "AppKit", kind = "framework")]
extern "C" {}
#[link(name = "objc")]
extern "C" {
#[link_name = "objc_msgSend"]
fn msg_send_id(receiver: Id, selector: Sel) -> Id;
#[link_name = "objc_msgSend"]
fn msg_send_id_f64(receiver: Id, selector: Sel, value: c_double) -> Id;
#[link_name = "objc_msgSend"]
fn msg_send_id_id_sel_id(receiver: Id, selector: Sel, a: Id, b: Sel, c: Id) -> Id;
#[link_name = "objc_msgSend"]
fn msg_send_id_id_id(receiver: Id, selector: Sel, a: Id, b: Id) -> Id;
#[link_name = "objc_msgSend"]
fn msg_send_id_id(receiver: Id, selector: Sel, a: Id) -> Id;
#[link_name = "objc_msgSend"]
fn msg_send_void_size(receiver: Id, selector: Sel, width: c_double, height: c_double);
#[link_name = "objc_msgSend"]
fn msg_send_id_ptr(receiver: Id, selector: Sel, value: *const c_char) -> Id;
#[link_name = "objc_msgSend"]
fn msg_send_id_usize(receiver: Id, selector: Sel, value: usize) -> Id;
#[link_name = "objc_msgSend"]
fn msg_send_usize(receiver: Id, selector: Sel) -> usize;
#[link_name = "objc_msgSend"]
fn msg_send_void_bool(receiver: Id, selector: Sel, value: Bool);
#[link_name = "objc_msgSend"]
fn msg_send_void_id(receiver: Id, selector: Sel, value: Id);
#[link_name = "objc_msgSend"]
fn msg_send_bool_usize(receiver: Id, selector: Sel, value: usize) -> Bool;
#[link_name = "objc_msgSend"]
fn msg_send_void_id_sel_u32_u32(
receiver: Id,
selector: Sel,
a: Id,
b: Sel,
c: c_uint,
d: c_uint,
);
}

View File

@@ -22,17 +22,6 @@ use crate::{
use super::{client_object::ClientObject, client_row::ClientRow}; use super::{client_object::ClientObject, client_row::ClientRow};
#[cfg(target_os = "macos")]
fn set_button_content_label(button: &gtk::Button, label: &str) {
// The Reenable/Grant/Relaunch button wraps its icon+label in an
// AdwButtonContent (see window.ui). Walk into it and swap the label
// rather than GtkButton::set_label, which would replace the content
// widget and drop the icon.
if let Some(content) = button.child().and_downcast::<adw::ButtonContent>() {
content.set_label(label);
}
}
glib::wrapper! { glib::wrapper! {
pub struct Window(ObjectSubclass<imp::Window>) pub struct Window(ObjectSubclass<imp::Window>)
@extends adw::ApplicationWindow, gtk::Window, gtk::Widget, @extends adw::ApplicationWindow, gtk::Window, gtk::Widget,
@@ -365,13 +354,6 @@ impl Window {
.map(|ip| ip.to_string()) .map(|ip| ip.to_string())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
client_object.set_ips(ips); 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> { fn client_object_for_handle(&self, handle: ClientHandle) -> Option<ClientObject> {
@@ -450,10 +432,6 @@ impl Window {
pub(super) fn show_toast(&self, msg: &str) { pub(super) fn show_toast(&self, msg: &str) {
let toast = adw::Toast::new(msg); let toast = adw::Toast::new(msg);
self.add_toast(toast);
}
pub(super) fn add_toast(&self, toast: adw::Toast) {
let toast_overlay = &self.imp().toast_overlay; let toast_overlay = &self.imp().toast_overlay;
toast_overlay.add_toast(toast); toast_overlay.add_toast(toast);
} }
@@ -468,61 +446,14 @@ impl Window {
self.update_capture_emulation_status(); self.update_capture_emulation_status();
} }
#[cfg(target_os = "macos")]
pub(super) fn refresh_capture_emulation_status(&self) {
self.update_capture_emulation_status();
}
fn update_capture_emulation_status(&self) { fn update_capture_emulation_status(&self) {
let capture = self.imp().capture_active.get(); let capture = self.imp().capture_active.get();
let emulation = self.imp().emulation_active.get(); let emulation = self.imp().emulation_active.get();
self.imp().capture_status_row.set_visible(!capture);
#[cfg(target_os = "macos")] self.imp().emulation_status_row.set_visible(!emulation);
{ self.imp()
// On macOS, capture and emulation share the same TCC gate .capture_emulation_group
// (Accessibility). Collapse to a single warning row — .set_visible(!capture || !emulation);
// emulation_status_row stays hidden and capture_status_row
// doubles as the shared status indicator. Its text and
// button mutate based on whether we're waiting for AX or
// waiting for the user to relaunch the app.
let anything_off = !capture || !emulation;
self.imp().emulation_status_row.set_visible(false);
self.imp().capture_status_row.set_visible(anything_off);
self.imp().capture_emulation_group.set_visible(anything_off);
if anything_off {
self.update_macos_warning_row_text();
}
}
#[cfg(not(target_os = "macos"))]
{
self.imp().capture_status_row.set_visible(!capture);
self.imp().emulation_status_row.set_visible(!emulation);
self.imp()
.capture_emulation_group
.set_visible(!capture || !emulation);
}
}
#[cfg(target_os = "macos")]
fn update_macos_warning_row_text(&self) {
let row = &self.imp().capture_status_row;
let button = &self.imp().input_capture_button;
if crate::macos_privacy::accessibility_granted() {
// AX granted but capture/emulation still off → the daemon
// subprocess bailed at startup and needs a fresh process to
// re-initialize with the new grant in place.
row.set_title("relaunch required");
row.set_subtitle("Accessibility granted — restart to activate capture and emulation");
set_button_content_label(button, "Relaunch");
} else {
// AX missing → send the user to System Settings.
row.set_title("input capture is disabled");
row.set_subtitle("grant Accessibility permission to enable");
set_button_content_label(button, "Grant");
}
} }
pub(super) fn set_authorized_keys(&self, fingerprints: HashMap<String, String>) { pub(super) fn set_authorized_keys(&self, fingerprints: HashMap<String, String>) {

View File

@@ -142,32 +142,11 @@ impl Window {
#[template_callback] #[template_callback]
fn handle_emulation(&self) { fn handle_emulation(&self) {
// On macOS the emulation_status_row is hidden — capture_status_row
// acts as the shared warning (see update_capture_emulation_status).
// This handler still fires for the non-macOS platforms where the
// emulation row is distinct.
self.obj().request_emulation(); self.obj().request_emulation();
} }
#[template_callback] #[template_callback]
fn handle_capture(&self) { fn handle_capture(&self) {
#[cfg(target_os = "macos")]
{
use crate::macos_privacy;
if macos_privacy::accessibility_granted() {
// AX granted but the row is still visible => the daemon
// subprocess bailed before AX was in place and needs a
// fresh process. Quit + relaunch via Launch Services.
log::info!("capture row clicked in relaunch-required state");
macos_privacy::relaunch_bundle();
if let Some(app) = self.obj().application() {
app.quit();
}
return;
}
log::info!("capture row clicked in AX-missing state, opening pane");
macos_privacy::open_accessibility_settings();
}
self.obj().request_capture(); self.obj().request_capture();
} }

View File

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

View File

@@ -176,12 +176,6 @@ pub struct ClientState {
pub has_pressed_keys: bool, pub has_pressed_keys: bool,
/// dns resolving in progress /// dns resolving in progress
pub resolving: bool, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]

View File

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

View File

@@ -63,15 +63,6 @@ pub enum ProtoEvent {
Ping, Ping,
/// Response to [`ProtoEvent::Ping`], true if emulation is enabled / available /// Response to [`ProtoEvent::Ping`], true if emulation is enabled / available
Pong(bool), 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 { impl Display for ProtoEvent {
@@ -89,10 +80,6 @@ impl Display for ProtoEvent {
if *alive { "alive" } else { "not available" } if *alive { "alive" } else { "not available" }
) )
} }
ProtoEvent::Hello { commit } => {
let s = std::str::from_utf8(commit).unwrap_or("????????");
write!(f, "Hello({s})")
}
} }
} }
} }
@@ -111,7 +98,6 @@ pub enum EventType {
Enter, Enter,
Leave, Leave,
Ack, Ack,
Hello,
} }
impl ProtoEvent { impl ProtoEvent {
@@ -134,7 +120,6 @@ impl ProtoEvent {
ProtoEvent::Enter(_) => EventType::Enter, ProtoEvent::Enter(_) => EventType::Enter,
ProtoEvent::Leave(_) => EventType::Leave, ProtoEvent::Leave(_) => EventType::Leave,
ProtoEvent::Ack(_) => EventType::Ack, ProtoEvent::Ack(_) => EventType::Ack,
ProtoEvent::Hello { .. } => EventType::Hello,
} }
} }
} }
@@ -189,13 +174,6 @@ impl TryFrom<[u8; MAX_EVENT_SIZE]> for ProtoEvent {
EventType::Enter => Ok(Self::Enter(decode_u8(&mut buf)?.try_into()?)), EventType::Enter => Ok(Self::Enter(decode_u8(&mut buf)?.try_into()?)),
EventType::Leave => Ok(Self::Leave(decode_u32(&mut buf)?)), EventType::Leave => Ok(Self::Leave(decode_u32(&mut buf)?)),
EventType::Ack => Ok(Self::Ack(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 })
}
} }
} }
} }
@@ -260,11 +238,6 @@ impl From<ProtoEvent> for ([u8; MAX_EVENT_SIZE], usize) {
ProtoEvent::Enter(pos) => encode_u8(buf, len, pos as u8), ProtoEvent::Enter(pos) => encode_u8(buf, len, pos as u8),
ProtoEvent::Leave(serial) => encode_u32(buf, len, serial), ProtoEvent::Leave(serial) => encode_u32(buf, len, serial),
ProtoEvent::Ack(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) (buf, len)

View File

@@ -43,9 +43,6 @@ bundle_path=$(dirname "$(dirname "$(dirname "$exec_path")")")
# Path to the Frameworks directory # Path to the Frameworks directory
fwks_path="$bundle_path/Contents/Frameworks" fwks_path="$bundle_path/Contents/Frameworks"
mkdir -p "$fwks_path" mkdir -p "$fwks_path"
# Path to bundled GTK/GSettings data
resources_path="$bundle_path/Contents/Resources"
share_path="$resources_path/share"
# Copy and fix references for a binary (executable or dylib) # Copy and fix references for a binary (executable or dylib)
# #
@@ -61,10 +58,6 @@ fix_references() {
libs=$(otool -L "$bin" | awk -v homebrew="$homebrew_path" '$0 ~ homebrew {print $1}') libs=$(otool -L "$bin" | awk -v homebrew="$homebrew_path" '$0 ~ homebrew {print $1}')
echo "$libs" | while IFS= read -r old_path; do echo "$libs" | while IFS= read -r old_path; do
if [ -z "$old_path" ]; then
continue
fi
local base_name="$(basename "$old_path")" local base_name="$(basename "$old_path")"
local dest="$fwks_path/$base_name" local dest="$fwks_path/$base_name"
@@ -88,42 +81,6 @@ fix_references() {
fix_references "$exec_path" fix_references "$exec_path"
copy_runtime_data() {
mkdir -p "$share_path"
if [ -d "$homebrew_path/share/glib-2.0/schemas" ]; then
mkdir -p "$share_path/glib-2.0"
rm -rf "$share_path/glib-2.0/schemas"
cp -RL "$homebrew_path/share/glib-2.0/schemas" "$share_path/glib-2.0/schemas"
if command -v glib-compile-schemas >/dev/null 2>&1; then
glib-compile-schemas "$share_path/glib-2.0/schemas"
elif [ -x "$homebrew_path/bin/glib-compile-schemas" ]; then
"$homebrew_path/bin/glib-compile-schemas" "$share_path/glib-2.0/schemas"
fi
fi
if [ -d "$homebrew_path/share/gtk-4.0" ]; then
rm -rf "$share_path/gtk-4.0"
cp -RL "$homebrew_path/share/gtk-4.0" "$share_path/gtk-4.0"
fi
if [ -d "$homebrew_path/share/icons/Adwaita" ]; then
mkdir -p "$share_path/icons"
rm -rf "$share_path/icons/Adwaita"
cp -RL "$homebrew_path/share/icons/Adwaita" "$share_path/icons/Adwaita"
fi
}
copy_runtime_data
# cargo-bundle preserves the source path under Contents/Resources (so
# `target/menubar-template.png` lands at `Resources/target/...`). Flatten it
# so NSBundle pathForResource: finds the file at the Resources root.
if [ -f "$resources_path/target/menubar-template.png" ]; then
mv "$resources_path/target/menubar-template.png" "$resources_path/menubar-template.png"
rmdir "$resources_path/target" 2>/dev/null || true
fi
# Ensure the main executable has our Frameworks path in its RPATH # Ensure the main executable has our Frameworks path in its RPATH
if ! otool -l "$exec_path" | grep -q "@executable_path/../Frameworks"; then if ! otool -l "$exec_path" | grep -q "@executable_path/../Frameworks"; then
echo "Adding RPATH to $exec_path" echo "Adding RPATH to $exec_path"

View File

@@ -3,15 +3,8 @@ set -e
usage() { usage() {
cat <<EOF cat <<EOF
$0: Make a macOS icns file from an SVG with rsvg-convert, ImageMagick and iconutil. $0: Make a macOS icns file from an SVG with ImageMagick and iconutil.
usage: $0 [SVG [ICNS [ICONSET]]
Follows the Big Sur+ icon template:
- 1024x1024 canvas with a rounded-square (squircle) background
- Icon artwork scaled to fit inside an 824x824 content area, centered
- Transparent padding outside the squircle so the Dock/Finder render it
like other first-party macOS apps.
usage: $0 [SVG [ICNS [ICONSET]]]
ARGUMENTS ARGUMENTS
SVG The SVG file to convert SVG The SVG file to convert
@@ -35,103 +28,15 @@ iconset="${3:-./target/icon.iconset}"
set -u set -u
workdir="$(dirname "$iconset")/icon-work" mkdir -p "$iconset"
rm -rf "$iconset" "$workdir" magick "$svg" -background none -resize 1024x1024 "$iconset"/icon_512x512@2x.png
mkdir -p "$iconset" "$workdir" magick "$svg" -background none -resize 512x512 "$iconset"/icon_512x512.png
magick "$svg" -background none -resize 256x256 "$iconset"/icon_256x256.png
# Big Sur+ macOS icon template proportions (in a 1024 canvas): magick "$svg" -background none -resize 128x128 "$iconset"/icon_128x128.png
# canvas = 1024 magick "$svg" -background none -resize 64x64 "$iconset"/icon_32x32@2x.png
# squircle = 824 (the white rounded-square background, inset 100px) magick "$svg" -background none -resize 32x32 "$iconset"/icon_32x32.png
# content = 560 (artwork inside the squircle, with generous margin) magick "$svg" -background none -resize 16x16 "$iconset"/icon_16x16.png
# radius = 185 (~22.5% of the squircle, the characteristic curvature) cp "$iconset"/icon_512x512.png "$iconset"/icon_256x256@2x.png
CANVAS=1024 cp "$iconset"/icon_256x256.png "$iconset"/icon_128x128@2x.png
SQUIRCLE=824 cp "$iconset"/icon_32x32.png "$iconset"/icon_16x16@2x.png
CONTENT=560 iconutil -c icns "$iconset" -o "$icns"
RADIUS=185
BG_COLOR="#FFFFFF"
SQUIRCLE_OFFSET=$(( (CANVAS - SQUIRCLE) / 2 ))
CONTENT_OFFSET=$(( (CANVAS - CONTENT) / 2 ))
# 1) Render the SVG to the content size at full fidelity.
# rsvg-convert handles our SVG correctly; ImageMagick sometimes crops it.
rsvg-convert -w "$CONTENT" -h "$CONTENT" "$svg" -o "$workdir/content.png"
# 2) Draw the rounded-square (squircle) background on a transparent canvas.
# The squircle is inset from the canvas edges (transparent padding), so the
# Dock/Finder render it at the same visual size as other first-party apps.
magick -size ${CANVAS}x${CANVAS} xc:none \
-fill "$BG_COLOR" \
-draw "roundrectangle ${SQUIRCLE_OFFSET},${SQUIRCLE_OFFSET} $((CANVAS-SQUIRCLE_OFFSET-1)),$((CANVAS-SQUIRCLE_OFFSET-1)) $RADIUS,$RADIUS" \
"$workdir/background.png"
# 3) Composite the artwork onto the background, centered inside the content area.
magick "$workdir/background.png" \
"$workdir/content.png" -geometry +${CONTENT_OFFSET}+${CONTENT_OFFSET} -composite \
-colorspace sRGB -type TrueColorAlpha PNG32:"$workdir/icon-1024.png"
# 4) Generate each iconset size from the master so all sizes share the same
# squircle proportions and look consistent at every resolution.
for size in 1024 512 256 128 64 32 16; do
magick "$workdir/icon-1024.png" -resize ${size}x${size} \
-colorspace sRGB -type TrueColorAlpha PNG32:"$workdir/${size}.png"
done
cp "$workdir/1024.png" "$iconset"/icon_512x512@2x.png
cp "$workdir/512.png" "$iconset"/icon_512x512.png
cp "$workdir/512.png" "$iconset"/icon_256x256@2x.png
cp "$workdir/256.png" "$iconset"/icon_256x256.png
cp "$workdir/256.png" "$iconset"/icon_128x128@2x.png
cp "$workdir/128.png" "$iconset"/icon_128x128.png
cp "$workdir/64.png" "$iconset"/icon_32x32@2x.png
cp "$workdir/32.png" "$iconset"/icon_32x32.png
cp "$workdir/32.png" "$iconset"/icon_16x16@2x.png
cp "$workdir/16.png" "$iconset"/icon_16x16.png
mkdir -p "$(dirname "$icns")"
# Menu bar template icon: flatten all RGB channels to 0 (black) while keeping
# alpha so the artwork reads as a clean silhouette. NSStatusBarButton tints
# template images to match the menu bar appearance in light and dark modes.
menubar_template="$(dirname "$icns")/menubar-template.png"
rsvg-convert -w 44 -h 44 "$svg" -o "$workdir/menubar-44.png"
magick "$workdir/menubar-44.png" -channel RGB -evaluate set 0 +channel \
"$menubar_template"
if ! iconutil -c icns "$iconset" -o "$icns"; then
if ! command -v perl >/dev/null 2>&1; then
echo "iconutil failed and perl is not available for the fallback icns writer" >&2
exit 1
fi
echo "iconutil rejected the iconset; writing icns directly" >&2
perl - "$icns" "$iconset" <<'PERL'
use strict;
use warnings;
my ($icns, $iconset) = @ARGV;
my @icons = (
[ 'icp4', "$iconset/icon_16x16.png" ],
[ 'ic11', "$iconset/icon_16x16\@2x.png" ],
[ 'icp5', "$iconset/icon_32x32.png" ],
[ 'ic12', "$iconset/icon_32x32\@2x.png" ],
[ 'ic07', "$iconset/icon_128x128.png" ],
[ 'ic13', "$iconset/icon_128x128\@2x.png" ],
[ 'ic08', "$iconset/icon_256x256.png" ],
[ 'ic14', "$iconset/icon_256x256\@2x.png" ],
[ 'ic09', "$iconset/icon_512x512.png" ],
[ 'ic10', "$iconset/icon_512x512\@2x.png" ],
);
my $body = '';
for my $icon (@icons) {
my ($type, $path) = @$icon;
open my $fh, '<:raw', $path or die "$path: $!";
local $/;
my $png = <$fh>;
$body .= $type . pack('N', length($png) + 8) . $png;
}
open my $out, '>:raw', $icns or die "$icns: $!";
print {$out} 'icns' . pack('N', length($body) + 8) . $body;
PERL
fi

View File

@@ -8,7 +8,7 @@ use futures::StreamExt;
use input_capture::{ use input_capture::{
CaptureError, CaptureEvent, CaptureHandle, InputCapture, InputCaptureError, Position, CaptureError, CaptureEvent, CaptureHandle, InputCapture, InputCaptureError, Position,
}; };
use input_event::{Event, KeyboardEvent, scancode}; use input_event::scancode;
use lan_mouse_proto::ProtoEvent; use lan_mouse_proto::ProtoEvent;
use local_channel::mpsc::{Receiver, Sender, channel}; use local_channel::mpsc::{Receiver, Sender, channel};
use tokio::task::{JoinHandle, spawn_local}; use tokio::task::{JoinHandle, spawn_local};
@@ -376,41 +376,6 @@ impl CaptureTask {
async fn release_capture(&mut self, capture: &mut InputCapture) -> Result<(), CaptureError> { async fn release_capture(&mut self, capture: &mut InputCapture) -> Result<(), CaptureError> {
// If we have an active client, notify them we're leaving // If we have an active client, notify them we're leaving
if let Some(handle) = self.active_client.take() { if let Some(handle) = self.active_client.take() {
// Synthesize key-up events for every key still held in the
// capture's pressed_keys set BEFORE sending Leave. Without
// this, pressing the release-bind chord (typically all four
// modifiers) leaves the peer with phantom held modifiers:
// the down events were forwarded while capture was active,
// but the matching up events arrive after the local tap
// flips to passthrough and never reach the peer. The peer
// then runs every subsequent keystroke through those held
// mods until its watchdog times out (1+ s) or our Leave
// arrives — and Leave can be lost over UDP/DTLS.
for key in capture.take_pressed_keys() {
let key_up = ProtoEvent::Input(Event::Keyboard(KeyboardEvent::Key {
time: 0,
key: key as u32,
state: 0,
}));
if let Err(e) = self.conn.send(key_up, handle).await {
log::warn!("failed to send key-up to client {handle}: {e}");
}
}
// Reset the modifier mask too. The peer's input-emulation
// layer keeps a separate XKB-style modifier state that's
// updated by KeyboardEvent::Modifiers, distinct from the
// pressed_keys set drained above. Without this, an
// already-locked CapsLock would survive the release.
let mods_zero = ProtoEvent::Input(Event::Keyboard(KeyboardEvent::Modifiers {
depressed: 0,
latched: 0,
locked: 0,
group: 0,
}));
if let Err(e) = self.conn.send(mods_zero, handle).await {
log::warn!("failed to reset modifiers on client {handle}: {e}");
}
log::info!("sending Leave event to client {handle}"); log::info!("sending Leave event to client {handle}");
if let Err(e) = self.conn.send(ProtoEvent::Leave(0), handle).await { if let Err(e) = self.conn.send(ProtoEvent::Leave(0), handle).await {
log::warn!("failed to send Leave to client {handle}: {e}"); log::warn!("failed to send Leave to client {handle}: {e}");

View File

@@ -282,12 +282,6 @@ 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> { pub(crate) fn active_addr(&self, handle: ClientHandle) -> Option<SocketAddr> {
self.clients self.clients
.borrow() .borrow()

View File

@@ -1,7 +1,6 @@
use crate::capture_test::TestCaptureArgs; use crate::capture_test::TestCaptureArgs;
use crate::emulation_test::TestEmulationArgs; use crate::emulation_test::TestEmulationArgs;
use clap::{Parser, Subcommand, ValueEnum}; use clap::{Parser, Subcommand, ValueEnum};
use notify::event::ModifyKind;
use notify::{EventKind, RecommendedWatcher, Watcher}; use notify::{EventKind, RecommendedWatcher, Watcher};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
@@ -28,18 +27,6 @@ use shadow_rs::shadow;
shadow!(build); 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 CONFIG_FILE_NAME: &str = "config.toml";
const CERT_FILE_NAME: &str = "lan-mouse.pem"; const CERT_FILE_NAME: &str = "lan-mouse.pem";
@@ -131,13 +118,13 @@ pub enum Command {
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ValueEnum)] #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ValueEnum)]
pub enum CaptureBackend { pub enum CaptureBackend {
#[cfg(libei_capture)] #[cfg(all(unix, feature = "libei_capture", not(target_os = "macos")))]
#[serde(rename = "input-capture-portal")] #[serde(rename = "input-capture-portal")]
InputCapturePortal, InputCapturePortal,
#[cfg(layer_shell_capture)] #[cfg(all(unix, feature = "layer_shell_capture", not(target_os = "macos")))]
#[serde(rename = "layer-shell")] #[serde(rename = "layer-shell")]
LayerShell, LayerShell,
#[cfg(x11_capture)] #[cfg(all(unix, feature = "x11_capture", not(target_os = "macos")))]
#[serde(rename = "x11")] #[serde(rename = "x11")]
X11, X11,
#[cfg(windows)] #[cfg(windows)]
@@ -153,11 +140,11 @@ pub enum CaptureBackend {
impl Display for CaptureBackend { impl Display for CaptureBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
#[cfg(libei_capture)] #[cfg(all(unix, feature = "libei_capture", not(target_os = "macos")))]
CaptureBackend::InputCapturePortal => write!(f, "input-capture-portal"), CaptureBackend::InputCapturePortal => write!(f, "input-capture-portal"),
#[cfg(layer_shell_capture)] #[cfg(all(unix, feature = "layer_shell_capture", not(target_os = "macos")))]
CaptureBackend::LayerShell => write!(f, "layer-shell"), CaptureBackend::LayerShell => write!(f, "layer-shell"),
#[cfg(x11_capture)] #[cfg(all(unix, feature = "x11_capture", not(target_os = "macos")))]
CaptureBackend::X11 => write!(f, "X11"), CaptureBackend::X11 => write!(f, "X11"),
#[cfg(windows)] #[cfg(windows)]
CaptureBackend::Windows => write!(f, "windows"), CaptureBackend::Windows => write!(f, "windows"),
@@ -171,11 +158,11 @@ impl Display for CaptureBackend {
impl From<CaptureBackend> for input_capture::Backend { impl From<CaptureBackend> for input_capture::Backend {
fn from(backend: CaptureBackend) -> Self { fn from(backend: CaptureBackend) -> Self {
match backend { match backend {
#[cfg(libei_capture)] #[cfg(all(unix, feature = "libei_capture", not(target_os = "macos")))]
CaptureBackend::InputCapturePortal => Self::InputCapturePortal, CaptureBackend::InputCapturePortal => Self::InputCapturePortal,
#[cfg(layer_shell_capture)] #[cfg(all(unix, feature = "layer_shell_capture", not(target_os = "macos")))]
CaptureBackend::LayerShell => Self::LayerShell, CaptureBackend::LayerShell => Self::LayerShell,
#[cfg(x11_capture)] #[cfg(all(unix, feature = "x11_capture", not(target_os = "macos")))]
CaptureBackend::X11 => Self::X11, CaptureBackend::X11 => Self::X11,
#[cfg(windows)] #[cfg(windows)]
CaptureBackend::Windows => Self::Windows, CaptureBackend::Windows => Self::Windows,
@@ -188,16 +175,16 @@ impl From<CaptureBackend> for input_capture::Backend {
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ValueEnum)] #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ValueEnum)]
pub enum EmulationBackend { pub enum EmulationBackend {
#[cfg(wlroots_emulation)] #[cfg(all(unix, feature = "wlroots_emulation", not(target_os = "macos")))]
#[serde(rename = "wlroots")] #[serde(rename = "wlroots")]
Wlroots, Wlroots,
#[cfg(libei_emulation)] #[cfg(all(unix, feature = "libei_emulation", not(target_os = "macos")))]
#[serde(rename = "libei")] #[serde(rename = "libei")]
Libei, Libei,
#[cfg(rdp_emulation)] #[cfg(all(unix, feature = "rdp_emulation", not(target_os = "macos")))]
#[serde(rename = "xdp")] #[serde(rename = "xdp")]
Xdp, Xdp,
#[cfg(x11_emulation)] #[cfg(all(unix, feature = "x11_emulation", not(target_os = "macos")))]
#[serde(rename = "x11")] #[serde(rename = "x11")]
X11, X11,
#[cfg(windows)] #[cfg(windows)]
@@ -213,13 +200,13 @@ pub enum EmulationBackend {
impl From<EmulationBackend> for input_emulation::Backend { impl From<EmulationBackend> for input_emulation::Backend {
fn from(backend: EmulationBackend) -> Self { fn from(backend: EmulationBackend) -> Self {
match backend { match backend {
#[cfg(wlroots_emulation)] #[cfg(all(unix, feature = "wlroots_emulation", not(target_os = "macos")))]
EmulationBackend::Wlroots => Self::Wlroots, EmulationBackend::Wlroots => Self::Wlroots,
#[cfg(libei_emulation)] #[cfg(all(unix, feature = "libei_emulation", not(target_os = "macos")))]
EmulationBackend::Libei => Self::Libei, EmulationBackend::Libei => Self::Libei,
#[cfg(rdp_emulation)] #[cfg(all(unix, feature = "rdp_emulation", not(target_os = "macos")))]
EmulationBackend::Xdp => Self::Xdp, EmulationBackend::Xdp => Self::Xdp,
#[cfg(x11_emulation)] #[cfg(all(unix, feature = "x11_emulation", not(target_os = "macos")))]
EmulationBackend::X11 => Self::X11, EmulationBackend::X11 => Self::X11,
#[cfg(windows)] #[cfg(windows)]
EmulationBackend::Windows => Self::Windows, EmulationBackend::Windows => Self::Windows,
@@ -233,13 +220,13 @@ impl From<EmulationBackend> for input_emulation::Backend {
impl Display for EmulationBackend { impl Display for EmulationBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
#[cfg(wlroots_emulation)] #[cfg(all(unix, feature = "wlroots_emulation", not(target_os = "macos")))]
EmulationBackend::Wlroots => write!(f, "wlroots"), EmulationBackend::Wlroots => write!(f, "wlroots"),
#[cfg(libei_emulation)] #[cfg(all(unix, feature = "libei_emulation", not(target_os = "macos")))]
EmulationBackend::Libei => write!(f, "libei"), EmulationBackend::Libei => write!(f, "libei"),
#[cfg(rdp_emulation)] #[cfg(all(unix, feature = "rdp_emulation", not(target_os = "macos")))]
EmulationBackend::Xdp => write!(f, "xdg-desktop-portal"), EmulationBackend::Xdp => write!(f, "xdg-desktop-portal"),
#[cfg(x11_emulation)] #[cfg(all(unix, feature = "x11_emulation", not(target_os = "macos")))]
EmulationBackend::X11 => write!(f, "X11"), EmulationBackend::X11 => write!(f, "X11"),
#[cfg(windows)] #[cfg(windows)]
EmulationBackend::Windows => write!(f, "windows"), EmulationBackend::Windows => write!(f, "windows"),
@@ -347,23 +334,6 @@ impl Config {
.config .config
.clone() .clone()
.unwrap_or(default_path()?.join(CONFIG_FILE_NAME)); .unwrap_or(default_path()?.join(CONFIG_FILE_NAME));
let config_dir = config_path
.parent()
.expect("config directory")
.to_path_buf();
// Ensure the config directory exists and write a default config file
// if none is present. Runs on every Config::new(), regardless of which
// entry path (GUI main, spawned daemon, CLI, test commands) we're on,
// so a fresh Mac never hits "No such file or directory" on config.toml
// and notify::Watcher (which requires the dir to exist on macOS
// FSEvents and some Linux backends) has a concrete path to watch.
fs::create_dir_all(&config_dir)?;
if !config_path.exists() {
let default_toml = toml::to_string_pretty(&ConfigToml::default())
.expect("default ConfigToml serialization cannot fail");
fs::write(&config_path, default_toml)?;
}
let config_toml = match ConfigToml::new(&config_path) { let config_toml = match ConfigToml::new(&config_path) {
Err(e) => { Err(e) => {
@@ -388,6 +358,10 @@ impl Config {
}, },
notify::Config::default(), notify::Config::default(),
)?; )?;
let config_dir = config_path
.parent()
.expect("config directory")
.to_path_buf();
let mut config = Config { let mut config = Config {
args, args,
cert_path, cert_path,
@@ -419,9 +393,7 @@ impl Config {
if event.paths.contains(&self.config_path) if event.paths.contains(&self.config_path)
&& matches!( && matches!(
event.kind, event.kind,
EventKind::Create(_) EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
| EventKind::Modify(ModifyKind::Data(_))
| EventKind::Remove(_)
) )
&& self.read_from_disk()? && self.read_from_disk()?
{ {
@@ -539,11 +511,6 @@ impl Config {
} }
Err(e) => log::warn!("{:?} {e}", self.config_path()), Err(e) => log::warn!("{:?} {e}", self.config_path()),
}; };
if changed {
log::info!("config changed");
} else {
log::info!("config unchanged");
}
Ok(changed) Ok(changed)
} }

View File

@@ -1,5 +1,4 @@
use crate::client::ClientManager; use crate::client::ClientManager;
use crate::config::local_commit;
use lan_mouse_ipc::{ClientHandle, DEFAULT_PORT}; use lan_mouse_ipc::{ClientHandle, DEFAULT_PORT};
use lan_mouse_proto::{MAX_EVENT_SIZE, ProtoEvent}; use lan_mouse_proto::{MAX_EVENT_SIZE, ProtoEvent};
use local_channel::mpsc::{Receiver, Sender, channel}; use local_channel::mpsc::{Receiver, Sender, channel};
@@ -198,19 +197,6 @@ async fn connect_to_handle(
conns.lock().await.insert(addr, conn.clone()); conns.lock().await.insert(addr, conn.clone());
connecting.lock().await.remove(&handle); 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 // poll connection for active
spawn_local(ping_pong(addr, conn.clone(), ping_response.clone())); spawn_local(ping_pong(addr, conn.clone(), ping_response.clone()));
@@ -269,26 +255,16 @@ async fn receive_loop(
) { ) {
let mut buf = [0u8; MAX_EVENT_SIZE]; let mut buf = [0u8; MAX_EVENT_SIZE];
while conn.recv(&mut buf).await.is_ok() { while conn.recv(&mut buf).await.is_ok() {
match buf.try_into() { if let Ok(event) = buf.try_into() {
Ok(event) => { log::trace!("{addr} <==<==<== {event}");
log::trace!("{addr} <==<==<== {event}"); match event {
match event { ProtoEvent::Pong(b) => {
ProtoEvent::Pong(b) => { client_manager.set_active_addr(handle, Some(addr));
client_manager.set_active_addr(handle, Some(addr)); client_manager.set_alive(handle, b);
client_manager.set_alive(handle, b); ping_response.borrow_mut().insert(addr);
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"); log::warn!("recv error");
@@ -304,7 +280,6 @@ async fn disconnect(
log::warn!("client ({handle}) @ {addr} connection closed"); log::warn!("client ({handle}) @ {addr} connection closed");
conns.lock().await.remove(&addr); conns.lock().await.remove(&addr);
client_manager.set_active_addr(handle, None); client_manager.set_active_addr(handle, None);
client_manager.set_peer_commit(handle, None);
let active: Vec<SocketAddr> = conns.lock().await.keys().copied().collect(); let active: Vec<SocketAddr> = conns.lock().await.keys().copied().collect();
log::info!("active connections: {active:?}"); log::info!("active connections: {active:?}");
} }

View File

@@ -1,4 +1,3 @@
use crate::config::local_commit;
use crate::listen::{LanMouseListener, ListenEvent, ListenerCreationError}; use crate::listen::{LanMouseListener, ListenEvent, ListenerCreationError};
use futures::StreamExt; use futures::StreamExt;
use input_emulation::{EmulationHandle, InputEmulation, InputEmulationError}; use input_emulation::{EmulationHandle, InputEmulation, InputEmulationError};
@@ -53,17 +52,6 @@ pub(crate) enum EmulationEvent {
EmulationEnabled, EmulationEnabled,
/// capture should be released /// capture should be released
ReleaseNotify, 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 { enum EmulationRequest {
@@ -162,21 +150,6 @@ impl ListenTask {
} }
ProtoEvent::Input(event) => self.emulation_proxy.consume(event, addr), ProtoEvent::Input(event) => self.emulation_proxy.consume(event, addr),
ProtoEvent::Ping => self.listener.reply(addr, ProtoEvent::Pong(self.emulation_proxy.emulation_active.get())).await, 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,16 +259,8 @@ async fn read_loop(
.send(ListenEvent::Msg { event, addr }) .send(ListenEvent::Msg { event, addr })
.expect("channel closed"), .expect("channel closed"),
Err(e) => { Err(e) => {
// Skip the malformed/unknown datagram and keep log::warn!("error receiving event: {e}");
// listening. Each DTLS recv returns one full break;
// 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")] #[cfg(feature = "gtk")]
{ {
let mut service = start_service()?; let mut service = start_service()?;
let res = lan_mouse_gtk::run(config::local_commit()); let res = lan_mouse_gtk::run();
#[cfg(unix)] #[cfg(unix)]
{ {
// on unix we give the service a chance to terminate gracefully // on unix we give the service a chance to terminate gracefully

View File

@@ -319,17 +319,6 @@ impl Service {
EmulationEvent::Connected { addr, fingerprint } => { EmulationEvent::Connected { addr, fingerprint } => {
self.notify_frontend(FrontendEvent::DeviceConnected { 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);
}
}
} }
} }