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