1use super::session::InputLease;2use super::*;34pub(super) const MEDIA_CAPABILITIES: &[Capability] = &[Capability::VideoH264];5const AUDIO_CAPABILITIES: &[Capability] = &[Capability::AudioOpus];6pub(super) const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);7const INPUT_ACK_TIMEOUT: Duration = Duration::from_secs(1);8const REJECTION_DRAIN_TIMEOUT: Duration = Duration::from_secs(1);91011struct InputSessionGuard<F>12where13 F: FnOnce() -> Result<()>,14{15 _lease: InputLease,16 reset: Option<F>,17}1819impl<F> InputSessionGuard<F>20where21 F: FnOnce() -> Result<()>,22{23 fn new(lease: InputLease, reset: F) -> Self {24 Self {25 _lease: lease,26 reset: Some(reset),27 }28 }2930 fn finish(mut self) -> Result<()> {31 self.run()32 }3334 fn run(&mut self) -> Result<()> {35 let Some(reset) = self.reset.take() else {36 return Ok(());37 };38 reset()39 }40}4142impl<F> Drop for InputSessionGuard<F>43where44 F: FnOnce() -> Result<()>,45{46 fn drop(&mut self) {47 if let Err(error) = self.run() {48 error!(%error, "failed to release held input transitions while dropping session");49 }50 }51}525354pub struct MediaV3Handler {55 pub config: HostConfig,56 pub sessions: Arc<SessionRegistry>,57 pub authorization: AuthorizationPolicy,58}596061pub struct ControlHandler {62 pub config: HostConfig,63 pub sessions: Arc<SessionRegistry>,64 pub authorization: AuthorizationPolicy,65}666768pub struct MediaFeedbackHandler {69 pub config: HostConfig,70 pub sessions: Arc<SessionRegistry>,71 pub authorization: AuthorizationPolicy,72}7374/// Upstream MoQ admission guarded by an already-authenticated control lease.75///76/// This deliberately does not use `iroh_moq::Moq::protocol_handler`: that77/// actor makes a completed session globally visible before application-level78/// acceptance. Consuming the exact pending attachment first prevents MoQ from79/// bypassing Sigil's invitation, enrollment, and one-client gate.8081pub struct AuthorizedMoqHandler {82 pub sessions: Arc<SessionRegistry>,83 pub origin: Origin,84}8586impl ProtocolHandler for MediaV3Handler {87 async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {88 let remote = connection.remote_id();89 if let Err(error) = serve_media_v3(90 connection,91 self.config.clone(),92 &self.sessions,93 &self.authorization,94 )95 .await96 {97 warn!(%remote, %error, "media v3 connection ended");98 }99 Ok(())100 }101}102103impl ProtocolHandler for ControlHandler {104 async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {105 let remote = connection.remote_id();106 if let Err(error) = serve_control_moq(107 connection,108 self.config.clone(),109 &self.sessions,110 &self.authorization,111 )112 .await113 {114 warn!(%remote, %error, "MoQ control connection ended");115 }116 Ok(())117 }118}119120impl ProtocolHandler for MediaFeedbackHandler {121 async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {122 let remote = connection.remote_id();123 if let Err(error) = serve_media_feedback(124 connection,125 &self.config,126 &self.sessions,127 &self.authorization,128 )129 .await130 {131 warn!(%remote, %error, "media feedback connection ended");132 }133 Ok(())134 }135}136137impl ProtocolHandler for AuthorizedMoqHandler {138 async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {139 let remote = connection.remote_id();140 let attachment = match self.sessions.claim_moq(remote) {141 Ok(attachment) => attachment,142 Err(error) => {143 connection.close(MOQ_REJECT_CODE.into(), b"unauthorized MoQ attachment");144 warn!(%remote, %error, "rejected unsolicited MoQ connection");145 return Ok(());146 }147 };148 if let Err(error) = serve_authorized_moq(connection, self.origin, attachment).await {149 warn!(%remote, %error, "authorized MoQ connection ended");150 }151 Ok(())152 }153}154155156pub struct InputHandler {157 pub backend: InputBackend,158 pub pointer_positions: Option<PointerPositionTracker>,159 pub sessions: Arc<SessionRegistry>,160}161162163pub struct AudioHandler {164 pub config: HostConfig,165 pub sessions: Arc<SessionRegistry>,166}167168impl ProtocolHandler for AudioHandler {169 async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {170 let remote = connection.remote_id();171 if let Err(error) = serve_audio(connection, self.config.clone(), &self.sessions).await {172 warn!(%remote, %error, "audio connection ended");173 }174 Ok(())175 }176}177178impl ProtocolHandler for InputHandler {179 async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {180 let remote = connection.remote_id();181 if let Err(error) = serve_input(182 connection,183 &self.backend,184 self.pointer_positions.as_ref(),185 &self.sessions,186 )187 .await188 {189 warn!(%remote, %error, "input connection ended");190 }191 Ok(())192 }193}194195async fn serve_input(196 connection: Connection,197 backend: &InputBackend,198 pointer_positions: Option<&PointerPositionTracker>,199 sessions: &Arc<SessionRegistry>,200) -> Result<()> {201 let remote = connection.remote_id();202 let handshake_permit = sessions203 .pending_handshakes204 .try_acquire()205 .context("too many pending handshakes")?;206 let (mut send, mut recv) = tokio::time::timeout(HANDSHAKE_TIMEOUT, connection.accept_bi())207 .await208 .context("timed out accepting input stream")?209 .context("accepting input stream")?;210 let hello = receive_hello_unconstrained(&mut recv).await?;211 drop(handshake_permit);212 debug!(%remote, agent = %hello.agent, "input hello received");213 ensure!(214 hello.invitation.is_none(),215 "invitations are accepted only on the first media handshake"216 );217 let lease = match sessions.claim_input(remote, hello.nonce) {218 Ok(lease) => lease,219 Err(error) => {220 send_rejection(&mut send, error.to_string()).await?;221 return Err(error);222 }223 };224 let session_id = lease.session_id;225 let grants = lease.grants;226 // Owning the lease guarantees cancellation or unwinding neutralizes the227 // virtual devices before another input stream can be admitted.228 let session_guard = InputSessionGuard::new(lease, || backend.reset_session());229 let supported =230 supported_input_capabilities(backend.capabilities(), pointer_positions.is_some());231 let negotiated = negotiated_input_capabilities(&hello, &supported, grants);232233 let ack_enabled = negotiated.contains(&Capability::InputAck);234 let feedback_enabled = negotiated.contains(&Capability::PointerPositionFeedback);235 let visibility_feedback_enabled = negotiated.contains(&Capability::PointerVisibilityFeedback);236 let mut pointer_positions = pointer_positions237 .filter(|_| feedback_enabled)238 .map(PointerPositionTracker::subscribe);239 write_host_hello(240 &mut send,241 &HostHello::accepted(session_id, negotiated.clone()),242 )243 .await?;244 info!(%remote, session_id, "input client accepted");245246 let session_result: Result<()> = async {247 let mut received_events = 0_u64;248 if let Some(pointer_positions) = pointer_positions.as_ref() {249 let pointer_state = *pointer_positions.borrow();250 let (pointer_position, pointer_visible) =251 pointer_feedback_fields(Some(pointer_state), visibility_feedback_enabled);252 tokio::time::timeout(253 INPUT_ACK_TIMEOUT,254 write_input_ack(255 &mut send,256 &InputAck {257 sequence: received_events,258 pointer_position,259 pointer_visible,260 },261 ),262 )263 .await264 .context("timed out writing initial pointer position")??;265 }266 loop {267 if !sessions.is_active(remote, session_id) {268 debug!(%remote, session_id, "media session ended; closing input");269 break;270 }271 tokio::select! {272 _ = sessions.session_changed.notified() => continue,273 changed = async {274 pointer_positions275 .as_mut()276 .expect("feedback branch is guarded")277 .changed()278 .await279 }, if pointer_positions.is_some() => {280 changed.context("Xwayland pointer tracker stopped")?;281 let pointer_state = {282 let receiver = pointer_positions283 .as_mut()284 .expect("feedback branch is guarded");285 *receiver.borrow_and_update()286 };287 let (pointer_position, pointer_visible) =288 pointer_feedback_fields(Some(pointer_state), visibility_feedback_enabled);289 tokio::time::timeout(290 INPUT_ACK_TIMEOUT,291 write_input_ack(292 &mut send,293 &InputAck {294 sequence: received_events,295 pointer_position,296 pointer_visible,297 },298 ),299 )300 .await301 .context("timed out writing pointer position feedback")??;302 }303 event = read_input_event(&mut recv) => {304 let Some(event) = event? else {305 break;306 };307 if !sessions.is_active(remote, session_id) {308 debug!(%remote, session_id, "discarding input after media ended");309 break;310 }311 match backend.apply(&event, &negotiated)? {312 InputDisposition::Probed => {313 debug!(%remote, "input liveness probe acknowledged");314 }315 InputDisposition::Observed => {316 info!(%remote, event_type = input_event_type(&event), "input event observed");317 }318 InputDisposition::Disabled => {319 debug!(%remote, event_type = input_event_type(&event), "input event ignored because injection is disabled");320 }321 322 InputDisposition::Injected => {323 debug!(%remote, event_type = input_event_type(&event), "input event injected");324 }325 InputDisposition::TextIgnored => {326 debug!(%remote, event_type = "text", "text input is unsupported and was ignored");327 }328 }329 received_events = received_events.saturating_add(1);330 if ack_enabled {331 let pointer_state = pointer_positions332 .as_ref()333 .map(|positions| *positions.borrow());334 let (pointer_position, pointer_visible) =335 pointer_feedback_fields(pointer_state, visibility_feedback_enabled);336 tokio::time::timeout(337 INPUT_ACK_TIMEOUT,338 write_input_ack(339 &mut send,340 &InputAck {341 sequence: received_events,342 pointer_position,343 pointer_visible,344 },345 ),346 )347 .await348 .context("timed out writing input acknowledgment")??;349 }350 }351 }352 }353 Ok(())354 }355 .await;356 let reset_result = session_guard357 .finish()358 .context("releasing held input transitions at session end");359 if session_result.is_err()360 && let Err(error) = &reset_result361 {362 error!(%error, "input session and held-transition release both failed");363 }364 let result = session_result.and(reset_result);365 info!(%remote, "input client released");366 result367}368369async fn serve_audio(370 connection: Connection,371 config: HostConfig,372 sessions: &Arc<SessionRegistry>,373) -> Result<()> {374 let remote = connection.remote_id();375 let handshake_permit = sessions376 .pending_handshakes377 .try_acquire()378 .context("too many pending handshakes")?;379 let (mut send, mut recv) = tokio::time::timeout(HANDSHAKE_TIMEOUT, connection.accept_bi())380 .await381 .context("timed out accepting audio handshake stream")?382 .context("accepting audio handshake stream")?;383 let hello = receive_hello(&mut recv, Capability::AudioOpus).await?;384 drop(handshake_permit);385 debug!(%remote, agent = %hello.agent, "audio hello received");386 ensure!(387 hello.invitation.is_none(),388 "invitations are accepted only on the first media handshake"389 );390391 if config.audio.is_none() {392 send_rejection(&mut send, "audio is unavailable").await?;393 bail!("audio is not configured");394 }395 let maximum_datagram = connection.max_datagram_size();396 if maximum_datagram.is_none_or(|maximum| maximum < AUDIO_HEADER_LEN + MAX_AUDIO_PAYLOAD_LEN) {397 send_rejection(&mut send, "peer cannot carry v1 audio datagrams").await?;398 bail!(399 "peer audio datagram limit {:?} is below {}",400 maximum_datagram,401 AUDIO_HEADER_LEN + MAX_AUDIO_PAYLOAD_LEN402 );403 }404 let lease = match sessions.claim_audio(remote, hello.nonce) {405 Ok(lease) => lease,406 Err(error) => {407 send_rejection(&mut send, error.to_string()).await?;408 return Err(error);409 }410 };411 ensure!(412 lease.grants.contains(InvitationGrants::VIEW),413 "active Portal session lacks audio view permission"414 );415 let (mut audio_receiver, audio_task) =416 match spawn_pipewire_audio(config, lease.session_clock).await {417 Ok(source) => source,418 Err(error) => {419 send_rejection(&mut send, "audio source is unavailable").await?;420 return Err(error);421 }422 };423 let audio_task = SourceTaskGuard::new(audio_task);424 write_host_hello(425 &mut send,426 &HostHello::accepted(427 lease.session_id,428 negotiated_capabilities(&hello, AUDIO_CAPABILITIES),429 ),430 )431 .await?;432 send.finish()?;433 info!(%remote, session_id = lease.session_id, "audio client accepted");434435 let session_result: Result<()> = async {436 loop {437 if !sessions.is_active(remote, lease.session_id) {438 break;439 }440 tokio::select! {441 _ = sessions.session_changed.notified() => continue,442 packet = audio_receiver.recv() => {443 let packet = packet.context("audio source stopped")?;444 let flags = if packet.discontinuity {445 AudioFlags::DISCONTINUITY446 } else {447 AudioFlags::NONE448 };449 let header = AudioPacketHeader::opus(450 packet.payload.len(),451 packet.sequence,452 packet.capture_timestamp_us,453 packet.pts_us,454 flags,455 )?;456 let datagram = AudioPacket::new(header, packet.payload.as_ref().to_vec())?457 .encode_datagram()?;458 match connection.send_datagram(datagram.into()) {459 Ok(()) => {}460 Err(error) => {461 // The non-waiting API bounds the QUIC datagram buffer by462 // evicting stale datagrams. Its errors mean the negotiated463 // path cannot carry the fixed v1 packet and are terminal.464 return Err(error).context("sending bounded audio datagram");465 }466 }467 }468 result = connection.closed() => {469 debug!(%remote, ?result, "audio connection closed");470 break;471 }472 }473 }474 Ok(())475 }476 .await;477 audio_task.abort_and_wait().await;478 drop(lease);479 info!(%remote, "audio client released");480 session_result481}482483pub(super) async fn send_rejection(484 send: &mut iroh::endpoint::SendStream,485 message: impl Into<String>,486) -> Result<()> {487 write_host_hello(send, &HostHello::rejected(message)).await?;488 send.finish()?;489 if tokio::time::timeout(REJECTION_DRAIN_TIMEOUT, send.stopped())490 .await491 .is_err()492 {493 debug!("timed out waiting for peer to acknowledge handshake rejection");494 }495 Ok(())496}497498pub(super) fn negotiated_capabilities(499 hello: &ClientHello,500 supported: &[Capability],501) -> Vec<Capability> {502 supported503 .iter()504 .copied()505 .filter(|capability| hello.capabilities.contains(capability))506 .collect()507}508509fn negotiated_input_capabilities(510 hello: &ClientHello,511 supported: &[Capability],512 grants: InvitationGrants,513) -> Vec<Capability> {514 let mut negotiated = negotiated_capabilities(hello, supported);515 negotiated.retain(|capability| input_capability_authorized(*capability, grants));516 if !negotiated.contains(&Capability::PointerPositionFeedback) {517 negotiated.retain(|capability| *capability != Capability::PointerVisibilityFeedback);518 }519 negotiated520}521522fn input_capability_authorized(capability: Capability, grants: InvitationGrants) -> bool {523 match capability {524 Capability::AbsolutePointer525 | Capability::RelativePointer526 | Capability::Keyboard527 | Capability::Text528 | Capability::PointerPositionFeedback529 | Capability::PointerVisibilityFeedback => {530 grants.contains(InvitationGrants::POINTER_KEYBOARD)531 }532 Capability::Gamepad => grants.contains(InvitationGrants::GAMEPAD),533 Capability::InputAck => {534 grants.contains(InvitationGrants::POINTER_KEYBOARD)535 || grants.contains(InvitationGrants::GAMEPAD)536 }537 Capability::VideoH264 | Capability::AudioOpus => false,538 }539}540541fn pointer_feedback_fields(542 pointer_state: Option<PointerState>,543 visibility_feedback_enabled: bool,544) -> (Option<sigil_protocol::PointerPosition>, Option<bool>) {545 match pointer_state {546 Some(state) if visibility_feedback_enabled => (state.position, Some(state.visible)),547 Some(state) => (state.position.filter(|_| state.visible), None),548 None => (None, None),549 }550}551552fn supported_input_capabilities(553 backend: &[Capability],554 pointer_feedback_available: bool,555) -> Vec<Capability> {556 let mut supported = backend.to_vec();557 if pointer_feedback_available && supported.contains(&Capability::RelativePointer) {558 supported.push(Capability::PointerPositionFeedback);559 supported.push(Capability::PointerVisibilityFeedback);560 }561 supported562}563564fn input_event_type(event: &sigil_protocol::InputEvent) -> &'static str {565 match event {566 sigil_protocol::InputEvent::Probe => "probe",567 sigil_protocol::InputEvent::MouseMove { .. } => "mouse-move",568 sigil_protocol::InputEvent::MouseMoveRelative { .. } => "mouse-move-relative",569 sigil_protocol::InputEvent::MousePositionSync { .. } => "mouse-position-sync",570 sigil_protocol::InputEvent::MouseClick { .. } => "mouse-click",571 sigil_protocol::InputEvent::MouseDown { .. } => "mouse-down",572 sigil_protocol::InputEvent::MouseUp { .. } => "mouse-up",573 sigil_protocol::InputEvent::MouseScroll { .. } => "mouse-scroll",574 sigil_protocol::InputEvent::KeyDown { .. } => "key-down",575 sigil_protocol::InputEvent::KeyUp { .. } => "key-up",576 sigil_protocol::InputEvent::KeyClick { .. } => "key-click",577 sigil_protocol::InputEvent::Text { .. } => "text",578 sigil_protocol::InputEvent::Gamepad { .. } => "gamepad-snapshot",579 }580}581582pub(super) async fn receive_hello<R>(reader: &mut R, required: Capability) -> Result<ClientHello>583where584 R: tokio::io::AsyncRead + Unpin,585{586 let hello = tokio::time::timeout(HANDSHAKE_TIMEOUT, read_client_hello(reader))587 .await588 .context("timed out waiting for client hello")??589 .context("client closed before hello")?;590 ensure!(591 hello.capabilities.contains(&required),592 "client did not offer required capability {required:?}"593 );594 Ok(hello)595}596597async fn receive_hello_unconstrained<R>(reader: &mut R) -> Result<ClientHello>598where599 R: tokio::io::AsyncRead + Unpin,600{601 tokio::time::timeout(HANDSHAKE_TIMEOUT, read_client_hello(reader))602 .await603 .context("timed out waiting for client hello")??604 .context("client closed before hello")605}606607608mod tests {609 use std::cell::Cell;610 use std::panic::{AssertUnwindSafe, catch_unwind};611 use std::sync::atomic::{AtomicBool, Ordering};612613 use super::*;614615 616 fn input_session_guard_resets_before_releasing_lease_on_drop() {617 let sessions = Arc::new(SessionRegistry::default());618 let remote = endpoint(1);619 let nonce = [1; 16];620 let _media = sessions621 .claim(remote, nonce, InvitationGrants::ALL)622 .unwrap();623 let input = sessions.claim_input(remote, nonce).unwrap();624 let resets = Cell::new(0);625 let reset_saw_lease_held = Cell::new(false);626627 {628 let _guard = InputSessionGuard::new(input, || {629 resets.set(resets.get() + 1);630 reset_saw_lease_held.set(sessions.claim_input(remote, nonce).is_err());631 Ok(())632 });633 }634635 assert_eq!(resets.get(), 1);636 assert!(reset_saw_lease_held.get());637 assert!(sessions.claim_input(remote, nonce).is_ok());638 }639640 641 fn input_session_guard_finish_propagates_reset_error_once() {642 let sessions = Arc::new(SessionRegistry::default());643 let remote = endpoint(1);644 let nonce = [2; 16];645 let _media = sessions646 .claim(remote, nonce, InvitationGrants::ALL)647 .unwrap();648 let input = sessions.claim_input(remote, nonce).unwrap();649 let resets = Cell::new(0);650651 let error = InputSessionGuard::new(input, || {652 resets.set(resets.get() + 1);653 Err(anyhow::anyhow!("reset failed"))654 })655 .finish()656 .unwrap_err();657658 assert_eq!(error.to_string(), "reset failed");659 assert_eq!(resets.get(), 1);660 assert!(sessions.claim_input(remote, nonce).is_ok());661 }662663 664 fn input_session_guard_resets_before_releasing_lease_during_unwind() {665 let sessions = Arc::new(SessionRegistry::default());666 let remote = endpoint(1);667 let nonce = [3; 16];668 let _media = sessions669 .claim(remote, nonce, InvitationGrants::ALL)670 .unwrap();671 let input = sessions.claim_input(remote, nonce).unwrap();672 let reset_saw_lease_held = Cell::new(false);673674 let panic_result = catch_unwind(AssertUnwindSafe(|| {675 let _guard = InputSessionGuard::new(input, || {676 reset_saw_lease_held.set(sessions.claim_input(remote, nonce).is_err());677 Ok(())678 });679 panic!("simulate input task panic");680 }));681682 assert!(panic_result.is_err());683 assert!(reset_saw_lease_held.get());684 assert!(sessions.claim_input(remote, nonce).is_ok());685 }686687 688 async fn input_session_guard_resets_before_releasing_lease_when_task_is_cancelled() {689 let sessions = Arc::new(SessionRegistry::default());690 let remote = endpoint(1);691 let nonce = [4; 16];692 let _media = sessions693 .claim(remote, nonce, InvitationGrants::ALL)694 .unwrap();695 let input = sessions.claim_input(remote, nonce).unwrap();696 let reset_saw_lease_held = Arc::new(AtomicBool::new(false));697 let task_sessions = Arc::clone(&sessions);698 let task_reset_saw_lease_held = Arc::clone(&reset_saw_lease_held);699 let (armed_tx, armed_rx) = tokio::sync::oneshot::channel();700701 let task = tokio::spawn(async move {702 let _guard = InputSessionGuard::new(input, move || {703 task_reset_saw_lease_held.store(704 task_sessions.claim_input(remote, nonce).is_err(),705 Ordering::SeqCst,706 );707 Ok(())708 });709 armed_tx.send(()).unwrap();710 std::future::pending::<()>().await;711 });712713 armed_rx.await.unwrap();714 task.abort();715 assert!(task.await.unwrap_err().is_cancelled());716 assert!(reset_saw_lease_held.load(Ordering::SeqCst));717 assert!(sessions.claim_input(remote, nonce).is_ok());718 }719720 721 fn capability_negotiation_is_an_exact_intersection() {722 let hello = ClientHello::new(723 "test",724 [0; 16],725 vec![726 Capability::AbsolutePointer,727 Capability::RelativePointer,728 Capability::Keyboard,729 Capability::Text,730 Capability::Gamepad,731 Capability::VideoH264,732 Capability::AudioOpus,733 ],734 );735 assert_eq!(736 negotiated_capabilities(737 &hello,738 &[739 Capability::RelativePointer,740 Capability::Keyboard,741 Capability::Gamepad,742 ]743 ),744 vec![745 Capability::RelativePointer,746 Capability::Keyboard,747 Capability::Gamepad,748 ]749 );750 assert!(negotiated_capabilities(&hello, &[Capability::InputAck]).is_empty());751 assert_eq!(752 negotiated_capabilities(&hello, MEDIA_CAPABILITIES),753 vec![Capability::VideoH264]754 );755 assert_eq!(756 negotiated_capabilities(&hello, AUDIO_CAPABILITIES),757 vec![Capability::AudioOpus]758 );759 }760761 762 fn enrollment_grants_are_a_strict_input_capability_ceiling() {763 let hello = ClientHello::new(764 "test",765 [0; 16],766 vec![767 Capability::RelativePointer,768 Capability::AbsolutePointer,769 Capability::Keyboard,770 Capability::Text,771 Capability::Gamepad,772 Capability::InputAck,773 Capability::PointerPositionFeedback,774 Capability::PointerVisibilityFeedback,775 ],776 );777 let supported = hello.capabilities.clone();778779 assert!(780 negotiated_input_capabilities(&hello, &supported, InvitationGrants::VIEW).is_empty()781 );782 assert_eq!(783 negotiated_input_capabilities(784 &hello,785 &supported,786 InvitationGrants::VIEW.union(InvitationGrants::POINTER_KEYBOARD),787 ),788 vec![789 Capability::RelativePointer,790 Capability::AbsolutePointer,791 Capability::Keyboard,792 Capability::Text,793 Capability::InputAck,794 Capability::PointerPositionFeedback,795 Capability::PointerVisibilityFeedback,796 ]797 );798 assert_eq!(799 negotiated_input_capabilities(800 &hello,801 &supported,802 InvitationGrants::VIEW.union(InvitationGrants::GAMEPAD),803 ),804 vec![Capability::Gamepad, Capability::InputAck]805 );806 }807808 809 fn pointer_feedback_is_advertised_only_with_tracker_and_relative_input() {810 assert_eq!(811 supported_input_capabilities(&[Capability::RelativePointer], false),812 vec![Capability::RelativePointer]813 );814 assert_eq!(815 supported_input_capabilities(&[Capability::RelativePointer], true),816 vec![817 Capability::RelativePointer,818 Capability::PointerPositionFeedback,819 Capability::PointerVisibilityFeedback,820 ]821 );822 assert_eq!(823 supported_input_capabilities(&[Capability::InputAck], true),824 vec![Capability::InputAck]825 );826 }827828 829 fn old_pointer_feedback_client_gets_legacy_host_hello_and_ack_shape() {830 let hello = ClientHello::new(831 "old-client",832 [0; 16],833 vec![834 Capability::RelativePointer,835 Capability::PointerPositionFeedback,836 ],837 );838 let supported = supported_input_capabilities(&[Capability::RelativePointer], true);839 let negotiated = negotiated_input_capabilities(&hello, &supported, InvitationGrants::ALL);840 assert_eq!(841 negotiated,842 vec![843 Capability::RelativePointer,844 Capability::PointerPositionFeedback,845 ]846 );847 assert_eq!(848 serde_json::to_string(&HostHello::accepted(7, negotiated)).unwrap(),849 r#"{"version":1,"accepted":true,"session_id":7,"capabilities":["relative_pointer","pointer_position_feedback"],"message":null}"#850 );851852 let position = sigil_protocol::PointerPosition { x: 320, y: 200 };853 let (pointer_position, pointer_visible) = pointer_feedback_fields(854 Some(PointerState {855 position: Some(position),856 visible: true,857 }),858 false,859 );860 assert_eq!(861 serde_json::to_string(&InputAck {862 sequence: 1,863 pointer_position,864 pointer_visible,865 })866 .unwrap(),867 r#"{"sequence":1,"pointer_position":{"x":320,"y":200}}"#868 );869870 let (pointer_position, pointer_visible) = pointer_feedback_fields(871 Some(PointerState {872 position: Some(position),873 visible: false,874 }),875 false,876 );877 assert_eq!(878 serde_json::to_string(&InputAck {879 sequence: 1,880 pointer_position,881 pointer_visible,882 })883 .unwrap(),884 r#"{"sequence":1}"#885 );886 }887888 889 fn pointer_visibility_feedback_requires_position_feedback() {890 let visibility_only = ClientHello::new(891 "invalid-client",892 [0; 16],893 vec![Capability::PointerVisibilityFeedback],894 );895 let supported = supported_input_capabilities(&[Capability::RelativePointer], true);896 assert!(897 negotiated_input_capabilities(&visibility_only, &supported, InvitationGrants::ALL)898 .is_empty()899 );900901 let upgraded = ClientHello::new(902 "upgraded-client",903 [0; 16],904 vec![905 Capability::RelativePointer,906 Capability::PointerPositionFeedback,907 Capability::PointerVisibilityFeedback,908 ],909 );910 assert_eq!(911 negotiated_input_capabilities(&upgraded, &supported, InvitationGrants::ALL),912 supported913 );914915 let position = sigil_protocol::PointerPosition { x: 320, y: 200 };916 assert_eq!(917 pointer_feedback_fields(918 Some(PointerState {919 position: Some(position),920 visible: false,921 }),922 true,923 ),924 (Some(position), Some(false))925 );926 }927}