cat / goq
clone

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

ci/pipeline-ergonomics
goq / src-tauri / src / media / input_delivery.rs
input_delivery.rs 43.1K
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 let (tx, rx) = tokio::sync::mpsc::channel::<InputEvent>(CLIENT_INPUT_QUEUE_CAPACITY);551 *state.input_send.lock().await = Some(tx);552 *state.input_connection.lock().await = Some((context.native_generation, connection));553 if availability.pointer_position_feedback || availability.input_ack {554 tokio::spawn(run_input_feedback_v2(555 recv,556 Arc::clone(&context.network_diagnostics),557 availability.pointer_position_feedback,558 availability.input_ack,559 context.pointer_channel,560 context.media_session_id,561 focus_generation,562 ));563 } else {564 drop(recv);565 }566 tokio::spawn(run_input_forwarder(567 send,568 rx,569 capabilities,570 Arc::clone(&context.network_diagnostics),571 ));572 Ok(availability)573}574575pub(crate) async fn run_input_feedback(576 mut input_feedback: iroh::endpoint::RecvStream,577 feedback_diagnostics: Arc<StdMutex<NetworkSessionDiagnostics>>,578 mut pointer_feedback_enabled: bool,579 input_ack_enabled: bool,580 pointer_channel: Channel<PointerFeedbackPayload>,581) {582 let terminal_reason = loop {583 let response = match read_input_ack(&mut input_feedback).await {584 Ok(Some(response)) => response,585 Ok(None) => {586 lock_network_diagnostics(&feedback_diagnostics).mark_input_feedback_closed();587 break PointerFeedbackTerminalReason::Eof;588 }589 Err(error) => {590 lock_network_diagnostics(&feedback_diagnostics).mark_input_feedback_malformed();591 eprintln!("[client] invalid input feedback: {error}");592 break PointerFeedbackTerminalReason::Malformed;593 }594 };595 if let Err(error) = observe_input_ack_if_negotiated(596 &feedback_diagnostics,597 input_ack_enabled,598 response.sequence,599 Instant::now(),600 ) {601 eprintln!("[client] invalid input acknowledgement: {error}");602 break PointerFeedbackTerminalReason::Malformed;603 }604 if pointer_feedback_enabled605 && pointer_channel606 .send(PointerFeedbackPayload::Position {607 sequence: response.sequence,608 position: response.pointer_position,609 pointer_visible: response.pointer_visible,610 })611 .is_err()612 {613 // Losing the webview's pointer channel must not stop ACK614 // draining and apply backpressure to host input.615 pointer_feedback_enabled = false;616 if !input_ack_enabled {617 return;618 }619 }620 };621 // The session-owned channel emits at most one terminal message.622 // JavaScript rejects deliveries from superseded channel closures.623 let _ = pointer_channel.send(PointerFeedbackPayload::Terminal {624 reason: terminal_reason,625 });626}627628pub(crate) async fn run_input_forwarder(629 mut input_stream: InputWire,630 rx: tokio::sync::mpsc::Receiver<InputEvent>,631 input_capabilities: Vec<Capability>,632 input_send_diagnostics: Arc<StdMutex<NetworkSessionDiagnostics>>,633) {634 let mut rx = rx;635 const MOUSE_INTERVAL: Duration = Duration::from_millis(16);636 let started = Instant::now();637 let mut last_absolute_mouse_time = started.checked_sub(MOUSE_INTERVAL).unwrap_or(started);638 let mut last_relative_mouse_time = started.checked_sub(MOUSE_INTERVAL).unwrap_or(started);639 let mut pending_relative = RelativePointerAccumulator::default();640 let mut input_open = true;641642 while input_open {643 let event = if pending_relative.is_pending() {644 let wait = MOUSE_INTERVAL.saturating_sub(last_relative_mouse_time.elapsed());645 if wait.is_zero() {646 let Some(event) = pending_relative.take() else {647 continue;648 };649 if let Err(error) = write_client_input_event(650 &mut input_stream,651 &event,652 Some(&input_send_diagnostics),653 )654 .await655 {656 eprintln!("[client] input stream write failed: {error}; disconnecting");657 break;658 }659 last_relative_mouse_time = Instant::now();660 continue;661 }662 tokio::select! {663 event = rx.recv() => event,664 () = tokio::time::sleep(wait) => {665 let Some(event) = pending_relative.take() else {666 continue;667 };668 if let Err(error) = write_client_input_event(669 &mut input_stream,670 &event,671 Some(&input_send_diagnostics),672 ).await {673 eprintln!("[client] input stream write failed: {error}; disconnecting");674 break;675 }676 last_relative_mouse_time = Instant::now();677 continue;678 }679 }680 } else {681 rx.recv().await682 };683 let Some(event) = event else {684 input_open = false;685 continue;686 };687 // The host's accepted capability set is an authorization boundary.688 // Drop unavailable event classes silently so event contents never689 // reach logs or the wire even if a compromised webview invokes the690 // command directly.691 if !input_event_allowed(&input_capabilities, &event) {692 continue;693 }694 let Some(event) = stage_relative_input(&mut pending_relative, event) else {695 continue;696 };697 let mut flushed_relative_barrier = false;698 while let Some(relative_barrier) = pending_relative.take() {699 if let Err(error) = write_client_input_event(700 &mut input_stream,701 &relative_barrier,702 Some(&input_send_diagnostics),703 )704 .await705 {706 eprintln!("[client] input stream write failed: {error}; disconnecting");707 input_open = false;708 break;709 }710 flushed_relative_barrier = true;711 }712 if !input_open {713 break;714 }715 if flushed_relative_barrier {716 last_relative_mouse_time = Instant::now();717 }718 if matches!(event, InputEvent::MouseMove { .. }) {719 let now = Instant::now();720 if now.duration_since(last_absolute_mouse_time) < MOUSE_INTERVAL {721 continue;722 }723 last_absolute_mouse_time = now;724 }725 if let Err(error) =726 write_client_input_event(&mut input_stream, &event, Some(&input_send_diagnostics)).await727 {728 eprintln!("[client] input stream write failed: {error}; disconnecting");729 break;730 }731 }732 while let Some(event) = pending_relative.take() {733 if let Err(error) =734 write_client_input_event(&mut input_stream, &event, Some(&input_send_diagnostics)).await735 {736 eprintln!("[client] final relative input write failed: {error}");737 break;738 }739 }740 input_stream.finish();741}742743pub(crate) async fn run_input_feedback_v2(744 mut input_feedback: iroh::endpoint::RecvStream,745 feedback_diagnostics: Arc<StdMutex<NetworkSessionDiagnostics>>,746 mut pointer_feedback_enabled: bool,747 input_ack_enabled: bool,748 pointer_channel: Channel<PointerFeedbackPayload>,749 session_id: u64,750 focus_generation: u64,751) {752 let terminal_reason = loop {753 let response = match read_input_ack_v2(&mut input_feedback).await {754 Ok(Some(response)) => response,755 Ok(None) => break PointerFeedbackTerminalReason::Eof,756 Err(error) => {757 eprintln!("[client] invalid input v2 feedback: {error}");758 break PointerFeedbackTerminalReason::Malformed;759 }760 };761 if response.session_id != session_id762 || response.slot != ControllerSlot::ZERO763 || response.focus_generation != focus_generation764 {765 break PointerFeedbackTerminalReason::Malformed;766 }767 if observe_input_ack_if_negotiated(768 &feedback_diagnostics,769 input_ack_enabled,770 response.ack.sequence,771 Instant::now(),772 )773 .is_err()774 {775 break PointerFeedbackTerminalReason::Malformed;776 }777 if pointer_feedback_enabled778 && pointer_channel779 .send(PointerFeedbackPayload::Position {780 sequence: response.ack.sequence,781 position: response.ack.pointer_position,782 pointer_visible: response.ack.pointer_visible,783 })784 .is_err()785 {786 pointer_feedback_enabled = false;787 if !input_ack_enabled {788 return;789 }790 }791 };792 let _ = pointer_channel.send(PointerFeedbackPayload::Terminal {793 reason: terminal_reason,794 });795}796797pub(crate) async fn send_input(state: &AppState, event: InputEvent) -> Result<bool, String> {798 if state.session_control.lock().await.is_some() {799 let focused = state800 .session_snapshot801 .lock()802 .await803 .as_ref()804 .is_some_and(|(_, snapshot)| snapshot.self_focus_generation().is_some());805 if !focused {806 return Ok(false);807 }808 }809 let tx = state810 .input_send811 .lock()812 .await813 .clone()814 .ok_or_else(|| "Not connected to host".to_string())?;815 match tx.try_send(event) {816 Ok(()) => Ok(true),817 Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => Ok(false),818 Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {819 Err("Input channel closed".to_string())820 }821 }822}823824#[cfg(test)]825mod tests {826 use super::*;827828 #[test]829 fn pointer_feedback_channel_has_explicit_bounded_terminal_envelopes() {830 let position = serde_json::to_value(PointerFeedbackPayload::Position {831 sequence: 7,832 position: Some(PointerPosition { x: 1280, y: 800 }),833 pointer_visible: Some(false),834 })835 .unwrap();836 assert_eq!(position["type"], "position");837 assert_eq!(position["sequence"], 7);838 assert_eq!(position["position"]["x"], 1280);839 assert_eq!(position["pointer_visible"], false);840841 let legacy = serde_json::to_value(PointerFeedbackPayload::Position {842 sequence: 8,843 position: Some(PointerPosition { x: 640, y: 400 }),844 pointer_visible: None,845 })846 .unwrap();847 assert!(legacy.get("pointer_visible").is_none());848849 let eof = serde_json::to_value(PointerFeedbackPayload::Terminal {850 reason: PointerFeedbackTerminalReason::Eof,851 })852 .unwrap();853 assert_eq!(854 eof,855 serde_json::json!({ "type": "terminal", "reason": "eof" })856 );857858 let malformed = serde_json::to_value(PointerFeedbackPayload::Terminal {859 reason: PointerFeedbackTerminalReason::Malformed,860 })861 .unwrap();862 assert_eq!(863 malformed,864 serde_json::json!({ "type": "terminal", "reason": "malformed" })865 );866 }867868 #[test]869 fn input_events_require_their_negotiated_capability() {870 let pointer = [Capability::AbsolutePointer];871 assert!(input_event_allowed(872 &pointer,873 &InputEvent::MouseMove { x: 1, y: 2 }874 ));875 assert!(input_event_allowed(876 &pointer,877 &InputEvent::MouseClick { b: 1 }878 ));879 assert!(input_event_allowed(880 &pointer,881 &InputEvent::MouseDown { b: 1 }882 ));883 assert!(input_event_allowed(&pointer, &InputEvent::MouseUp { b: 1 }));884 assert!(input_event_allowed(885 &pointer,886 &InputEvent::MouseScroll { dx: 0, dy: 1 }887 ));888 assert!(!input_event_allowed(889 &pointer,890 &InputEvent::MouseMoveRelative { dx: 1, dy: -2 }891 ));892 assert!(!input_event_allowed(893 &pointer,894 &InputEvent::MousePositionSync { x: 640, y: 400 }895 ));896 assert!(!input_event_allowed(897 &pointer,898 &InputEvent::KeyDown { k: "A".into() }899 ));900 assert!(!input_event_allowed(901 &pointer,902 &InputEvent::Text { s: "a".into() }903 ));904905 let relative_pointer = [Capability::RelativePointer];906 assert!(input_event_allowed(907 &relative_pointer,908 &InputEvent::MouseMoveRelative { dx: 1, dy: -2 }909 ));910 assert!(input_event_allowed(911 &relative_pointer,912 &InputEvent::MousePositionSync { x: 640, y: 400 }913 ));914 assert!(input_event_allowed(915 &relative_pointer,916 &InputEvent::MouseDown { b: 1 }917 ));918 assert!(input_event_allowed(919 &relative_pointer,920 &InputEvent::MouseUp { b: 1 }921 ));922 assert!(input_event_allowed(923 &relative_pointer,924 &InputEvent::MouseScroll { dx: 0, dy: 1 }925 ));926 assert!(!input_event_allowed(927 &relative_pointer,928 &InputEvent::MouseMove { x: 1, y: 2 }929 ));930931 let keyboard = [Capability::Keyboard];932 assert!(input_event_allowed(933 &keyboard,934 &InputEvent::KeyDown { k: "A".into() }935 ));936 assert!(input_event_allowed(937 &keyboard,938 &InputEvent::KeyUp { k: "A".into() }939 ));940 assert!(input_event_allowed(941 &keyboard,942 &InputEvent::KeyClick { k: "A".into() }943 ));944 assert!(!input_event_allowed(945 &keyboard,946 &InputEvent::Text { s: "a".into() }947 ));948949 let text = [Capability::Text];950 assert!(input_event_allowed(951 &text,952 &InputEvent::Text { s: "a".into() }953 ));954 assert!(!input_event_allowed(955 &text,956 &InputEvent::KeyDown { k: "A".into() }957 ));958959 let gamepad = [Capability::Gamepad];960 assert!(input_event_allowed(961 &gamepad,962 &InputEvent::Gamepad {963 state: sigil_protocol::GamepadState::default(),964 }965 ));966 assert!(!input_event_allowed(967 &keyboard,968 &InputEvent::Gamepad {969 state: sigil_protocol::GamepadState::default(),970 }971 ));972 }973974 #[test]975 fn empty_input_capabilities_are_view_only() {976 let capabilities = [];977 assert!(!input_event_allowed(978 &capabilities,979 &InputEvent::MouseMove { x: 1, y: 2 }980 ));981 assert!(!input_event_allowed(982 &capabilities,983 &InputEvent::KeyDown { k: "A".into() }984 ));985 assert!(!input_event_allowed(986 &capabilities,987 &InputEvent::Text { s: "a".into() }988 ));989 assert_eq!(990 InputAvailability::from_capabilities(&capabilities),991 InputAvailability {992 relative_pointer: false,993 pointer_position_feedback: false,994 absolute_pointer: false,995 keyboard: false,996 text: false,997 gamepad: false,998 input_ack: false,999 control: false,1000 }1001 );1002 }10031004 #[test]1005 fn input_availability_reports_each_accepted_capability_exactly() {1006 assert_eq!(1007 InputAvailability::from_capabilities(&[Capability::AbsolutePointer, Capability::Text]),1008 InputAvailability {1009 relative_pointer: false,1010 pointer_position_feedback: false,1011 absolute_pointer: true,1012 keyboard: false,1013 text: true,1014 gamepad: false,1015 input_ack: false,1016 control: true,1017 }1018 );1019 assert_eq!(1020 InputAvailability::from_capabilities(&[Capability::Keyboard]),1021 InputAvailability {1022 relative_pointer: false,1023 pointer_position_feedback: false,1024 absolute_pointer: false,1025 keyboard: true,1026 text: false,1027 gamepad: false,1028 input_ack: false,1029 control: true,1030 }1031 );1032 assert_eq!(1033 InputAvailability::from_capabilities(&[Capability::Gamepad]),1034 InputAvailability {1035 relative_pointer: false,1036 pointer_position_feedback: false,1037 absolute_pointer: false,1038 keyboard: false,1039 text: false,1040 gamepad: true,1041 input_ack: false,1042 control: true,1043 }1044 );1045 assert!(1046 InputAvailability::from_capabilities(&[Capability::Gamepad, Capability::InputAck])1047 .input_ack1048 );1049 assert_eq!(1050 InputAvailability::from_capabilities(&[1051 Capability::RelativePointer,1052 Capability::PointerPositionFeedback,1053 ]),1054 InputAvailability {1055 relative_pointer: true,1056 pointer_position_feedback: true,1057 absolute_pointer: false,1058 keyboard: false,1059 text: false,1060 gamepad: false,1061 input_ack: false,1062 control: true,1063 }1064 );1065 }10661067 #[test]1068 fn pointer_feedback_without_input_ack_does_not_fail_ack_validation() {1069 let diagnostics = StdMutex::new(NetworkSessionDiagnostics::new(Instant::now(), false));1070 assert!(observe_input_ack_if_negotiated(&diagnostics, false, 0, Instant::now()).is_ok());1071 }10721073 #[test]1074 fn input_capability_fallbacks_remove_only_one_protocol_extension_at_a_time() {1075 let offers = input_capability_offers_for(InvitationGrants::ALL, true);1076 assert_eq!(offers.len(), 8);1077 let visibility = &offers[0];1078 let position = &offers[1];1079 let relative = &offers[2];1080 let inherited = &offers[3];10811082 assert!(1083 offers[..4]1084 .iter()1085 .all(|offer| offer.contains(&Capability::InputAck))1086 );1087 assert!(1088 offers[4..]1089 .iter()1090 .all(|offer| !offer.contains(&Capability::InputAck))1091 );1092 assert!(visibility.contains(&Capability::PointerVisibilityFeedback));1093 assert!(visibility.contains(&Capability::PointerPositionFeedback));1094 assert!(!position.contains(&Capability::PointerVisibilityFeedback));1095 assert!(position.contains(&Capability::PointerPositionFeedback));1096 assert!(!relative.contains(&Capability::PointerVisibilityFeedback));1097 assert!(!relative.contains(&Capability::PointerPositionFeedback));1098 assert!(relative.contains(&Capability::RelativePointer));1099 assert!(!inherited.contains(&Capability::RelativePointer));1100 assert_eq!(1101 inherited.as_slice(),1102 &[1103 Capability::AbsolutePointer,1104 Capability::Keyboard,1105 Capability::Text,1106 Capability::Gamepad,1107 Capability::InputAck,1108 ]1109 );1110 }11111112 #[test]1113 fn disabled_relative_capture_preserves_only_non_relative_input_offers() {1114 let offers = input_capability_offers_for(InvitationGrants::ALL, false);1115 assert_eq!(1116 offers,1117 [1118 vec![1119 Capability::AbsolutePointer,1120 Capability::Keyboard,1121 Capability::Text,1122 Capability::Gamepad,1123 Capability::InputAck,1124 ],1125 vec![1126 Capability::AbsolutePointer,1127 Capability::Keyboard,1128 Capability::Text,1129 Capability::Gamepad,1130 ],1131 ]1132 );1133 assert!(offers.iter().flatten().all(|capability| !matches!(1134 capability,1135 Capability::RelativePointer1136 | Capability::PointerPositionFeedback1137 | Capability::PointerVisibilityFeedback1138 )));1139 }11401141 #[test]1142 fn compiled_pointer_policy_controls_the_actual_negotiation_offers() {1143 let offers = input_capability_offers(InvitationGrants::ALL);1144 let offers_relative_pointer = offers1145 .iter()1146 .flatten()1147 .any(|capability| *capability == Capability::RelativePointer);1148 assert_eq!(offers_relative_pointer, relative_pointer_capture_enabled());1149 assert!(1150 offers1151 .iter()1152 .all(|offer| offer.contains(&Capability::AbsolutePointer))1153 );1154 }11551156 #[test]1157 fn negotiated_capability_mask_fails_closed_without_losing_other_input() {1158 let accepted = vec![1159 Capability::RelativePointer,1160 Capability::PointerPositionFeedback,1161 Capability::PointerVisibilityFeedback,1162 Capability::AbsolutePointer,1163 Capability::Keyboard,1164 Capability::Text,1165 Capability::Gamepad,1166 Capability::InputAck,1167 ];1168 let masked = mask_unavailable_relative_pointer_capabilities(accepted.clone(), false);1169 assert_eq!(1170 masked,1171 [1172 Capability::AbsolutePointer,1173 Capability::Keyboard,1174 Capability::Text,1175 Capability::Gamepad,1176 Capability::InputAck,1177 ]1178 );1179 assert_eq!(1180 mask_unavailable_relative_pointer_capabilities(accepted.clone(), true),1181 accepted1182 );1183 assert_eq!(1184 InputAvailability::from_capabilities(&masked),1185 InputAvailability {1186 relative_pointer: false,1187 pointer_position_feedback: false,1188 absolute_pointer: true,1189 keyboard: true,1190 text: true,1191 gamepad: true,1192 input_ack: true,1193 control: true,1194 }1195 );1196 }11971198 #[test]1199 fn local_invitation_grants_bound_input_offers_before_the_host_intersection() {1200 let view_only = input_capability_offers(InvitationGrants::VIEW);1201 assert!(view_only.iter().all(Vec::is_empty));12021203 let pointer = input_capability_offers(1204 InvitationGrants::VIEW.union(InvitationGrants::POINTER_KEYBOARD),1205 );1206 assert!(pointer[0].contains(&Capability::Keyboard));1207 assert!(pointer[0].contains(&Capability::InputAck));1208 assert!(!pointer[0].contains(&Capability::Gamepad));12091210 let gamepad =1211 input_capability_offers(InvitationGrants::VIEW.union(InvitationGrants::GAMEPAD));1212 assert_eq!(gamepad[0], vec![Capability::Gamepad, Capability::InputAck]);1213 assert_eq!(gamepad[1], vec![Capability::Gamepad]);1214 }12151216 #[test]1217 fn relative_pointer_accumulator_coalesces_chunks_and_resets() {1218 let mut accumulator = RelativePointerAccumulator::default();1219 accumulator.push(10, -20);1220 accumulator.push(5, 8);1221 assert_eq!(1222 accumulator.take(),1223 Some(InputEvent::MouseMoveRelative { dx: 15, dy: -12 })1224 );1225 assert_eq!(accumulator.take(), None);12261227 accumulator.push(RELATIVE_POINTER_DELTA_MAX, RELATIVE_POINTER_DELTA_MIN);1228 accumulator.push(1_000, -1_000);1229 assert_eq!(1230 accumulator.take(),1231 Some(InputEvent::MouseMoveRelative {1232 dx: RELATIVE_POINTER_DELTA_MAX,1233 dy: RELATIVE_POINTER_DELTA_MIN,1234 })1235 );1236 assert_eq!(1237 accumulator.take(),1238 Some(InputEvent::MouseMoveRelative {1239 dx: 1_000,1240 dy: -1_000,1241 })1242 );1243 assert_eq!(accumulator.take(), None);1244 }12451246 #[test]1247 fn relative_motion_chunks_are_staged_immediately_before_a_following_button() {1248 let mut accumulator = RelativePointerAccumulator::default();1249 assert_eq!(1250 stage_relative_input(1251 &mut accumulator,1252 InputEvent::MouseMoveRelative {1253 dx: RELATIVE_POINTER_DELTA_MAX,1254 dy: RELATIVE_POINTER_DELTA_MIN,1255 }1256 ),1257 None1258 );1259 assert_eq!(1260 stage_relative_input(1261 &mut accumulator,1262 InputEvent::MouseMoveRelative { dx: 7, dy: -1 }1263 ),1264 None1265 );1266 assert_eq!(1267 stage_relative_input(&mut accumulator, InputEvent::MouseDown { b: 1 }),1268 Some(InputEvent::MouseDown { b: 1 })1269 );1270 assert_eq!(1271 accumulator.take(),1272 Some(InputEvent::MouseMoveRelative {1273 dx: RELATIVE_POINTER_DELTA_MAX,1274 dy: RELATIVE_POINTER_DELTA_MIN,1275 })1276 );1277 assert_eq!(1278 accumulator.take(),1279 Some(InputEvent::MouseMoveRelative { dx: 7, dy: -1 })1280 );1281 assert_eq!(accumulator.take(), None);1282 }1283}