cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

fix/hero-release-note
goq / src-tauri / src / media / input_delivery.rs
input_delivery.rs 43.9K
1use std::sync::{Arc, Mutex as StdMutex};2use std::time::{Duration, Instant};34use iroh::Endpoint;5use serde::Serialize;6use sigil_protocol::{7 Capability, ControllerSlot, INPUT_ALPN_V1, INPUT_ALPN_V2, InputClientHelloV2, InputEvent,8 InputEventV2, InvitationGrants, PointerPosition, RELATIVE_POINTER_DELTA_MAX,9 RELATIVE_POINTER_DELTA_MIN, read_input_ack, read_input_ack_v2, read_input_host_hello_v2,10 write_input_client_hello_v2, write_input_event, write_input_event_v2,11};12use tauri::ipc::Channel;1314use crate::commands::state::{AppState, SessionSnapshotState};15use crate::media::network_diagnostics::{NetworkSessionDiagnostics, lock_network_diagnostics};16use crate::media::transport::{NegotiatedV1Stream, negotiate_v1};17use crate::platform_capabilities::relative_pointer_capture_enabled;1819pub(crate) const CLIENT_INPUT_QUEUE_CAPACITY: usize = 256;2021#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]22pub(crate) struct InputAvailability {23 pub(crate) relative_pointer: bool,24 pub(crate) pointer_position_feedback: bool,25 pub(crate) absolute_pointer: bool,26 pub(crate) keyboard: bool,27 pub(crate) text: bool,28 pub(crate) gamepad: bool,29 pub(crate) input_ack: bool,30 pub(crate) control: bool,31}3233pub(crate) fn v2_input_eligibility(grants: InvitationGrants) -> InputAvailability {34 let capabilities = input_capability_offers(grants)35 .into_iter()36 .next()37 .unwrap_or_default();38 InputAvailability::from_capabilities(&capabilities)39}4041impl InputAvailability {42 fn from_capabilities(capabilities: &[Capability]) -> Self {43 let relative_pointer = capabilities.contains(&Capability::RelativePointer);44 let pointer_position_feedback = capabilities.contains(&Capability::PointerPositionFeedback);45 let absolute_pointer = capabilities.contains(&Capability::AbsolutePointer);46 let keyboard = capabilities.contains(&Capability::Keyboard);47 let text = capabilities.contains(&Capability::Text);48 let gamepad = capabilities.contains(&Capability::Gamepad);49 let input_ack = capabilities.contains(&Capability::InputAck);50 Self {51 relative_pointer,52 pointer_position_feedback,53 absolute_pointer,54 keyboard,55 text,56 gamepad,57 input_ack,58 control: relative_pointer || absolute_pointer || keyboard || text || gamepad,59 }60 }61}6263#[derive(Serialize, Clone, Copy)]64#[serde(tag = "type", rename_all = "snake_case")]65pub enum PointerFeedbackPayload {66 Position {67 sequence: u64,68 position: Option<PointerPosition>,69 #[serde(skip_serializing_if = "Option::is_none")]70 pointer_visible: Option<bool>,71 },72 Terminal {73 reason: PointerFeedbackTerminalReason,74 },75}7677#[derive(Serialize, Clone, Copy)]78#[serde(rename_all = "snake_case")]79pub enum PointerFeedbackTerminalReason {80 Eof,81 Malformed,82}8384#[derive(Debug, Default, PartialEq, Eq)]85struct RelativePointerAccumulator {86 dx: i64,87 dy: i64,88}8990impl RelativePointerAccumulator {91 fn push(&mut self, dx: i32, dy: i32) {92 self.dx = self.dx.saturating_add(i64::from(dx));93 self.dy = self.dy.saturating_add(i64::from(dy));94 }9596 fn take(&mut self) -> Option<InputEvent> {97 if self.dx == 0 && self.dy == 0 {98 return None;99 }100 let dx = self.dx.clamp(101 i64::from(RELATIVE_POINTER_DELTA_MIN),102 i64::from(RELATIVE_POINTER_DELTA_MAX),103 ) as i32;104 let dy = self.dy.clamp(105 i64::from(RELATIVE_POINTER_DELTA_MIN),106 i64::from(RELATIVE_POINTER_DELTA_MAX),107 ) as i32;108 let event = InputEvent::MouseMoveRelative { dx, dy };109 self.dx -= i64::from(dx);110 self.dy -= i64::from(dy);111 Some(event)112 }113114 fn is_pending(&self) -> bool {115 self.dx != 0 || self.dy != 0116 }117}118119fn stage_relative_input(120 pending: &mut RelativePointerAccumulator,121 event: InputEvent,122) -> Option<InputEvent> {123 match event {124 InputEvent::MouseMoveRelative { dx, dy } => {125 pending.push(dx, dy);126 None127 }128 event => Some(event),129 }130}131132async fn open_negotiated_input_stream(133 endpoint: &Endpoint,134 address: &iroh::EndpointAddr,135 nonce: [u8; 16],136 capabilities: Vec<Capability>,137) -> Result<138 (139 iroh::endpoint::Connection,140 iroh::endpoint::SendStream,141 iroh::endpoint::RecvStream,142 NegotiatedV1Stream,143 ),144 String,145> {146 let connection = endpoint147 .connect(address.clone(), INPUT_ALPN_V1)148 .await149 .map_err(|error| format!("Failed to connect input stream: {error}"))?;150 let (mut send, mut recv) = connection151 .open_bi()152 .await153 .map_err(|error| format!("Failed to open input stream: {error}"))?;154 let negotiation = negotiate_v1(155 &mut send,156 &mut recv,157 nonce,158 capabilities,159 None,160 "input",161 None,162 )163 .await?;164 Ok((connection, send, recv, negotiation))165}166167fn input_capability_offers_for(168 grants: InvitationGrants,169 relative_pointer_capture: bool,170) -> Vec<Vec<Capability>> {171 // Keep the relative compatibility ladder ordered from newest to oldest.172 // When local relative capture is unavailable, deliberately collapse it to173 // only the inherited absolute-pointer offer.174 let mut base = Vec::with_capacity(if relative_pointer_capture { 4 } else { 1 });175 if relative_pointer_capture {176 base.extend([177 vec![178 Capability::RelativePointer,179 Capability::PointerPositionFeedback,180 Capability::PointerVisibilityFeedback,181 Capability::AbsolutePointer,182 Capability::Keyboard,183 Capability::Text,184 Capability::Gamepad,185 ],186 vec![187 Capability::RelativePointer,188 Capability::PointerPositionFeedback,189 Capability::AbsolutePointer,190 Capability::Keyboard,191 Capability::Text,192 Capability::Gamepad,193 ],194 vec![195 Capability::RelativePointer,196 Capability::AbsolutePointer,197 Capability::Keyboard,198 Capability::Text,199 Capability::Gamepad,200 ],201 ]);202 }203 base.push(vec![204 Capability::AbsolutePointer,205 Capability::Keyboard,206 Capability::Text,207 Capability::Gamepad,208 ]);209 let has_input_grant = grants.contains(InvitationGrants::POINTER_KEYBOARD)210 || grants.contains(InvitationGrants::GAMEPAD);211 let mut offers = Vec::with_capacity(base.len() * 2);212 if has_input_grant {213 for mut offer in base.clone() {214 offer.push(Capability::InputAck);215 offers.push(offer);216 }217 }218 offers.extend(base);219 for offer in &mut offers {220 offer.retain(|capability| match capability {221 Capability::Gamepad => grants.contains(InvitationGrants::GAMEPAD),222 Capability::AbsolutePointer223 | Capability::RelativePointer224 | Capability::Keyboard225 | Capability::Text226 | Capability::PointerPositionFeedback227 | Capability::PointerVisibilityFeedback => {228 grants.contains(InvitationGrants::POINTER_KEYBOARD)229 }230 Capability::InputAck => has_input_grant,231 _ => true,232 });233 }234 offers.dedup();235 offers236}237238fn input_capability_offers(grants: InvitationGrants) -> Vec<Vec<Capability>> {239 input_capability_offers_for(grants, relative_pointer_capture_enabled())240}241242fn mask_unavailable_relative_pointer_capabilities(243 mut capabilities: Vec<Capability>,244 relative_pointer_capture: bool,245) -> Vec<Capability> {246 if !relative_pointer_capture {247 capabilities.retain(|capability| {248 !matches!(249 capability,250 Capability::RelativePointer251 | Capability::PointerPositionFeedback252 | Capability::PointerVisibilityFeedback253 )254 });255 }256 capabilities257}258259fn input_event_allowed(capabilities: &[Capability], event: &InputEvent) -> bool {260 match event {261 InputEvent::Probe => capabilities.contains(&Capability::InputAck),262 InputEvent::MouseMove { .. } => capabilities.contains(&Capability::AbsolutePointer),263 InputEvent::MouseMoveRelative { .. } => capabilities.contains(&Capability::RelativePointer),264 InputEvent::MousePositionSync { .. } => capabilities.contains(&Capability::RelativePointer),265 InputEvent::MouseClick { .. }266 | InputEvent::MouseDown { .. }267 | InputEvent::MouseUp { .. }268 | InputEvent::MouseScroll { .. } => {269 capabilities.contains(&Capability::RelativePointer)270 || capabilities.contains(&Capability::AbsolutePointer)271 }272 InputEvent::KeyDown { .. } | InputEvent::KeyUp { .. } | InputEvent::KeyClick { .. } => {273 capabilities.contains(&Capability::Keyboard)274 }275 InputEvent::Text { .. } => capabilities.contains(&Capability::Text),276 InputEvent::Gamepad { .. } => capabilities.contains(&Capability::Gamepad),277 }278}279280async fn write_client_input_event(281 stream: &mut InputWire,282 event: &InputEvent,283 diagnostics: Option<&Arc<StdMutex<NetworkSessionDiagnostics>>>,284) -> Result<(), String> {285 if let Some(diagnostics) = diagnostics {286 lock_network_diagnostics(diagnostics).begin_input_send(Instant::now());287 }288 stream.write(event).await289}290291fn observe_input_ack_if_negotiated(292 diagnostics: &StdMutex<NetworkSessionDiagnostics>,293 negotiated: bool,294 sequence: u64,295 now: Instant,296) -> Result<(), String> {297 if !negotiated {298 return Ok(());299 }300 lock_network_diagnostics(diagnostics).observe_input_ack(sequence, now)301}302303pub(crate) struct InputSession {304 pub(crate) connection: iroh::endpoint::Connection,305 pub(crate) send: InputWire,306 pub(crate) recv: iroh::endpoint::RecvStream,307 pub(crate) capabilities: Vec<Capability>,308 pub(crate) availability: InputAvailability,309}310311#[derive(Clone)]312pub(crate) struct V2InputContext {313 pub(crate) endpoint: Endpoint,314 pub(crate) address: iroh::EndpointAddr,315 pub(crate) nonce: [u8; 16],316 pub(crate) media_session_id: u64,317 pub(crate) native_generation: u64,318 pub(crate) grants: InvitationGrants,319 pub(crate) pointer_channel: Channel<PointerFeedbackPayload>,320 pub(crate) network_diagnostics: Arc<StdMutex<NetworkSessionDiagnostics>>,321}322323pub(crate) enum InputWire {324 V1(iroh::endpoint::SendStream),325 V2 {326 send: iroh::endpoint::SendStream,327 session_id: u64,328 slot: ControllerSlot,329 focus_generation: u64,330 native_generation: u64,331 session_snapshot: Arc<tokio::sync::Mutex<Option<SessionSnapshotState>>>,332 },333}334335impl InputWire {336 async fn write(&mut self, event: &InputEvent) -> Result<(), String> {337 match self {338 Self::V1(send) => write_input_event(send, event)339 .await340 .map_err(|error| error.to_string()),341 Self::V2 {342 send,343 session_id,344 slot,345 focus_generation,346 native_generation,347 session_snapshot,348 } => {349 let owns_focus =350 session_snapshot351 .lock()352 .await353 .as_ref()354 .is_some_and(|(generation, snapshot)| {355 *generation == *native_generation356 && snapshot.self_focus_generation() == Some(*focus_generation)357 });358 if !owns_focus {359 return Err(360 "Authoritative focus changed before the input wire write".to_string()361 );362 }363 write_input_event_v2(364 send,365 &InputEventV2 {366 session_id: *session_id,367 slot: *slot,368 focus_generation: *focus_generation,369 event: event.clone(),370 },371 )372 .await373 .map_err(|error| error.to_string())374 }375 }376 }377378 fn finish(&mut self) {379 let result = match self {380 Self::V1(send) | Self::V2 { send, .. } => send.finish(),381 };382 let _ = result;383 }384}385386pub(crate) async fn open_input_session(387 endpoint: &Endpoint,388 address: &iroh::EndpointAddr,389 nonce: [u8; 16],390 media_session_id: u64,391 grants: InvitationGrants,392) -> Result<InputSession, String> {393 let mut errors = Vec::new();394 let mut accepted = None;395 // Older hosts reject unknown capability enum values. Try all four396 // pointer feature levels with ACK first, then repeat the exact legacy397 // offers without ACK so lack of diagnostics never forces absolute398 // pointer input prematurely.399 for capabilities in input_capability_offers(grants) {400 match open_negotiated_input_stream(endpoint, address, nonce, capabilities).await {401 Ok(result) => {402 accepted = Some(result);403 break;404 }405 Err(error) => errors.push(error),406 }407 }408 let (connection, send, recv, input_negotiation) = accepted409 .ok_or_else(|| format!("All input capability offers failed: {}", errors.join("; ")))?;410 if input_negotiation.session_id != media_session_id {411 return Err("Host returned mismatched media and input sessions".to_string());412 }413 let capabilities = input_negotiation.capabilities;414 // A malformed or future host must not be able to re-enable a local input415 // path that this Portal build deliberately withheld from its offers.416 let capabilities = mask_unavailable_relative_pointer_capabilities(417 capabilities,418 relative_pointer_capture_enabled(),419 );420 let availability = InputAvailability::from_capabilities(&capabilities);421 Ok(InputSession {422 connection,423 send: InputWire::V1(send),424 recv,425 capabilities,426 availability,427 })428}429430#[allow(clippy::too_many_arguments)]431pub(crate) async fn open_input_session_v2(432 endpoint: &Endpoint,433 address: &iroh::EndpointAddr,434 nonce: [u8; 16],435 media_session_id: u64,436 focus_generation: u64,437 native_generation: u64,438 session_snapshot: Arc<tokio::sync::Mutex<Option<SessionSnapshotState>>>,439 grants: InvitationGrants,440) -> Result<InputSession, String> {441 let offered = input_capability_offers(grants)442 .into_iter()443 .next()444 .unwrap_or_default();445 let connection = endpoint446 .connect(address.clone(), INPUT_ALPN_V2)447 .await448 .map_err(|error| format!("Failed to connect input v2 stream: {error}"))?;449 let (mut send, mut recv) = connection450 .open_bi()451 .await452 .map_err(|error| format!("Failed to open input v2 stream: {error}"))?;453 let hello = InputClientHelloV2::new(454 "portal/0.1.0",455 nonce,456 media_session_id,457 ControllerSlot::ZERO,458 focus_generation,459 offered.clone(),460 );461 write_input_client_hello_v2(&mut send, &hello)462 .await463 .map_err(|error| format!("Failed to send input v2 handshake: {error}"))?;464 let response =465 tokio::time::timeout(Duration::from_secs(10), read_input_host_hello_v2(&mut recv))466 .await467 .map_err(|_| "Timed out waiting for input v2 handshake".to_string())?468 .map_err(|error| format!("Invalid input v2 handshake: {error}"))?469 .ok_or_else(|| "Host closed during input v2 handshake".to_string())?;470 if !response.accepted {471 return Err(format!(472 "Host rejected input v2 stream: {}",473 response.message.as_deref().unwrap_or("unspecified reason")474 ));475 }476 if response.session_id != Some(media_session_id)477 || response.slot != Some(ControllerSlot::ZERO)478 || response.focus_generation != Some(focus_generation)479 {480 return Err("Host returned a mismatched input v2 focus binding".to_string());481 }482 if let Some(unoffered) = response483 .capabilities484 .iter()485 .find(|capability| !offered.contains(capability))486 {487 return Err(format!(488 "Host accepted unoffered input v2 capability {unoffered:?}"489 ));490 }491 let capabilities = mask_unavailable_relative_pointer_capabilities(492 response.capabilities,493 relative_pointer_capture_enabled(),494 );495 let availability = InputAvailability::from_capabilities(&capabilities);496 Ok(InputSession {497 connection,498 send: InputWire::V2 {499 send,500 session_id: media_session_id,501 slot: ControllerSlot::ZERO,502 focus_generation,503 native_generation,504 session_snapshot,505 },506 recv,507 capabilities,508 availability,509 })510}511512pub(crate) async fn start_focused_input_v2(state: &AppState) -> Result<InputAvailability, String> {513 let context = state514 .v2_input_context515 .lock()516 .await517 .clone()518 .ok_or_else(|| "No control v2 input context is active".to_string())?;519 let snapshot = state520 .session_snapshot521 .lock()522 .await523 .as_ref()524 .filter(|(generation, _)| *generation == context.native_generation)525 .map(|(_, snapshot)| snapshot.clone())526 .ok_or_else(|| "No matching control v2 session snapshot is active".to_string())?;527 let focus_generation = snapshot528 .self_focus_generation()529 .ok_or_else(|| "This Portal does not hold slot-0 input focus".to_string())?;530 if state.input_send.lock().await.is_some() {531 return Ok(v2_input_eligibility(context.grants));532 }533 let InputSession {534 connection,535 send,536 recv,537 capabilities,538 availability,539 } = open_input_session_v2(540 &context.endpoint,541 &context.address,542 context.nonce,543 context.media_session_id,544 focus_generation,545 context.native_generation,546 Arc::clone(&state.session_snapshot),547 context.grants,548 )549 .await?;550 // The v1 path records this at negotiation time. Without the same call here551 // the ack diagnostics stay "not negotiated" while the input stream really552 // did negotiate InputAck, so the host's first acknowledgement is rejected553 // as unsolicited and control v2 tears the session down.554 lock_network_diagnostics(&context.network_diagnostics)555 .set_input_ack_negotiated(availability.input_ack);556 let (tx, rx) = tokio::sync::mpsc::channel::<InputEvent>(CLIENT_INPUT_QUEUE_CAPACITY);557 *state.input_send.lock().await = Some(tx);558 *state.input_connection.lock().await = Some((context.native_generation, connection));559 if availability.pointer_position_feedback || availability.input_ack {560 tokio::spawn(run_input_feedback_v2(561 recv,562 Arc::clone(&context.network_diagnostics),563 availability.pointer_position_feedback,564 availability.input_ack,565 context.pointer_channel,566 context.media_session_id,567 focus_generation,568 ));569 } else {570 drop(recv);571 }572 tokio::spawn(run_input_forwarder(573 send,574 rx,575 capabilities,576 Arc::clone(&context.network_diagnostics),577 ));578 Ok(availability)579}580581pub(crate) async fn run_input_feedback(582 mut input_feedback: iroh::endpoint::RecvStream,583 feedback_diagnostics: Arc<StdMutex<NetworkSessionDiagnostics>>,584 mut pointer_feedback_enabled: bool,585 input_ack_enabled: bool,586 pointer_channel: Channel<PointerFeedbackPayload>,587) {588 let terminal_reason = loop {589 let response = match read_input_ack(&mut input_feedback).await {590 Ok(Some(response)) => response,591 Ok(None) => {592 lock_network_diagnostics(&feedback_diagnostics).mark_input_feedback_closed();593 break PointerFeedbackTerminalReason::Eof;594 }595 Err(error) => {596 lock_network_diagnostics(&feedback_diagnostics).mark_input_feedback_malformed();597 eprintln!("[client] invalid input feedback: {error}");598 break PointerFeedbackTerminalReason::Malformed;599 }600 };601 if let Err(error) = observe_input_ack_if_negotiated(602 &feedback_diagnostics,603 input_ack_enabled,604 response.sequence,605 Instant::now(),606 ) {607 eprintln!("[client] invalid input acknowledgement: {error}");608 break PointerFeedbackTerminalReason::Malformed;609 }610 if pointer_feedback_enabled611 && pointer_channel612 .send(PointerFeedbackPayload::Position {613 sequence: response.sequence,614 position: response.pointer_position,615 pointer_visible: response.pointer_visible,616 })617 .is_err()618 {619 // Losing the webview's pointer channel must not stop ACK620 // draining and apply backpressure to host input.621 pointer_feedback_enabled = false;622 if !input_ack_enabled {623 return;624 }625 }626 };627 // The session-owned channel emits at most one terminal message.628 // JavaScript rejects deliveries from superseded channel closures.629 let _ = pointer_channel.send(PointerFeedbackPayload::Terminal {630 reason: terminal_reason,631 });632}633634pub(crate) async fn run_input_forwarder(635 mut input_stream: InputWire,636 rx: tokio::sync::mpsc::Receiver<InputEvent>,637 input_capabilities: Vec<Capability>,638 input_send_diagnostics: Arc<StdMutex<NetworkSessionDiagnostics>>,639) {640 let mut rx = rx;641 const MOUSE_INTERVAL: Duration = Duration::from_millis(16);642 let started = Instant::now();643 let mut last_absolute_mouse_time = started.checked_sub(MOUSE_INTERVAL).unwrap_or(started);644 let mut last_relative_mouse_time = started.checked_sub(MOUSE_INTERVAL).unwrap_or(started);645 let mut pending_relative = RelativePointerAccumulator::default();646 let mut input_open = true;647648 while input_open {649 let event = if pending_relative.is_pending() {650 let wait = MOUSE_INTERVAL.saturating_sub(last_relative_mouse_time.elapsed());651 if wait.is_zero() {652 let Some(event) = pending_relative.take() else {653 continue;654 };655 if let Err(error) = write_client_input_event(656 &mut input_stream,657 &event,658 Some(&input_send_diagnostics),659 )660 .await661 {662 eprintln!("[client] input stream write failed: {error}; disconnecting");663 break;664 }665 last_relative_mouse_time = Instant::now();666 continue;667 }668 tokio::select! {669 event = rx.recv() => event,670 () = tokio::time::sleep(wait) => {671 let Some(event) = pending_relative.take() else {672 continue;673 };674 if let Err(error) = write_client_input_event(675 &mut input_stream,676 &event,677 Some(&input_send_diagnostics),678 ).await {679 eprintln!("[client] input stream write failed: {error}; disconnecting");680 break;681 }682 last_relative_mouse_time = Instant::now();683 continue;684 }685 }686 } else {687 rx.recv().await688 };689 let Some(event) = event else {690 input_open = false;691 continue;692 };693 // The host's accepted capability set is an authorization boundary.694 // Drop unavailable event classes silently so event contents never695 // reach logs or the wire even if a compromised webview invokes the696 // command directly.697 if !input_event_allowed(&input_capabilities, &event) {698 continue;699 }700 let Some(event) = stage_relative_input(&mut pending_relative, event) else {701 continue;702 };703 let mut flushed_relative_barrier = false;704 while let Some(relative_barrier) = pending_relative.take() {705 if let Err(error) = write_client_input_event(706 &mut input_stream,707 &relative_barrier,708 Some(&input_send_diagnostics),709 )710 .await711 {712 eprintln!("[client] input stream write failed: {error}; disconnecting");713 input_open = false;714 break;715 }716 flushed_relative_barrier = true;717 }718 if !input_open {719 break;720 }721 if flushed_relative_barrier {722 last_relative_mouse_time = Instant::now();723 }724 if matches!(event, InputEvent::MouseMove { .. }) {725 let now = Instant::now();726 if now.duration_since(last_absolute_mouse_time) < MOUSE_INTERVAL {727 continue;728 }729 last_absolute_mouse_time = now;730 }731 if let Err(error) =732 write_client_input_event(&mut input_stream, &event, Some(&input_send_diagnostics)).await733 {734 eprintln!("[client] input stream write failed: {error}; disconnecting");735 break;736 }737 }738 while let Some(event) = pending_relative.take() {739 if let Err(error) =740 write_client_input_event(&mut input_stream, &event, Some(&input_send_diagnostics)).await741 {742 eprintln!("[client] final relative input write failed: {error}");743 break;744 }745 }746 input_stream.finish();747}748749pub(crate) async fn run_input_feedback_v2(750 mut input_feedback: iroh::endpoint::RecvStream,751 feedback_diagnostics: Arc<StdMutex<NetworkSessionDiagnostics>>,752 mut pointer_feedback_enabled: bool,753 input_ack_enabled: bool,754 pointer_channel: Channel<PointerFeedbackPayload>,755 session_id: u64,756 focus_generation: u64,757) {758 let terminal_reason = loop {759 let response = match read_input_ack_v2(&mut input_feedback).await {760 Ok(Some(response)) => response,761 Ok(None) => break PointerFeedbackTerminalReason::Eof,762 Err(error) => {763 eprintln!("[client] invalid input v2 feedback: {error}");764 break PointerFeedbackTerminalReason::Malformed;765 }766 };767 if response.session_id != session_id768 || response.slot != ControllerSlot::ZERO769 || response.focus_generation != focus_generation770 {771 eprintln!(772 "[client] input v2 feedback binding mismatch: got session={} slot={:?} focus={}, expected session={} slot=0 focus={}",773 response.session_id,774 response.slot,775 response.focus_generation,776 session_id,777 focus_generation778 );779 break PointerFeedbackTerminalReason::Malformed;780 }781 if let Err(error) = observe_input_ack_if_negotiated(782 &feedback_diagnostics,783 input_ack_enabled,784 response.ack.sequence,785 Instant::now(),786 ) {787 eprintln!("[client] input v2 acknowledgement rejected: {error}");788 break PointerFeedbackTerminalReason::Malformed;789 }790 if pointer_feedback_enabled791 && pointer_channel792 .send(PointerFeedbackPayload::Position {793 sequence: response.ack.sequence,794 position: response.ack.pointer_position,795 pointer_visible: response.ack.pointer_visible,796 })797 .is_err()798 {799 pointer_feedback_enabled = false;800 if !input_ack_enabled {801 return;802 }803 }804 };805 let _ = pointer_channel.send(PointerFeedbackPayload::Terminal {806 reason: terminal_reason,807 });808}809810pub(crate) async fn send_input(state: &AppState, event: InputEvent) -> Result<bool, String> {811 if state.session_control.lock().await.is_some() {812 let focused = state813 .session_snapshot814 .lock()815 .await816 .as_ref()817 .is_some_and(|(_, snapshot)| snapshot.self_focus_generation().is_some());818 if !focused {819 return Ok(false);820 }821 }822 let tx = state823 .input_send824 .lock()825 .await826 .clone()827 .ok_or_else(|| "Not connected to host".to_string())?;828 match tx.try_send(event) {829 Ok(()) => Ok(true),830 Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => Ok(false),831 Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {832 Err("Input channel closed".to_string())833 }834 }835}836837#[cfg(test)]838mod tests {839 use super::*;840841 #[test]842 fn pointer_feedback_channel_has_explicit_bounded_terminal_envelopes() {843 let position = serde_json::to_value(PointerFeedbackPayload::Position {844 sequence: 7,845 position: Some(PointerPosition { x: 1280, y: 800 }),846 pointer_visible: Some(false),847 })848 .unwrap();849 assert_eq!(position["type"], "position");850 assert_eq!(position["sequence"], 7);851 assert_eq!(position["position"]["x"], 1280);852 assert_eq!(position["pointer_visible"], false);853854 let legacy = serde_json::to_value(PointerFeedbackPayload::Position {855 sequence: 8,856 position: Some(PointerPosition { x: 640, y: 400 }),857 pointer_visible: None,858 })859 .unwrap();860 assert!(legacy.get("pointer_visible").is_none());861862 let eof = serde_json::to_value(PointerFeedbackPayload::Terminal {863 reason: PointerFeedbackTerminalReason::Eof,864 })865 .unwrap();866 assert_eq!(867 eof,868 serde_json::json!({ "type": "terminal", "reason": "eof" })869 );870871 let malformed = serde_json::to_value(PointerFeedbackPayload::Terminal {872 reason: PointerFeedbackTerminalReason::Malformed,873 })874 .unwrap();875 assert_eq!(876 malformed,877 serde_json::json!({ "type": "terminal", "reason": "malformed" })878 );879 }880881 #[test]882 fn input_events_require_their_negotiated_capability() {883 let pointer = [Capability::AbsolutePointer];884 assert!(input_event_allowed(885 &pointer,886 &InputEvent::MouseMove { x: 1, y: 2 }887 ));888 assert!(input_event_allowed(889 &pointer,890 &InputEvent::MouseClick { b: 1 }891 ));892 assert!(input_event_allowed(893 &pointer,894 &InputEvent::MouseDown { b: 1 }895 ));896 assert!(input_event_allowed(&pointer, &InputEvent::MouseUp { b: 1 }));897 assert!(input_event_allowed(898 &pointer,899 &InputEvent::MouseScroll { dx: 0, dy: 1 }900 ));901 assert!(!input_event_allowed(902 &pointer,903 &InputEvent::MouseMoveRelative { dx: 1, dy: -2 }904 ));905 assert!(!input_event_allowed(906 &pointer,907 &InputEvent::MousePositionSync { x: 640, y: 400 }908 ));909 assert!(!input_event_allowed(910 &pointer,911 &InputEvent::KeyDown { k: "A".into() }912 ));913 assert!(!input_event_allowed(914 &pointer,915 &InputEvent::Text { s: "a".into() }916 ));917918 let relative_pointer = [Capability::RelativePointer];919 assert!(input_event_allowed(920 &relative_pointer,921 &InputEvent::MouseMoveRelative { dx: 1, dy: -2 }922 ));923 assert!(input_event_allowed(924 &relative_pointer,925 &InputEvent::MousePositionSync { x: 640, y: 400 }926 ));927 assert!(input_event_allowed(928 &relative_pointer,929 &InputEvent::MouseDown { b: 1 }930 ));931 assert!(input_event_allowed(932 &relative_pointer,933 &InputEvent::MouseUp { b: 1 }934 ));935 assert!(input_event_allowed(936 &relative_pointer,937 &InputEvent::MouseScroll { dx: 0, dy: 1 }938 ));939 assert!(!input_event_allowed(940 &relative_pointer,941 &InputEvent::MouseMove { x: 1, y: 2 }942 ));943944 let keyboard = [Capability::Keyboard];945 assert!(input_event_allowed(946 &keyboard,947 &InputEvent::KeyDown { k: "A".into() }948 ));949 assert!(input_event_allowed(950 &keyboard,951 &InputEvent::KeyUp { k: "A".into() }952 ));953 assert!(input_event_allowed(954 &keyboard,955 &InputEvent::KeyClick { k: "A".into() }956 ));957 assert!(!input_event_allowed(958 &keyboard,959 &InputEvent::Text { s: "a".into() }960 ));961962 let text = [Capability::Text];963 assert!(input_event_allowed(964 &text,965 &InputEvent::Text { s: "a".into() }966 ));967 assert!(!input_event_allowed(968 &text,969 &InputEvent::KeyDown { k: "A".into() }970 ));971972 let gamepad = [Capability::Gamepad];973 assert!(input_event_allowed(974 &gamepad,975 &InputEvent::Gamepad {976 state: sigil_protocol::GamepadState::default(),977 }978 ));979 assert!(!input_event_allowed(980 &keyboard,981 &InputEvent::Gamepad {982 state: sigil_protocol::GamepadState::default(),983 }984 ));985 }986987 #[test]988 fn empty_input_capabilities_are_view_only() {989 let capabilities = [];990 assert!(!input_event_allowed(991 &capabilities,992 &InputEvent::MouseMove { x: 1, y: 2 }993 ));994 assert!(!input_event_allowed(995 &capabilities,996 &InputEvent::KeyDown { k: "A".into() }997 ));998 assert!(!input_event_allowed(999 &capabilities,1000 &InputEvent::Text { s: "a".into() }1001 ));1002 assert_eq!(1003 InputAvailability::from_capabilities(&capabilities),1004 InputAvailability {1005 relative_pointer: false,1006 pointer_position_feedback: false,1007 absolute_pointer: false,1008 keyboard: false,1009 text: false,1010 gamepad: false,1011 input_ack: false,1012 control: false,1013 }1014 );1015 }10161017 #[test]1018 fn input_availability_reports_each_accepted_capability_exactly() {1019 assert_eq!(1020 InputAvailability::from_capabilities(&[Capability::AbsolutePointer, Capability::Text]),1021 InputAvailability {1022 relative_pointer: false,1023 pointer_position_feedback: false,1024 absolute_pointer: true,1025 keyboard: false,1026 text: true,1027 gamepad: false,1028 input_ack: false,1029 control: true,1030 }1031 );1032 assert_eq!(1033 InputAvailability::from_capabilities(&[Capability::Keyboard]),1034 InputAvailability {1035 relative_pointer: false,1036 pointer_position_feedback: false,1037 absolute_pointer: false,1038 keyboard: true,1039 text: false,1040 gamepad: false,1041 input_ack: false,1042 control: true,1043 }1044 );1045 assert_eq!(1046 InputAvailability::from_capabilities(&[Capability::Gamepad]),1047 InputAvailability {1048 relative_pointer: false,1049 pointer_position_feedback: false,1050 absolute_pointer: false,1051 keyboard: false,1052 text: false,1053 gamepad: true,1054 input_ack: false,1055 control: true,1056 }1057 );1058 assert!(1059 InputAvailability::from_capabilities(&[Capability::Gamepad, Capability::InputAck])1060 .input_ack1061 );1062 assert_eq!(1063 InputAvailability::from_capabilities(&[1064 Capability::RelativePointer,1065 Capability::PointerPositionFeedback,1066 ]),1067 InputAvailability {1068 relative_pointer: true,1069 pointer_position_feedback: true,1070 absolute_pointer: false,1071 keyboard: false,1072 text: false,1073 gamepad: false,1074 input_ack: false,1075 control: true,1076 }1077 );1078 }10791080 #[test]1081 fn pointer_feedback_without_input_ack_does_not_fail_ack_validation() {1082 let diagnostics = StdMutex::new(NetworkSessionDiagnostics::new(Instant::now(), false));1083 assert!(observe_input_ack_if_negotiated(&diagnostics, false, 0, Instant::now()).is_ok());1084 }10851086 #[test]1087 fn input_capability_fallbacks_remove_only_one_protocol_extension_at_a_time() {1088 let offers = input_capability_offers_for(InvitationGrants::ALL, true);1089 assert_eq!(offers.len(), 8);1090 let visibility = &offers[0];1091 let position = &offers[1];1092 let relative = &offers[2];1093 let inherited = &offers[3];10941095 assert!(1096 offers[..4]1097 .iter()1098 .all(|offer| offer.contains(&Capability::InputAck))1099 );1100 assert!(1101 offers[4..]1102 .iter()1103 .all(|offer| !offer.contains(&Capability::InputAck))1104 );1105 assert!(visibility.contains(&Capability::PointerVisibilityFeedback));1106 assert!(visibility.contains(&Capability::PointerPositionFeedback));1107 assert!(!position.contains(&Capability::PointerVisibilityFeedback));1108 assert!(position.contains(&Capability::PointerPositionFeedback));1109 assert!(!relative.contains(&Capability::PointerVisibilityFeedback));1110 assert!(!relative.contains(&Capability::PointerPositionFeedback));1111 assert!(relative.contains(&Capability::RelativePointer));1112 assert!(!inherited.contains(&Capability::RelativePointer));1113 assert_eq!(1114 inherited.as_slice(),1115 &[1116 Capability::AbsolutePointer,1117 Capability::Keyboard,1118 Capability::Text,1119 Capability::Gamepad,1120 Capability::InputAck,1121 ]1122 );1123 }11241125 #[test]1126 fn disabled_relative_capture_preserves_only_non_relative_input_offers() {1127 let offers = input_capability_offers_for(InvitationGrants::ALL, false);1128 assert_eq!(1129 offers,1130 [1131 vec![1132 Capability::AbsolutePointer,1133 Capability::Keyboard,1134 Capability::Text,1135 Capability::Gamepad,1136 Capability::InputAck,1137 ],1138 vec![1139 Capability::AbsolutePointer,1140 Capability::Keyboard,1141 Capability::Text,1142 Capability::Gamepad,1143 ],1144 ]1145 );1146 assert!(offers.iter().flatten().all(|capability| !matches!(1147 capability,1148 Capability::RelativePointer1149 | Capability::PointerPositionFeedback1150 | Capability::PointerVisibilityFeedback1151 )));1152 }11531154 #[test]1155 fn compiled_pointer_policy_controls_the_actual_negotiation_offers() {1156 let offers = input_capability_offers(InvitationGrants::ALL);1157 let offers_relative_pointer = offers1158 .iter()1159 .flatten()1160 .any(|capability| *capability == Capability::RelativePointer);1161 assert_eq!(offers_relative_pointer, relative_pointer_capture_enabled());1162 assert!(1163 offers1164 .iter()1165 .all(|offer| offer.contains(&Capability::AbsolutePointer))1166 );1167 }11681169 #[test]1170 fn negotiated_capability_mask_fails_closed_without_losing_other_input() {1171 let accepted = vec![1172 Capability::RelativePointer,1173 Capability::PointerPositionFeedback,1174 Capability::PointerVisibilityFeedback,1175 Capability::AbsolutePointer,1176 Capability::Keyboard,1177 Capability::Text,1178 Capability::Gamepad,1179 Capability::InputAck,1180 ];1181 let masked = mask_unavailable_relative_pointer_capabilities(accepted.clone(), false);1182 assert_eq!(1183 masked,1184 [1185 Capability::AbsolutePointer,1186 Capability::Keyboard,1187 Capability::Text,1188 Capability::Gamepad,1189 Capability::InputAck,1190 ]1191 );1192 assert_eq!(1193 mask_unavailable_relative_pointer_capabilities(accepted.clone(), true),1194 accepted1195 );1196 assert_eq!(1197 InputAvailability::from_capabilities(&masked),1198 InputAvailability {1199 relative_pointer: false,1200 pointer_position_feedback: false,1201 absolute_pointer: true,1202 keyboard: true,1203 text: true,1204 gamepad: true,1205 input_ack: true,1206 control: true,1207 }1208 );1209 }12101211 #[test]1212 fn local_invitation_grants_bound_input_offers_before_the_host_intersection() {1213 let view_only = input_capability_offers(InvitationGrants::VIEW);1214 assert!(view_only.iter().all(Vec::is_empty));12151216 let pointer = input_capability_offers(1217 InvitationGrants::VIEW.union(InvitationGrants::POINTER_KEYBOARD),1218 );1219 assert!(pointer[0].contains(&Capability::Keyboard));1220 assert!(pointer[0].contains(&Capability::InputAck));1221 assert!(!pointer[0].contains(&Capability::Gamepad));12221223 let gamepad =1224 input_capability_offers(InvitationGrants::VIEW.union(InvitationGrants::GAMEPAD));1225 assert_eq!(gamepad[0], vec![Capability::Gamepad, Capability::InputAck]);1226 assert_eq!(gamepad[1], vec![Capability::Gamepad]);1227 }12281229 #[test]1230 fn relative_pointer_accumulator_coalesces_chunks_and_resets() {1231 let mut accumulator = RelativePointerAccumulator::default();1232 accumulator.push(10, -20);1233 accumulator.push(5, 8);1234 assert_eq!(1235 accumulator.take(),1236 Some(InputEvent::MouseMoveRelative { dx: 15, dy: -12 })1237 );1238 assert_eq!(accumulator.take(), None);12391240 accumulator.push(RELATIVE_POINTER_DELTA_MAX, RELATIVE_POINTER_DELTA_MIN);1241 accumulator.push(1_000, -1_000);1242 assert_eq!(1243 accumulator.take(),1244 Some(InputEvent::MouseMoveRelative {1245 dx: RELATIVE_POINTER_DELTA_MAX,1246 dy: RELATIVE_POINTER_DELTA_MIN,1247 })1248 );1249 assert_eq!(1250 accumulator.take(),1251 Some(InputEvent::MouseMoveRelative {1252 dx: 1_000,1253 dy: -1_000,1254 })1255 );1256 assert_eq!(accumulator.take(), None);1257 }12581259 #[test]1260 fn relative_motion_chunks_are_staged_immediately_before_a_following_button() {1261 let mut accumulator = RelativePointerAccumulator::default();1262 assert_eq!(1263 stage_relative_input(1264 &mut accumulator,1265 InputEvent::MouseMoveRelative {1266 dx: RELATIVE_POINTER_DELTA_MAX,1267 dy: RELATIVE_POINTER_DELTA_MIN,1268 }1269 ),1270 None1271 );1272 assert_eq!(1273 stage_relative_input(1274 &mut accumulator,1275 InputEvent::MouseMoveRelative { dx: 7, dy: -1 }1276 ),1277 None1278 );1279 assert_eq!(1280 stage_relative_input(&mut accumulator, InputEvent::MouseDown { b: 1 }),1281 Some(InputEvent::MouseDown { b: 1 })1282 );1283 assert_eq!(1284 accumulator.take(),1285 Some(InputEvent::MouseMoveRelative {1286 dx: RELATIVE_POINTER_DELTA_MAX,1287 dy: RELATIVE_POINTER_DELTA_MIN,1288 })1289 );1290 assert_eq!(1291 accumulator.take(),1292 Some(InputEvent::MouseMoveRelative { dx: 7, dy: -1 })1293 );1294 assert_eq!(accumulator.take(), None);1295 }1296}