diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 939f57c..decb7b4 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -201,6 +201,22 @@ jobs:
git tag -a "${{ steps.version.outputs.tag }}" -m "MyKVM ${{ steps.version.outputs.tag }}"
git push origin "${{ steps.version.outputs.tag }}"
+ - name: Create draft release
+ if: steps.version.outputs.should_release == 'true'
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ TAG: ${{ steps.version.outputs.tag }}
+ CHANNEL: ${{ steps.version.outputs.channel }}
+ RELEASE_NOTES: ${{ steps.version.outputs.release_notes }}
+ run: |
+ set -euo pipefail
+ args=("$TAG" --draft --title "MyKVM $TAG" --notes "$RELEASE_NOTES" --target "$GITHUB_SHA")
+ if [[ "$CHANNEL" == "beta" ]]; then
+ args+=(--prerelease)
+ fi
+ gh release create "${args[@]}" --repo "$GITHUB_REPOSITORY"
+
build:
name: Build ${{ matrix.name }}
needs: prepare
diff --git a/package.json b/package.json
index f6f6d8e..ed1020b 100644
--- a/package.json
+++ b/package.json
@@ -31,7 +31,6 @@
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tauri-apps/cli": "^2.11.2",
- "@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
diff --git a/public/edge-drop.html b/public/edge-drop.html
deleted file mode 100644
index fbbb676..0000000
--- a/public/edge-drop.html
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/public/file-drop-landing.html b/public/file-drop-landing.html
deleted file mode 100644
index ecae259..0000000
--- a/public/file-drop-landing.html
+++ /dev/null
@@ -1,89 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src-tauri/src/input.rs b/src-tauri/src/input.rs
index 8cba7e7..984b4c3 100644
--- a/src-tauri/src/input.rs
+++ b/src-tauri/src/input.rs
@@ -99,7 +99,92 @@ const MACOS_RAW_GESTURE_EVENT_TYPES: &[u32] = &[
#[cfg(target_os = "windows")]
const WINDOWS_DESKTOP_CHECK_INTERVAL_MS: u64 = 250;
-static REMOTE_MOUSE_STATE: OnceLock> = OnceLock::new();
+/// How often the full pairing credentials (~0.5KB, mostly the base64 transport
+/// certificate) ride an input packet. Between refreshes, packets omit them and
+/// the receiver authorizes by its per-source cache — cutting steady-state
+/// input datagrams from ~0.8KB to ~0.15KB. Must stay below
+/// `INPUT_ORIGIN_CACHE_TTL` so the receiver's authorization never lapses
+/// mid-session.
+const INPUT_FULL_CRED_REFRESH: Duration = Duration::from_secs(2);
+/// How long the receiver treats a source address as authorized after a
+/// credentialled packet, so it can admit the credential-less packets in
+/// between. A peer that never proved the pairing secret from this address
+/// never gets an entry, so credential-less packets from it are always rejected.
+const INPUT_ORIGIN_CACHE_TTL: Duration = Duration::from_secs(5);
+
+/// True when a full-credential input packet is due for a destination whose last
+/// credentialled send was `last_sent` (or never). Pure half of
+/// `should_send_full_input_credentials`.
+fn credential_send_due(last_sent: Option, now: Instant) -> bool {
+ last_sent
+ .map(|last| now.saturating_duration_since(last) >= INPUT_FULL_CRED_REFRESH)
+ .unwrap_or(true)
+}
+
+/// True when a source authorized at `authorized_at` is still within the cache
+/// TTL. Pure half of `input_origin_recently_authorized`.
+fn origin_authorization_fresh(authorized_at: Option, now: Instant) -> bool {
+ authorized_at
+ .map(|at| now.saturating_duration_since(at) < INPUT_ORIGIN_CACHE_TTL)
+ .unwrap_or(false)
+}
+
+/// Per-destination timestamp of the last full-credential input packet. Keyed by
+/// the target's QUIC address so alternating between two targets still refreshes
+/// each independently.
+fn input_full_cred_tracker() -> &'static Mutex> {
+ static TRACKER: OnceLock>> = OnceLock::new();
+ TRACKER.get_or_init(|| Mutex::new(HashMap::new()))
+}
+
+/// Decides whether to attach full credentials to the packet now being built for
+/// `addr`, and records the decision so the next `INPUT_FULL_CRED_REFRESH`
+/// window of packets can omit them. On lock poisoning it errs toward including
+/// credentials (a larger but always-authorizable packet).
+fn should_send_full_input_credentials(addr: &str) -> bool {
+ let Ok(mut tracker) = input_full_cred_tracker().lock() else {
+ return true;
+ };
+ let now = Instant::now();
+ let due = credential_send_due(tracker.get(addr).copied(), now);
+ if due {
+ tracker.retain(|_, last| {
+ now.saturating_duration_since(*last) < INPUT_ORIGIN_CACHE_TTL.saturating_mul(4)
+ });
+ tracker.insert(addr.to_string(), now);
+ }
+ due
+}
+
+/// Source addresses that sent a valid credentialled input packet within
+/// `INPUT_ORIGIN_CACHE_TTL`, so their credential-less packets can be admitted.
+fn authorized_input_origins() -> &'static Mutex> {
+ static ORIGINS: OnceLock>> = OnceLock::new();
+ ORIGINS.get_or_init(|| Mutex::new(HashMap::new()))
+}
+
+fn cache_authorized_input_origin(source: SocketAddr) {
+ if let Ok(mut origins) = authorized_input_origins().lock() {
+ let now = Instant::now();
+ origins.retain(|_, last| now.saturating_duration_since(*last) < INPUT_ORIGIN_CACHE_TTL);
+ origins.insert(source, now);
+ }
+}
+
+fn input_origin_recently_authorized(source: SocketAddr) -> bool {
+ let authorized_at = authorized_input_origins()
+ .lock()
+ .ok()
+ .and_then(|origins| origins.get(&source).copied());
+ origin_authorization_fresh(authorized_at, Instant::now())
+}
+
+/// Last injected remote cursor position, packed x<<32|y, plus held buttons.
+/// Plain atomics: these are touched on every received mouse event and a
+/// global mutex there is contention for nothing (the fields are independent
+/// and a racing reader tolerates one event of skew).
+static REMOTE_MOUSE_POSITION: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0);
+static REMOTE_MOUSE_BUTTONS: AtomicU64 = AtomicU64::new(0);
#[cfg(target_os = "macos")]
static MACOS_ACCESSIBILITY_PROMPTED: AtomicBool = AtomicBool::new(false);
#[cfg(target_os = "windows")]
@@ -156,6 +241,34 @@ pub struct ClipboardTarget {
pub expires_at: Option,
}
+fn str_ref_is_empty(value: &&str) -> bool {
+ value.is_empty()
+}
+
+/// Borrowing serialization mirror of [`InputPacket`]: identical named
+/// MessagePack bytes when every field is populated (guarded by a test), but
+/// building one clones none of the ~0.8KB of credential strings — send_packet
+/// runs per mouse event. The credential fields are also skipped when empty, so
+/// steady-state packets (which omit them — see send_packet) drop ~0.5KB of the
+/// static pairing block, mostly the base64 transport certificate.
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct InputPacketRef<'a> {
+ protocol: &'a str,
+ target_device_id: &'a str,
+ #[serde(skip_serializing_if = "str_ref_is_empty")]
+ origin_device_id: &'a str,
+ origin_port: u16,
+ #[serde(skip_serializing_if = "str_ref_is_empty")]
+ origin_transport_public_key: &'a str,
+ origin_protocol_version: u16,
+ #[serde(skip_serializing_if = "str_ref_is_empty")]
+ cluster_id: &'a str,
+ #[serde(skip_serializing_if = "str_ref_is_empty")]
+ pair_secret: &'a str,
+ event: &'a InputEvent,
+}
+
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct InputPacket {
@@ -202,13 +315,6 @@ enum InputControlCommand {
SecureAttention,
}
-#[derive(Debug, Default, Clone, Copy)]
-struct RemoteMouseState {
- x: i32,
- y: i32,
- buttons: u64,
-}
-
pub fn stopped_capture_status() -> NativeStageStatus {
NativeStageStatus {
state: "stubbed".into(),
@@ -705,6 +811,7 @@ fn start_input_capture(
input_events: Arc,
switch_request: Arc>>,
) -> NativeStageStatus {
+ invalidate_input_targets_cache();
start_platform_capture(
targets,
layout_state,
@@ -938,8 +1045,8 @@ fn start_platform_capture(
switch_request: Arc>>,
) -> NativeStageStatus {
use windows_sys::Win32::UI::WindowsAndMessaging::{
- PeekMessageW, SetWindowsHookExW, UnhookWindowsHookEx, MSG, PM_REMOVE, WH_KEYBOARD_LL,
- WH_MOUSE_LL,
+ MsgWaitForMultipleObjects, PeekMessageW, SetWindowsHookExW, UnhookWindowsHookEx, MSG,
+ PM_REMOVE, QS_ALLINPUT, WH_KEYBOARD_LL, WH_MOUSE_LL,
};
let target_count = targets.len();
@@ -1018,10 +1125,18 @@ fn start_platform_capture(
}
}
drain_switch_request_windows(&context);
+ // Low-level hook callbacks are dispatched only while this thread
+ // services its message queue. Blocking on the queue (with a short
+ // timeout for the desktop/switch checks above) instead of sleeping
+ // 10ms between polls removes up to 10-16ms of added latency per
+ // input event — the sleep also quantised to ~15.6ms without a
+ // timeBeginPeriod call, batching a 1000Hz mouse into ~64Hz bursts.
+ // Slow queue servicing is also what makes Windows silently drop
+ // low-level hooks.
unsafe {
+ let _ = MsgWaitForMultipleObjects(0, std::ptr::null(), 0, 20, QS_ALLINPUT);
while PeekMessageW(&mut message, std::ptr::null_mut(), 0, 0, PM_REMOVE) != 0 {}
}
- thread::sleep(Duration::from_millis(10));
}
unsafe {
@@ -1185,14 +1300,53 @@ fn build_input_targets(layout: &LayoutState, native_layout: &LayoutState) -> Vec
targets
}
+/// How long a built target list may be served from cache. Crossing geometry
+/// and pairing context only change on layout edits and peer presence flips;
+/// a quarter second of staleness is invisible at a screen edge, while
+/// rebuilding per mouse move cost a blocking layout lock, a LanPeer build
+/// (UDP-socket IP probe) and a dozen String clones inside the event tap.
+const INPUT_TARGETS_TTL: Duration = Duration::from_millis(250);
+
+static INPUT_TARGETS_CACHE: Mutex>)>> = Mutex::new(None);
+
fn current_input_targets(
layout_state: &Arc>,
native_layout: &LayoutState,
-) -> Vec {
- layout_state
- .lock()
- .map(|layout| build_input_targets(&layout, native_layout))
- .unwrap_or_default()
+) -> Arc> {
+ if let Ok(cache) = INPUT_TARGETS_CACHE.lock() {
+ if let Some((built_at, targets)) = cache.as_ref() {
+ if built_at.elapsed() < INPUT_TARGETS_TTL {
+ return Arc::clone(targets);
+ }
+ }
+ }
+
+ // Never block the event tap on a held layout lock (a save may be writing
+ // to disk under it): serve the stale cache instead and retry next event.
+ match layout_state.try_lock() {
+ Ok(layout) => {
+ let targets = Arc::new(build_input_targets(&layout, native_layout));
+ drop(layout);
+ if let Ok(mut cache) = INPUT_TARGETS_CACHE.lock() {
+ *cache = Some((Instant::now(), Arc::clone(&targets)));
+ }
+ targets
+ }
+ Err(_) => INPUT_TARGETS_CACHE
+ .lock()
+ .ok()
+ .and_then(|cache| cache.as_ref().map(|(_, targets)| Arc::clone(targets)))
+ .unwrap_or_default(),
+ }
+}
+
+/// Drops the cached target list so the next event rebuilds it — called when
+/// capture starts, so a fresh session can never act on the previous
+/// session's pairing context.
+fn invalidate_input_targets_cache() {
+ if let Ok(mut cache) = INPUT_TARGETS_CACHE.lock() {
+ *cache = None;
+ }
}
fn touching_edge(local: &Screen, remote: &Screen) -> Option {
@@ -1273,21 +1427,42 @@ fn send_packet(
layout_state: &Arc>,
input_events: &Arc,
) -> bool {
- let packet_context = input_packet_context(target, event, layout_state);
- let event = packet_context.event;
- let packet = InputPacket {
- protocol: INPUT_PROTOCOL.into(),
- target_device_id: target.device_id.clone(),
- origin_device_id: packet_context.origin_device_id,
+ let mut packet_context = input_packet_context(target, event, layout_state);
+ let Some(peer) = packet_context.peer.take() else {
+ return false;
+ };
+ // Attach the full pairing credentials only ~once per INPUT_FULL_CRED_REFRESH
+ // per destination; steady-state packets omit them (empty -> skipped on the
+ // wire) and the receiver authorizes them from its per-source cache. This is
+ // what actually shrinks the datagram; the borrowed mirror above only kept
+ // the omitted-string case allocation-free.
+ let include_credentials = should_send_full_input_credentials(&peer.addr);
+ let packet = InputPacketRef {
+ protocol: INPUT_PROTOCOL,
+ target_device_id: &target.device_id,
+ origin_device_id: if include_credentials {
+ &packet_context.origin_device_id
+ } else {
+ ""
+ },
origin_port: quic_transport.port(),
- origin_transport_public_key: quic_transport.public_key().to_string(),
+ origin_transport_public_key: if include_credentials {
+ quic_transport.public_key()
+ } else {
+ ""
+ },
origin_protocol_version: quic_transport::PROTOCOL_VERSION,
- cluster_id: packet_context.cluster_id,
- pair_secret: packet_context.pair_secret,
- event,
- };
- let Some(peer) = packet_context.peer else {
- return false;
+ cluster_id: if include_credentials {
+ &packet_context.cluster_id
+ } else {
+ ""
+ },
+ pair_secret: if include_credentials {
+ &packet_context.pair_secret
+ } else {
+ ""
+ },
+ event: &packet_context.event,
};
let payload = match rmp_serde::to_vec_named(&packet) {
@@ -1392,6 +1567,15 @@ fn input_packet_context(
event,
};
+ // Mouse events — essentially every packet — always use the context cached
+ // on the target at build time: it is at most INPUT_TARGETS_TTL stale and
+ // needs no layout lock, no origin re-derivation and no address formatting.
+ // Key events still consult the live layout for the modifier remap; they
+ // arrive at typing rate, not at mouse rate.
+ if !matches!(event, InputEvent::Key { .. }) {
+ return fallback_context(event);
+ }
+
let layout = match layout_state.try_lock() {
Ok(layout) => layout,
Err(TryLockError::WouldBlock) => return fallback_context(event),
@@ -1530,86 +1714,144 @@ fn mark_target_offline(
device.online = false;
}
-fn target_is_online(target: &InputTarget, layout_state: &Arc>) -> bool {
- layout_state
- .lock()
- .ok()
- .and_then(|layout| {
- layout
- .devices
- .iter()
- .find(|device| device.id == target.device_id)
- .map(|device| device.online && device.input_ready)
- })
- .unwrap_or(false)
-}
-
-pub fn try_inject_packet_from_source(
- layout: &LayoutState,
+/// Handles one received input-plane datagram end to end. Structured for the
+/// per-move cost, not readability of the rare cases:
+/// - decode runs entirely OUTSIDE the layout lock, and input packets decode
+/// first (they outnumber control packets by orders of magnitude; the old
+/// control-first order fully parsed every mouse move against the wrong
+/// schema before trying the right one)
+/// - the layout lock covers only authorization + coordinate mapping;
+/// injection is syscalls and runs after the lock is released, so a slow
+/// WindowServer round-trip can no longer stretch the critical section every
+/// other hot path contends on
+pub fn handle_input_datagram(
+ layout_state: &Arc>,
native_layout: &LayoutState,
payload: &[u8],
source: SocketAddr,
input_events: &Arc,
- local_peer_id: &str,
clipboard_target: &Arc>>,
) -> bool {
- let Some(packet) = decode_input_packet(payload) else {
- return false;
- };
-
- if packet.protocol != INPUT_PROTOCOL {
- return false;
- }
-
- if !packet_authorized(layout, &packet) {
- warn_unauthorized_packet(layout, &packet);
- return true;
- }
-
- if !packet_targets_local(layout, &packet.target_device_id, local_peer_id) {
+ if let Some(packet) = decode_input_packet(payload) {
+ if packet.protocol != INPUT_PROTOCOL {
+ return false;
+ }
+ // Steady-state datagrams omit the pairing block (it rides ~once per
+ // INPUT_FULL_CRED_REFRESH). A credentialled packet is authorized in
+ // full and (re)authorizes this source; a credential-less one is
+ // admitted only while that authorization is still fresh, so a peer that
+ // never proved the pairing secret from this address can never inject.
+ let carries_credentials = !packet.pair_secret.trim().is_empty();
+ let command = {
+ let Ok(layout) = layout_state.lock() else {
+ return false;
+ };
+ if carries_credentials {
+ if !packet_authorized(&layout, &packet) {
+ warn_unauthorized_packet(&layout, &packet);
+ return true;
+ }
+ cache_authorized_input_origin(source);
+ } else if !input_origin_recently_authorized(source) {
+ return true;
+ }
+ let local_peer_id = cached_local_peer_id(&layout);
+ if !packet_targets_local(&layout, &packet.target_device_id, &local_peer_id) {
+ return true;
+ }
+ // No-op for credential-less packets (empty key); the clipboard
+ // target was set by the last credentialled packet and persists.
+ refresh_clipboard_target(clipboard_target, &layout, &packet, source);
+ input_event_to_command(&layout, native_layout, packet.event)
+ };
+ let Some(command) = command else {
+ return true;
+ };
+ if dispatch_input_command(command) {
+ input_events.fetch_add(1, Ordering::Relaxed);
+ }
return true;
}
- if packet.origin_port != 0 && !packet.origin_transport_public_key.trim().is_empty() {
- let device_id = if packet.origin_device_id.trim().is_empty() {
- source.ip().to_string()
- } else {
- packet.origin_device_id.clone()
+ if let Some(packet) = decode_input_control_packet(payload) {
+ let Ok(layout) = layout_state.lock() else {
+ return false;
};
- // Persist the controller as our clipboard peer so a copy made on this
- // machine syncs back to it immediately, without needing the remote
- // cursor to re-enter. Refreshed on every input packet; cleared when
- // input/clipboard stops.
- set_clipboard_target(
- clipboard_target,
- device_id,
- format!("{}:{}", source.ip(), packet.origin_port),
- packet.origin_transport_public_key.clone(),
- packet.origin_protocol_version,
- layout.cluster_id.clone(),
- layout.pair_secret.clone(),
- None,
- );
+ let local_peer_id = cached_local_peer_id(&layout);
+ return handle_control_packet(&layout, packet, source, &local_peer_id);
}
- let injected = inject_input_event(layout, native_layout, packet.event);
- if injected {
- input_events.fetch_add(1, Ordering::Relaxed);
+ false
+}
+
+/// The local peer id is derived from the hostname + LAN address and is needed
+/// for every received packet; deriving it builds a full LanPeer (screens and
+/// all). Cache the id briefly instead of rebuilding it per datagram.
+fn cached_local_peer_id(layout: &LayoutState) -> String {
+ const LOCAL_PEER_ID_TTL: Duration = Duration::from_secs(5);
+ static CACHE: Mutex> = Mutex::new(None);
+
+ if let Ok(mut cached) = CACHE.lock() {
+ if let Some((resolved_at, id)) = cached.as_ref() {
+ if resolved_at.elapsed() < LOCAL_PEER_ID_TTL {
+ return id.clone();
+ }
+ }
+ let id = crate::local_peer_from_layout(layout).id;
+ *cached = Some((Instant::now(), id.clone()));
+ return id;
}
+ crate::local_peer_from_layout(layout).id
+}
- true
+/// Persist the controller as our clipboard peer so a copy made on this
+/// machine syncs back to it immediately, without needing the remote cursor to
+/// re-enter. The target only actually changes on the first packet of a
+/// session (or a controller switch); skip the five per-packet allocations the
+/// unconditional rewrite used to pay on every mouse move.
+fn refresh_clipboard_target(
+ clipboard_target: &Arc>>,
+ layout: &LayoutState,
+ packet: &InputPacket,
+ source: SocketAddr,
+) {
+ if packet.origin_port == 0 || packet.origin_transport_public_key.trim().is_empty() {
+ return;
+ }
+ if !packet.origin_device_id.trim().is_empty() {
+ if let Ok(target) = clipboard_target.lock() {
+ if target.as_ref().is_some_and(|target| {
+ target.device_id == packet.origin_device_id
+ && target.transport_public_key == packet.origin_transport_public_key
+ && target.protocol_version == packet.origin_protocol_version
+ }) {
+ return;
+ }
+ }
+ }
+ let device_id = if packet.origin_device_id.trim().is_empty() {
+ source.ip().to_string()
+ } else {
+ packet.origin_device_id.clone()
+ };
+ set_clipboard_target(
+ clipboard_target,
+ device_id,
+ format!("{}:{}", source.ip(), packet.origin_port),
+ packet.origin_transport_public_key.clone(),
+ packet.origin_protocol_version,
+ layout.cluster_id.clone(),
+ layout.pair_secret.clone(),
+ None,
+ );
}
-pub fn try_handle_control_packet_from_source(
+fn handle_control_packet(
layout: &LayoutState,
- payload: &[u8],
+ packet: InputControlPacket,
source: SocketAddr,
local_peer_id: &str,
) -> bool {
- let Some(packet) = decode_input_control_packet(payload) else {
- return false;
- };
-
if packet.protocol != INPUT_CONTROL_PROTOCOL {
return false;
}
@@ -1869,44 +2111,31 @@ fn scale_size(value: i32, scale: f64) -> i32 {
.clamp(1.0, i32::MAX as f64) as i32
}
-fn remote_mouse_state() -> &'static Mutex {
- REMOTE_MOUSE_STATE.get_or_init(|| Mutex::new(RemoteMouseState::default()))
+fn pack_remote_position(x: i32, y: i32) -> i64 {
+ ((x as i64) << 32) | (y as u32 as i64)
+}
+
+fn unpack_remote_position(packed: i64) -> (i32, i32) {
+ ((packed >> 32) as i32, packed as u32 as i32)
}
fn update_remote_mouse_position(x: i32, y: i32) -> Option {
- let Ok(mut state) = remote_mouse_state().lock() else {
- return None;
- };
- state.x = x;
- state.y = y;
- primary_button_from_mask(state.buttons)
+ REMOTE_MOUSE_POSITION.store(pack_remote_position(x, y), Ordering::Relaxed);
+ button_from_mask(REMOTE_MOUSE_BUTTONS.load(Ordering::Relaxed))
}
fn update_remote_mouse_button(button: MouseButton, down: bool) -> (i32, i32) {
- let Ok(mut state) = remote_mouse_state().lock() else {
- return (0, 0);
- };
if down {
- state.buttons |= mouse_button_mask(button);
+ REMOTE_MOUSE_BUTTONS.fetch_or(mouse_button_mask(button), Ordering::Relaxed);
} else {
- state.buttons &= !mouse_button_mask(button);
+ REMOTE_MOUSE_BUTTONS.fetch_and(!mouse_button_mask(button), Ordering::Relaxed);
}
- (state.x, state.y)
+ unpack_remote_position(REMOTE_MOUSE_POSITION.load(Ordering::Relaxed))
}
-fn primary_button_from_mask(mask: u64) -> Option {
- button_from_mask(mask)
-}
-
-fn inject_input_event(
- layout: &LayoutState,
- native_layout: &LayoutState,
- event: InputEvent,
-) -> bool {
- let Some(command) = input_event_to_command(layout, native_layout, event) else {
- return false;
- };
-
+/// Executes a mapped input command on this machine. Runs outside the layout
+/// lock — injection is syscalls and must not stretch the critical section.
+fn dispatch_input_command(command: InputCommand) -> bool {
#[cfg(target_os = "windows")]
{
// Inject locally on the normal desktop; hand off to the privileged SYSTEM
@@ -2589,7 +2818,8 @@ fn set_control_clipboard_target(
unsafe extern "system" fn windows_mouse_proc(code: i32, wparam: usize, lparam: isize) -> isize {
use windows_sys::Win32::UI::WindowsAndMessaging::{
CallNextHookEx, MSLLHOOKSTRUCT, WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MBUTTONDOWN, WM_MBUTTONUP,
- WM_MOUSEHWHEEL, WM_MOUSEMOVE, WM_MOUSEWHEEL, WM_RBUTTONDOWN, WM_RBUTTONUP,
+ WM_MOUSEHWHEEL, WM_MOUSEMOVE, WM_MOUSEWHEEL, WM_RBUTTONDOWN, WM_RBUTTONUP, WM_XBUTTONDOWN,
+ WM_XBUTTONUP,
};
if code < 0 {
@@ -2609,7 +2839,12 @@ unsafe extern "system" fn windows_mouse_proc(code: i32, wparam: usize, lparam: i
let handled = match message {
WM_MOUSEMOVE => handle_windows_mouse_move(&context, event.pt.x as f64, event.pt.y as f64),
WM_LBUTTONDOWN | WM_LBUTTONUP | WM_RBUTTONDOWN | WM_RBUTTONUP | WM_MBUTTONDOWN
- | WM_MBUTTONUP => handle_windows_mouse_button(&context, message),
+ | WM_MBUTTONUP | WM_XBUTTONDOWN | WM_XBUTTONUP => {
+ // For the X (side) buttons the pressed button rides the high word of
+ // mouseData (XBUTTON1 = back, XBUTTON2 = forward); other buttons
+ // ignore it.
+ handle_windows_mouse_button(&context, message, event.mouseData)
+ }
WM_MOUSEWHEEL | WM_MOUSEHWHEEL => handle_windows_scroll(&context, message, event.mouseData),
_ => false,
};
@@ -2945,7 +3180,7 @@ fn handle_windows_mouse_move(context: &WindowsCaptureContext, x: f64, y: f64) ->
}
let targets = current_input_targets(&context.layout_state, &context.native_layout);
- if let Some(active_target) = crossing_target(&targets, x, y, dx, dy, &context.layout_state) {
+ if let Some(active_target) = crossing_target(&targets, x, y, dx, dy) {
let anchor = local_anchor_point(&active_target);
hide_windows_cursor_if_needed(context);
set_windows_cursor(anchor.0.round() as i32, anchor.1.round() as i32);
@@ -2980,9 +3215,10 @@ fn handle_windows_mouse_move(context: &WindowsCaptureContext, x: f64, y: f64) ->
}
#[cfg(target_os = "windows")]
-fn handle_windows_mouse_button(context: &WindowsCaptureContext, message: u32) -> bool {
+fn handle_windows_mouse_button(context: &WindowsCaptureContext, message: u32, mouse_data: u32) -> bool {
use windows_sys::Win32::UI::WindowsAndMessaging::{
WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MBUTTONDOWN, WM_MBUTTONUP, WM_RBUTTONDOWN, WM_RBUTTONUP,
+ WM_XBUTTONDOWN, WM_XBUTTONUP, XBUTTON1,
};
let active = context
@@ -2993,6 +3229,12 @@ fn handle_windows_mouse_button(context: &WindowsCaptureContext, message: u32) ->
let Some(active_target) = active else {
return false;
};
+ // WM_XBUTTON* packs which side button in the high word of mouseData.
+ let x_button = if (mouse_data >> 16) as u16 == XBUTTON1 as u16 {
+ MouseButton::Back
+ } else {
+ MouseButton::Forward
+ };
let (button, down) = match message {
WM_LBUTTONDOWN => (MouseButton::Left, true),
WM_LBUTTONUP => (MouseButton::Left, false),
@@ -3000,6 +3242,8 @@ fn handle_windows_mouse_button(context: &WindowsCaptureContext, message: u32) ->
WM_RBUTTONUP => (MouseButton::Right, false),
WM_MBUTTONDOWN => (MouseButton::Middle, true),
WM_MBUTTONUP => (MouseButton::Middle, false),
+ WM_XBUTTONDOWN => (x_button, true),
+ WM_XBUTTONUP => (x_button, false),
_ => return false,
};
@@ -3501,9 +3745,8 @@ fn crossing_target(
y: f64,
dx: f64,
dy: f64,
- layout_state: &Arc>,
) -> Option {
- crossing_target_with_transform(targets, x, y, dx, dy, false, layout_state)
+ crossing_target_with_transform(targets, x, y, dx, dy, false)
}
fn crossing_target_with_transform(
@@ -3513,15 +3756,14 @@ fn crossing_target_with_transform(
dx: f64,
dy: f64,
invert_y: bool,
- layout_state: &Arc>,
) -> Option {
targets
.iter()
.find_map(|target| {
- if !target_is_online(target, layout_state) {
- return None;
- }
-
+ // No per-move online re-check here: build_input_targets only
+ // emits online + input-ready devices and the target cache
+ // refreshes within INPUT_TARGETS_TTL, so the check only re-took
+ // the layout lock per target per event to learn the same answer.
crossing_layout_point(target, x, y, dx, dy).map(|point| (target, point))
})
.map(|(target, (mapped_x, mapped_y))| {
@@ -3675,7 +3917,7 @@ fn mac_crossing_target(
dy: f64,
) -> Option {
if let Some(target) =
- crossing_target_with_transform(targets, x, y, dx, dy, false, &context.layout_state)
+ crossing_target_with_transform(targets, x, y, dx, dy, false)
{
return Some(target);
}
@@ -3688,7 +3930,7 @@ fn mac_crossing_target(
return None;
}
- crossing_target_with_transform(targets, x, flipped_y, dx, -dy, true, &context.layout_state)
+ crossing_target_with_transform(targets, x, flipped_y, dx, -dy, true)
}
#[cfg(target_os = "macos")]
@@ -3911,11 +4153,20 @@ fn local_anchor_point(active: &ActiveTarget) -> (f64, f64) {
local_return_point(active)
}
-/// When control returns to the local machine, move the controlled cursor into
-/// the far (bottom-right) corner of the remote screen instead of leaving it
-/// parked at the shared edge. True cursor hiding isn't reliably possible on the
-/// controlled side, so tucking it into a corner is the seamless-feeling
-/// approximation.
+/// When control returns to the local machine, tuck the controlled cursor out
+/// of the way. True cursor hiding isn't reliably possible on the controlled
+/// side, so tucking it is the seamless-feeling approximation.
+///
+/// Controlled macOS: park ON a clipping edge, at the end nearest this
+/// machine. The arrow's hotspot is its top-left tip and the body extends
+/// down-right, so only the RIGHT and BOTTOM screen edges clip it to a barely
+/// visible sliver (why the old far-corner park "looked like a dot"); the left
+/// and top edges show the full arrow. Corners themselves are avoided: macOS
+/// hot corners fire on pointer position alone, so the far-corner park
+/// triggered actions like "Show all applications" on every single handoff.
+///
+/// Controlled Windows (and anything else): keep the long-standing far-corner
+/// park unchanged.
#[cfg_attr(not(any(target_os = "windows", target_os = "macos")), allow(dead_code))]
fn send_remote_cursor_park(
quic_transport: &quic_transport::TransportHandle,
@@ -3923,19 +4174,56 @@ fn send_remote_cursor_park(
layout_state: &Arc>,
input_events: &Arc,
) -> bool {
+ let (park_x, park_y) = remote_park_point(active);
send_packet(
quic_transport,
&active.target,
InputEvent::MouseMove {
screen_id: active.current_screen_id.clone(),
- x: (active.current_screen.width - 1).max(0),
- y: (active.current_screen.height - 1).max(0),
+ x: park_x,
+ y: park_y,
},
layout_state,
input_events,
)
}
+/// Distance a shared-edge park keeps from the screen corners, so an exit near
+/// the top/bottom of the edge cannot land the parked cursor inside a macOS
+/// hot-corner trip zone.
+const PARK_CORNER_CLEARANCE: i32 = 64;
+
+#[cfg_attr(not(any(target_os = "windows", target_os = "macos")), allow(dead_code))]
+fn remote_park_point(active: &ActiveTarget) -> (i32, i32) {
+ let width = active.current_screen.width;
+ let height = active.current_screen.height;
+ if !active.target.target_platform.eq_ignore_ascii_case("macos") {
+ return ((width - 1).max(0), (height - 1).max(0));
+ }
+
+ // `edge` is the LOCAL screen edge crossed to enter the remote, so this
+ // machine sits on the OPPOSITE side of the controlled screen. Pick the
+ // clipping edge (right/bottom) closest to that side.
+ let clear = |position: i32, extent: i32| {
+ position.clamp(
+ PARK_CORNER_CLEARANCE.min((extent / 2).max(0)),
+ (extent - 1 - PARK_CORNER_CLEARANCE).max((extent / 2).max(0)),
+ )
+ };
+ let x = active.x.round() as i32;
+ let y = active.y.round() as i32;
+ match active.target.edge {
+ // This machine is west of the Mac: bottom edge, west end.
+ Edge::Right => (clear(0, width), (height - 1).max(0)),
+ // East: the east edge itself clips — keep the exit height.
+ Edge::Left => ((width - 1).max(0), clear(y, height)),
+ // North: east edge, north end.
+ Edge::Bottom => ((width - 1).max(0), clear(0, height)),
+ // South: bottom edge — keep the exit x.
+ Edge::Top => (clear(x, width), (height - 1).max(0)),
+ }
+}
+
#[cfg(target_os = "macos")]
fn enter_remote_target_macos(context: &MacCaptureContext, active_target: ActiveTarget) {
use core_graphics::geometry::CGPoint;
@@ -5178,7 +5466,12 @@ fn inject_mouse_move(x: i32, y: i32, drag_button: Option) {
let (event_type, mouse_button) = match drag_button {
Some(MouseButton::Left) => (CGEventType::LeftMouseDragged, CGMouseButton::Left),
Some(MouseButton::Right) => (CGEventType::RightMouseDragged, CGMouseButton::Right),
- Some(MouseButton::Middle) => (CGEventType::OtherMouseDragged, CGMouseButton::Center),
+ // Middle and the side buttons are all "other" drags. button_from_mask
+ // never actually reports a side button as a drag, so this is only here
+ // for exhaustiveness.
+ Some(MouseButton::Middle | MouseButton::Back | MouseButton::Forward) => {
+ (CGEventType::OtherMouseDragged, CGMouseButton::Center)
+ }
None => (CGEventType::MouseMoved, CGMouseButton::Left),
};
@@ -5192,11 +5485,161 @@ fn inject_mouse_move(x: i32, y: i32, drag_button: Option) {
}
}
+/// One pressed-button record for injected macOS click counting.
+#[cfg(target_os = "macos")]
+#[derive(Debug, Clone, Copy)]
+struct MacClickDown {
+ button: MouseButton,
+ x: i32,
+ y: i32,
+ at: Instant,
+ count: u8,
+}
+
+/// Click counting for injected macOS mouse buttons. macOS does NOT infer
+/// double-clicks from timing for synthetic events — every injected Down/Up
+/// with `kCGMouseEventClickState` 0 is an independent single click, so remote
+/// double-clicks never registered in apps. Replicate the native rule: a press
+/// within the system double-click interval and a few px of the previous one
+/// raises the click count (capped at triple), and the release repeats the
+/// count of the press it pairs with.
+#[cfg(target_os = "macos")]
+#[derive(Debug, Default)]
+struct MacClickTracker {
+ last_down: Option,
+ pressed: [Option; 3],
+}
+
+#[cfg(target_os = "macos")]
+impl MacClickTracker {
+ const MAX_DISTANCE_PX: i32 = 8;
+
+ fn event_count(
+ &mut self,
+ button: MouseButton,
+ down: bool,
+ x: i32,
+ y: i32,
+ now: Instant,
+ double_click_interval: Duration,
+ ) -> i64 {
+ let index = match button {
+ MouseButton::Left => 0,
+ MouseButton::Right => 1,
+ MouseButton::Middle => 2,
+ // Side (back/forward) buttons are navigation, not click targets:
+ // no double-click chaining and no pressed-slot tracking.
+ MouseButton::Back | MouseButton::Forward => return i64::from(down),
+ };
+
+ if down {
+ let count = self
+ .last_down
+ .filter(|last| {
+ last.button == button
+ && now.saturating_duration_since(last.at) <= double_click_interval
+ && click_points_are_near(last.x, last.y, x, y, Self::MAX_DISTANCE_PX)
+ })
+ .map(|last| last.count.saturating_add(1).min(3))
+ .unwrap_or(1);
+ let click = MacClickDown {
+ button,
+ x,
+ y,
+ at: now,
+ count,
+ };
+ self.last_down = Some(click);
+ self.pressed[index] = Some(click);
+ return i64::from(count);
+ }
+
+ let Some(click) = self.pressed[index].take() else {
+ return 0;
+ };
+ if click_points_are_near(click.x, click.y, x, y, Self::MAX_DISTANCE_PX) {
+ i64::from(click.count)
+ } else {
+ // The button moved while held: that was a drag, not a click, and
+ // it must not chain into a double-click either.
+ self.last_down = None;
+ 0
+ }
+ }
+}
+
+#[cfg(target_os = "macos")]
+fn click_points_are_near(x1: i32, y1: i32, x2: i32, y2: i32, max_distance: i32) -> bool {
+ let dx = i64::from(x1) - i64::from(x2);
+ let dy = i64::from(y1) - i64::from(y2);
+ let max = i64::from(max_distance);
+ dx * dx + dy * dy <= max * max
+}
+
+/// The user's configured double-click speed ([NSEvent doubleClickInterval]),
+/// resolved once via the ObjC runtime and clamped to a sane range so a broken
+/// answer degrades to the macOS default feel instead of breaking clicks.
+#[cfg(target_os = "macos")]
+fn macos_double_click_interval() -> Duration {
+ static INTERVAL: OnceLock = OnceLock::new();
+ *INTERVAL.get_or_init(|| {
+ use std::ffi::c_void;
+ use std::os::raw::c_char;
+
+ #[link(name = "objc")]
+ extern "C" {
+ fn objc_getClass(name: *const c_char) -> *mut c_void;
+ fn sel_registerName(name: *const c_char) -> *mut c_void;
+ fn objc_msgSend();
+ }
+
+ let seconds = unsafe {
+ let class = objc_getClass(b"NSEvent\0".as_ptr() as *const c_char);
+ if class.is_null() {
+ 0.5
+ } else {
+ let selector = sel_registerName(b"doubleClickInterval\0".as_ptr() as *const c_char);
+ let get_interval: extern "C" fn(*mut c_void, *mut c_void) -> f64 =
+ std::mem::transmute(objc_msgSend as *const ());
+ get_interval(class, selector)
+ }
+ };
+ Duration::from_secs_f64(if seconds.is_finite() && (0.1..=2.0).contains(&seconds) {
+ seconds
+ } else {
+ 0.5
+ })
+ })
+}
+
+#[cfg(target_os = "macos")]
+fn macos_click_state(button: MouseButton, down: bool, x: i32, y: i32) -> i64 {
+ macos_click_tracker()
+ .lock()
+ .map(|mut tracker| {
+ tracker.event_count(
+ button,
+ down,
+ x,
+ y,
+ Instant::now(),
+ macos_double_click_interval(),
+ )
+ })
+ .unwrap_or(if down { 1 } else { 0 })
+}
+
+#[cfg(target_os = "macos")]
+fn macos_click_tracker() -> &'static Mutex {
+ static TRACKER: OnceLock> = OnceLock::new();
+ TRACKER.get_or_init(|| Mutex::new(MacClickTracker::default()))
+}
+
#[cfg(target_os = "macos")]
fn inject_mouse_button(button: MouseButton, down: bool, x: i32, y: i32) {
use core_graphics::{
display::CGDisplay,
- event::{CGEvent, CGEventTapLocation, CGEventType, CGMouseButton},
+ event::{CGEvent, CGEventTapLocation, CGEventType, CGMouseButton, EventField},
event_source::{CGEventSource, CGEventSourceStateID},
geometry::CGPoint,
};
@@ -5204,19 +5647,33 @@ fn inject_mouse_button(button: MouseButton, down: bool, x: i32, y: i32) {
let Ok(source) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
return;
};
- let (event_type, mouse_button) = match (button, down) {
- (MouseButton::Left, true) => (CGEventType::LeftMouseDown, CGMouseButton::Left),
- (MouseButton::Left, false) => (CGEventType::LeftMouseUp, CGMouseButton::Left),
- (MouseButton::Right, true) => (CGEventType::RightMouseDown, CGMouseButton::Right),
- (MouseButton::Right, false) => (CGEventType::RightMouseUp, CGMouseButton::Right),
- (MouseButton::Middle, true) => (CGEventType::OtherMouseDown, CGMouseButton::Center),
- (MouseButton::Middle, false) => (CGEventType::OtherMouseUp, CGMouseButton::Center),
+ // Side buttons are "other" mouse events distinguished only by their button
+ // number (macOS: 2 = middle, 3 = back, 4 = forward); new_mouse_event has no
+ // CGMouseButton for 3/4, so create an Other event and stamp the number.
+ let (event_type, mouse_button, button_number) = match (button, down) {
+ (MouseButton::Left, true) => (CGEventType::LeftMouseDown, CGMouseButton::Left, None),
+ (MouseButton::Left, false) => (CGEventType::LeftMouseUp, CGMouseButton::Left, None),
+ (MouseButton::Right, true) => (CGEventType::RightMouseDown, CGMouseButton::Right, None),
+ (MouseButton::Right, false) => (CGEventType::RightMouseUp, CGMouseButton::Right, None),
+ (MouseButton::Middle, true) => (CGEventType::OtherMouseDown, CGMouseButton::Center, None),
+ (MouseButton::Middle, false) => (CGEventType::OtherMouseUp, CGMouseButton::Center, None),
+ (MouseButton::Back, true) => (CGEventType::OtherMouseDown, CGMouseButton::Center, Some(3)),
+ (MouseButton::Back, false) => (CGEventType::OtherMouseUp, CGMouseButton::Center, Some(3)),
+ (MouseButton::Forward, true) => (CGEventType::OtherMouseDown, CGMouseButton::Center, Some(4)),
+ (MouseButton::Forward, false) => (CGEventType::OtherMouseUp, CGMouseButton::Center, Some(4)),
};
let point = CGPoint::new(x as f64, y as f64);
let _ = CGDisplay::warp_mouse_cursor_position(point);
if let Ok(event) = CGEvent::new_mouse_event(source, event_type, point, mouse_button) {
+ if let Some(number) = button_number {
+ event.set_integer_value_field(EventField::MOUSE_EVENT_BUTTON_NUMBER, number);
+ }
+ event.set_integer_value_field(
+ EventField::MOUSE_EVENT_CLICK_STATE,
+ macos_click_state(button, down, x, y),
+ );
event.post(CGEventTapLocation::HID);
}
}
@@ -5247,11 +5704,20 @@ fn inject_scroll(delta_x: i32, delta_y: i32) {
#[cfg(target_os = "macos")]
static MAC_INJECT_FLAGS: AtomicU64 = AtomicU64::new(0);
+/// Latch so a held (auto-repeating) remote Caps Lock toggles the input source
+/// exactly once until its key-up arrives.
+#[cfg(target_os = "macos")]
+static MACOS_CAPS_LOCK_DOWN: AtomicBool = AtomicBool::new(false);
+
/// Clears the tracked injected-modifier flags. Called when receiving stops so a
/// dropped modifier key-up cannot leave Shift/Ctrl/Cmd stuck on for later keys.
#[cfg(target_os = "macos")]
pub fn reset_injected_modifiers() {
MAC_INJECT_FLAGS.store(0, Ordering::Relaxed);
+ MACOS_CAPS_LOCK_DOWN.store(false, Ordering::Relaxed);
+ if let Ok(mut tracker) = macos_click_tracker().lock() {
+ *tracker = MacClickTracker::default();
+ }
}
#[cfg(not(target_os = "macos"))]
@@ -5279,6 +5745,30 @@ fn inject_key(key_code: u16, down: bool) {
event_source::{CGEventSource, CGEventSourceStateID},
};
+ // VK_CAPITAL: replicate the macOS "Caps Lock switches input sources"
+ // behaviour for remote input. macOS honours that setting only for the
+ // physical key — an injected caps keycode toggles neither the IME nor the
+ // caps state — so post the system "Select the previous input source"
+ // hotkey (⌃Space) instead: HIToolbox then performs the switch exactly as
+ // for a physical press, including refreshing the focused app's input
+ // session (TISSelectInputSource from a background process updates the
+ // menu-bar indicator but the focused app keeps typing in the old source
+ // until refocused). Remote Caps Lock therefore never acts as a
+ // letter-case toggle on this Mac.
+ // ponytail: assumes the ⌃Space symbolic hotkey is enabled (macOS default,
+ // verified on this deployment); read com.apple.symbolichotkeys key 60 if
+ // this ever needs to adapt.
+ if key_code == 0x14 {
+ if down {
+ if !MACOS_CAPS_LOCK_DOWN.swap(true, Ordering::Relaxed) {
+ macos_post_select_previous_input_source();
+ }
+ } else {
+ MACOS_CAPS_LOCK_DOWN.store(false, Ordering::Relaxed);
+ }
+ return;
+ }
+
// Keep the running modifier state in sync, so the modifier event itself and
// every later key carry the right flags.
if let Some(flag) = windows_vk_to_mac_flag(key_code) {
@@ -5301,15 +5791,79 @@ fn inject_key(key_code: u16, down: bool) {
};
match CGEvent::new_keyboard_event(source, mac_code, down) {
Ok(event) => {
- event.set_flags(CGEventFlags::from_bits_truncate(
- MAC_INJECT_FLAGS.load(Ordering::Relaxed),
- ));
+ let mut flags = CGEventFlags::from_bits_truncate(MAC_INJECT_FLAGS.load(Ordering::Relaxed));
+ // A physical Mac keyboard stamps the function-section flags on
+ // arrow/nav keys (arrows additionally carry the numeric-pad bit).
+ // Shortcut matching — system ones like ⌃← "move left a space" and
+ // app key equivalents like ⌘← — compares those flags, so injected
+ // arrows without them fire nothing: the historic "Ctrl+arrows do
+ // nothing on the Mac" bug.
+ flags |= mac_function_section_flags(mac_code);
+ event.set_flags(flags);
event.post(CGEventTapLocation::HID);
}
Err(_) => log::warn!("inject_key: failed to build keyboard event for mac code {mac_code}"),
}
}
+/// Extra CGEventFlags a physical keyboard sets for function-section keys:
+/// arrows (123-126) carry Fn + numeric-pad; Home/End/PageUp/PageDown/forward
+/// Delete carry Fn. Everything else gets nothing extra.
+#[cfg(target_os = "macos")]
+fn mac_function_section_flags(mac_code: u16) -> core_graphics::event::CGEventFlags {
+ use core_graphics::event::CGEventFlags;
+
+ match mac_code {
+ // Left, Right, Down, Up
+ 123..=126 => CGEventFlags::CGEventFlagSecondaryFn | CGEventFlags::CGEventFlagNumericPad,
+ // Home(115), PageUp(116), forward Delete(117), End(119), PageDown(121)
+ 115 | 116 | 117 | 119 | 121 => CGEventFlags::CGEventFlagSecondaryFn,
+ _ => CGEventFlags::empty(),
+ }
+}
+
+/// Posts the system input-source toggle hotkey (⌃Space, symbolic hotkey 60)
+/// as a full physical-like sequence: Control down, Space down/up, Control up.
+/// Flags are set per event and deliberately plain ⌃ — a concurrently held
+/// remote modifier would form a different chord and miss the hotkey; the next
+/// injected key restores the tracked flags anyway.
+#[cfg(target_os = "macos")]
+fn macos_post_select_previous_input_source() {
+ use core_graphics::{
+ event::{CGEvent, CGEventFlags, CGEventTapLocation},
+ event_source::{CGEventSource, CGEventSourceStateID},
+ };
+
+ const MAC_KEY_CONTROL: u16 = 59; // kVK_Control
+ const MAC_KEY_SPACE: u16 = 49; // kVK_Space
+
+ let control = CGEventFlags::CGEventFlagControl;
+ let no_flags = CGEventFlags::empty();
+ let sequence = [
+ (MAC_KEY_CONTROL, true, control),
+ (MAC_KEY_SPACE, true, control),
+ (MAC_KEY_SPACE, false, control),
+ (MAC_KEY_CONTROL, false, no_flags),
+ ];
+ for (mac_code, down, flags) in sequence {
+ let Ok(source) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
+ log::warn!("caps toggle: failed to create CGEventSource");
+ return;
+ };
+ match CGEvent::new_keyboard_event(source, mac_code, down) {
+ Ok(event) => {
+ event.set_flags(flags);
+ event.post(CGEventTapLocation::HID);
+ }
+ Err(_) => {
+ log::warn!("caps toggle: failed to build keyboard event for mac code {mac_code}");
+ return;
+ }
+ }
+ }
+ log::info!("[diag] caps: posted input-source toggle (ctrl+space)");
+}
+
#[cfg(target_os = "windows")]
fn inject_mouse_move(x: i32, y: i32, drag_button: Option) {
crate::windows_input::inject_mouse_move(x, y, drag_button);
@@ -5691,6 +6245,139 @@ mod tests {
assert_eq!(guarded.x, 1.0);
}
+ #[cfg(target_os = "macos")]
+ #[test]
+ fn macos_click_tracker_emits_matching_double_click_counts() {
+ let mut tracker = MacClickTracker::default();
+ let start = Instant::now();
+ let interval = Duration::from_millis(500);
+
+ assert_eq!(
+ tracker.event_count(MouseButton::Left, true, 100, 200, start, interval),
+ 1
+ );
+ assert_eq!(
+ tracker.event_count(
+ MouseButton::Left,
+ false,
+ 100,
+ 200,
+ start + Duration::from_millis(40),
+ interval,
+ ),
+ 1
+ );
+ assert_eq!(
+ tracker.event_count(
+ MouseButton::Left,
+ true,
+ 102,
+ 201,
+ start + Duration::from_millis(180),
+ interval,
+ ),
+ 2,
+ "a nearby press inside the interval must raise the click count"
+ );
+ assert_eq!(
+ tracker.event_count(
+ MouseButton::Left,
+ false,
+ 102,
+ 201,
+ start + Duration::from_millis(220),
+ interval,
+ ),
+ 2
+ );
+ }
+
+ #[cfg(target_os = "macos")]
+ #[test]
+ fn macos_click_tracker_resets_after_drag_timeout_or_button_change() {
+ let mut tracker = MacClickTracker::default();
+ let start = Instant::now();
+ let interval = Duration::from_millis(500);
+
+ assert_eq!(
+ tracker.event_count(MouseButton::Left, true, 10, 10, start, interval),
+ 1
+ );
+ assert_eq!(
+ tracker.event_count(
+ MouseButton::Left,
+ false,
+ 30,
+ 30,
+ start + Duration::from_millis(40),
+ interval,
+ ),
+ 0,
+ "a drag release is not a click"
+ );
+ assert_eq!(
+ tracker.event_count(
+ MouseButton::Right,
+ true,
+ 10,
+ 10,
+ start + Duration::from_millis(100),
+ interval,
+ ),
+ 1,
+ "a different button starts its own click chain"
+ );
+ assert_eq!(
+ tracker.event_count(
+ MouseButton::Left,
+ true,
+ 10,
+ 10,
+ start + Duration::from_millis(700),
+ interval,
+ ),
+ 1,
+ "a press after the interval starts over at a single click"
+ );
+ }
+
+ #[test]
+ fn remote_park_point_tucks_mac_at_shared_edge_and_keeps_windows_corner() {
+ let target = target_for_coordinate_tests(); // edge=Right, platform=windows, remote 2560x1440
+ let remote = target.remote_screen.clone();
+ let mut active = ActiveTarget {
+ target,
+ current_screen: remote.clone(),
+ current_screen_id: remote.id.clone(),
+ x: 12.0,
+ y: 700.0,
+ invert_y: false,
+ };
+
+ // Controlled Windows keeps the long-standing far-corner park.
+ assert_eq!(remote_park_point(&active), (2559, 1439));
+
+ // Controlled macOS entered through OUR right edge (this machine sits
+ // to its west): bottom clipping edge, west end, clear of the corner.
+ active.target.target_platform = "macos".into();
+ assert_eq!(
+ remote_park_point(&active),
+ (PARK_CORNER_CLEARANCE, 1439)
+ );
+
+ // Entered through OUR left edge (this machine to its east): the east
+ // edge clips the arrow itself — park there at the exit height.
+ active.target.edge = Edge::Left;
+ assert_eq!(remote_park_point(&active), (2559, 700));
+
+ // An exit right next to a corner still keeps hot-corner clearance.
+ active.y = 1439.0;
+ assert_eq!(
+ remote_park_point(&active),
+ (2559, 1439 - PARK_CORNER_CLEARANCE)
+ );
+ }
+
#[test]
fn screen_switch_hotkey_matching_requires_exact_modifiers() {
let hotkeys = crate::ScreenSwitchHotkeys {
@@ -5846,14 +6533,7 @@ mod tests {
#[test]
fn hotkey_return_uses_recorded_point_then_local_screen_center() {
- let active = crossing_target(
- &[target_for_coordinate_tests()],
- 1919.0,
- 500.0,
- 40.0,
- 0.0,
- &Arc::new(Mutex::new(layout_for_target_tests())),
- )
+ let active = crossing_target(&[target_for_coordinate_tests()], 1919.0, 500.0, 40.0, 0.0)
.expect("target should be active");
assert_eq!(
@@ -5952,6 +6632,141 @@ mod tests {
}
}
+ #[test]
+ fn side_button_events_round_trip_on_the_wire() {
+ for button in [MouseButton::Back, MouseButton::Forward] {
+ let event = InputEvent::MouseButton { button, down: true };
+ let encoded = rmp_serde::to_vec_named(&event).expect("encode side button");
+ let decoded: InputEvent = rmp_serde::from_slice(&encoded).expect("decode side button");
+ assert_eq!(decoded, InputEvent::MouseButton { button, down: true });
+ }
+ // Distinct masks so a held side button never aliases another button.
+ let masks = [
+ mouse_button_mask(MouseButton::Left),
+ mouse_button_mask(MouseButton::Right),
+ mouse_button_mask(MouseButton::Middle),
+ mouse_button_mask(MouseButton::Back),
+ mouse_button_mask(MouseButton::Forward),
+ ];
+ for (i, a) in masks.iter().enumerate() {
+ for b in &masks[i + 1..] {
+ assert_ne!(a, b, "mouse button masks must be distinct");
+ }
+ }
+ }
+
+ #[test]
+ fn borrowed_packet_mirror_encodes_identically_to_input_packet() {
+ let packet = InputPacket {
+ protocol: INPUT_PROTOCOL.into(),
+ target_device_id: "peer-device".into(),
+ origin_device_id: "local-device".into(),
+ origin_port: 47833,
+ origin_transport_public_key: "local-public-key".into(),
+ origin_protocol_version: quic_transport::PROTOCOL_VERSION,
+ cluster_id: "cluster-test".into(),
+ pair_secret: "secret-test".into(),
+ event: InputEvent::MouseMove {
+ screen_id: "display-1".into(),
+ x: 320,
+ y: 240,
+ },
+ };
+ let mirror = InputPacketRef {
+ protocol: &packet.protocol,
+ target_device_id: &packet.target_device_id,
+ origin_device_id: &packet.origin_device_id,
+ origin_port: packet.origin_port,
+ origin_transport_public_key: &packet.origin_transport_public_key,
+ origin_protocol_version: packet.origin_protocol_version,
+ cluster_id: &packet.cluster_id,
+ pair_secret: &packet.pair_secret,
+ event: &packet.event,
+ };
+
+ assert_eq!(
+ rmp_serde::to_vec_named(&packet).expect("encode owned packet"),
+ rmp_serde::to_vec_named(&mirror).expect("encode borrowed mirror"),
+ "the send-path mirror must stay byte-identical to InputPacket on the wire"
+ );
+ }
+
+ #[test]
+ fn credential_less_packet_omits_the_pairing_block_and_still_decodes() {
+ let event = InputEvent::MouseMove {
+ screen_id: "display-1".into(),
+ x: 320,
+ y: 240,
+ };
+ let full = InputPacketRef {
+ protocol: INPUT_PROTOCOL,
+ target_device_id: "peer-device",
+ origin_device_id: "local-device",
+ origin_port: 47833,
+ // ~roughly the size of a real base64 transport certificate
+ origin_transport_public_key: &"A".repeat(492),
+ origin_protocol_version: quic_transport::PROTOCOL_VERSION,
+ cluster_id: "cluster-test",
+ pair_secret: "secret-test",
+ event: &event,
+ };
+ let lean = InputPacketRef {
+ protocol: INPUT_PROTOCOL,
+ target_device_id: "peer-device",
+ origin_device_id: "",
+ origin_port: 47833,
+ origin_transport_public_key: "",
+ origin_protocol_version: quic_transport::PROTOCOL_VERSION,
+ cluster_id: "",
+ pair_secret: "",
+ event: &event,
+ };
+
+ let full_bytes = rmp_serde::to_vec_named(&full).expect("encode full");
+ let lean_bytes = rmp_serde::to_vec_named(&lean).expect("encode lean");
+ assert!(
+ lean_bytes.len() + 400 < full_bytes.len(),
+ "credential-less packet ({} bytes) should be far smaller than full ({} bytes)",
+ lean_bytes.len(),
+ full_bytes.len()
+ );
+
+ // The receiver still decodes it, with the omitted credentials defaulted
+ // to empty so it takes the cached-authorization path.
+ let decoded = decode_input_packet(&lean_bytes).expect("decode lean packet");
+ assert!(decoded.pair_secret.is_empty());
+ assert!(decoded.origin_transport_public_key.is_empty());
+ assert_eq!(decoded.target_device_id, "peer-device");
+ assert_eq!(decoded.origin_port, 47833);
+ }
+
+ #[test]
+ fn credential_refresh_and_origin_cache_windows() {
+ let t0 = Instant::now();
+ // Refresh: due when never sent, not due within the window, due after it.
+ assert!(credential_send_due(None, t0));
+ assert!(!credential_send_due(
+ Some(t0),
+ t0 + INPUT_FULL_CRED_REFRESH - Duration::from_millis(1)
+ ));
+ assert!(credential_send_due(Some(t0), t0 + INPUT_FULL_CRED_REFRESH));
+
+ // Cache: never-seen is not fresh; within TTL is fresh; past TTL is not.
+ assert!(!origin_authorization_fresh(None, t0));
+ assert!(origin_authorization_fresh(
+ Some(t0),
+ t0 + INPUT_ORIGIN_CACHE_TTL - Duration::from_millis(1)
+ ));
+ assert!(!origin_authorization_fresh(
+ Some(t0),
+ t0 + INPUT_ORIGIN_CACHE_TTL
+ ));
+
+ // The refresh interval must stay under the cache TTL, or authorization
+ // would lapse between credentialled packets.
+ assert!(INPUT_FULL_CRED_REFRESH < INPUT_ORIGIN_CACHE_TTL);
+ }
+
#[test]
fn input_packet_context_uses_stable_peer_origin_id() {
let layout = layout_for_target_tests();
@@ -5959,6 +6774,20 @@ mod tests {
let layout_state = Arc::new(Mutex::new(layout));
let target = target_for_coordinate_tests();
+ // Key events consult the live layout and must resolve the stable id.
+ let context = input_packet_context(
+ &target,
+ InputEvent::Key {
+ key_code: 0x41,
+ down: true,
+ },
+ &layout_state,
+ );
+ assert_ne!(expected_origin_id, "local-device");
+ assert_eq!(context.origin_device_id, expected_origin_id);
+
+ // Mouse events take the hot path: the context cached on the target at
+ // build time, which carries the same stable id without a layout lock.
let context = input_packet_context(
&target,
InputEvent::MouseMove {
@@ -5968,9 +6797,7 @@ mod tests {
},
&layout_state,
);
-
- assert_ne!(expected_origin_id, "local-device");
- assert_eq!(context.origin_device_id, expected_origin_id);
+ assert_eq!(context.origin_device_id, target.origin_device_id);
}
#[test]
@@ -6203,7 +7030,7 @@ mod tests {
fn fast_crossing_carries_entry_delta_into_remote() {
let target = target_for_coordinate_tests();
let layout_state = Arc::new(Mutex::new(layout_for_target_tests()));
- let active = crossing_target(&[target], 1919.0, 500.0, 40.0, 0.0, &layout_state)
+ let active = crossing_target(&[target], 1919.0, 500.0, 40.0, 0.0)
.expect("fast edge movement should cross");
assert!(
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 4ef4127..8a338f1 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -1,7 +1,6 @@
use std::{
- collections::{hash_map::DefaultHasher, HashMap, HashSet},
+ collections::HashMap,
env, fs,
- hash::{Hash, Hasher},
io::{Read, Write},
net::{Ipv4Addr, SocketAddr, UdpSocket},
path::{Path, PathBuf},
@@ -19,8 +18,7 @@ use serde::{Deserialize, Serialize};
use tauri::{
menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
- AppHandle, DragDropEvent, Emitter, Manager, Monitor, WebviewUrl, WebviewWindowBuilder,
- WindowEvent, Wry,
+ AppHandle, Emitter, Manager, Monitor, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry,
};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
@@ -67,13 +65,6 @@ const CLIPBOARD_WRITE_RETRY_DELAY_MS: u64 = 30;
const FILE_TRANSFER_PROTOCOL: &str = "mykvm.file-transfer.v1";
const FILE_TRANSFER_CHUNK_BYTES: usize = 256 * 1024;
const FILE_TRANSFER_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024 * 1024;
-const FILE_TRANSFER_DESTINATION_POINTER: &str = "pointer";
-const EDGE_DROP_WINDOWS_ENABLED: bool = false;
-const EDGE_DROP_LABEL_PREFIX: &str = "mykvm-edge-drop-";
-const EDGE_DROP_THICKNESS: i32 = 8;
-const FILE_DROP_LANDING_LABEL: &str = "mykvm-file-drop-landing";
-const FILE_DROP_LANDING_WIDTH: f64 = 96.0;
-const FILE_DROP_LANDING_HEIGHT: f64 = 72.0;
const LOG_MAX_FILE_SIZE_BYTES: u128 = 1024 * 1024;
const AUTOSTART_ARG: &str = "--mykvm-autostart";
const QUIT_EXISTING_ARG: &str = "--mykvm-quit-existing";
@@ -486,8 +477,6 @@ struct FileTransferPacket {
cluster_id: String,
pair_secret: String,
file_name: String,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- destination_hint: Option,
total_bytes: u64,
chunk_index: u64,
offset: u64,
@@ -495,35 +484,6 @@ struct FileTransferPacket {
data: Vec,
}
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
-enum EdgeDropSide {
- Left,
- Right,
- Top,
- Bottom,
-}
-
-impl EdgeDropSide {
- fn as_str(self) -> &'static str {
- match self {
- EdgeDropSide::Left => "left",
- EdgeDropSide::Right => "right",
- EdgeDropSide::Top => "top",
- EdgeDropSide::Bottom => "bottom",
- }
- }
-}
-
-#[derive(Debug, Clone, PartialEq)]
-struct EdgeDropWindowSpec {
- label: String,
- target_device_id: String,
- x: f64,
- y: f64,
- width: f64,
- height: f64,
-}
-
struct AppRuntime {
app_handle: AppHandle,
layout: Arc>,
@@ -532,7 +492,6 @@ struct AppRuntime {
peers: Arc>>,
pairing_challenge: Arc>>,
file_transfers: Arc>>,
- edge_drop_targets: Arc>>,
quic_transport: Mutex>,
discovery_stop: Mutex >>,
input_stop: Mutex >>,
@@ -571,7 +530,6 @@ impl AppRuntime {
peers: Arc::new(Mutex::new(Vec::new())),
pairing_challenge: Arc::new(Mutex::new(None)),
file_transfers: Arc::new(Mutex::new(HashMap::new())),
- edge_drop_targets: Arc::new(Mutex::new(HashMap::new())),
quic_transport: Mutex::new(None),
discovery_stop: Mutex::new(None),
input_stop: Mutex::new(None),
@@ -735,7 +693,6 @@ impl AppRuntime {
let layout_for_input = Arc::clone(&self.layout);
let layout_for_clipboard = Arc::clone(&self.layout);
- let layout_for_file_transfer = Arc::clone(&self.layout);
let layout_for_pairing = Arc::clone(&self.layout);
let native_layout_for_input = self.native_layout();
let input_receive_enabled = Arc::clone(&self.input_receive_enabled);
@@ -758,26 +715,12 @@ impl AppRuntime {
if !input_receive_enabled.load(Ordering::Relaxed) {
return;
}
- let Ok(layout) = layout_for_input.lock() else {
- return;
- };
- let current_peer = local_peer_from_layout(&layout);
- if input::try_handle_control_packet_from_source(
- &layout,
- &payload,
- source,
- ¤t_peer.id,
- ) {
- transport_packets_for_input.fetch_add(1, Ordering::Relaxed);
- return;
- }
- if input::try_inject_packet_from_source(
- &layout,
+ if input::handle_input_datagram(
+ &layout_for_input,
&native_layout_for_input,
&payload,
source,
&input_events,
- ¤t_peer.id,
&clipboard_target,
) {
transport_packets_for_input.fetch_add(1, Ordering::Relaxed);
@@ -797,27 +740,34 @@ impl AppRuntime {
return true;
}
- if let Ok(layout) = layout_for_file_transfer.lock() {
- let current_peer = local_peer_from_layout(&layout);
- if handle_file_transfer_packet(
- &payload,
- &layout,
- ¤t_peer.id,
- &file_transfers,
- &app_handle_for_file_transfer,
- ) {
- transport_packets_for_stream.fetch_add(1, Ordering::Relaxed);
- return true;
- }
+ // Snapshot the layout instead of holding the lock through the
+ // handlers: clipboard writes retry with sleeps, spawn pbcopy and
+ // decode up to 32MB of base64, and file transfers write chunks to
+ // disk — holding the layout lock through any of that stalls the
+ // input hot paths (which take the same lock) for tens to hundreds
+ // of ms per sync. Stream packets are rare; one clone is nothing.
+ let layout = {
+ let Ok(layout) = layout_for_clipboard.lock() else {
+ return false;
+ };
+ layout.clone()
+ };
+ let current_peer = local_peer_from_layout(&layout);
+
+ if handle_file_transfer_packet(
+ &payload,
+ &layout,
+ ¤t_peer.id,
+ &file_transfers,
+ &app_handle_for_file_transfer,
+ ) {
+ transport_packets_for_stream.fetch_add(1, Ordering::Relaxed);
+ return true;
}
if !clipboard_receive_enabled.load(Ordering::Relaxed) {
return false;
}
- let Ok(layout) = layout_for_clipboard.lock() else {
- return false;
- };
- let current_peer = local_peer_from_layout(&layout);
if handle_clipboard_packet(
&payload,
&layout,
@@ -2120,15 +2070,8 @@ fn send_files_to_device(
paths: Vec,
state: tauri::State<'_, AppRuntime>,
) -> Result {
- send_files_to_device_with_destination(state.inner(), &device_id, paths, None)
-}
-
-fn send_files_to_device_with_destination(
- state: &AppRuntime,
- device_id: &str,
- paths: Vec,
- destination_hint: Option<&str>,
-) -> Result {
+ let state = state.inner();
+ let device_id = device_id.as_str();
if paths.is_empty() {
return Err("请选择要传输的文件。".into());
}
@@ -2151,13 +2094,7 @@ fn send_files_to_device_with_destination(
let mut byte_count = 0_u64;
for file in files {
- let packet_count = send_transfer_file(
- &quic_transport,
- &local_peer.id,
- &target,
- &file,
- destination_hint,
- )?;
+ let packet_count = send_transfer_file(&quic_transport, &local_peer.id, &target, &file)?;
state
.transport_packets
.fetch_add(packet_count, Ordering::Relaxed);
@@ -2172,464 +2109,6 @@ fn send_files_to_device_with_destination(
})
}
-fn start_edge_drop_window_sync(app_handle: AppHandle) {
- thread::spawn(move || loop {
- let state = app_handle.state::();
- let runtime = state.inner();
- let layout = runtime.layout_snapshot();
- let peers = active_peer_snapshot(&runtime.peers);
- let specs = edge_drop_specs_for_window_visibility(
- &layout,
- &peers,
- runtime.main_window_visible.load(Ordering::Relaxed),
- );
- let next_labels = specs
- .iter()
- .map(|spec| spec.label.clone())
- .collect::>();
- let next_targets = specs
- .iter()
- .map(|spec| (spec.label.clone(), spec.target_device_id.clone()))
- .collect::>();
- let stale_labels = runtime
- .edge_drop_targets
- .lock()
- .map(|mut targets| {
- let stale = targets
- .keys()
- .filter(|label| !next_labels.contains(*label))
- .cloned()
- .collect::>();
- *targets = next_targets;
- stale
- })
- .unwrap_or_default();
-
- let main_handle = app_handle.clone();
- let _ = app_handle.run_on_main_thread(move || {
- sync_edge_drop_windows_on_main(&main_handle, specs, stale_labels);
- });
-
- thread::sleep(Duration::from_millis(1500));
- });
-}
-
-fn sync_edge_drop_windows_on_main(
- app_handle: &AppHandle,
- specs: Vec,
- stale_labels: Vec,
-) {
- for label in stale_labels {
- if let Some(window) = app_handle.get_webview_window(&label) {
- let _ = window.hide();
- }
- }
-
- for spec in specs {
- if let Some(window) = app_handle.get_webview_window(&spec.label) {
- let _ = window.set_position(tauri::Position::Logical(tauri::LogicalPosition::new(
- spec.x, spec.y,
- )));
- let _ = window.set_size(tauri::Size::Logical(tauri::LogicalSize::new(
- spec.width,
- spec.height,
- )));
- let _ = window.set_always_on_top(true);
- let _ = window.set_focusable(false);
- if !window.is_visible().unwrap_or(false) {
- let _ = window.show();
- }
- continue;
- }
-
- let build_result = WebviewWindowBuilder::new(
- app_handle,
- &spec.label,
- WebviewUrl::App("edge-drop.html".into()),
- )
- .title("")
- .position(spec.x, spec.y)
- .inner_size(spec.width, spec.height)
- .decorations(false)
- .resizable(false)
- .maximizable(false)
- .minimizable(false)
- .closable(false)
- .shadow(false)
- .transparent(true)
- .always_on_top(true)
- .visible_on_all_workspaces(true)
- .skip_taskbar(true)
- .focused(false)
- .focusable(false)
- .build();
-
- if let Err(error) = build_result {
- log::warn!("edge drop window {} failed: {error}", spec.label);
- }
- }
-}
-
-fn handle_edge_drop_window_event(window: &tauri::Window, event: &WindowEvent) -> bool {
- let label = window.label();
- if !label.starts_with(EDGE_DROP_LABEL_PREFIX) {
- return false;
- }
-
- match event {
- WindowEvent::CloseRequested { api, .. } => {
- api.prevent_close();
- let _ = window.hide();
- }
- WindowEvent::DragDrop(DragDropEvent::Drop { paths, .. }) => {
- if paths.is_empty() {
- return true;
- }
- let app_handle = window.app_handle().clone();
- let label = label.to_string();
- let paths = paths
- .iter()
- .map(|path| path.to_string_lossy().into_owned())
- .collect::>();
- thread::spawn(move || {
- let state = app_handle.state::();
- let target_device_id = state
- .edge_drop_targets
- .lock()
- .ok()
- .and_then(|targets| targets.get(&label).cloned());
- let Some(target_device_id) = target_device_id else {
- log::warn!("edge drop ignored: no target for {label}");
- return;
- };
-
- match send_files_to_device_with_destination(
- state.inner(),
- &target_device_id,
- paths,
- Some(FILE_TRANSFER_DESTINATION_POINTER),
- ) {
- Ok(summary) => log::info!(
- "edge drop sent {} file(s) to {} bytes={}",
- summary.file_count,
- summary.target_name,
- summary.byte_count
- ),
- Err(error) => log::warn!("edge drop transfer failed: {error}"),
- }
- });
- }
- _ => {}
- }
-
- true
-}
-
-fn handle_file_drop_landing_window_event(window: &tauri::Window, event: &WindowEvent) -> bool {
- if window.label() != FILE_DROP_LANDING_LABEL {
- return false;
- }
-
- if let WindowEvent::CloseRequested { api, .. } = event {
- api.prevent_close();
- let _ = window.hide();
- }
-
- true
-}
-
-fn edge_drop_specs_for_layout(layout: &LayoutState, peers: &[LanPeer]) -> Vec {
- if !layout.file_transfer_enabled {
- return Vec::new();
- }
-
- let Some(local_device) = layout.devices.iter().find(|device| device.role == "local") else {
- return Vec::new();
- };
-
- let mut specs = match layout.machine_role.as_str() {
- "server" => server_edge_drop_specs(layout, local_device),
- "client" => client_edge_drop_specs(layout, local_device, peers),
- _ => Vec::new(),
- };
- specs.sort_by(|left, right| left.label.cmp(&right.label));
- specs
-}
-
-fn edge_drop_specs_for_window_visibility(
- layout: &LayoutState,
- peers: &[LanPeer],
- main_window_visible: bool,
-) -> Vec {
- if main_window_visible {
- edge_drop_specs_for_layout(layout, peers)
- } else {
- Vec::new()
- }
-}
-
-fn server_edge_drop_specs(layout: &LayoutState, local_device: &Device) -> Vec {
- let mut specs = Vec::new();
- for device in layout.devices.iter().filter(|device| {
- device.role != "local"
- && device.online
- && device.input_ready
- && device.protocol_version == quic_transport::PROTOCOL_VERSION
- && !device.transport_public_key.trim().is_empty()
- }) {
- for local_screen in &local_device.screens {
- for remote_screen in &device.screens {
- if edge_screens_overlap(local_screen, remote_screen) {
- continue;
- }
- let Some(side) = edge_touching_side(local_screen, remote_screen) else {
- continue;
- };
- let Some((x, y, width, height)) =
- edge_drop_rect_between(local_screen, remote_screen, side)
- else {
- continue;
- };
- specs.push(EdgeDropWindowSpec {
- label: edge_drop_label(&device.id, local_screen, Some(remote_screen), side),
- target_device_id: device.id.clone(),
- x,
- y,
- width,
- height,
- });
- }
- }
- }
- specs
-}
-
-fn client_edge_drop_specs(
- layout: &LayoutState,
- local_device: &Device,
- peers: &[LanPeer],
-) -> Vec {
- let Some(target_device_id) = online_paired_controller_id(layout, peers) else {
- return Vec::new();
- };
-
- let mut specs = Vec::new();
- for local_screen in &local_device.screens {
- for side in [
- EdgeDropSide::Left,
- EdgeDropSide::Right,
- EdgeDropSide::Top,
- EdgeDropSide::Bottom,
- ] {
- if local_edge_has_neighbor(&local_device.screens, local_screen, side) {
- continue;
- }
- let (x, y, width, height) = outer_edge_drop_rect(local_screen, side);
- specs.push(EdgeDropWindowSpec {
- label: edge_drop_label(&target_device_id, local_screen, None, side),
- target_device_id: target_device_id.clone(),
- x,
- y,
- width,
- height,
- });
- }
- }
-
- specs
-}
-
-fn online_paired_controller_id(layout: &LayoutState, peers: &[LanPeer]) -> Option {
- layout
- .paired_controllers
- .iter()
- .find(|controller| {
- peers.iter().any(|peer| {
- (peer.id == controller.id
- || (!controller.transport_public_key.trim().is_empty()
- && peer.transport_public_key == controller.transport_public_key))
- && peer.protocol_version == quic_transport::PROTOCOL_VERSION
- && !peer.transport_public_key.trim().is_empty()
- && peer.quic_port != 0
- })
- })
- .map(|controller| controller.id.clone())
-}
-
-fn edge_drop_label(
- target_device_id: &str,
- local_screen: &Screen,
- remote_screen: Option<&Screen>,
- side: EdgeDropSide,
-) -> String {
- let remote = remote_screen
- .map(|screen| edge_label_component(&screen.id))
- .unwrap_or_else(|| "outer".into());
- format!(
- "{}{}-{}-{}-{}",
- EDGE_DROP_LABEL_PREFIX,
- edge_label_component(target_device_id),
- edge_label_component(&local_screen.id),
- remote,
- side.as_str()
- )
-}
-
-fn edge_label_component(value: &str) -> String {
- let component = value
- .chars()
- .map(|character| {
- if character.is_ascii_alphanumeric() {
- character.to_ascii_lowercase()
- } else {
- '-'
- }
- })
- .collect::();
- let component = component
- .trim_matches('-')
- .chars()
- .take(48)
- .collect::();
- if !component.is_empty() {
- return component;
- }
-
- let mut hasher = DefaultHasher::new();
- value.hash(&mut hasher);
- format!("id-{:016x}", hasher.finish())
-}
-
-fn edge_drop_rect_between(
- local: &Screen,
- remote: &Screen,
- side: EdgeDropSide,
-) -> Option<(f64, f64, f64, f64)> {
- let thickness = EDGE_DROP_THICKNESS;
- let (x, y, width, height) = match side {
- EdgeDropSide::Left | EdgeDropSide::Right => {
- let y = local.y.max(remote.y);
- let height = (local.y + local.height).min(remote.y + remote.height) - y;
- if height <= 0 {
- return None;
- }
- let x = if side == EdgeDropSide::Right {
- local.x + local.width - thickness
- } else {
- local.x
- };
- (x, y, thickness, height)
- }
- EdgeDropSide::Top | EdgeDropSide::Bottom => {
- let x = local.x.max(remote.x);
- let width = (local.x + local.width).min(remote.x + remote.width) - x;
- if width <= 0 {
- return None;
- }
- let y = if side == EdgeDropSide::Bottom {
- local.y + local.height - thickness
- } else {
- local.y
- };
- (x, y, width, thickness)
- }
- };
-
- Some((x as f64, y as f64, width as f64, height as f64))
-}
-
-fn outer_edge_drop_rect(screen: &Screen, side: EdgeDropSide) -> (f64, f64, f64, f64) {
- let thickness = EDGE_DROP_THICKNESS;
- let (x, y, width, height) = match side {
- EdgeDropSide::Left => (screen.x, screen.y, thickness, screen.height),
- EdgeDropSide::Right => (
- screen.x + screen.width - thickness,
- screen.y,
- thickness,
- screen.height,
- ),
- EdgeDropSide::Top => (screen.x, screen.y, screen.width, thickness),
- EdgeDropSide::Bottom => (
- screen.x,
- screen.y + screen.height - thickness,
- screen.width,
- thickness,
- ),
- };
-
- (x as f64, y as f64, width as f64, height as f64)
-}
-
-fn local_edge_has_neighbor(screens: &[Screen], screen: &Screen, side: EdgeDropSide) -> bool {
- screens
- .iter()
- .filter(|candidate| candidate.id != screen.id)
- .any(|candidate| edge_touching_side(screen, candidate) == Some(side))
-}
-
-fn edge_touching_side(local: &Screen, remote: &Screen) -> Option {
- if edge_near(local.x + local.width, remote.x)
- && edge_ranges_overlap(
- local.y,
- local.y + local.height,
- remote.y,
- remote.y + remote.height,
- )
- {
- return Some(EdgeDropSide::Right);
- }
-
- if edge_near(local.x, remote.x + remote.width)
- && edge_ranges_overlap(
- local.y,
- local.y + local.height,
- remote.y,
- remote.y + remote.height,
- )
- {
- return Some(EdgeDropSide::Left);
- }
-
- if edge_near(local.y + local.height, remote.y)
- && edge_ranges_overlap(
- local.x,
- local.x + local.width,
- remote.x,
- remote.x + remote.width,
- )
- {
- return Some(EdgeDropSide::Bottom);
- }
-
- if edge_near(local.y, remote.y + remote.height)
- && edge_ranges_overlap(
- local.x,
- local.x + local.width,
- remote.x,
- remote.x + remote.width,
- )
- {
- return Some(EdgeDropSide::Top);
- }
-
- None
-}
-
-fn edge_screens_overlap(local: &Screen, remote: &Screen) -> bool {
- local.x < remote.x + remote.width
- && local.x + local.width > remote.x
- && local.y < remote.y + remote.height
- && local.y + local.height > remote.y
-}
-
-fn edge_near(a: i32, b: i32) -> bool {
- (a - b).abs() <= 80
-}
-
-fn edge_ranges_overlap(a_start: i32, a_end: i32, b_start: i32, b_end: i32) -> bool {
- i32::min(a_end, b_end) - i32::max(a_start, b_start) > 80
-}
-
#[tauri::command]
fn sync_window_chrome(window: tauri::WebviewWindow, theme: String) -> Result<(), String> {
#[cfg(target_os = "windows")]
@@ -3326,12 +2805,6 @@ pub fn run() {
.build(),
)
.on_window_event(|window, event| {
- if handle_edge_drop_window_event(window, event) {
- return;
- }
- if handle_file_drop_landing_window_event(window, event) {
- return;
- }
if window.label() == "main" {
if let WindowEvent::Focused(focused) = event {
set_main_window_focused(window.app_handle(), *focused);
@@ -3418,9 +2891,6 @@ pub fn run() {
setup_macos_cursor_hider(app);
#[cfg(target_os = "macos")]
setup_macos_window_visibility_watcher(app);
- if EDGE_DROP_WINDOWS_ENABLED {
- start_edge_drop_window_sync(app.handle().clone());
- }
setup_tray(app)?;
if let Err(error) = sync_runtime_toggle_shortcut(app.handle()) {
log::warn!("failed to register quick start/stop shortcut: {error}");
@@ -4611,6 +4081,27 @@ fn local_host_label() -> String {
}
fn local_ip_address() -> Option {
+ // Callers include per-event hot paths (input target building, packet
+ // origin resolution) and the discovery loop; the UDP-socket probe is four
+ // syscalls. The LAN address changes on network reconfigs, not per event —
+ // cache it briefly.
+ const LOCAL_IP_CACHE_TTL: Duration = Duration::from_secs(5);
+ static CACHE: Mutex)>> = Mutex::new(None);
+
+ if let Ok(mut cached) = CACHE.lock() {
+ if let Some((probed_at, address)) = cached.as_ref() {
+ if probed_at.elapsed() < LOCAL_IP_CACHE_TTL {
+ return address.clone();
+ }
+ }
+ let fresh = probe_local_ip_address();
+ *cached = Some((Instant::now(), fresh.clone()));
+ return fresh;
+ }
+ probe_local_ip_address()
+}
+
+fn probe_local_ip_address() -> Option {
let socket = UdpSocket::bind("0.0.0.0:0").ok()?;
socket.connect("8.8.8.8:80").ok()?;
let address = socket.local_addr().ok()?;
@@ -4920,6 +4411,14 @@ fn clipboard_ready_status() -> NativeStageStatus {
struct ClipboardPacket {
protocol: String,
origin_id: String,
+ // The sender's QUIC transport public key. Defaulted so packets from older
+ // peers (which never sent it) still decode as an empty string. Authorization
+ // matches on this stable key first, exactly like input packets, so a copy
+ // still syncs after the origin's derived peer id drifts (e.g. its LAN IP
+ // changed since pairing) — the bug where input kept working but clipboard
+ // silently stopped in one direction.
+ #[serde(default)]
+ origin_transport_public_key: String,
#[serde(default)]
target_id: String,
#[serde(default)]
@@ -4954,6 +4453,7 @@ struct ClipboardFormat {
fn clipboard_packet_from_content(
content: ClipboardContent,
origin_id: String,
+ origin_transport_public_key: String,
target_id: String,
cluster_id: String,
pair_secret: String,
@@ -4964,6 +4464,7 @@ fn clipboard_packet_from_content(
ClipboardContent::Text(text) => ClipboardPacket {
protocol: CLIPBOARD_PROTOCOL.into(),
origin_id,
+ origin_transport_public_key,
target_id,
cluster_id,
pair_secret,
@@ -4980,6 +4481,7 @@ fn clipboard_packet_from_content(
ClipboardContent::Image(image) => ClipboardPacket {
protocol: CLIPBOARD_PROTOCOL.into(),
origin_id,
+ origin_transport_public_key,
target_id,
cluster_id,
pair_secret,
@@ -5093,6 +4595,7 @@ fn run_clipboard_sync(
let packet = clipboard_packet_from_content(
content,
local_peer_id.clone(),
+ quic_transport.public_key().to_string(),
target.device_id.clone(),
target.cluster_id.clone(),
target.pair_secret.clone(),
@@ -5359,10 +4862,19 @@ fn clipboard_packet_authorized(layout: &LayoutState, packet: &ClipboardPacket) -
}
if layout.machine_role == "client" && !layout.paired_controllers.is_empty() {
- return layout
- .paired_controllers
- .iter()
- .any(|controller| controller.id == packet.origin_id);
+ // Mirror input-packet authorization (packet_authorized_fields): match on
+ // the STABLE transport public key first, then the id, then the legacy
+ // "local-device" fallback. Matching by id alone silently rejected a
+ // controller whose derived peer id had drifted (LAN IP change) even
+ // though its key was unchanged — which is why input kept working while
+ // clipboard from that controller stopped.
+ let key = packet.origin_transport_public_key.trim();
+ return layout.paired_controllers.iter().any(|controller| {
+ (!key.is_empty() && controller.transport_public_key == key)
+ || controller.id == packet.origin_id
+ }) || (layout.paired_controllers.len() == 1
+ && packet.origin_id == "local-device"
+ && !key.is_empty());
}
true
@@ -5515,7 +5027,6 @@ fn send_transfer_file(
origin_id: &str,
target: &FileTransferTarget,
file: &TransferFile,
- destination_hint: Option<&str>,
) -> Result {
let transfer_id = format!("file-{}-{}", now_ms(), random_hex(8));
let mut packet_count = 0_u64;
@@ -5532,7 +5043,6 @@ fn send_transfer_file(
file.total_bytes,
0,
0,
- destination_hint,
Vec::new(),
),
)?;
@@ -5563,7 +5073,6 @@ fn send_transfer_file(
file.total_bytes,
chunk_index,
offset,
- destination_hint,
data,
),
)?;
@@ -5584,7 +5093,6 @@ fn send_transfer_file(
file.total_bytes,
chunk_index,
offset,
- destination_hint,
Vec::new(),
),
)?;
@@ -5603,7 +5111,6 @@ fn file_transfer_packet(
total_bytes: u64,
chunk_index: u64,
offset: u64,
- destination_hint: Option<&str>,
data: Vec,
) -> FileTransferPacket {
FileTransferPacket {
@@ -5615,7 +5122,6 @@ fn file_transfer_packet(
cluster_id: target.cluster_id.clone(),
pair_secret: target.pair_secret.clone(),
file_name: file_name.into(),
- destination_hint: destination_hint.map(str::to_string),
total_bytes,
chunk_index,
offset,
@@ -5653,23 +5159,7 @@ fn handle_file_transfer_packet(
log::warn!("file transfer receive failed: could not resolve receive directory");
return false;
};
- let pointer_receive_root = if packet.kind == "start"
- && packet.destination_hint.as_deref() == Some(FILE_TRANSFER_DESTINATION_POINTER)
- {
- file_transfer_pointer_receive_root(app)
- } else {
- None
- };
-
- handle_decoded_file_transfer_packet(
- packet,
- layout,
- local_peer_id,
- transfers,
- &receive_root,
- pointer_receive_root.as_deref(),
- Some(app),
- )
+ handle_decoded_file_transfer_packet(packet, layout, local_peer_id, transfers, &receive_root)
}
fn file_transfer_receive_root(app: &AppHandle) -> Result {
@@ -5683,63 +5173,6 @@ fn file_transfer_receive_root(app: &AppHandle) -> Result {
.map_err(|error| format!("failed to resolve file transfer receive directory: {error}"))
}
-fn file_transfer_pointer_receive_root(app: &AppHandle) -> Option {
- platform_pointer_receive_root(app).and_then(usable_existing_directory)
-}
-
-#[cfg(target_os = "macos")]
-fn platform_pointer_receive_root(app: &AppHandle) -> Option {
- app.path()
- .desktop_dir()
- .ok()
- .or_else(macos_finder_insertion_directory)
-}
-
-#[cfg(not(target_os = "macos"))]
-fn platform_pointer_receive_root(app: &AppHandle) -> Option {
- app.path().desktop_dir().ok()
-}
-
-fn usable_existing_directory(path: PathBuf) -> Option {
- if path.is_absolute() && path.is_dir() {
- Some(path)
- } else {
- None
- }
-}
-
-#[cfg(target_os = "macos")]
-fn macos_finder_insertion_directory() -> Option {
- let script = r#"
-tell application "Finder"
- try
- set targetFolder to insertion location as alias
- return POSIX path of targetFolder
- on error
- return ""
- end try
-end tell
-"#;
- let output = Command::new("osascript")
- .arg("-e")
- .arg(script)
- .output()
- .ok()?;
- if !output.status.success() {
- return None;
- }
-
- let path = String::from_utf8_lossy(&output.stdout)
- .trim()
- .trim_end_matches('/')
- .to_string();
- if path.is_empty() {
- return None;
- }
-
- Some(PathBuf::from(path))
-}
-
#[cfg(test)]
fn handle_file_transfer_packet_with_root(
payload: &[u8],
@@ -5747,41 +5180,11 @@ fn handle_file_transfer_packet_with_root(
local_peer_id: &str,
transfers: &Arc>>,
receive_root: &Path,
-) -> bool {
- handle_file_transfer_packet_with_destination_root(
- payload,
- layout,
- local_peer_id,
- transfers,
- receive_root,
- None,
- None,
- )
-}
-
-#[cfg(test)]
-fn handle_file_transfer_packet_with_destination_root(
- payload: &[u8],
- layout: &LayoutState,
- local_peer_id: &str,
- transfers: &Arc>>,
- receive_root: &Path,
- pointer_receive_root: Option<&Path>,
- landing_app: Option<&AppHandle>,
) -> bool {
let Some(packet) = decode_wire_packet::(payload) else {
return false;
};
-
- handle_decoded_file_transfer_packet(
- packet,
- layout,
- local_peer_id,
- transfers,
- receive_root,
- pointer_receive_root,
- landing_app,
- )
+ handle_decoded_file_transfer_packet(packet, layout, local_peer_id, transfers, receive_root)
}
fn handle_decoded_file_transfer_packet(
@@ -5790,8 +5193,6 @@ fn handle_decoded_file_transfer_packet(
local_peer_id: &str,
transfers: &Arc>>,
receive_root: &Path,
- pointer_receive_root: Option<&Path>,
- landing_app: Option<&AppHandle>,
) -> bool {
if packet.protocol != FILE_TRANSFER_PROTOCOL {
return false;
@@ -5809,132 +5210,14 @@ fn handle_decoded_file_transfer_packet(
return true;
}
- let destination_root =
- file_transfer_destination_root(&packet, receive_root, pointer_receive_root);
match packet.kind.as_str() {
- "start" => {
- let show_landing =
- packet.destination_hint.as_deref() == Some(FILE_TRANSFER_DESTINATION_POINTER);
- let file_name = packet.file_name.clone();
- let accepted = start_incoming_file_transfer(packet, transfers, destination_root);
- if accepted && show_landing {
- if let Some(app) = landing_app {
- show_file_drop_landing_window(app, &file_name);
- }
- }
- accepted
- }
+ "start" => start_incoming_file_transfer(packet, transfers, receive_root),
"chunk" => append_incoming_file_transfer_chunk(packet, transfers),
"finish" => finish_incoming_file_transfer(packet, transfers),
_ => false,
}
}
-fn file_transfer_destination_root<'a>(
- packet: &FileTransferPacket,
- receive_root: &'a Path,
- pointer_receive_root: Option<&'a Path>,
-) -> &'a Path {
- if packet.destination_hint.as_deref() == Some(FILE_TRANSFER_DESTINATION_POINTER) {
- return pointer_receive_root.unwrap_or(receive_root);
- }
-
- receive_root
-}
-
-fn show_file_drop_landing_window(app: &AppHandle, _file_name: &str) {
- let Some((x, y)) = current_pointer_position() else {
- return;
- };
- let x = x - FILE_DROP_LANDING_WIDTH / 2.0;
- let y = y - FILE_DROP_LANDING_HEIGHT / 2.0;
-
- if let Some(window) = app.get_webview_window(FILE_DROP_LANDING_LABEL) {
- let _ = window.set_position(tauri::Position::Logical(tauri::LogicalPosition::new(x, y)));
- let _ = window.set_always_on_top(true);
- let _ = window.set_focusable(false);
- let _ = window.show();
- schedule_file_drop_landing_hide(app.clone());
- return;
- }
-
- let build_result = WebviewWindowBuilder::new(
- app,
- FILE_DROP_LANDING_LABEL,
- WebviewUrl::App("file-drop-landing.html".into()),
- )
- .title("")
- .position(x, y)
- .inner_size(FILE_DROP_LANDING_WIDTH, FILE_DROP_LANDING_HEIGHT)
- .decorations(false)
- .resizable(false)
- .maximizable(false)
- .minimizable(false)
- .closable(false)
- .shadow(false)
- .transparent(true)
- .always_on_top(true)
- .visible_on_all_workspaces(true)
- .skip_taskbar(true)
- .focused(false)
- .focusable(false)
- .build();
-
- if let Err(error) = build_result {
- log::warn!("file drop landing window failed: {error}");
- return;
- }
-
- schedule_file_drop_landing_hide(app.clone());
-}
-
-fn schedule_file_drop_landing_hide(app: AppHandle) {
- thread::spawn(move || {
- thread::sleep(Duration::from_millis(2200));
- let main_handle = app.clone();
- let _ = app.run_on_main_thread(move || {
- if let Some(window) = main_handle.get_webview_window(FILE_DROP_LANDING_LABEL) {
- let _ = window.hide();
- }
- });
- });
-}
-
-fn current_pointer_position() -> Option<(f64, f64)> {
- platform_current_pointer_position()
-}
-
-#[cfg(target_os = "macos")]
-fn platform_current_pointer_position() -> Option<(f64, f64)> {
- use core_graphics::{
- event::CGEvent,
- event_source::{CGEventSource, CGEventSourceStateID},
- };
-
- let source = CGEventSource::new(CGEventSourceStateID::CombinedSessionState).ok()?;
- let event = CGEvent::new(source).ok()?;
- let point = event.location();
- Some((point.x, point.y))
-}
-
-#[cfg(target_os = "windows")]
-fn platform_current_pointer_position() -> Option<(f64, f64)> {
- use windows_sys::Win32::{Foundation::POINT, UI::WindowsAndMessaging::GetCursorPos};
-
- let mut point = POINT { x: 0, y: 0 };
- let ok = unsafe { GetCursorPos(&mut point) };
- if ok == 0 {
- return None;
- }
-
- Some((point.x as f64, point.y as f64))
-}
-
-#[cfg(not(any(target_os = "macos", target_os = "windows")))]
-fn platform_current_pointer_position() -> Option<(f64, f64)> {
- None
-}
-
fn start_incoming_file_transfer(
packet: FileTransferPacket,
transfers: &Arc>>,
@@ -7803,105 +7086,6 @@ mod tests {
assert_eq!(layout.devices[1].host, "10.0.0.2");
}
- #[test]
- fn edge_drop_specs_follow_server_screen_adjacency() {
- let mut layout = test_layout();
- layout.devices[1].screens[0].x = 1920;
-
- let specs = edge_drop_specs_for_layout(&layout, &[]);
-
- assert_eq!(specs.len(), 1);
- let spec = &specs[0];
- assert_eq!(spec.target_device_id, "peer-client-10-0-0-2");
- assert_eq!(spec.x, 1912.0);
- assert_eq!(spec.y, 0.0);
- assert_eq!(spec.width, EDGE_DROP_THICKNESS as f64);
- assert_eq!(spec.height, 1080.0);
- }
-
- #[test]
- fn edge_drop_specs_respect_file_transfer_setting() {
- let mut layout = test_layout();
- layout.devices[1].screens[0].x = 1920;
- layout.file_transfer_enabled = false;
-
- let specs = edge_drop_specs_for_layout(&layout, &[]);
-
- assert!(specs.is_empty());
- }
-
- #[test]
- fn edge_drop_specs_pause_when_main_window_is_hidden() {
- let mut layout = test_layout();
- layout.devices[1].screens[0].x = 1920;
-
- assert_eq!(
- edge_drop_specs_for_window_visibility(&layout, &[], true).len(),
- 1
- );
- assert!(edge_drop_specs_for_window_visibility(&layout, &[], false).is_empty());
- }
-
- #[test]
- fn edge_drop_specs_skip_internal_client_monitor_edges() {
- let mut layout = test_layout();
- layout.machine_role = "client".into();
- layout.input_mode = "receive".into();
- layout.devices.truncate(1);
- let mut second_screen = test_screen("local-device");
- second_screen.id = "local-device-display-2".into();
- second_screen.x = 1920;
- layout.devices[0].screens.push(second_screen);
- layout.paired_controllers = vec![PairedController {
- id: "peer-server-10-0-0-1".into(),
- name: "Server".into(),
- host: "server".into(),
- ip: "10.0.0.1".into(),
- transport_public_key: "server-public-key".into(),
- protocol_version: quic_transport::PROTOCOL_VERSION,
- cluster_id: layout.cluster_id.clone(),
- paired_at_ms: now_ms(),
- }];
- let mut peer = test_peer();
- peer.id = "peer-server-10-0-0-1".into();
- peer.name = "Server".into();
- peer.machine_role = "server".into();
- peer.transport_public_key = "server-public-key".into();
-
- let specs = edge_drop_specs_for_layout(&layout, &[peer]);
-
- assert_eq!(specs.len(), 6);
- assert!(specs.iter().all(|spec| {
- spec.target_device_id == "peer-server-10-0-0-1" && spec.width > 0.0 && spec.height > 0.0
- }));
- assert!(!specs.iter().any(|spec| {
- spec.x == (1920 - EDGE_DROP_THICKNESS) as f64
- && spec.y == 0.0
- && spec.width == EDGE_DROP_THICKNESS as f64
- && spec.height == 1080.0
- }));
- assert!(specs.iter().any(|spec| {
- spec.x == (3840 - EDGE_DROP_THICKNESS) as f64
- && spec.y == 0.0
- && spec.width == EDGE_DROP_THICKNESS as f64
- && spec.height == 1080.0
- }));
- }
-
- #[test]
- fn edge_drop_label_handles_non_ascii_ids() {
- let mut local = test_screen("local-device");
- local.id = "显示器".into();
- let mut remote = test_screen("remote-device");
- remote.id = "远程".into();
-
- let label = edge_drop_label("设备", &local, Some(&remote), EdgeDropSide::Right);
-
- assert!(label.starts_with(EDGE_DROP_LABEL_PREFIX));
- assert!(label.contains("id-"));
- assert!(label.ends_with("-right"));
- }
-
#[test]
fn peer_presence_does_not_add_unapproved_peer_screens() {
let mut layout = test_layout();
@@ -8296,6 +7480,7 @@ mod tests {
let mut packet = ClipboardPacket {
protocol: CLIPBOARD_PROTOCOL.into(),
origin_id: "attacker".into(),
+ origin_transport_public_key: String::new(),
target_id: "local-device".into(),
cluster_id: layout.cluster_id.clone(),
pair_secret: layout.pair_secret.clone(),
@@ -8315,6 +7500,54 @@ mod tests {
assert!(clipboard_packet_authorized(&layout, &packet));
}
+ #[test]
+ fn clipboard_packet_authorized_by_transport_key_after_id_drift() {
+ // Regression for the macOS->Windows one-way clipboard bug: input kept
+ // working (it matches the stable transport key) but clipboard was
+ // rejected because it matched the origin id alone, which drifts when the
+ // controller's LAN IP changes. Clipboard must accept the same key.
+ let mut layout = test_layout();
+ layout.machine_role = "client".into();
+ layout.paired_controllers = vec![PairedController {
+ id: "server-10-0-0-1".into(),
+ name: "Server".into(),
+ host: "server".into(),
+ ip: "10.0.0.1".into(),
+ transport_public_key: "server-key".into(),
+ protocol_version: quic_transport::PROTOCOL_VERSION,
+ cluster_id: layout.cluster_id.clone(),
+ paired_at_ms: now_ms(),
+ }];
+ let mut packet = ClipboardPacket {
+ protocol: CLIPBOARD_PROTOCOL.into(),
+ // id no longer matches the recorded controller (IP moved),
+ origin_id: "server-10-0-0-77".into(),
+ origin_transport_public_key: "server-key".into(),
+ target_id: "local-device".into(),
+ cluster_id: layout.cluster_id.clone(),
+ pair_secret: layout.pair_secret.clone(),
+ signature: "text:hi".into(),
+ formats: vec![ClipboardFormat {
+ kind: "plainText".into(),
+ text: "hi".into(),
+ image: None,
+ }],
+ text: "hi".into(),
+ image: None,
+ sequence: 1,
+ };
+
+ assert!(
+ clipboard_packet_authorized(&layout, &packet),
+ "a drifted id with the paired transport key must still be authorized"
+ );
+ packet.origin_transport_public_key = "attacker-key".into();
+ assert!(
+ !clipboard_packet_authorized(&layout, &packet),
+ "neither the id nor the key matches — must be rejected"
+ );
+ }
+
#[test]
fn clipboard_image_signature_includes_content_hash() {
let first = ClipboardContent::Image(ClipboardImage {
@@ -8342,6 +7575,7 @@ mod tests {
rgba_base64: "A".repeat(encoded_len),
}),
"local-device".into(),
+ String::new(),
"peer-device".into(),
"cluster-test".into(),
"secret-test".into(),
@@ -8362,6 +7596,7 @@ mod tests {
let packet = clipboard_packet_from_content(
ClipboardContent::Text("hello".into()),
"local-device".into(),
+ String::new(),
"peer-device".into(),
"cluster-test".into(),
"secret-test".into(),
@@ -8401,6 +7636,7 @@ mod tests {
let packet = ClipboardPacket {
protocol: CLIPBOARD_PROTOCOL.into(),
origin_id: "peer-client-10-0-0-2".into(),
+ origin_transport_public_key: String::new(),
target_id: "local-device".into(),
cluster_id: layout.cluster_id.clone(),
pair_secret: layout.pair_secret.clone(),
@@ -8449,6 +7685,7 @@ mod tests {
let packet = clipboard_packet_from_content(
ClipboardContent::Text("中文测试 abc 123".into()),
"peer-client-10-0-0-2".into(),
+ String::new(),
"local-device".into(),
layout.cluster_id.clone(),
layout.pair_secret.clone(),
@@ -8489,6 +7726,7 @@ mod tests {
let packet = ClipboardPacket {
protocol: CLIPBOARD_PROTOCOL.into(),
origin_id: "peer-client-10-0-0-2".into(),
+ origin_transport_public_key: String::new(),
target_id: String::new(),
cluster_id: layout.cluster_id.clone(),
pair_secret: layout.pair_secret.clone(),
@@ -8534,6 +7772,7 @@ mod tests {
let first = clipboard_packet_from_content(
ClipboardContent::Text("new".into()),
"peer-client-10-0-0-2".into(),
+ String::new(),
"local-device".into(),
layout.cluster_id.clone(),
layout.pair_secret.clone(),
@@ -8542,6 +7781,7 @@ mod tests {
let stale = clipboard_packet_from_content(
ClipboardContent::Text("old".into()),
"peer-client-10-0-0-2".into(),
+ String::new(),
"local-device".into(),
layout.cluster_id.clone(),
layout.pair_secret.clone(),
@@ -8575,6 +7815,7 @@ mod tests {
let packet = clipboard_packet_from_content(
ClipboardContent::Text("hello".into()),
"peer-client-10-0-0-2".into(),
+ String::new(),
"local-device".into(),
layout.cluster_id.clone(),
layout.pair_secret.clone(),
@@ -8679,66 +7920,6 @@ mod tests {
let _ = fs::remove_dir_all(root);
}
- #[test]
- fn file_transfer_pointer_hint_writes_to_drop_destination() {
- let layout = test_layout();
- let default_root = temp_test_dir("file-transfer-default-root");
- let drop_root = temp_test_dir("file-transfer-pointer-root");
- let transfers = Arc::new(Mutex::new(HashMap::new()));
-
- for packet in [
- test_file_transfer_packet_with_destination_hint(
- "start",
- "transfer-pointer",
- "desktop.txt",
- 7,
- 0,
- 0,
- b"",
- Some(FILE_TRANSFER_DESTINATION_POINTER),
- ),
- test_file_transfer_packet_with_destination_hint(
- "chunk",
- "transfer-pointer",
- "desktop.txt",
- 7,
- 0,
- 0,
- b"desktop",
- Some(FILE_TRANSFER_DESTINATION_POINTER),
- ),
- test_file_transfer_packet_with_destination_hint(
- "finish",
- "transfer-pointer",
- "desktop.txt",
- 7,
- 1,
- 7,
- b"",
- Some(FILE_TRANSFER_DESTINATION_POINTER),
- ),
- ] {
- let payload = encode_wire_packet(&packet).expect("file packet should encode");
- assert!(handle_file_transfer_packet_with_destination_root(
- &payload,
- &layout,
- "local-device",
- &transfers,
- &default_root,
- Some(&drop_root),
- None,
- ));
- }
-
- assert!(!default_root.join("desktop.txt").exists());
- assert_eq!(
- fs::read_to_string(drop_root.join("desktop.txt")).expect("received file"),
- "desktop"
- );
- let _ = fs::remove_dir_all(default_root);
- let _ = fs::remove_dir_all(drop_root);
- }
-
#[test]
fn file_transfer_rejects_out_of_order_chunks() {
let layout = test_layout();
@@ -8791,29 +7972,6 @@ mod tests {
chunk_index: u64,
offset: u64,
data: &[u8],
- ) -> FileTransferPacket {
- test_file_transfer_packet_with_destination_hint(
- kind,
- transfer_id,
- file_name,
- total_bytes,
- chunk_index,
- offset,
- data,
- None,
- )
- }
-
- #[allow(clippy::too_many_arguments)]
- fn test_file_transfer_packet_with_destination_hint(
- kind: &str,
- transfer_id: &str,
- file_name: &str,
- total_bytes: u64,
- chunk_index: u64,
- offset: u64,
- data: &[u8],
- destination_hint: Option<&str>,
) -> FileTransferPacket {
FileTransferPacket {
protocol: FILE_TRANSFER_PROTOCOL.into(),
@@ -8824,7 +7982,6 @@ mod tests {
cluster_id: "cluster-test".into(),
pair_secret: "secret-test".into(),
file_name: file_name.into(),
- destination_hint: destination_hint.map(str::to_string),
total_bytes,
chunk_index,
offset,
diff --git a/src-tauri/src/quic_transport.rs b/src-tauri/src/quic_transport.rs
index ed98641..d5cb7b7 100644
--- a/src-tauri/src/quic_transport.rs
+++ b/src-tauri/src/quic_transport.rs
@@ -3,9 +3,9 @@ use std::{
fs,
net::{SocketAddr, ToSocketAddrs},
path::{Path, PathBuf},
- sync::{mpsc, Arc},
+ sync::{mpsc, Arc, Mutex},
thread,
- time::Duration,
+ time::{Duration, Instant},
};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
@@ -33,6 +33,16 @@ const MAX_DATAGRAM_BYTES: usize = 16 * 1024;
pub(crate) const MAX_STREAM_BYTES: usize = 48 * 1024 * 1024;
const PORT_SCAN_COUNT: u16 = 64;
const QUIC_WORKER_THREADS: usize = 2;
+// Datagram-health fast-fail (concept adopted from PR #22): after this many
+// consecutive send/connect failures to a peer, sends short-circuit with an
+// error until the retry window elapses, so the input layer releases the
+// cursor immediately instead of freezing behind connect timeouts.
+const DATAGRAM_FAIL_THRESHOLD: u32 = 3;
+const DATAGRAM_RETRY_WINDOW: Duration = Duration::from_secs(3);
+const MAX_HEALTH_PEERS: usize = 64;
+// Streams (clipboard, files) are handled in spawned tasks; cap the concurrent
+// in-flight count so a burst cannot spawn unbounded copies of a 48MB write.
+const MAX_CONCURRENT_STREAMS: usize = 8;
type DatagramHandler = Arc, SocketAddr) + Send + Sync + 'static>;
type StreamHandler = Arc, SocketAddr) -> bool + Send + Sync + 'static>;
@@ -44,11 +54,78 @@ pub struct PeerEndpoint {
pub protocol_version: u16,
}
+/// Consecutive-failure record for one peer address. Shared between the
+/// caller-facing handle (fast-fail check) and the transport loop (updates).
+#[derive(Debug, Clone, Copy)]
+struct PeerHealth {
+ consecutive_failures: u32,
+ last_failure: Instant,
+}
+
+type HealthMap = Arc>>;
+
+fn peer_fast_fail_active(health: &HealthMap, addr: &str) -> bool {
+ health
+ .lock()
+ .map(|health| {
+ health.get(addr).is_some_and(|entry| {
+ entry.consecutive_failures >= DATAGRAM_FAIL_THRESHOLD
+ && entry.last_failure.elapsed() < DATAGRAM_RETRY_WINDOW
+ })
+ })
+ .unwrap_or(false)
+}
+
+fn record_peer_failure(health: &HealthMap, addr: &str, error: &str) {
+ let Ok(mut health) = health.lock() else {
+ return;
+ };
+ if health.len() >= MAX_HEALTH_PEERS && !health.contains_key(addr) {
+ if let Some(stale) = health
+ .iter()
+ .min_by_key(|(_, entry)| entry.last_failure)
+ .map(|(key, _)| key.clone())
+ {
+ health.remove(&stale);
+ }
+ }
+ let now = Instant::now();
+ let entry = health.entry(addr.to_string()).or_insert(PeerHealth {
+ consecutive_failures: 0,
+ last_failure: now,
+ });
+ entry.consecutive_failures = entry.consecutive_failures.saturating_add(1);
+ entry.last_failure = now;
+ // Log the first failure and the transition into fast-fail; everything in
+ // between and every muted retry is debug. The old unconditional warn wrote
+ // a disk line every few seconds for as long as a peer stayed unreachable.
+ match entry.consecutive_failures {
+ 1 => log::warn!("QUIC send to {addr} failed: {error}"),
+ DATAGRAM_FAIL_THRESHOLD => log::warn!(
+ "QUIC sends to {addr} keep failing; muting attempts to one probe per {}s: {error}",
+ DATAGRAM_RETRY_WINDOW.as_secs()
+ ),
+ _ => log::debug!("QUIC send to {addr} still failing: {error}"),
+ }
+}
+
+fn record_peer_success(health: &HealthMap, addr: &str) {
+ let Ok(mut health) = health.lock() else {
+ return;
+ };
+ if let Some(entry) = health.remove(addr) {
+ if entry.consecutive_failures >= DATAGRAM_FAIL_THRESHOLD {
+ log::info!("QUIC sends to {addr} recovered");
+ }
+ }
+}
+
#[derive(Clone)]
pub struct TransportHandle {
commands: tokio_mpsc::UnboundedSender,
port: u16,
public_key: String,
+ peer_health: HealthMap,
}
impl TransportHandle {
@@ -75,6 +152,14 @@ impl TransportHandle {
payload.len()
));
}
+ // Fail fast while the peer is known-dead so the input layer releases
+ // the cursor instead of streaming moves into a black hole.
+ if peer_fast_fail_active(&self.peer_health, &peer.addr) {
+ return Err(format!(
+ "QUIC peer {} unreachable ({DATAGRAM_FAIL_THRESHOLD}+ consecutive failures)",
+ peer.addr
+ ));
+ }
self.commands
.send(TransportCommand::SendDatagram { peer, payload })
@@ -85,15 +170,6 @@ impl TransportHandle {
&self,
peer: PeerEndpoint,
payload: Vec,
- ) -> Result<(), String> {
- self.send_stream_inner(peer, payload, true)
- }
-
- fn send_stream_inner(
- &self,
- peer: PeerEndpoint,
- payload: Vec,
- ack_required: bool,
) -> Result<(), String> {
if payload.len() > MAX_STREAM_BYTES {
return Err(format!(
@@ -101,13 +177,18 @@ impl TransportHandle {
payload.len()
));
}
+ if peer_fast_fail_active(&self.peer_health, &peer.addr) {
+ return Err(format!(
+ "QUIC peer {} unreachable ({DATAGRAM_FAIL_THRESHOLD}+ consecutive failures)",
+ peer.addr
+ ));
+ }
let (result_tx, result_rx) = mpsc::channel();
self.commands
.send(TransportCommand::SendStream {
peer,
payload,
- ack_required,
result: result_tx,
})
.map_err(|_| "QUIC transport is stopped".to_string())?;
@@ -129,7 +210,6 @@ enum TransportCommand {
SendStream {
peer: PeerEndpoint,
payload: Vec,
- ack_required: bool,
result: mpsc::Sender>,
},
Shutdown,
@@ -154,6 +234,8 @@ pub fn start(
let identity = load_or_create_identity(&identity_dir)?;
let (ready_tx, ready_rx) = mpsc::channel();
let (command_tx, command_rx) = tokio_mpsc::unbounded_channel();
+ let peer_health: HealthMap = Arc::new(Mutex::new(HashMap::new()));
+ let loop_health = Arc::clone(&peer_health);
thread::Builder::new()
.name("mykvm-quic-transport".into())
@@ -177,6 +259,7 @@ pub fn start(
command_rx,
on_datagram,
on_stream,
+ loop_health,
ready_tx,
));
})
@@ -190,6 +273,7 @@ pub fn start(
commands: command_tx,
port: ready.port,
public_key: ready.public_key,
+ peer_health,
})
}
@@ -198,12 +282,22 @@ struct ReadyTransport {
public_key: String,
}
+/// A cached peer connection, or a marker that a background task is already
+/// establishing one (so a burst of mouse moves cannot spawn a connect storm).
+enum ConnectionSlot {
+ Connecting,
+ Ready(quinn::Connection),
+}
+
+type ConnectionMap = Arc>>;
+
async fn run_transport(
preferred_port: u16,
identity: TransportIdentity,
mut commands: tokio_mpsc::UnboundedReceiver,
on_datagram: DatagramHandler,
on_stream: StreamHandler,
+ health: HealthMap,
ready_tx: mpsc::Sender>,
) {
let (endpoint, public_key) = match bind_endpoint(preferred_port, &identity) {
@@ -225,27 +319,41 @@ async fn run_transport(
let _ = ready_tx.send(Ok(ReadyTransport { port, public_key }));
spawn_accept_loop(endpoint.clone(), on_datagram, on_stream);
- let mut connections: HashMap = HashMap::new();
+ // The command loop must never await network progress: one dead peer's 2s
+ // connect timeout or one 48MB stream write would stall every queued input
+ // datagram behind it (the "periodic input freeze + warn every 4s" bug).
+ // Datagrams go out synchronously on established connections; connection
+ // establishment and stream sends run in spawned tasks.
+ let connections: ConnectionMap = Arc::new(Mutex::new(HashMap::new()));
+ let stream_slots = Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_STREAMS));
while let Some(command) = commands.recv().await {
match command {
TransportCommand::SendDatagram { peer, payload } => {
- if let Err(error) = send_datagram(&endpoint, &mut connections, peer, payload).await
- {
- log::warn!("QUIC datagram send failed: {error}");
- }
+ send_datagram_nonblocking(&endpoint, &connections, &health, peer, payload);
}
TransportCommand::SendStream {
peer,
payload,
- ack_required,
result,
} => {
- let send_result =
- send_stream(&endpoint, &mut connections, peer, payload, ack_required).await;
- if let Err(error) = &send_result {
- log::warn!("QUIC stream send failed: {error}");
- }
- let _ = result.send(send_result);
+ let Ok(permit) = Arc::clone(&stream_slots).try_acquire_owned() else {
+ let _ = result.send(Err(format!(
+ "QUIC stream queue is full ({MAX_CONCURRENT_STREAMS} in flight)"
+ )));
+ continue;
+ };
+ let endpoint = endpoint.clone();
+ let connections = Arc::clone(&connections);
+ let health = Arc::clone(&health);
+ tokio::spawn(async move {
+ let outcome =
+ send_stream_task(&endpoint, &connections, &health, peer, payload).await;
+ if let Err(error) = &outcome {
+ log::warn!("QUIC stream send failed: {error}");
+ }
+ let _ = result.send(outcome);
+ drop(permit);
+ });
}
TransportCommand::Shutdown => break,
}
@@ -584,33 +692,129 @@ fn spawn_stream_reader(
});
}
-async fn send_datagram(
+/// Datagram send that never awaits: an established connection queues the
+/// payload synchronously (quinn's `send_datagram` is not async); a missing or
+/// dead connection drops this payload and kicks off a background connect —
+/// input datagrams are latest-wins, the next move follows within ~8ms, and
+/// `warm_quic_peer` keeps connections pre-established outside crossings.
+fn send_datagram_nonblocking(
endpoint: &Endpoint,
- connections: &mut HashMap,
+ connections: &ConnectionMap,
+ health: &HealthMap,
peer: PeerEndpoint,
payload: Vec,
-) -> Result<(), String> {
- let (key, connection) = connection_for(endpoint, connections, &peer).await?;
- match connection.send_datagram(payload.into()) {
- Ok(()) => Ok(()),
+) {
+ let key = match peer_key(&peer) {
+ Ok(key) => key,
Err(error) => {
- connections.remove(&key);
- Err(error.to_string())
+ record_peer_failure(health, &peer.addr, &error);
+ return;
+ }
+ };
+
+ let ready = {
+ let Ok(mut map) = connections.lock() else {
+ return;
+ };
+ match map.get(&key) {
+ Some(ConnectionSlot::Ready(connection)) if connection.close_reason().is_none() => {
+ Some(connection.clone())
+ }
+ // A background task is already dialing this peer; drop the payload.
+ Some(ConnectionSlot::Connecting) => return,
+ _ => {
+ map.remove(&key);
+ None
+ }
}
+ };
+
+ if let Some(connection) = ready {
+ match connection.send_datagram(payload.into()) {
+ Ok(()) => record_peer_success(health, &peer.addr),
+ Err(error) => {
+ if let Ok(mut map) = connections.lock() {
+ map.remove(&key);
+ }
+ record_peer_failure(health, &peer.addr, &error.to_string());
+ }
+ }
+ return;
}
+
+ // Known-dead peer inside its retry window: skip even the background dial
+ // so an unreachable box costs nothing between probes.
+ if peer_fast_fail_active(health, &peer.addr) {
+ return;
+ }
+ if let Ok(mut map) = connections.lock() {
+ map.insert(key.clone(), ConnectionSlot::Connecting);
+ }
+ let endpoint = endpoint.clone();
+ let connections = Arc::clone(connections);
+ let health = Arc::clone(health);
+ tokio::spawn(async move {
+ match establish_connection(&endpoint, &peer, &key).await {
+ Ok(connection) => {
+ if let Ok(mut map) = connections.lock() {
+ map.insert(key, ConnectionSlot::Ready(connection));
+ }
+ record_peer_success(&health, &peer.addr);
+ }
+ Err(error) => {
+ if let Ok(mut map) = connections.lock() {
+ map.remove(&key);
+ }
+ record_peer_failure(&health, &peer.addr, &error);
+ }
+ }
+ });
}
-async fn send_stream(
+/// Stream send running inside its own task: reuses a ready connection or
+/// dials one inline (a racing datagram dial at worst produces one redundant
+/// connection that is dropped on replacement — streams are rare).
+async fn send_stream_task(
endpoint: &Endpoint,
- connections: &mut HashMap,
+ connections: &ConnectionMap,
+ health: &HealthMap,
peer: PeerEndpoint,
payload: Vec,
- ack_required: bool,
) -> Result<(), String> {
- let (key, connection) = connection_for(endpoint, connections, &peer).await?;
- let result = send_stream_on_connection(connection, payload, ack_required).await;
+ let key = peer_key(&peer)?;
+ let existing = {
+ let Ok(map) = connections.lock() else {
+ return Err("QUIC connection map is poisoned".into());
+ };
+ match map.get(&key) {
+ Some(ConnectionSlot::Ready(connection)) if connection.close_reason().is_none() => {
+ Some(connection.clone())
+ }
+ _ => None,
+ }
+ };
+ let connection = match existing {
+ Some(connection) => connection,
+ None => match establish_connection(endpoint, &peer, &key).await {
+ Ok(connection) => {
+ if let Ok(mut map) = connections.lock() {
+ map.insert(key.clone(), ConnectionSlot::Ready(connection.clone()));
+ }
+ record_peer_success(health, &peer.addr);
+ connection
+ }
+ Err(error) => {
+ record_peer_failure(health, &peer.addr, &error);
+ return Err(error);
+ }
+ },
+ };
+
+ let result = send_stream_on_connection(connection, payload).await;
if result.is_err() {
- connections.remove(&key);
+ if let Ok(mut map) = connections.lock() {
+ map.remove(&key);
+ }
}
result
}
@@ -618,7 +822,6 @@ async fn send_stream(
async fn send_stream_on_connection(
connection: quinn::Connection,
payload: Vec,
- ack_required: bool,
) -> Result<(), String> {
let (mut send, mut recv) = connection
.open_bi()
@@ -629,19 +832,11 @@ async fn send_stream_on_connection(
.map_err(|error| format!("failed to write QUIC stream: {error}"))?;
send.finish()
.map_err(|error| format!("failed to finish QUIC stream: {error}"))?;
- let ack = tokio::time::timeout(Duration::from_millis(500), recv.read_to_end(64)).await;
- if ack_required {
- match ack {
- Ok(Ok(bytes)) => verify_stream_ack(&bytes)?,
- Ok(Err(error)) => {
- return Err(format!("failed to read QUIC stream ack: {error}"));
- }
- Err(_) => {
- return Err("QUIC stream ack timed out".into());
- }
- }
+ match tokio::time::timeout(Duration::from_millis(500), recv.read_to_end(64)).await {
+ Ok(Ok(bytes)) => verify_stream_ack(&bytes),
+ Ok(Err(error)) => Err(format!("failed to read QUIC stream ack: {error}")),
+ Err(_) => Err("QUIC stream ack timed out".into()),
}
- Ok(())
}
fn verify_stream_ack(bytes: &[u8]) -> Result<(), String> {
@@ -655,30 +850,19 @@ fn verify_stream_ack(bytes: &[u8]) -> Result<(), String> {
}
}
-async fn connection_for(
+async fn establish_connection(
endpoint: &Endpoint,
- connections: &mut HashMap,
peer: &PeerEndpoint,
-) -> Result<(PeerKey, quinn::Connection), String> {
- let key = peer_key(peer)?;
-
- if let Some(connection) = connections.get(&key) {
- if connection.close_reason().is_none() {
- return Ok((key, connection.clone()));
- }
- }
- connections.remove(&key);
-
+ key: &PeerKey,
+) -> Result {
let config = client_config(peer)?;
let connecting = endpoint
.connect_with(config, key.addr, SERVER_NAME)
.map_err(|error| format!("failed to start QUIC connection to {}: {error}", key.addr))?;
- let connection = tokio::time::timeout(Duration::from_secs(2), connecting)
+ tokio::time::timeout(Duration::from_secs(2), connecting)
.await
.map_err(|_| format!("QUIC connection to {} timed out", key.addr))?
- .map_err(|error| format!("failed to connect QUIC to {}: {error}", key.addr))?;
- connections.insert(key.clone(), connection.clone());
- Ok((key, connection))
+ .map_err(|error| format!("failed to connect QUIC to {}: {error}", key.addr))
}
fn peer_key(peer: &PeerEndpoint) -> Result {
@@ -699,6 +883,35 @@ fn resolve_peer_addr(addr: &str) -> Result {
mod tests {
use super::*;
+ #[test]
+ fn peer_health_fast_fails_after_threshold_and_recovers_on_success() {
+ let health: HealthMap = Arc::new(Mutex::new(HashMap::new()));
+ let addr = "10.0.0.9:47834";
+
+ for strikes in 1..DATAGRAM_FAIL_THRESHOLD {
+ record_peer_failure(&health, addr, "timeout");
+ assert!(
+ !peer_fast_fail_active(&health, addr),
+ "{strikes} failures must not fast-fail yet"
+ );
+ }
+ record_peer_failure(&health, addr, "timeout");
+ assert!(
+ peer_fast_fail_active(&health, addr),
+ "reaching the threshold enters fast-fail"
+ );
+ assert!(
+ !peer_fast_fail_active(&health, "10.0.0.8:47834"),
+ "health is tracked per peer address"
+ );
+
+ record_peer_success(&health, addr);
+ assert!(
+ !peer_fast_fail_active(&health, addr),
+ "one successful send clears the fast-fail state"
+ );
+ }
+
fn make_cert() -> CertificateDer<'static> {
rcgen::generate_simple_self_signed(vec!["mykvm.local".to_string()])
.unwrap()
diff --git a/src-tauri/src/shared_input.rs b/src-tauri/src/shared_input.rs
index fa12139..4933933 100644
--- a/src-tauri/src/shared_input.rs
+++ b/src-tauri/src/shared_input.rs
@@ -4,6 +4,8 @@ use std::path::PathBuf;
pub const LEFT_BUTTON_MASK: u64 = 1;
pub const RIGHT_BUTTON_MASK: u64 = 1 << 1;
pub const MIDDLE_BUTTON_MASK: u64 = 1 << 2;
+pub const BACK_BUTTON_MASK: u64 = 1 << 3;
+pub const FORWARD_BUTTON_MASK: u64 = 1 << 4;
pub const INPUT_PIPE_PREFIX: &str = r"\\.\pipe\mykvm-input-s";
pub const INPUT_SERVICE_NAME: &str = "MyKVMInputService";
pub const INPUT_SERVICE_DISPLAY_NAME: &str = "MyKVM Lock Screen Input Service";
@@ -23,6 +25,9 @@ pub enum MouseButton {
Left,
Right,
Middle,
+ // The side navigation buttons (Windows XBUTTON1/XBUTTON2, "back"/"forward").
+ Back,
+ Forward,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -68,6 +73,8 @@ pub fn mouse_button_mask(button: MouseButton) -> u64 {
MouseButton::Left => LEFT_BUTTON_MASK,
MouseButton::Right => RIGHT_BUTTON_MASK,
MouseButton::Middle => MIDDLE_BUTTON_MASK,
+ MouseButton::Back => BACK_BUTTON_MASK,
+ MouseButton::Forward => FORWARD_BUTTON_MASK,
}
}
diff --git a/src-tauri/src/windows_input.rs b/src-tauri/src/windows_input.rs
index c8931ac..6cdbaa8 100644
--- a/src-tauri/src/windows_input.rs
+++ b/src-tauri/src/windows_input.rs
@@ -1,9 +1,47 @@
#![cfg(target_os = "windows")]
-use std::ptr;
+use std::{
+ ptr,
+ sync::{Mutex, OnceLock},
+ time::{Duration, Instant},
+};
use crate::shared_input::{mouse_button_mask, InputCommand, MouseButton};
+/// Explains a refused button/key injection, throttled to one line per 10s.
+///
+/// Windows blocks `SendInput` from a standard-user process into an elevated or
+/// uiAccess foreground window (Task Manager — which ships `uiAccess="true"` — a
+/// UAC-elevated app, etc.) with `ERROR_ACCESS_DENIED`; the events are dropped
+/// silently, which reads to the user as "remote control just stopped" even
+/// though MyKVM is still running. Cursor MOVE keeps working because it goes
+/// through SetCursorPos, not SendInput — so only clicks and keys die, exactly
+/// the reported symptom. Surface it instead of failing mutely (this replaces a
+/// leftover per-event debug write to C:\ProgramData\MyKVM\*.txt).
+fn note_injection_refused(kind: &str, error: u32) {
+ use windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED;
+
+ static LAST_WARN: OnceLock> = OnceLock::new();
+ let cell = LAST_WARN.get_or_init(|| Mutex::new(Instant::now() - Duration::from_secs(60)));
+ if let Ok(mut last) = cell.lock() {
+ if last.elapsed() < Duration::from_secs(10) {
+ return;
+ }
+ *last = Instant::now();
+ }
+
+ if error == ERROR_ACCESS_DENIED {
+ log::warn!(
+ "injected {kind} refused by Windows (ERROR_ACCESS_DENIED): an elevated or \
+ uiAccess window (e.g. Task Manager, a UAC-elevated app) has focus. A \
+ standard-user MyKVM cannot hook or inject into higher-privilege windows — \
+ restart MyKVM as administrator (Settings) on this machine to control them."
+ );
+ } else {
+ log::warn!("injected {kind} was refused: SendInput failed with error {error}");
+ }
+}
+
pub fn inject_command(command: &InputCommand, pressed_keys: &mut Vec, button_mask: &mut u64) {
if matches!(command, InputCommand::ReleaseAll) {
release_pressed_inputs(pressed_keys, button_mask);
@@ -219,10 +257,13 @@ pub fn inject_mouse_move(x: i32, y: i32, _drag_button: Option) {
}
pub fn inject_mouse_button(button: MouseButton, down: bool, x: i32, y: i32) {
- use windows_sys::Win32::UI::Input::KeyboardAndMouse::{
- SendInput, INPUT, INPUT_0, INPUT_MOUSE, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP,
- MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP,
- MOUSEINPUT,
+ use windows_sys::Win32::UI::{
+ Input::KeyboardAndMouse::{
+ SendInput, INPUT, INPUT_0, INPUT_MOUSE, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP,
+ MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP,
+ MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, MOUSEINPUT,
+ },
+ WindowsAndMessaging::{XBUTTON1, XBUTTON2},
};
if x != 0 || y != 0 {
@@ -234,13 +275,20 @@ pub fn inject_mouse_button(button: MouseButton, down: bool, x: i32, y: i32) {
// spawned injection thread on some desktops, which produced the "cursor
// moves but cannot click" symptom. SendInput reports failures via its
// return value and is the recommended injection API.
- let flag = match (button, down) {
- (MouseButton::Left, true) => MOUSEEVENTF_LEFTDOWN,
- (MouseButton::Left, false) => MOUSEEVENTF_LEFTUP,
- (MouseButton::Right, true) => MOUSEEVENTF_RIGHTDOWN,
- (MouseButton::Right, false) => MOUSEEVENTF_RIGHTUP,
- (MouseButton::Middle, true) => MOUSEEVENTF_MIDDLEDOWN,
- (MouseButton::Middle, false) => MOUSEEVENTF_MIDDLEUP,
+ //
+ // The side buttons ride MOUSEEVENTF_X* with the button in mouseData
+ // (XBUTTON1 = back, XBUTTON2 = forward).
+ let (flag, mouse_data) = match (button, down) {
+ (MouseButton::Left, true) => (MOUSEEVENTF_LEFTDOWN, 0),
+ (MouseButton::Left, false) => (MOUSEEVENTF_LEFTUP, 0),
+ (MouseButton::Right, true) => (MOUSEEVENTF_RIGHTDOWN, 0),
+ (MouseButton::Right, false) => (MOUSEEVENTF_RIGHTUP, 0),
+ (MouseButton::Middle, true) => (MOUSEEVENTF_MIDDLEDOWN, 0),
+ (MouseButton::Middle, false) => (MOUSEEVENTF_MIDDLEUP, 0),
+ (MouseButton::Back, true) => (MOUSEEVENTF_XDOWN, XBUTTON1 as i32),
+ (MouseButton::Back, false) => (MOUSEEVENTF_XUP, XBUTTON1 as i32),
+ (MouseButton::Forward, true) => (MOUSEEVENTF_XDOWN, XBUTTON2 as i32),
+ (MouseButton::Forward, false) => (MOUSEEVENTF_XUP, XBUTTON2 as i32),
};
let input = INPUT {
@@ -249,7 +297,7 @@ pub fn inject_mouse_button(button: MouseButton, down: bool, x: i32, y: i32) {
mi: MOUSEINPUT {
dx: 0,
dy: 0,
- mouseData: 0,
+ mouseData: mouse_data as u32,
dwFlags: flag,
time: 0,
dwExtraInfo: 0,
@@ -257,14 +305,8 @@ pub fn inject_mouse_button(button: MouseButton, down: bool, x: i32, y: i32) {
},
};
unsafe {
- let sent = SendInput(1, &input, std::mem::size_of:: () as i32);
- if sent == 0 {
- let err = windows_sys::Win32::Foundation::GetLastError();
- std::fs::write(
- "C:\\ProgramData\\MyKVM\\helper-btn-err.txt",
- format!("mouse button {flag:?} error {err}\n"),
- )
- .ok();
+ if SendInput(1, &input, std::mem::size_of:: () as i32) == 0 {
+ note_injection_refused("mouse button", windows_sys::Win32::Foundation::GetLastError());
}
}
}
@@ -328,14 +370,8 @@ pub fn inject_key(key_code: u16, down: bool) {
},
};
unsafe {
- let sent = SendInput(1, &input, std::mem::size_of:: () as i32);
- if sent == 0 {
- let err = windows_sys::Win32::Foundation::GetLastError();
- std::fs::write(
- "C:\\ProgramData\\MyKVM\\helper-key-err.txt",
- format!("key {key_code:#04x} down={down} error {err}\n"),
- )
- .ok();
+ if SendInput(1, &input, std::mem::size_of:: () as i32) == 0 {
+ note_injection_refused("key", windows_sys::Win32::Foundation::GetLastError());
}
}
}
diff --git a/src/App.css b/src/App.css
index c1819d1..f6b2cdc 100644
--- a/src/App.css
+++ b/src/App.css
@@ -12,13 +12,6 @@
padding-top: 0;
}
-.app-shell.custom-chrome-mac {
- height: 100vh;
- min-height: 100vh;
- border-radius: 18px;
- overflow: hidden auto;
-}
-
.app-shell.theme-light {
color: #172033;
background: linear-gradient(180deg, #f8fbff 0, #f2f5fa 280px), #f2f5fa;
@@ -31,8 +24,6 @@
.theme-light .layout-panel,
.theme-light .loading-panel,
.theme-light .connection-row,
-.theme-light .protocol-grid div,
-.theme-light .metric-grid div,
.theme-light .network-meta div,
.theme-light .runtime-meta div {
border-color: #d7deea;
@@ -41,11 +32,8 @@
.theme-light .brand-mark,
.theme-light .secondary-button,
-.theme-light .screen-chip,
-.theme-light .role-status-pill,
.theme-light .add-device-form input,
.theme-light .settings-number-input,
-.theme-light .settings-text-input,
.theme-light .hotkey-recorder-button,
.theme-light .role-switcher button,
.theme-light .segmented-control,
@@ -67,9 +55,7 @@
.theme-light .layout-toolbar h1,
.theme-light .surface-card h2,
.theme-light .connection-title strong,
-.theme-light .protocol-grid strong,
.theme-light .runtime-meta dd,
-.theme-light .metric-grid dd,
.theme-light .network-meta dd {
color: #172033;
}
@@ -227,8 +213,7 @@
color: #ffffff;
}
-.theme-light .secondary-button:hover,
-.theme-light .screen-chip:hover {
+.theme-light .secondary-button:hover {
background: #e9eef6;
}
@@ -410,11 +395,7 @@
.header-actions,
.window-controls,
.header-tabs,
-.status-strip,
-.layout-toolbar,
-.panel-heading,
-.device-topline,
-.mini-panel-heading {
+.layout-toolbar {
display: flex;
align-items: center;
}
@@ -503,7 +484,6 @@
.window-controls button,
.primary-button,
.secondary-button,
-.screen-chip,
.screen-rect {
border: 0;
cursor: pointer;
@@ -551,55 +531,6 @@
color: #ffffff;
}
-.mac-window-controls {
- position: relative;
- z-index: 2;
- align-self: center;
- gap: 2px;
- height: 36px;
- padding: 0 7px 0 9px;
-}
-
-.mac-window-controls button {
- width: 22px;
- height: 22px;
- padding: 0;
- display: grid;
- place-items: center;
- background: transparent;
- box-shadow: none;
- -webkit-app-region: no-drag;
-}
-
-.mac-window-controls button::before {
- content: "";
- width: 12px;
- height: 12px;
- border-radius: 999px;
- border: 1px solid rgba(0, 0, 0, 0.22);
- box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
-}
-
-.mac-window-controls button.close::before {
- background: #ff5f57;
-}
-
-.mac-window-controls button.minimize::before {
- background: #febc2e;
-}
-
-.mac-window-controls button.maximize::before {
- background: #28c840;
-}
-
-.mac-window-controls button:hover::before {
- filter: brightness(0.95);
-}
-
-.theme-light .mac-window-controls button::before {
- border-color: rgba(31, 41, 55, 0.18);
-}
-
.window-control-icon {
width: 12px;
height: 12px;
@@ -623,23 +554,8 @@
justify-self: auto;
}
-.role-status-pill {
- display: inline-flex;
- align-items: center;
- min-height: 32px;
- padding: 0 10px;
- border: 1px solid rgba(63, 63, 70, 0.9);
- border-radius: 999px;
- background: #242429;
- color: #d4d4d8;
- font-size: 12px;
- font-weight: 800;
- white-space: nowrap;
-}
-
.primary-button,
-.secondary-button,
-.screen-chip {
+.secondary-button {
min-height: 34px;
border-radius: 8px;
padding: 0 12px;
@@ -654,27 +570,23 @@
color: #f8fafc;
}
-.secondary-button,
-.screen-chip {
+.secondary-button {
border: 1px solid rgba(63, 63, 70, 0.92);
background: #242429;
color: #d4d4d8;
}
.secondary-button:hover,
-.screen-chip:hover,
.primary-button:hover {
transform: translateY(-1px);
}
-.secondary-button:hover,
-.screen-chip:hover {
+.secondary-button:hover {
background: #2d2d33;
}
.primary-button:disabled,
-.secondary-button:disabled,
-.screen-chip:disabled {
+.secondary-button:disabled {
cursor: progress;
opacity: 0.55;
}
@@ -734,35 +646,6 @@
font-size: 13px;
}
-.status-strip {
- min-height: 62px;
- gap: 10px;
- padding: 0 4px;
-}
-
-.status-strip div {
- min-width: 116px;
- padding: 10px 14px;
- border: 1px solid rgba(63, 63, 70, 0.82);
- border-radius: 10px;
- background: #222226;
-}
-
-.status-strip span {
- display: block;
- color: #fafafa;
- font-size: 20px;
- line-height: 1;
- font-weight: 800;
-}
-
-.status-strip label {
- display: block;
- margin-top: 7px;
- color: #8f8f99;
- font-size: 12px;
-}
-
.workspace-shell {
flex: 1;
min-height: 0;
@@ -770,7 +653,6 @@
}
.layout-panel,
-.inspector-panel,
.loading-panel {
min-width: 0;
border: 1px solid rgba(63, 63, 70, 0.88);
@@ -787,16 +669,12 @@
gap: 12px;
}
-.layout-toolbar,
-.panel-heading {
+.layout-toolbar {
justify-content: space-between;
gap: 14px;
}
-.layout-toolbar h1,
-.panel-heading h2,
-.device-card h3,
-.mini-panel h3 {
+.layout-toolbar h1 {
margin: 0;
}
@@ -1022,17 +900,6 @@
font-size: 12px;
}
-.inspector-panel {
- min-height: 0;
- overflow: hidden;
-}
-
-.tab-panel {
- height: 100%;
- padding: 16px;
- overflow: auto;
-}
-
.page-panel {
flex: 0 0 auto;
min-height: auto;
@@ -1304,34 +1171,21 @@
line-height: 1.55;
}
-.metric-grid,
-.device-meta,
.network-meta {
display: grid;
gap: 10px;
}
-.metric-grid {
- grid-template-columns: repeat(3, minmax(0, 1fr));
-}
-
-.metric-grid div,
-.network-meta div,
-.mini-panel,
-.device-card,
-.peer-list li {
+.network-meta div {
border: 1px solid rgba(63, 63, 70, 0.82);
border-radius: 10px;
background: #242429;
}
-.metric-grid div,
.network-meta div {
padding: 10px;
}
-.metric-grid dt,
-.device-meta dt,
.network-meta dt {
color: #71717a;
font-size: 11px;
@@ -1340,8 +1194,6 @@
text-transform: uppercase;
}
-.metric-grid dd,
-.device-meta dd,
.network-meta dd {
margin: 4px 0 0;
color: #f4f4f5;
@@ -1349,24 +1201,6 @@
overflow-wrap: anywhere;
}
-.arrange-toolbar,
-.device-actions,
-.screen-list {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
-}
-
-.arrange-toolbar {
- margin-top: 14px;
-}
-
-.arrange-toolbar span {
- width: 100%;
- color: #a1a1aa;
- font-size: 13px;
-}
-
.add-device-form {
display: grid;
grid-template-columns: 1fr 1fr auto auto;
@@ -1379,8 +1213,7 @@
}
.add-device-form input,
-.settings-number-input,
-.settings-text-input {
+.settings-number-input {
width: 100%;
min-width: 0;
border: 1px solid rgba(63, 63, 70, 0.92);
@@ -1391,15 +1224,13 @@
}
.add-device-form input,
-.settings-number-input,
-.settings-text-input {
+.settings-number-input {
min-height: 34px;
padding: 0 10px;
}
.add-device-form input:focus,
-.settings-number-input:focus,
-.settings-text-input:focus {
+.settings-number-input:focus {
border-color: #2786ff;
}
@@ -1531,48 +1362,6 @@
color: #f4f4f5;
}
-.runtime-summary-card {
- display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(280px, 0.42fr);
- gap: 16px;
- align-items: start;
-}
-
-.protocol-card {
- display: grid;
- grid-template-columns: minmax(0, 0.44fr) minmax(0, 1fr);
- gap: 16px;
- align-items: start;
-}
-
-.protocol-grid {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 8px;
-}
-
-.protocol-grid div {
- min-height: 72px;
- padding: 12px;
- border: 1px solid rgba(63, 63, 70, 0.82);
- border-radius: 8px;
- background: #242429;
-}
-
-.protocol-grid span {
- display: block;
- color: #60a5fa;
- font-size: 12px;
- font-weight: 800;
-}
-
-.protocol-grid strong {
- display: block;
- margin-top: 8px;
- color: #f4f4f5;
- font-size: 14px;
-}
-
.runtime-meta {
margin: 0;
display: grid;
@@ -1599,28 +1388,11 @@
font-size: 13px;
}
-.device-list,
-.stack,
-.peer-list,
-.adjacency-list {
+.stack {
display: grid;
gap: 10px;
}
-.device-card,
-.mini-panel {
- padding: 12px;
-}
-
-.device-card.active {
- border-color: rgba(39, 134, 255, 0.9);
- background: #202a36;
-}
-
-.device-topline {
- gap: 10px;
-}
-
.device-badge {
width: 13px;
height: 13px;
@@ -1640,104 +1412,10 @@
background: #f59e0b;
}
-.device-topline p,
-.adjacency-list,
-.peer-list,
-.screen-list,
-.error-banner,
-.mini-panel p {
- margin: 0;
-}
-
-.device-topline p {
- color: #a1a1aa;
- font-size: 13px;
-}
-
-.role-pill {
- display: inline-flex;
- align-items: center;
- margin-left: 8px;
- padding: 2px 7px;
- border-radius: 999px;
- background: #303038;
- color: #d4d4d8;
- font-size: 12px;
- font-weight: 800;
-}
-
-.role-pill-local {
- background: rgba(39, 134, 255, 0.18);
- color: #60a5fa;
-}
-
-.role-pill-client {
- background: rgba(20, 184, 166, 0.16);
- color: #2dd4bf;
-}
-
-.device-meta {
- grid-template-columns: 1fr 0.7fr;
- margin-top: 12px;
-}
-
-.device-actions {
- margin-top: 12px;
-}
-
.danger-button {
color: #fda4af;
}
-.screen-list,
-.peer-list,
-.adjacency-list {
- list-style: none;
- padding: 0;
-}
-
-.screen-list {
- margin-top: 12px;
-}
-
-.screen-chip {
- font-size: 12px;
-}
-
-.screen-chip.selected {
- background: #2786ff;
- border-color: #2786ff;
- color: #f8fafc;
-}
-
-.empty-screen-note {
- color: #71717a;
- font-size: 13px;
-}
-
-.peer-list {
- margin-bottom: 14px;
-}
-
-.peer-list li {
- display: flex;
- justify-content: space-between;
- gap: 10px;
- padding: 10px;
- font-size: 13px;
-}
-
-.peer-list div {
- min-width: 0;
- display: grid;
- gap: 3px;
-}
-
-.peer-list span {
- color: #8f8f99;
- overflow-wrap: anywhere;
-}
-
.runtime-line {
margin: 8px 0;
color: #f4f4f5;
@@ -1745,12 +1423,6 @@
font-weight: 800;
}
-.mini-panel p:last-child {
- color: #a1a1aa;
- font-size: 13px;
- overflow-wrap: anywhere;
-}
-
.network-meta {
margin-top: 4px;
}
@@ -2007,20 +1679,6 @@
line-height: 1.55;
}
-.adjacency-list {
- margin-top: 14px;
-}
-
-.adjacency-list li {
- display: flex;
- justify-content: space-between;
- gap: 10px;
- padding: 9px 0;
- border-top: 1px solid rgba(63, 63, 70, 0.72);
- color: #d4d4d8;
- font-size: 13px;
-}
-
.info-banner {
margin: 0;
padding: 10px 12px;
@@ -2447,9 +2105,6 @@
.connection-stack,
.settings-layout,
- .runtime-summary-card,
- .protocol-card,
- .protocol-grid,
.connection-add-card {
grid-template-columns: 1fr;
}
@@ -2461,10 +2116,6 @@
.connection-actions {
justify-content: flex-start;
}
-
- .inspector-panel {
- max-height: 42vh;
- }
}
@media (max-width: 560px) {
@@ -2503,22 +2154,16 @@
margin: 0 -12px 0;
}
- .status-strip {
- overflow-x: auto;
- }
-
.layout-board {
min-height: 420px;
}
.add-device-form,
- .metric-grid,
.compact-meta,
.settings-control-row,
.role-choice-grid,
.page-heading,
- .runtime-meta,
- .protocol-grid {
+ .runtime-meta {
grid-template-columns: 1fr;
}
diff --git a/src/App.tsx b/src/App.tsx
index 1cb3295..b056f93 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -51,7 +51,6 @@ import { APP_VERSION, REPOSITORY_URL } from "./constants";
import { TEXT } from "./i18n";
import type { AppText } from "./i18n";
import {
- edgeSwitchHotkeyFromKeyboardEvent,
formatEdgeSwitchHotkeyForDisplay,
hotkeyFromKeyboardEvent,
metaKeyLabelForPlatform,
@@ -643,7 +642,6 @@ function App() {
navigator.platform.toLowerCase();
const metaKeyLabel = metaKeyLabelForPlatform(localPlatform);
const usesWindowsChrome = localPlatform.includes("win");
- const usesCustomChrome = usesWindowsChrome;
const inputServiceInstalled = Boolean(runtime?.inputService.installed);
const inputServiceReady =
inputServiceInstalled &&
@@ -674,7 +672,7 @@ function App() {
const shellClassName = `app-shell ${chromeClassName} theme-${resolvedTheme}`;
function renderWindowTitlebar() {
- if (!usesCustomChrome) {
+ if (!usesWindowsChrome) {
return null;
}
@@ -1400,7 +1398,7 @@ function App() {
}
const captureEdgeSwitchHotkey = useEffectEvent((event: KeyboardEvent) => {
- const hotkey = edgeSwitchHotkeyFromKeyboardEvent(event, metaKeyLabel);
+ const hotkey = hotkeyFromKeyboardEvent(event, metaKeyLabel);
if (!hotkey) {
return;
}
@@ -3354,7 +3352,7 @@ function hotkeyTagLabel(part: string, platform: string) {
case "disabled":
return "Off";
default:
- return part.length === 1 ? part.toUpperCase() : part.toUpperCase();
+ return part.toUpperCase();
}
}
diff --git a/src/desktopApi.ts b/src/desktopApi.ts
index 2ac66c6..c051ef2 100644
--- a/src/desktopApi.ts
+++ b/src/desktopApi.ts
@@ -1,5 +1,5 @@
import { invoke, isTauri } from '@tauri-apps/api/core'
-import { RELEASES_URL, REPOSITORY_URL } from './constants'
+import { APP_VERSION, RELEASES_URL, REPOSITORY_URL } from './constants'
import { defaultLayout } from './defaultLayout'
import type {
AppStateSnapshot,
@@ -29,70 +29,47 @@ export interface FileTransferSummary {
byteCount: number
}
-const FALLBACK_RUNTIME: RuntimeStatus = {
+// ponytail: static stopped-state stub so `npm run dev` in a plain browser still
+// renders the layout editor; the real runtime lives in the Tauri backend.
+const STUB_DETAIL = 'Available only in the Tauri desktop runtime.'
+
+const BROWSER_RUNTIME: RuntimeStatus = {
started: false,
- transport: {
- state: 'stubbed',
- detail: 'Desktop fallback mode does not start discovery, input capture, or injection.',
- },
- capture: {
- state: 'stubbed',
- detail: 'Global input capture will be implemented in the Rust layer.',
- },
- inject: {
- state: 'stubbed',
- detail: 'System input injection will be implemented in the Rust layer.',
- },
- clipboard: {
- state: 'stubbed',
- detail: '剪贴板同步需要在 Tauri 桌面端运行。',
- },
- privilege: {
- isElevated: false,
- canElevate: false,
- detail: 'Administrator restart is available only in the Windows desktop runtime.',
- },
+ transport: { state: 'stubbed', detail: STUB_DETAIL },
+ capture: { state: 'stubbed', detail: STUB_DETAIL },
+ inject: { state: 'stubbed', detail: STUB_DETAIL },
+ clipboard: { state: 'stubbed', detail: STUB_DETAIL },
+ privilege: { isElevated: false, canElevate: false, detail: STUB_DETAIL },
inputService: {
installed: false,
running: false,
workerSessionId: null,
pipeAvailable: false,
sasAvailable: false,
- detail: 'Windows lock screen input service is available only in the Windows desktop runtime.',
+ detail: STUB_DETAIL,
},
discovery: {
state: 'idle',
- detail: 'LAN discovery is available only in the Tauri desktop runtime.',
- port: 47833,
+ detail: STUB_DETAIL,
+ port: defaultLayout.transportPort,
localPeer: {
id: 'browser-preview',
- name: 'Desktop fallback',
+ name: 'Browser preview',
platform: navigator.platform,
machineRole: defaultLayout.machineRole,
clusterId: defaultLayout.clusterId,
pairingRequired: false,
- host: window.location.hostname || 'localhost',
+ host: 'localhost',
ip: '127.0.0.1',
transportPort: defaultLayout.transportPort,
quicPort: defaultLayout.quicPort,
transportPublicKey: '',
protocolVersion: 1,
- screenCount: 1,
+ screenCount: 0,
inputReady: false,
- screens: [
- {
- id: 'browser-display-1',
- name: 'Browser display',
- x: defaultLayout.devices[0].screens[0].x,
- y: defaultLayout.devices[0].screens[0].y,
- width: defaultLayout.devices[0].screens[0].width,
- height: defaultLayout.devices[0].screens[0].height,
- scale: defaultLayout.devices[0].screens[0].scale,
- isPrimary: true,
- },
- ],
- appVersion: '0.1.0',
- lastSeenMs: Date.now(),
+ screens: [],
+ appVersion: APP_VERSION,
+ lastSeenMs: 0,
},
peers: [],
},
@@ -106,13 +83,11 @@ const FALLBACK_RUNTIME: RuntimeStatus = {
},
}
-let browserRuntime = FALLBACK_RUNTIME
-
export async function loadAppState(): Promise {
if (!isTauri()) {
return {
layout: defaultLayout,
- runtime: browserRuntime,
+ runtime: BROWSER_RUNTIME,
}
}
@@ -123,7 +98,7 @@ export async function saveLayout(layout: LayoutState): Promise
if (!isTauri()) {
return {
layout,
- runtime: browserRuntime,
+ runtime: BROWSER_RUNTIME,
}
}
@@ -134,7 +109,7 @@ export async function resetPairing(): Promise {
if (!isTauri()) {
return {
layout: { ...defaultLayout, pairedControllers: [] },
- runtime: browserRuntime,
+ runtime: BROWSER_RUNTIME,
}
}
@@ -159,29 +134,7 @@ export async function setAutostart(enabled: boolean): Promise {
export async function startRuntime(): Promise {
if (!isTauri()) {
- browserRuntime = {
- started: true,
- transport: {
- state: 'ready',
- detail: 'Desktop fallback does not start native discovery, input capture, or injection.',
- },
- capture: FALLBACK_RUNTIME.capture,
- inject: FALLBACK_RUNTIME.inject,
- clipboard: FALLBACK_RUNTIME.clipboard,
- privilege: FALLBACK_RUNTIME.privilege,
- inputService: FALLBACK_RUNTIME.inputService,
- pairing: FALLBACK_RUNTIME.pairing,
- discovery: {
- ...FALLBACK_RUNTIME.discovery,
- detail: 'Desktop fallback cannot scan the LAN. Start the Tauri desktop app to use UDP discovery.',
- localPeer: {
- ...FALLBACK_RUNTIME.discovery.localPeer,
- lastSeenMs: Date.now(),
- },
- },
- }
-
- return browserRuntime
+ return BROWSER_RUNTIME
}
return invoke('start_runtime')
@@ -189,18 +142,7 @@ export async function startRuntime(): Promise {
export async function readRuntimeStatus(): Promise {
if (!isTauri()) {
- browserRuntime = {
- ...browserRuntime,
- discovery: {
- ...browserRuntime.discovery,
- localPeer: {
- ...browserRuntime.discovery.localPeer,
- lastSeenMs: Date.now(),
- },
- },
- }
-
- return browserRuntime
+ return BROWSER_RUNTIME
}
return invoke('read_runtime_status')
@@ -209,21 +151,20 @@ export async function readRuntimeStatus(): Promise {
export async function readDiagnosticInfo(): Promise {
if (!isTauri()) {
return {
- report: 'Desktop diagnostics are available only in the Tauri desktop runtime.',
- appVersion: '0.1.0',
+ report: STUB_DETAIL,
+ appVersion: APP_VERSION,
platform: navigator.platform,
role: defaultLayout.machineRole,
- runtimeStarted: browserRuntime.started,
- localName: browserRuntime.discovery.localPeer.name,
- localIp: browserRuntime.discovery.localPeer.ip,
- discoveryPort: browserRuntime.discovery.port,
- quicPort: browserRuntime.discovery.localPeer.quicPort,
- peerCount: browserRuntime.discovery.peers.length,
- knownDevices: [],
+ runtimeStarted: false,
+ localName: BROWSER_RUNTIME.discovery.localPeer.name,
+ localIp: BROWSER_RUNTIME.discovery.localPeer.ip,
+ discoveryPort: BROWSER_RUNTIME.discovery.port,
+ quicPort: BROWSER_RUNTIME.discovery.localPeer.quicPort,
+ peerCount: 0,
logDir: '',
configDir: '',
- networkHint: 'Desktop diagnostics are available only in the Tauri desktop runtime.',
- firewallHint: 'Desktop diagnostics are available only in the Tauri desktop runtime.',
+ networkHint: STUB_DETAIL,
+ firewallHint: STUB_DETAIL,
}
}
@@ -240,8 +181,7 @@ export async function openLogDirectory(): Promise {
export async function stopRuntime(): Promise {
if (!isTauri()) {
- browserRuntime = FALLBACK_RUNTIME
- return browserRuntime
+ return BROWSER_RUNTIME
}
return invoke('stop_runtime')
@@ -249,7 +189,7 @@ export async function stopRuntime(): Promise {
export async function scanLanPeers(): Promise {
if (!isTauri()) {
- return browserRuntime.discovery
+ return BROWSER_RUNTIME.discovery
}
return invoke('scan_lan_peers')
@@ -284,7 +224,7 @@ export async function confirmLanPairing(host: string, code: string) {
export async function dismissPairingRequest(): Promise {
if (!isTauri()) {
- return browserRuntime
+ return BROWSER_RUNTIME
}
return invoke('dismiss_pairing_request')
@@ -300,21 +240,13 @@ export async function writeClipboardText(text: string): Promise {
export async function readPerformanceSample(): Promise {
if (!isTauri()) {
- const memory = (performance as Performance & {
- memory?: {
- usedJSHeapSize: number
- jsHeapSizeLimit: number
- }
- }).memory
- const usedMb = memory ? memory.usedJSHeapSize / 1024 / 1024 : 96 + Math.sin(Date.now() / 2000) * 12
-
return {
timestampMs: Date.now(),
- appCpuPercent: Math.max(2, Math.min(100, 18 + Math.sin(Date.now() / 1500) * 10)),
- appMemoryMb: usedMb,
- transportPackets: Math.round(Date.now() / 1000) % 700,
- inputEvents: Math.round(Date.now() / 80) % 1200,
- clipboardPackets: Math.round(Date.now() / 5000) % 80,
+ appCpuPercent: 0,
+ appMemoryMb: 0,
+ transportPackets: 0,
+ inputEvents: 0,
+ clipboardPackets: 0,
}
}
@@ -331,7 +263,7 @@ export async function restartAsAdmin(): Promise {
export async function readInputServiceStatus(): Promise {
if (!isTauri()) {
- return browserRuntime.inputService
+ return BROWSER_RUNTIME.inputService
}
return invoke('read_input_service_status')
@@ -339,7 +271,7 @@ export async function readInputServiceStatus(): Promise {
export async function installInputService(): Promise {
if (!isTauri()) {
- return browserRuntime.inputService
+ return BROWSER_RUNTIME.inputService
}
return invoke('install_input_service')
@@ -347,20 +279,12 @@ export async function installInputService(): Promise {
export async function uninstallInputService(): Promise {
if (!isTauri()) {
- return browserRuntime.inputService
+ return BROWSER_RUNTIME.inputService
}
return invoke('uninstall_input_service')
}
-export async function sendSecureAttention(deviceId: string): Promise {
- if (!isTauri()) {
- return
- }
-
- await invoke('send_secure_attention', { deviceId })
-}
-
export async function sendFilesToDevice(deviceId: string, paths: string[]): Promise {
if (!isTauri()) {
return {
diff --git a/src/hotkeyInput.ts b/src/hotkeyInput.ts
index 7756363..61a52cf 100644
--- a/src/hotkeyInput.ts
+++ b/src/hotkeyInput.ts
@@ -24,13 +24,6 @@ const MODIFIER_KEYS = new Set([
"windows",
]);
-export function edgeSwitchHotkeyFromKeyboardEvent(
- event: HotkeyKeyboardEventLike,
- metaKeyLabel: MetaKeyLabel = "meta",
-): string | null {
- return hotkeyFromKeyboardEvent(event, metaKeyLabel);
-}
-
/// Generic hotkey capture used by both the runtime-toggle recorder and the
/// screen-switch direction recorders. Returns the canonical hotkey string, or
/// `null` to ignore the event (e.g. a bare modifier press), or `"disabled"` to
diff --git a/src/i18n.ts b/src/i18n.ts
index 12993b9..f45fc01 100644
--- a/src/i18n.ts
+++ b/src/i18n.ts
@@ -35,7 +35,6 @@ export const TEXT = {
minimize: "最小化",
maximize: "最大化",
close: "隐藏",
- copyright: "Copyright © 2026 MyKVM",
github: "GitHub",
},
loading: {
@@ -59,18 +58,14 @@ export const TEXT = {
roleTitle: "工作模式",
roleCopy:
"服务端负责管理布局并捕获输入;客户端保持轻量常驻,接收远端键鼠。",
- pairingTitle: "配对状态",
pairedWith: "已配对",
notPaired: "当前未配对",
resetPairing: "解除配对",
- resetPairingCopy:
- "清除本机已保存的配对,回到「待配对」状态,然后在服务端重新发起配对(重输验证码)。换电脑、升级后失配或想重配时用。",
autostart: "开机自启",
autostartOn: "开启",
autostartOff: "关闭",
edgeSwitchHotkey: "快捷启停",
edgeSwitchHotkeyRecording: "按下启停快捷键...",
- edgeSwitchHotkeyPlaceholder: "点击后按组合键 / f12 / disabled",
screenSwitchTitle: "快捷切屏",
screenSwitchRecording: "按下快捷键...",
screenSwitchCopy:
@@ -102,18 +97,12 @@ export const TEXT = {
adminRestarting: "正在重启",
inputServiceTitle: "锁屏控制服务",
inputServiceEyebrow: "Windows LocalSystem",
- inputServiceCopy:
- "Windows 被控端可安装本机 SYSTEM 服务,在锁屏、安全桌面和普通桌面间跟随注入远端授权输入。",
inputServicePromptTitle: "安装锁屏控制服务",
inputServicePromptCopy:
"这台 Windows 作为被控端时,需要本机 LocalSystem 服务才能在锁屏和 UAC 界面接收键鼠。安装会请求一次管理员授权。",
uninstallInputServicePromptTitle: "卸载锁屏控制服务",
uninstallInputServicePromptCopy:
"卸载后,这台 Windows 在锁屏和 UAC 界面将不能接收远端键鼠输入。",
- inputServiceInstalled: "服务",
- inputServiceWorker: "会话",
- inputServicePipe: "管道",
- inputServiceSas: "Ctrl+Alt+Del",
inputServiceReady: "已就绪",
inputServiceInstalledStatus: "已安装",
inputServiceNeedsInstall: "需要服务",
@@ -196,13 +185,7 @@ export const TEXT = {
scanningCopy: "正在搜索同网络下的 MyKVM 设备…",
pair: "配对",
repair: "重新配对",
- fileTransferTitle: "文件传输 (bate)",
- fileTransferCopy: "把文件拖到目标设备,MyKVM 会通过已配对的 QUIC 连接发送。(测试功能,可能不稳定)",
- fileTransferDrop: "拖入发送",
- fileTransferSending: "发送中",
fileTransferSent: "已发送",
- fileTransferUnavailable: "暂无在线且已配对的接收设备。",
- sendSecureAttention: "发送 Ctrl+Alt+Del",
pairingEyebrow: "PAIRING",
serverPairingTitle: "输入客户端验证码",
serverPairingCopy: "客户端屏幕上会显示 6 位验证码:",
@@ -221,7 +204,6 @@ export const TEXT = {
inputNotReady: "输入未就绪",
deviceNamePlaceholder: "设备名称",
hostPlaceholder: "Host 或 IP(可加 :端口,如 192.168.1.5:47833)",
- screensUnit: "屏",
},
errors: {
title: "操作失败",
@@ -229,7 +211,6 @@ export const TEXT = {
loadState: "加载桌面状态失败。",
saveLayout: "保存布局失败。",
updateRuntime: "更新原生运行时失败。",
- refreshState: "刷新桌面状态失败。",
scanLan: "扫描局域网设备失败。",
manualHostRequired: "请输入对方设备的 Host 或 IP。",
peerWithoutScreens: "在线,但没有上报屏幕信息。",
@@ -281,7 +262,6 @@ export const TEXT = {
minimize: "Minimize",
maximize: "Maximize",
close: "Hide",
- copyright: "Copyright © 2026 MyKVM",
github: "GitHub",
},
loading: {
@@ -307,18 +287,14 @@ export const TEXT = {
roleTitle: "Work Mode",
roleCopy:
"Server manages layout and captures input; Client stays lightweight and receives remote input.",
- pairingTitle: "Pairing",
pairedWith: "Paired with",
notPaired: "Not paired",
resetPairing: "Unpair",
- resetPairingCopy:
- "Clear this machine's saved pairing and return to \"needs pairing\", then re-initiate from the server (re-enter the code). Use after switching machines, an update mismatch, or to re-pair.",
autostart: "Launch at startup",
autostartOn: "On",
autostartOff: "Off",
edgeSwitchHotkey: "Quick toggle",
edgeSwitchHotkeyRecording: "Press start/stop shortcut...",
- edgeSwitchHotkeyPlaceholder: "Press shortcut / f12 / disabled",
screenSwitchTitle: "Quick switch",
screenSwitchRecording: "Press shortcut...",
screenSwitchCopy:
@@ -350,18 +326,12 @@ export const TEXT = {
adminRestarting: "Restarting",
inputServiceTitle: "Lock Screen Control",
inputServiceEyebrow: "Windows LocalSystem",
- inputServiceCopy:
- "Windows clients can install a local SYSTEM service to inject authorized remote input across the normal desktop, secure desktop, and lock screen.",
inputServicePromptTitle: "Install Lock Screen Control",
inputServicePromptCopy:
"This Windows client needs a local LocalSystem service to receive keyboard and mouse input on the lock screen and UAC desktop. Installing requests administrator approval once.",
uninstallInputServicePromptTitle: "Uninstall Lock Screen Control",
uninstallInputServicePromptCopy:
"After uninstalling, this Windows client cannot receive remote keyboard or mouse input on the lock screen or UAC desktop.",
- inputServiceInstalled: "Service",
- inputServiceWorker: "Session",
- inputServicePipe: "Pipe",
- inputServiceSas: "Ctrl+Alt+Del",
inputServiceReady: "Ready",
inputServiceInstalledStatus: "Installed",
inputServiceNeedsInstall: "Service Required",
@@ -444,13 +414,7 @@ export const TEXT = {
scanningCopy: "Searching for MyKVM devices on this network…",
pair: "Pair",
repair: "Re-pair",
- fileTransferTitle: "File Transfer (bate)",
- fileTransferCopy: "Drop files onto a target device to send them over the paired QUIC link. (Experimental, may be unstable)",
- fileTransferDrop: "Drop to send",
- fileTransferSending: "Sending",
fileTransferSent: "Sent",
- fileTransferUnavailable: "No online paired receiver is available.",
- sendSecureAttention: "Send Ctrl+Alt+Del",
pairingEyebrow: "PAIRING",
serverPairingTitle: "Enter Client Code",
serverPairingCopy: "The client screen shows a 6-digit code:",
@@ -469,7 +433,6 @@ export const TEXT = {
inputNotReady: "Input not ready",
deviceNamePlaceholder: "Device name",
hostPlaceholder: "Host or IP (optional :port, e.g. 192.168.1.5:47833)",
- screensUnit: "screens",
},
errors: {
title: "Operation Failed",
@@ -477,7 +440,6 @@ export const TEXT = {
loadState: "Failed to load desktop state.",
saveLayout: "Failed to save layout.",
updateRuntime: "Failed to update native runtime.",
- refreshState: "Failed to refresh desktop state.",
scanLan: "Failed to scan LAN peers.",
manualHostRequired: "Enter the other device host or IP.",
peerWithoutScreens: "is online, but did not report screen data.",
diff --git a/src/runtime.ts b/src/runtime.ts
index 18b3c46..7a9a70c 100644
--- a/src/runtime.ts
+++ b/src/runtime.ts
@@ -71,17 +71,6 @@ export interface AppStateSnapshot {
runtime: RuntimeStatus
}
-export interface DiagnosticDevice {
- name: string
- host: string
- role: string
- online: boolean
- inputReady: boolean
- discoveryPort: number
- quicPort: number
- sameSubnet?: boolean | null
-}
-
export interface DiagnosticInfo {
report: string
appVersion: string
@@ -93,7 +82,6 @@ export interface DiagnosticInfo {
discoveryPort: number
quicPort: number
peerCount: number
- knownDevices: DiagnosticDevice[]
logDir: string
configDir: string
networkHint: string