cat / goq
clone

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

Merge pull request #127 from FelineStateMachine/fix/architecture-review-findings

FelineStateMachine committed a3af7ab
Architecture & maintainability review fixes
commit
a3af7ab2d9a9b1d0411f7af7d9b6b2ade6350d90
parent
0bd0cd95c807400b7de34a2110bda529a238ef57
parent
c369cddfb8dcee18170deec9f5673050f8a78807
author
FelineStateMachine <dami.etoile@gmail.com>
66 files changed, +2905 -5743
M.gitignore1+
MCargo.lock104+--
MREADME.md14+-
Mcrates/sigil-host/src/bin/moq_catalog.rs39+-
Mcrates/sigil-host/src/bin/sigil-probe.rs540+--------
Dcrates/sigil-host/src/input.rs1963------------------------------
Acrates/sigil-host/src/input/acl.rs193+++
Acrates/sigil-host/src/input/keymap.rs745+++++++++++
Acrates/sigil-host/src/input/linux.rs691+++++++++++
Acrates/sigil-host/src/input/mod.rs351+++++
Mcrates/sigil-host/src/main.rs142+-
Mcrates/sigil-host/src/moq_catalog.rs34+-
Mcrates/sigil-host/src/server/handlers.rs48-
Dcrates/sigil-host/src/server/media_v2.rs821-------------
Mcrates/sigil-host/src/server/media_v3.rs2+-
Mcrates/sigil-host/src/server/mod.rs9+-
Mcrates/sigil-host/src/server/moq.rs27+
Mcrates/sigil-protocol/Cargo.toml1+
Mcrates/sigil-protocol/src/lib.rs25+-
Mcrates/sigil-protocol/src/media.rs154+--
Mcrates/sigil-protocol/src/media_v3.rs15+-
Mcrates/sigil-protocol/src/moq_catalog.rs44+
Mdecky/main.py11+-
Mdecky/py_modules/goq_sigil/controller.py48+-
Mdecky/py_modules/goq_sigil/errors.py10+-
Mdecky/tests/backend/test_controller.py35+
Mportal/audio-ui.mjs10+-
Mportal/audio-ui.test.mjs12+-
Mportal/connection-state.mjs4+-
Mportal/connection-state.test.mjs3-
Mportal/enrollment-ui.test.mjs7-
Mportal/frame-envelope.mjs6+-
Mportal/frame-envelope.test.mjs14+-
Mportal/frame-session.mjs38+-
Mportal/frame-session.test.mjs27-
Mportal/index.html3+-
Mportal/input-state.mjs14+
Mportal/input-state.test.mjs20+
Mportal/main.js45+-
Mportal/mouse-input-wiring.test.mjs16+
Aportal/runtime-files.txt35+
Mportal/session-controls-layout.test.mjs4+-
Mportal/stream-runtime.mjs24-
Mportal/stream-runtime.test.mjs27+-
Mportal/style.css10+-
Mportal/video-capability.mjs5+-
Mportal/video-capability.test.mjs11+-
Mportal/video-pipeline.mjs297+---
Mportal/video-pipeline.test.mjs230+---
Mscripts/loopback-proof.sh90+-
Mscripts/tests/compatibility-identifiers.sh12+-
Ascripts/tests/portal-runtime-payload.sh75+
Mscripts/verify-demo-build.sh16-
Msrc-tauri/Cargo.toml3-
Msrc-tauri/build.rs101++
Msrc-tauri/src/commands/network.rs5+-
Msrc-tauri/src/commands/state.rs126+--
Msrc-tauri/src/media/audio_delivery.rs6+-
Msrc-tauri/src/media/connect.rs149+-
Msrc-tauri/src/media/frame_channel.rs76+-
Msrc-tauri/src/media/input_delivery.rs106+-
Msrc-tauri/src/media/moq_catalog.rs39+-
Msrc-tauri/src/media/object_receiver.rs410+------
Msrc-tauri/src/media/transport.rs171+--
Msrc-tauri/src/media/video_delivery.rs332+-----
Msrc-tauri/tauri.conf.json2+-
M .gitignore +1 -0
M Cargo.lock +1 -103
@@ -620,12 +620,6 @@ version = "1.5.0"
620620 source = "registry+https://github.com/rust-lang/crates.io-index"621621 checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"622622 623-[[package]]624-name = "byteorder-lite"625-version = "0.1.0"626-source = "registry+https://github.com/rust-lang/crates.io-index"627-checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"628-629623 [[package]]630624 name = "bytes"631625 version = "1.12.1"
@@ -2931,20 +2925,6 @@ dependencies = [
29312925 "xmltree",29322926 ]29332927 2934-[[package]]2935-name = "image"2936-version = "0.25.10"2937-source = "registry+https://github.com/rust-lang/crates.io-index"2938-checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"2939-dependencies = [2940- "bytemuck",2941- "byteorder-lite",2942- "moxcms",2943- "num-traits",2944- "zune-core",2945- "zune-jpeg",2946-]2947-29482928 [[package]]29492929 name = "indexmap"29502930 version = "1.9.3"
@@ -3687,16 +3667,6 @@ dependencies = [
36873667 "web-transport-trait",36883668 ]36893669 3690-[[package]]3691-name = "moxcms"3692-version = "0.8.1"3693-source = "registry+https://github.com/rust-lang/crates.io-index"3694-checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"3695-dependencies = [3696- "num-traits",3697- "pxfm",3698-]3699-37003670 [[package]]37013671 name = "muda"37023672 version = "0.19.3"
@@ -3778,15 +3748,6 @@ dependencies = [
37783748 "n0-future",37793749 ]37803750 3781-[[package]]3782-name = "nasm-rs"3783-version = "0.3.2"3784-source = "registry+https://github.com/rust-lang/crates.io-index"3785-checksum = "706bf8a5e8c8ddb99128c3291d31bd21f4bcde17f0f4c20ec678d85c74faa149"3786-dependencies = [3787- "log",3788-]3789-37903751 [[package]]37913752 name = "ndk"37923753 version = "0.9.0"
@@ -4415,27 +4376,6 @@ version = "0.3.1"
44154376 source = "registry+https://github.com/rust-lang/crates.io-index"44164377 checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"44174378 4418-[[package]]4419-name = "openh264"4420-version = "0.7.2"4421-source = "registry+https://github.com/rust-lang/crates.io-index"4422-checksum = "4e0b96c5808e7e5cf977056b4da08796895f993570b4a0ad658f90764c3c18fe"4423-dependencies = [4424- "openh264-sys2",4425- "wide",4426-]4427-4428-[[package]]4429-name = "openh264-sys2"4430-version = "0.7.1"4431-source = "registry+https://github.com/rust-lang/crates.io-index"4432-checksum = "881ad6de62993ff97a2cfbc63e5f127ecc930255718f9adc4c68c1dfeb308132"4433-dependencies = [4434- "cc",4435- "nasm-rs",4436- "walkdir",4437-]4438-44394379 [[package]]44404380 name = "openssl-probe"44414381 version = "0.2.1"
@@ -4778,19 +4718,16 @@ name = "portal"
47784718 version = "0.1.0"47794719 dependencies = [47804720 "anyhow",4781- "base64 0.22.1",47824721 "bytes",47834722 "ctap-hid-fido2",47844723 "ed25519-dalek",47854724 "getrandom 0.4.3",47864725 "hang",4787- "image",47884726 "iroh",47894727 "iroh-moq",47904728 "libc",47914729 "log",47924730 "moq-net",4793- "openh264",47944731 "serde",47954732 "serde_json",47964733 "sha2 0.10.9",
@@ -4954,12 +4891,6 @@ dependencies = [
49544891 "unicode-ident",49554892 ]49564893 4957-[[package]]4958-name = "pxfm"4959-version = "0.1.30"4960-source = "registry+https://github.com/rust-lang/crates.io-index"4961-checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"4962-49634894 [[package]]49644895 name = "quick-xml"49654896 version = "0.41.0"
@@ -5309,15 +5240,6 @@ version = "1.0.23"
53095240 source = "registry+https://github.com/rust-lang/crates.io-index"53105241 checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"53115242 5312-[[package]]5313-name = "safe_arch"5314-version = "0.7.4"5315-source = "registry+https://github.com/rust-lang/crates.io-index"5316-checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323"5317-dependencies = [5318- "bytemuck",5319-]5320-53215243 [[package]]53225244 name = "same-file"53235245 version = "1.0.6"
@@ -5738,6 +5660,7 @@ version = "0.1.0"
57385660 dependencies = [57395661 "base64 0.22.1",57405662 "ed25519-dalek",5663+ "hang",57415664 "serde",57425665 "serde_json",57435666 "thiserror 2.0.19",
@@ -7465,16 +7388,6 @@ dependencies = [
74657388 "windows-core 0.61.2",74667389 ]74677390 7468-[[package]]7469-name = "wide"7470-version = "0.7.33"7471-source = "registry+https://github.com/rust-lang/crates.io-index"7472-checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03"7473-dependencies = [7474- "bytemuck",7475- "safe_arch",7476-]7477-74787391 [[package]]74797392 name = "widestring"74807393 version = "1.2.1"
@@ -8366,21 +8279,6 @@ version = "1.0.23"
83668279 source = "registry+https://github.com/rust-lang/crates.io-index"83678280 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"83688281 8369-[[package]]8370-name = "zune-core"8371-version = "0.5.1"8372-source = "registry+https://github.com/rust-lang/crates.io-index"8373-checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"8374-8375-[[package]]8376-name = "zune-jpeg"8377-version = "0.5.15"8378-source = "registry+https://github.com/rust-lang/crates.io-index"8379-checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"8380-dependencies = [8381- "zune-core",8382-]8383-83848282 [[package]]83858283 name = "zvariant"83868284 version = "5.13.1"
M README.md +9 -5
M crates/sigil-host/src/bin/moq_catalog.rs +5 -34
@@ -2,36 +2,7 @@ use std::time::Duration;
22 33 use anyhow::{Context, Result};44 use moq_net::{BroadcastConsumer, Error as MoqError, Track, TrackConsumer};5-use serde::{Deserialize, Serialize};6-use sigil_protocol::{MAX_MOQ_CATALOG_BYTES, MoqCatalogExtensionV1};7-8-#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]9-#[serde(rename_all = "camelCase")]10-struct GoqCatalogDocument {11- #[serde(flatten)]12- media: hang::Catalog,13- goq: MoqCatalogExtensionV1,14-}15-16-impl GoqCatalogDocument {17- #[cfg(test)]18- fn video_h264() -> Self {19- Self {20- media: hang::Catalog::default(),21- goq: MoqCatalogExtensionV1::video_h264(),22- }23- }24-25- fn validate(&self) -> Result<()> {26- anyhow::ensure!(27- self.media == hang::Catalog::default(),28- "Goq catalog must not advertise a standard Hang rendition for enveloped media"29- );30- self.goq31- .validate()32- .context("validating Goq catalog extension")33- }34-}5+use sigil_protocol::{GoqCatalogDocument, MAX_MOQ_CATALOG_BYTES};356 367 #[derive(Clone, Copy, Debug, PartialEq, Eq)]378 pub(crate) enum MoqCatalogMode {
@@ -118,7 +89,9 @@ pub(crate) async fn subscribe_goq_video_track(
11889 .context("reading Goq catalog frame")?;11990 let document: GoqCatalogDocument =12091 serde_json::from_slice(&snapshot).context("decoding Goq catalog snapshot")?;121- document.validate()?;92+ document93+ .validate()94+ .context("validating Goq catalog document")?;12295 let track = broadcast12396 .subscribe_track(&Track::new(document.goq.video.track.name))12497 .context("subscribing to catalog-selected Goq video track")?;
@@ -232,9 +205,7 @@ mod tests {
232205 subscribe_goq_video_track(&invalid_broadcast.consume(), Duration::from_millis(100))233206 .await;234207 assert!(result.err().is_some_and(|error| {235- error236- .to_string()237- .contains("validating Goq catalog extension")208+ format!("{error:#}").contains("validating Goq catalog document")238209 }));239210 240211 let mut stalled_broadcast = Broadcast::new().produce();
M crates/sigil-host/src/bin/sigil-probe.rs +20 -520
@@ -17,12 +17,11 @@ use sigil_protocol::{
1717 CONTROL_ALPN_V1, Capability, ClientHello, FrameFlags, GAMEPAD_AXIS_MAX, GAMEPAD_AXIS_MIN,1818 GAMEPAD_TRIGGER_MAX, GamepadState, INPUT_ALPN_V1, INVITATION_CLOCK_SKEW_SECS, InputAck,1919 InputEvent, InvitationGrants, KeyframeRequestReasonV3, MAX_INVITATION_TOKEN_LEN,20- MAX_MEDIA_GROUP_BYTES_V3, MAX_MEDIA_OBJECT_ID_V3, MEDIA_ALPN_V1, MEDIA_ALPN_V2, MEDIA_ALPN_V3,21- MEDIA_FEEDBACK_ALPN_V1, MediaCodec, MediaControlRequestV3, MediaFeedbackFlags,22- MediaFeedbackReportV1, MediaFrame, MediaObjectV3, PointerPosition, PointerSurfaceDimensions,23- ProtocolError, SignedInvitation, decode_media_frame_object, media_moq_broadcast_name,24- read_adaptive_bitrate_decision_v1, read_host_hello, read_input_ack, read_media_frame,25- read_media_object, read_media_object_v3, write_client_hello, write_input_event,20+ MAX_MEDIA_GROUP_BYTES_V3, MAX_MEDIA_OBJECT_ID_V3, MEDIA_ALPN_V3, MEDIA_FEEDBACK_ALPN_V1,21+ MediaCodec, MediaControlRequestV3, MediaFeedbackFlags, MediaFeedbackReportV1, MediaFrame,22+ MediaObjectV3, PointerPosition, PointerSurfaceDimensions, ProtocolError, SignedInvitation,23+ decode_media_frame_object, media_moq_broadcast_name, read_adaptive_bitrate_decision_v1,24+ read_host_hello, read_input_ack, read_media_object_v3, write_client_hello, write_input_event,2625 write_media_control_request_v3, write_media_feedback_report_v1,2726 };2827
@@ -69,16 +68,8 @@ struct Args {
6968 expect_size: Option<(u16, u16)>,7069 /// Exercise custom grouped v3 media instead of upstream MoQ. Intended only7170 /// for compatibility validation.72- #[arg(long, conflicts_with_all = ["media_v2", "media_v1"])]71+ #[arg(long)]7372 media_v3: bool,74- /// Exercise independent v2 media objects instead of upstream MoQ.75- /// Intended only for compatibility validation.76- #[arg(long, conflicts_with_all = ["media_v3", "media_v1"])]77- media_v2: bool,78- /// Exercise the reliable ordered v1 media stream instead of upstream MoQ.79- /// Intended only for compatibility validation.80- #[arg(long, conflicts_with_all = ["media_v3", "media_v2"])]81- media_v1: bool,8273 /// Request a configured recovery keyframe after three accepted frames,8374 /// then prove no delta history is delivered before the recovery barrier.8475 #[arg(long)]
@@ -92,7 +83,7 @@ struct Args {
9283 #[arg(9384 long,9485 value_parser = clap::value_parser!(u64).range(250..=5_000),95- conflicts_with_all = ["media_v3", "media_v2", "media_v1"]86+ conflicts_with = "media_v3"9687 )]9788 slow_consumer_ms: Option<u64>,9889 /// Require gamepad negotiation and emit one bounded non-neutral snapshot
@@ -112,7 +103,7 @@ struct Args {
112103 /// validating shadow-only encoder configurations.113104 #[arg(114105 long,115- conflicts_with_all = ["media_v3", "media_v2", "media_v1", "resolution_stall_smoke"]106+ conflicts_with_all = ["media_v3", "resolution_stall_smoke"]116107 )]117108 feedback_smoke: bool,118109 /// Prove adaptive-resolution liveness across a controlled Gamescope stall.
@@ -122,8 +113,6 @@ struct Args {
122113 value_name = "GATE_DIR",123114 conflicts_with_all = [124115 "media_v3",125- "media_v2",126- "media_v1",127116 "keyframe_smoke",128117 "slow_consumer_ms",129118 "minimum_fps",
@@ -137,8 +126,6 @@ struct Args {
137126 enum MediaTransport {138127 UpstreamMoq,139128 GroupedV3,140- IndependentV2,141- ReliableV1,142129 }143130 144131 impl MediaTransport {
@@ -146,8 +133,6 @@ impl MediaTransport {
146133 match self {147134 Self::UpstreamMoq => CONTROL_ALPN_V1,148135 Self::GroupedV3 => MEDIA_ALPN_V3,149- Self::IndependentV2 => MEDIA_ALPN_V2,150- Self::ReliableV1 => MEDIA_ALPN_V1,151136 }152137 }153138
@@ -155,8 +140,6 @@ impl MediaTransport {
155140 match self {156141 Self::UpstreamMoq => "iroh-moq",157142 Self::GroupedV3 => "grouped-v3",158- Self::IndependentV2 => "independent-v2",159- Self::ReliableV1 => "reliable-v1",160143 }161144 }162145
@@ -165,8 +148,6 @@ impl MediaTransport {
165148 Self::UpstreamMoq => std::str::from_utf8(iroh_moq::ALPN)166149 .expect("pinned iroh-moq ALPN must be printable UTF-8"),167150 Self::GroupedV3 => "sigil/media/3",168- Self::IndependentV2 => "sigil/media/2",169- Self::ReliableV1 => "sigil/media/1",170151 }171152 }172153 }
@@ -182,167 +163,6 @@ const RESOLUTION_STALL_RECOVERY_TIMEOUT: Duration = Duration::from_secs(2);
182163 const RESOLUTION_STALL_GATE_POLL_INTERVAL: Duration = Duration::from_millis(50);183164 const MAX_INVITATION_FILE_BYTES: u64 = (MAX_INVITATION_TOKEN_LEN + 2) as u64;184165 185-#[derive(Debug)]186-enum MediaObjectOutcome {187- Frame { index: u64, frame: MediaFrame },188- Dropped { index: u64 },189- Malformed { index: u64, error: ProtocolError },190-}191-192-impl MediaObjectOutcome {193- fn index(&self) -> u64 {194- match self {195- Self::Frame { index, .. } | Self::Dropped { index } | Self::Malformed { index, .. } => {196- *index197- }198- }199- }200-201- fn is_fast_forward_barrier(&self) -> bool {202- let Self::Frame { frame, .. } = self else {203- return false;204- };205- frame.header.flags.contains(FrameFlags::KEYFRAME)206- && frame.header.flags.contains(FrameFlags::CODEC_CONFIG)207- && frame.header.flags.contains(FrameFlags::DISCONTINUITY)208- }209-}210-211-#[derive(Debug)]212-struct MediaObjectReorder {213- next_index: u64,214- completed: BTreeMap<u64, MediaObjectOutcome>,215-}216-217-impl MediaObjectReorder {218- fn new(first_index: u64) -> Self {219- Self {220- next_index: first_index,221- completed: BTreeMap::new(),222- }223- }224-225- fn pending_len(&self) -> usize {226- self.completed.len()227- }228-229- fn push(&mut self, outcome: MediaObjectOutcome) -> Result<Option<MediaObjectOutcome>> {230- if matches!(outcome, MediaObjectOutcome::Malformed { .. }) {231- // Malformed objects remain terminal as soon as their read completes.232- return Ok(Some(outcome));233- }234- let index = outcome.index();235- if index < self.next_index {236- return Ok(Some(outcome));237- }238- if outcome.is_fast_forward_barrier() {239- self.completed240- .retain(|completed_index, _| *completed_index > index);241- self.next_index = index242- .checked_add(1)243- .context("media object reorder overflowed")?;244- return Ok(Some(outcome));245- }246- ensure!(247- self.completed.insert(index, outcome).is_none(),248- "media object {index} completed more than once"249- );250- self.take_next()251- }252-253- fn take_next(&mut self) -> Result<Option<MediaObjectOutcome>> {254- let Some(outcome) = self.completed.remove(&self.next_index) else {255- return Ok(None);256- };257- self.next_index = self258- .next_index259- .checked_add(1)260- .context("media object reorder overflowed")?;261- Ok(Some(outcome))262- }263-}264-265-struct MediaObjectReceiver {266- connection: iroh::endpoint::Connection,267- reads: tokio::task::JoinSet<MediaObjectOutcome>,268- reorder: MediaObjectReorder,269- next_index: u64,270- accepting: bool,271- read_timeout: Duration,272-}273-274-impl MediaObjectReceiver {275- fn new(connection: iroh::endpoint::Connection, read_timeout: Duration) -> Self {276- Self {277- connection,278- reads: tokio::task::JoinSet::new(),279- reorder: MediaObjectReorder::new(0),280- next_index: 0,281- accepting: true,282- read_timeout,283- }284- }285-286- async fn next(&mut self) -> Result<Option<MediaObjectOutcome>> {287- loop {288- if let Some(completed) = self.reorder.take_next()? {289- return Ok(Some(completed));290- }291- if !self.accepting && self.reads.is_empty() {292- ensure!(293- self.reorder.pending_len() == 0,294- "media connection closed with an incomplete object order"295- );296- return Ok(None);297- }298-299- tokio::select! {300- accepted = self.connection.accept_uni(),301- if self.accepting302- && self.reads.len() + self.reorder.pending_len()303- < MEDIA_OBJECT_CAPACITY => {304- match accepted {305- Ok(mut stream) => {306- let index = self.next_index;307- self.next_index = self308- .next_index309- .checked_add(1)310- .context("media object index overflowed")?;311- let read_timeout = self.read_timeout;312- self.reads.spawn(async move {313- match tokio::time::timeout(read_timeout, read_media_object(&mut stream)).await {314- Ok(Ok(frame)) => MediaObjectOutcome::Frame { index, frame },315- Ok(Err(ProtocolError::Io(_))) => MediaObjectOutcome::Dropped { index },316- Ok(Err(error)) => MediaObjectOutcome::Malformed { index, error },317- Err(_) => MediaObjectOutcome::Dropped { index },318- }319- });320- }321- Err(_) => self.accepting = false,322- }323- }324- completed = self.reads.join_next(), if !self.reads.is_empty() => {325- match completed.expect("guarded by non-empty media object task set") {326- Ok(outcome) => {327- if let Some(completed) = self.reorder.push(outcome)? {328- return Ok(Some(completed));329- }330- }331- Err(error) if error.is_cancelled() => continue,332- Err(error) => return Err(error).context("media object read task failed"),333- }334- }335- }336- }337- }338-}339-340-impl Drop for MediaObjectReceiver {341- fn drop(&mut self) {342- self.reads.abort_all();343- }344-}345-346166 #[derive(Debug)]347167 enum MediaObjectOutcomeV3 {348168 Object {
@@ -524,71 +344,6 @@ impl Drop for MediaObjectReceiverV3 {
524344 }525345 }526346 527-#[derive(Clone, Copy, Debug, PartialEq, Eq)]528-enum MediaObjectDecision {529- Deliver,530- DropLate,531- DropUntilKeyframe,532-}533-534-#[derive(Debug, Default)]535-struct MediaObjectSequence {536- completion_watermark: Option<u64>,537- last_sequence: Option<u64>,538- waiting_for_keyframe: bool,539-}540-541-impl MediaObjectSequence {542- fn new() -> Self {543- Self {544- waiting_for_keyframe: true,545- ..Self::default()546- }547- }548-549- fn note_drop(&mut self, index: u64) -> bool {550- if self551- .completion_watermark552- .is_some_and(|watermark| index <= watermark)553- {554- return false;555- }556- self.completion_watermark = Some(index);557- self.waiting_for_keyframe = true;558- true559- }560-561- fn classify(&mut self, index: u64, frame: &MediaFrame) -> MediaObjectDecision {562- if self563- .completion_watermark564- .is_some_and(|watermark| index <= watermark)565- {566- return MediaObjectDecision::DropLate;567- }568- self.completion_watermark = Some(index);569-570- let independently_decodable = frame.header.flags.contains(FrameFlags::KEYFRAME)571- && frame.header.flags.contains(FrameFlags::CODEC_CONFIG);572- let sequence_contiguous = self573- .last_sequence574- .is_none_or(|last| last.checked_add(1) == Some(frame.header.sequence));575- let sequence_monotonic = self576- .last_sequence577- .is_none_or(|last| frame.header.sequence > last);578-579- let resync_required =580- !sequence_monotonic || self.waiting_for_keyframe || !sequence_contiguous;581- if resync_required && (!independently_decodable || !sequence_monotonic) {582- self.waiting_for_keyframe = true;583- return MediaObjectDecision::DropUntilKeyframe;584- }585-586- self.last_sequence = Some(frame.header.sequence);587- self.waiting_for_keyframe = false;588- MediaObjectDecision::Deliver589- }590-}591-592347 #[derive(Clone, Copy, Debug, PartialEq, Eq)]593348 enum MediaObjectDecisionV3 {594349 Deliver { discontinuity: bool },
@@ -985,20 +740,6 @@ struct AcceptedMedia {
985740 v3_group_id: Option<u64>,986741 }987742 988-impl From<MediaFrame> for AcceptedMedia {989- fn from(frame: MediaFrame) -> Self {990- Self {991- flags: frame.header.flags,992- width: frame.header.width,993- height: frame.header.height,994- sequence: frame.header.sequence,995- capture_timestamp_us: frame.header.capture_timestamp_us,996- payload_len: frame.payload.len(),997- v3_group_id: None,998- }999- }1000-}1001-1002743 impl From<MediaObjectV3> for AcceptedMedia {1003744 fn from(object: MediaObjectV3) -> Self {1004745 Self {
@@ -1465,10 +1206,6 @@ async fn main() -> Result<()> {
14651206 args.timeout_seconds > 0,14661207 "--timeout-seconds must be greater than zero"14671208 );1468- ensure!(1469- !args.keyframe_smoke || (!args.media_v1 && !args.media_v2),1470- "--keyframe-smoke requires upstream MoQ or grouped v3 media"1471- );14721209 ensure!(14731210 !args.keyframe_smoke || args.frames >= KEYFRAME_SMOKE_MINIMUM_FRAMES,14741211 "--keyframe-smoke requires at least {KEYFRAME_SMOKE_MINIMUM_FRAMES} accepted frames"
@@ -1506,11 +1243,7 @@ async fn main() -> Result<()> {
15061243 .context("binding probe endpoint")?;15071244 let _ = tokio::time::timeout(Duration::from_secs(10), endpoint.online()).await;15081245 let address = EndpointAddr::new(args.node_id);1509- let media_transport = if args.media_v1 {1510- MediaTransport::ReliableV11511- } else if args.media_v2 {1512- MediaTransport::IndependentV21513- } else if args.media_v3 {1246+ let media_transport = if args.media_v3 {15141247 MediaTransport::GroupedV315151248 } else {15161249 MediaTransport::UpstreamMoq
@@ -1540,24 +1273,9 @@ async fn main() -> Result<()> {
15401273 )15411274 .await?;15421275 let session_id = media_negotiation.session_id;1543- let mut media_send = Some(media_send);1544- let mut media_recv = Some(media_recv);1545- match media_transport {1546- MediaTransport::UpstreamMoq | MediaTransport::GroupedV3 => {1547- // Sigil finishes its response half after HostHello. Keep our send1548- // half alive as the bounded keyframe-control stream.1549- drop(media_recv.take());1550- }1551- MediaTransport::IndependentV2 => {1552- media_send1553- .take()1554- .expect("media handshake stream is present")1555- .finish()1556- .context("finishing media v2 handshake request")?;1557- drop(media_recv.take());1558- }1559- MediaTransport::ReliableV1 => {}1560- }1276+ // Sigil finishes its response half after HostHello. Keep our send half1277+ // alive as the bounded keyframe-control stream.1278+ drop(media_recv);15611279 15621280 let mut moq_receiver = if media_transport == MediaTransport::UpstreamMoq {15631281 Some(
@@ -1780,11 +1498,9 @@ async fn main() -> Result<()> {
17801498 .finish()17811499 .context("finishing media feedback stream")?;17821500 input_send.finish().context("finishing input stream")?;1783- if let Some(mut media_send) = media_send {1784- media_send1785- .finish()1786- .context("finishing media request stream")?;1787- }1501+ media_send1502+ .finish()1503+ .context("finishing media request stream")?;17881504 feedback_connection.close(0_u32.into(), b"probe complete");17891505 input_connection.close(0_u32.into(), b"probe complete");17901506 moq_receiver
@@ -1844,13 +1560,6 @@ async fn main() -> Result<()> {
18441560 let mut dimensions = None;18451561 let mut first_accepted_at = None;18461562 let mut last_accepted_at = None;1847- let mut object_receiver = (media_transport == MediaTransport::IndependentV2).then(|| {1848- MediaObjectReceiver::new(1849- media_connection.clone(),1850- Duration::from_secs(args.timeout_seconds),1851- )1852- });1853- let mut object_sequence = MediaObjectSequence::new();18541563 let mut object_receiver_v3 = (media_transport == MediaTransport::GroupedV3).then(|| {18551564 MediaObjectReceiverV3::new(18561565 media_connection.clone(),
@@ -2029,57 +1738,6 @@ async fn main() -> Result<()> {
20291738 }20301739 }20311740 },2032- MediaTransport::ReliableV1 => (2033- tokio::time::timeout(2034- Duration::from_secs(args.timeout_seconds),2035- read_media_frame(2036- media_recv2037- .as_mut()2038- .expect("v1 media receive stream is present"),2039- ),2040- )2041- .await2042- .context("timed out waiting for media frame")??2043- .context("host closed the media stream")?2044- .into(),2045- false,2046- ),2047- MediaTransport::IndependentV2 => loop {2048- let outcome = tokio::time::timeout(2049- Duration::from_secs(args.timeout_seconds)2050- .saturating_add(Duration::from_secs(1)),2051- object_receiver2052- .as_mut()2053- .expect("v2 media object receiver is present")2054- .next(),2055- )2056- .await2057- .context("timed out waiting for media object")??2058- .context("host closed the media object connection")?;2059- match outcome {2060- MediaObjectOutcome::Dropped { index } => {2061- if object_sequence.note_drop(index) {2062- media_objects_dropped = media_objects_dropped.saturating_add(1);2063- } else {2064- media_objects_late = media_objects_late.saturating_add(1);2065- }2066- }2067- MediaObjectOutcome::Malformed { index, error } => {2068- bail!("media object {index} is malformed: {error}");2069- }2070- MediaObjectOutcome::Frame { index, frame } => {2071- match object_sequence.classify(index, &frame) {2072- MediaObjectDecision::Deliver => break (frame.into(), false),2073- MediaObjectDecision::DropLate => {2074- media_objects_late = media_objects_late.saturating_add(1);2075- }2076- MediaObjectDecision::DropUntilKeyframe => {2077- media_objects_dropped = media_objects_dropped.saturating_add(1);2078- }2079- }2080- }2081- }2082- },20831741 MediaTransport::GroupedV3 => loop {20841742 let outcome = tokio::time::timeout(20851743 Duration::from_secs(args.timeout_seconds)
@@ -2235,12 +1893,7 @@ async fn main() -> Result<()> {
22351893 );22361894 tokio::time::timeout(22371895 Duration::from_secs(1),2238- write_media_control_request_v3(2239- media_send2240- .as_mut()2241- .expect("v3 media control stream is present"),2242- &request,2243- ),1896+ write_media_control_request_v3(&mut media_send, &request),22441897 )22451898 .await22461899 .context("timed out writing v3 keyframe request")??;
@@ -2260,9 +1913,6 @@ async fn main() -> Result<()> {
22601913 );22611914 }22621915 MediaTransport::GroupedV3 => object_sequence_v3.request_recovery(),2263- MediaTransport::IndependentV2 | MediaTransport::ReliableV1 => {2264- unreachable!("keyframe smoke excludes v2 and v1")2265- }22661916 }22671917 }22681918 }
@@ -2355,11 +2005,9 @@ async fn main() -> Result<()> {
23552005 )23562006 });23572007 input_send.finish().context("finishing input stream")?;2358- if let Some(mut media_send) = media_send {2359- media_send2360- .finish()2361- .context("finishing media request stream")?;2362- }2008+ media_send2009+ .finish()2010+ .context("finishing media request stream")?;23632011 // Close both protocol connections explicitly. Relying only on endpoint23642012 // teardown can leave a peer waiting for QUIC idle timeout during repeated23652013 // short-lived probes, which obscures whether the host released its
@@ -3167,13 +2815,6 @@ mod tests {
31672815 .unwrap()31682816 }31692817 3170- fn media_outcome(index: u64, sequence: u64, flags: FrameFlags) -> MediaObjectOutcome {3171- MediaObjectOutcome::Frame {3172- index,3173- frame: media_frame(sequence, flags),3174- }3175- }3176-31772818 fn media_object_v3(31782819 group_id: u64,31792820 object_id: u32,
@@ -3211,62 +2852,6 @@ mod tests {
32112852 }32122853 }32132854 3214- #[test]3215- fn media_object_reorder_restores_accept_order_before_sequence_checks() {3216- let keyframe = FrameFlags::KEYFRAME.union(FrameFlags::CODEC_CONFIG);3217- let mut reorder = MediaObjectReorder::new(0);3218-3219- assert!(3220- reorder3221- .push(media_outcome(1, 11, FrameFlags::NONE))3222- .unwrap()3223- .is_none()3224- );3225- assert_eq!(reorder.pending_len(), 1);3226- assert_eq!(3227- reorder3228- .push(media_outcome(0, 10, keyframe))3229- .unwrap()3230- .unwrap()3231- .index(),3232- 03233- );3234- assert_eq!(reorder.take_next().unwrap().unwrap().index(), 1);3235- assert_eq!(reorder.pending_len(), 0);3236- }3237-3238- #[test]3239- fn discontinuity_keyframe_is_an_explicit_latest_frame_barrier() {3240- let barrier = FrameFlags::KEYFRAME3241- .union(FrameFlags::CODEC_CONFIG)3242- .union(FrameFlags::DISCONTINUITY);3243- let mut reorder = MediaObjectReorder::new(0);3244-3245- assert!(3246- reorder3247- .push(media_outcome(1, 11, FrameFlags::NONE))3248- .unwrap()3249- .is_none()3250- );3251- assert_eq!(3252- reorder3253- .push(media_outcome(2, 20, barrier))3254- .unwrap()3255- .unwrap()3256- .index(),3257- 23258- );3259- assert_eq!(reorder.pending_len(), 0);3260- assert_eq!(3261- reorder3262- .push(media_outcome(0, 10, FrameFlags::NONE))3263- .unwrap()3264- .unwrap()3265- .index(),3266- 03267- );3268- }3269-32702855 #[test]32712856 fn media_v3_discontinuity_object_zero_fast_forwards_accept_order() {32722857 let keyframe = FrameFlags::KEYFRAME.union(FrameFlags::CODEC_CONFIG);
@@ -3331,79 +2916,6 @@ mod tests {
33312916 assert!(accepted_frame_rate(55, Duration::from_secs(1)).unwrap() < 55.0);33322917 }33332918 3334- #[test]3335- fn media_objects_wait_for_a_decodable_keyframe_then_deliver_contiguous_frames() {3336- let mut sequence = MediaObjectSequence::new();3337- let delta = media_frame(1, FrameFlags::NONE);3338- let keyframe = media_frame(2, FrameFlags::KEYFRAME.union(FrameFlags::CODEC_CONFIG));3339- let next_delta = media_frame(3, FrameFlags::NONE);3340-3341- assert_eq!(3342- sequence.classify(0, &delta),3343- MediaObjectDecision::DropUntilKeyframe3344- );3345- assert_eq!(3346- sequence.classify(1, &keyframe),3347- MediaObjectDecision::Deliver3348- );3349- assert_eq!(3350- sequence.classify(2, &next_delta),3351- MediaObjectDecision::Deliver3352- );3353- }3354-3355- #[test]3356- fn dropped_objects_force_resync_and_late_completions_cannot_rewind() {3357- let mut sequence = MediaObjectSequence::new();3358- let first_keyframe = media_frame(10, FrameFlags::KEYFRAME.union(FrameFlags::CODEC_CONFIG));3359- let stale_delta = media_frame(11, FrameFlags::NONE);3360- let replacement_keyframe =3361- media_frame(20, FrameFlags::KEYFRAME.union(FrameFlags::CODEC_CONFIG));3362-3363- assert_eq!(3364- sequence.classify(0, &first_keyframe),3365- MediaObjectDecision::Deliver3366- );3367- assert!(sequence.note_drop(1));3368- assert_eq!(3369- sequence.classify(2, &replacement_keyframe),3370- MediaObjectDecision::Deliver3371- );3372- assert_eq!(3373- sequence.classify(1, &stale_delta),3374- MediaObjectDecision::DropLate3375- );3376- assert!(!sequence.note_drop(1));3377-3378- let next_delta = media_frame(21, FrameFlags::NONE);3379- assert_eq!(3380- sequence.classify(3, &next_delta),3381- MediaObjectDecision::Deliver3382- );3383- }3384-3385- #[test]3386- fn media_sequence_gap_drops_deltas_until_replacement_keyframe() {3387- let mut sequence = MediaObjectSequence::new();3388- let keyframe = media_frame(40, FrameFlags::KEYFRAME.union(FrameFlags::CODEC_CONFIG));3389- let gap_delta = media_frame(42, FrameFlags::NONE);3390- let replacement_keyframe =3391- media_frame(50, FrameFlags::KEYFRAME.union(FrameFlags::CODEC_CONFIG));3392-3393- assert_eq!(3394- sequence.classify(0, &keyframe),3395- MediaObjectDecision::Deliver3396- );3397- assert_eq!(3398- sequence.classify(1, &gap_delta),3399- MediaObjectDecision::DropUntilKeyframe3400- );3401- assert_eq!(3402- sequence.classify(2, &replacement_keyframe),3403- MediaObjectDecision::Deliver3404- );3405- }3406-34072919 #[test]34082920 fn media_v3_enforces_wire_group_object_and_sequence_continuity() {34092921 let keyframe = FrameFlags::KEYFRAME.union(FrameFlags::CODEC_CONFIG);
@@ -3810,11 +3322,9 @@ mod tests {
38103322 }38113323 38123324 #[test]3813- fn media_compatibility_flags_are_mutually_exclusive() {3325+ fn media_compatibility_flag_defaults_and_conflicts_are_enforced() {38143326 let node_id = SecretKey::from_bytes(&[0x2a; 32]).public().to_string();38153327 let default = Args::try_parse_from(["sigil-probe", "--node-id", &node_id]).unwrap();3816- assert!(!default.media_v1);3817- assert!(!default.media_v2);38183328 assert!(!default.media_v3);38193329 assert_eq!(default.identity, None);38203330 assert_eq!(default.invitation, None);
@@ -3833,16 +3343,6 @@ mod tests {
38333343 Args::try_parse_from(["sigil-probe", "--node-id", &node_id, "--feedback-smoke"])38343344 .unwrap();38353345 assert!(feedback.feedback_smoke);3836- for flags in [3837- ["--media-v1", "--media-v2"],3838- ["--media-v1", "--media-v3"],3839- ["--media-v2", "--media-v3"],3840- ] {3841- assert!(3842- Args::try_parse_from(["sigil-probe", "--node-id", &node_id, flags[0], flags[1],])3843- .is_err()3844- );3845- }38463346 assert!(38473347 Args::try_parse_from([38483348 "sigil-probe",
D crates/sigil-host/src/input.rs +0 -1963
@@ -1,1963 +0,0 @@
1-use std::fmt;2-#[cfg(any(target_os = "linux", test))]3-use std::mem::size_of;4-#[cfg(target_os = "linux")]5-use std::sync::Arc;6-#[cfg(any(target_os = "linux", test))]7-use std::sync::Mutex;8-9-#[cfg(target_os = "linux")]10-use anyhow::Context;11-use anyhow::{Result, bail};12-#[cfg(any(target_os = "linux", test))]13-use sigil_protocol::GamepadState;14-use sigil_protocol::{Capability, InputEvent};15-16-use crate::config::{HostConfig, InputMode};17-18-const ACK_ONLY_CAPABILITIES: &[Capability] = &[Capability::InputAck];19-const UINPUT_CAPABILITIES: &[Capability] = &[20- Capability::RelativePointer,21- Capability::Keyboard,22- Capability::Gamepad,23- Capability::InputAck,24-];25-26-#[cfg(any(target_os = "linux", test))]27-const POSIX_ACL_XATTR_VERSION: u32 = 0x0002;28-#[cfg(any(target_os = "linux", test))]29-const POSIX_ACL_ENTRY_BYTES: usize = 8;30-#[cfg(any(target_os = "linux", test))]31-const POSIX_ACL_REQUIRED_ENTRIES: usize = 5;32-#[cfg(any(target_os = "linux", test))]33-const POSIX_ACL_REQUIRED_BYTES: usize =34- size_of::<u32>() + POSIX_ACL_ENTRY_BYTES * POSIX_ACL_REQUIRED_ENTRIES;35-#[cfg(any(target_os = "linux", test))]36-const POSIX_ACL_UNDEFINED_ID: u32 = u32::MAX;37-#[cfg(any(target_os = "linux", test))]38-const POSIX_ACL_USER_OBJ: u16 = 0x01;39-#[cfg(any(target_os = "linux", test))]40-const POSIX_ACL_USER: u16 = 0x02;41-#[cfg(any(target_os = "linux", test))]42-const POSIX_ACL_GROUP_OBJ: u16 = 0x04;43-#[cfg(any(target_os = "linux", test))]44-const POSIX_ACL_MASK: u16 = 0x10;45-#[cfg(any(target_os = "linux", test))]46-const POSIX_ACL_OTHER: u16 = 0x20;47-#[cfg(any(target_os = "linux", test))]48-const POSIX_ACL_READ_WRITE: u16 = 0x06;49-50-/// Validate the only extended uinput ACL shape Sigil accepts.51-///52-/// Linux stores POSIX ACL xattrs as one little-endian version followed by53-/// fixed-size entries. Requiring the canonical five-entry form prevents a54-/// configured one-user exception from admitting extra users or groups.55-#[cfg(any(target_os = "linux", test))]56-fn validate_single_user_access_acl(57- bytes: &[u8],58- expected_uid: u32,59- expected_mode: u32,60-) -> Result<()> {61- if bytes.len() != POSIX_ACL_REQUIRED_BYTES {62- bail!(63- "configured uinput access ACL must contain exactly one named user and no named groups"64- );65- }66- let version = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);67- if version != POSIX_ACL_XATTR_VERSION {68- bail!("configured uinput access ACL has an unsupported Linux ACL version");69- }70-71- let expected_entries = [72- (73- POSIX_ACL_USER_OBJ,74- ((expected_mode >> 6) & 0o7) as u16,75- POSIX_ACL_UNDEFINED_ID,76- ),77- (POSIX_ACL_USER, POSIX_ACL_READ_WRITE, expected_uid),78- (79- POSIX_ACL_GROUP_OBJ,80- ((expected_mode >> 3) & 0o7) as u16,81- POSIX_ACL_UNDEFINED_ID,82- ),83- (84- POSIX_ACL_MASK,85- ((expected_mode >> 3) & 0o7) as u16,86- POSIX_ACL_UNDEFINED_ID,87- ),88- (89- POSIX_ACL_OTHER,90- (expected_mode & 0o7) as u16,91- POSIX_ACL_UNDEFINED_ID,92- ),93- ];94-95- for (index, (expected_tag, expected_permissions, expected_id)) in96- expected_entries.into_iter().enumerate()97- {98- let offset = size_of::<u32>() + index * POSIX_ACL_ENTRY_BYTES;99- let tag = u16::from_le_bytes([bytes[offset], bytes[offset + 1]]);100- let permissions = u16::from_le_bytes([bytes[offset + 2], bytes[offset + 3]]);101- let id = u32::from_le_bytes([102- bytes[offset + 4],103- bytes[offset + 5],104- bytes[offset + 6],105- bytes[offset + 7],106- ]);107- if permissions & !0o7 != 0 {108- bail!("configured uinput access ACL contains invalid permission bits");109- }110- if tag != expected_tag || permissions != expected_permissions || id != expected_id {111- bail!(112- "configured uinput access ACL does not match the exact one-user owner/group/mode contract"113- );114- }115- }116- Ok(())117-}118-119-#[cfg(any(target_os = "linux", test))]120-// This Linux input identity tuple is an external ABI consumed by Gamescope,121-// udev/libinput rules, and hardware evidence. Preserve it exactly; see122-// docs/compatibility-identifiers.md.123-const UINPUT_BUS_TYPE: u16 = 0x06; // Linux BUS_VIRTUAL124-#[cfg(any(target_os = "linux", test))]125-const UINPUT_VENDOR_ID: u16 = 0x5347;126-#[cfg(any(target_os = "linux", test))]127-const UINPUT_DEVICE_VERSION: u16 = 1;128-#[cfg(any(target_os = "linux", test))]129-const POINTER_PRODUCT_ID: u16 = 1;130-#[cfg(any(target_os = "linux", test))]131-const GAMEPAD_PRODUCT_ID: u16 = 2;132-#[cfg(any(target_os = "linux", test))]133-const KEYBOARD_PRODUCT_ID: u16 = 3;134-#[cfg(any(target_os = "linux", test))]135-const POINTER_DEVICE_NAME: &[u8] = b"Sigil Spark Virtual Pointer";136-#[cfg(any(target_os = "linux", test))]137-const KEYBOARD_DEVICE_NAME: &[u8] = b"Sigil Spark Virtual Keyboard";138-#[cfg(any(target_os = "linux", test))]139-const GAMEPAD_DEVICE_NAME: &[u8] = b"Sigil Spark Virtual Gamepad";140-141-#[cfg(any(target_os = "linux", test))]142-#[derive(Clone, Copy, Debug, PartialEq, Eq)]143-enum InputDeviceClass {144- Pointer,145- Keyboard,146- Gamepad,147-}148-149-#[cfg(any(target_os = "linux", test))]150-#[derive(Clone, Copy, Debug, PartialEq, Eq)]151-enum PointerReportEvent {152- RelativeX(i32),153- RelativeY(i32),154- Synchronize,155-}156-157-#[cfg(any(target_os = "linux", test))]158-/// Emulate an absolute position on the relative-only appliance pointer.159-///160-/// This is correct only when Gamescope clamps the first report at the surface161-/// origin and applies neither acceleration nor scaling to either report. The162-/// hardware UAT verifies both the synchronized position and one subsequent163-/// relative delta through the Xwayland pointer tracker.164-fn pointer_position_sync_report(x: i32, y: i32) -> [PointerReportEvent; 6] {165- [166- PointerReportEvent::RelativeX(sigil_protocol::RELATIVE_POINTER_DELTA_MIN),167- PointerReportEvent::RelativeY(sigil_protocol::RELATIVE_POINTER_DELTA_MIN),168- PointerReportEvent::Synchronize,169- PointerReportEvent::RelativeX(x),170- PointerReportEvent::RelativeY(y),171- PointerReportEvent::Synchronize,172- ]173-}174-175-#[cfg(any(target_os = "linux", test))]176-fn input_device_class(event: &InputEvent) -> Option<InputDeviceClass> {177- match event {178- InputEvent::Probe => None,179- InputEvent::MouseMove { .. }180- | InputEvent::MouseMoveRelative { .. }181- | InputEvent::MousePositionSync { .. }182- | InputEvent::MouseClick { .. }183- | InputEvent::MouseDown { .. }184- | InputEvent::MouseUp { .. }185- | InputEvent::MouseScroll { .. } => Some(InputDeviceClass::Pointer),186- InputEvent::KeyDown { .. } | InputEvent::KeyUp { .. } | InputEvent::KeyClick { .. } => {187- Some(InputDeviceClass::Keyboard)188- }189- InputEvent::Gamepad { .. } => Some(InputDeviceClass::Gamepad),190- InputEvent::Text { .. } => None,191- }192-}193-194-#[derive(Clone, Copy, Debug, PartialEq, Eq)]195-pub enum InputDisposition {196- Probed,197- Disabled,198- Observed,199- #[cfg(target_os = "linux")]200- Injected,201- TextIgnored,202-}203-204-#[derive(Clone)]205-pub struct InputBackend {206- mode: InputMode,207- #[cfg(target_os = "linux")]208- device: Option<Arc<std::sync::Mutex<linux::UinputDevice>>>,209-}210-211-impl fmt::Debug for InputBackend {212- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {213- formatter214- .debug_struct("InputBackend")215- .field("mode", &self.mode)216- .finish_non_exhaustive()217- }218-}219-220-impl InputBackend {221- /// Construct the backend before the network endpoint is exposed. For222- /// uinput this opens, validates, and registers the virtual device.223- pub fn initialize(config: &HostConfig) -> Result<Self> {224- match config.input_mode {225- InputMode::Disabled | InputMode::Log => Ok(Self {226- mode: config.input_mode.clone(),227- #[cfg(target_os = "linux")]228- device: None,229- }),230- InputMode::Uinput => {231- #[cfg(target_os = "linux")]232- {233- let settings = config234- .uinput235- .as_ref()236- .context("missing validated uinput configuration")?;237- let device = linux::UinputDevice::open(settings)?;238- Ok(Self {239- mode: InputMode::Uinput,240- device: Some(Arc::new(std::sync::Mutex::new(device))),241- })242- }243- #[cfg(not(target_os = "linux"))]244- {245- let _ = config;246- bail!("input_mode uinput is supported only on Linux")247- }248- }249- }250- }251-252- pub fn capabilities(&self) -> &'static [Capability] {253- capabilities_for_mode(&self.mode)254- }255-256- pub fn apply(&self, event: &InputEvent, negotiated: &[Capability]) -> Result<InputDisposition> {257- ensure_event_was_negotiated(&self.mode, event, negotiated)?;258- if matches!(event, InputEvent::Probe) {259- return Ok(InputDisposition::Probed);260- }261- match self.mode {262- InputMode::Disabled => Ok(InputDisposition::Disabled),263- InputMode::Log => Ok(InputDisposition::Observed),264- InputMode::Uinput => {265- if matches!(event, InputEvent::Text { .. }) {266- return Ok(InputDisposition::TextIgnored);267- }268- #[cfg(target_os = "linux")]269- {270- let device = self271- .device272- .as_ref()273- .context("uinput backend was not initialized")?;274- device275- .lock()276- .map_err(|_| anyhow::anyhow!("uinput backend lock poisoned"))?277- .apply(event)?;278- Ok(InputDisposition::Injected)279- }280- #[cfg(not(target_os = "linux"))]281- {282- let _ = event;283- bail!("uinput backend is unavailable on this platform")284- }285- }286- }287- }288-289- /// Release any transitions held by the ending input session so a dropped290- /// connection cannot leave a key or mouse button pressed.291- pub fn reset_session(&self) -> Result<()> {292- #[cfg(target_os = "linux")]293- if let Some(device) = &self.device {294- reset_through_poisoned_lock(device, linux::UinputDevice::release_all)?;295- }296- Ok(())297- }298-}299-300-#[cfg(any(target_os = "linux", test))]301-fn reset_through_poisoned_lock<T>(302- device: &Mutex<T>,303- reset: impl FnOnce(&mut T) -> Result<()>,304-) -> Result<()> {305- let mut device = match device.lock() {306- Ok(device) => device,307- Err(poisoned) => {308- // A panic while applying input poisons this mutex. Teardown must309- // still recover the device solely to release held transitions;310- // ordinary input continues to reject the poisoned lock.311- tracing::warn!("recovering poisoned uinput lock for session reset");312- poisoned.into_inner()313- }314- };315- reset(&mut device)316-}317-318-fn ensure_event_was_negotiated(319- mode: &InputMode,320- event: &InputEvent,321- negotiated: &[Capability],322-) -> Result<()> {323- if !matches!(mode, InputMode::Uinput) {324- return Ok(());325- }326- let required = match event {327- InputEvent::Probe => Some(Capability::InputAck),328- InputEvent::MouseMove { .. } => Some(Capability::AbsolutePointer),329- InputEvent::MouseMoveRelative { .. }330- | InputEvent::MousePositionSync { .. }331- | InputEvent::MouseClick { .. }332- | InputEvent::MouseDown { .. }333- | InputEvent::MouseUp { .. }334- | InputEvent::MouseScroll { .. } => Some(Capability::RelativePointer),335- InputEvent::KeyDown { .. } | InputEvent::KeyUp { .. } | InputEvent::KeyClick { .. } => {336- Some(Capability::Keyboard)337- }338- InputEvent::Gamepad { .. } => Some(Capability::Gamepad),339- // Text remains an explicit content-free no-op for compatibility with340- // clients that emit it alongside negotiated physical key transitions.341- InputEvent::Text { .. } => None,342- };343- if required.is_some_and(|capability| !negotiated.contains(&capability)) {344- bail!("input event class was not negotiated for this session");345- }346- Ok(())347-}348-349-fn capabilities_for_mode(mode: &InputMode) -> &'static [Capability] {350- match mode {351- InputMode::Disabled | InputMode::Log => ACK_ONLY_CAPABILITIES,352- InputMode::Uinput => UINPUT_CAPABILITIES,353- }354-}355-356-#[cfg(any(target_os = "linux", test))]357-#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]358-enum MappedKey {359- A,360- B,361- C,362- D,363- E,364- F,365- G,366- H,367- I,368- J,369- K,370- L,371- M,372- N,373- O,374- P,375- Q,376- R,377- S,378- T,379- U,380- V,381- W,382- X,383- Y,384- Z,385- Num0,386- Num1,387- Num2,388- Num3,389- Num4,390- Num5,391- Num6,392- Num7,393- Num8,394- Num9,395- Minus,396- Equal,397- LeftBrace,398- RightBrace,399- Backslash,400- Semicolon,401- Apostrophe,402- Grave,403- Comma,404- Dot,405- Slash,406- NonUsBackslash,407- Ro,408- Yen,409- Enter,410- Tab,411- Space,412- Backspace,413- Escape,414- LeftShift,415- RightShift,416- LeftCtrl,417- RightCtrl,418- LeftAlt,419- RightAlt,420- LeftMeta,421- RightMeta,422- Up,423- Down,424- Left,425- Right,426- Home,427- End,428- PageUp,429- PageDown,430- Insert,431- Delete,432- PrintScreen,433- F1,434- F2,435- F3,436- F4,437- F5,438- F6,439- F7,440- F8,441- F9,442- F10,443- F11,444- F12,445-}446-447-#[cfg(any(target_os = "linux", test))]448-const ALL_MAPPED_KEYS: &[MappedKey] = &[449- MappedKey::A,450- MappedKey::B,451- MappedKey::C,452- MappedKey::D,453- MappedKey::E,454- MappedKey::F,455- MappedKey::G,456- MappedKey::H,457- MappedKey::I,458- MappedKey::J,459- MappedKey::K,460- MappedKey::L,461- MappedKey::M,462- MappedKey::N,463- MappedKey::O,464- MappedKey::P,465- MappedKey::Q,466- MappedKey::R,467- MappedKey::S,468- MappedKey::T,469- MappedKey::U,470- MappedKey::V,471- MappedKey::W,472- MappedKey::X,473- MappedKey::Y,474- MappedKey::Z,475- MappedKey::Num0,476- MappedKey::Num1,477- MappedKey::Num2,478- MappedKey::Num3,479- MappedKey::Num4,480- MappedKey::Num5,481- MappedKey::Num6,482- MappedKey::Num7,483- MappedKey::Num8,484- MappedKey::Num9,485- MappedKey::Minus,486- MappedKey::Equal,487- MappedKey::LeftBrace,488- MappedKey::RightBrace,489- MappedKey::Backslash,490- MappedKey::Semicolon,491- MappedKey::Apostrophe,492- MappedKey::Grave,493- MappedKey::Comma,494- MappedKey::Dot,495- MappedKey::Slash,496- MappedKey::NonUsBackslash,497- MappedKey::Ro,498- MappedKey::Yen,499- MappedKey::Enter,500- MappedKey::Tab,501- MappedKey::Space,502- MappedKey::Backspace,503- MappedKey::Escape,504- MappedKey::LeftShift,505- MappedKey::RightShift,506- MappedKey::LeftCtrl,507- MappedKey::RightCtrl,508- MappedKey::LeftAlt,509- MappedKey::RightAlt,510- MappedKey::LeftMeta,511- MappedKey::RightMeta,512- MappedKey::Up,513- MappedKey::Down,514- MappedKey::Left,515- MappedKey::Right,516- MappedKey::Home,517- MappedKey::End,518- MappedKey::PageUp,519- MappedKey::PageDown,520- MappedKey::Insert,521- MappedKey::Delete,522- MappedKey::PrintScreen,523- MappedKey::F1,524- MappedKey::F2,525- MappedKey::F3,526- MappedKey::F4,527- MappedKey::F5,528- MappedKey::F6,529- MappedKey::F7,530- MappedKey::F8,531- MappedKey::F9,532- MappedKey::F10,533- MappedKey::F11,534- MappedKey::F12,535-];536-537-#[cfg(any(target_os = "linux", test))]538-fn map_key(value: &str) -> Option<MappedKey> {539- match value {540- "KeyA" => Some(MappedKey::A),541- "KeyB" => Some(MappedKey::B),542- "KeyC" => Some(MappedKey::C),543- "KeyD" => Some(MappedKey::D),544- "KeyE" => Some(MappedKey::E),545- "KeyF" => Some(MappedKey::F),546- "KeyG" => Some(MappedKey::G),547- "KeyH" => Some(MappedKey::H),548- "KeyI" => Some(MappedKey::I),549- "KeyJ" => Some(MappedKey::J),550- "KeyK" => Some(MappedKey::K),551- "KeyL" => Some(MappedKey::L),552- "KeyM" => Some(MappedKey::M),553- "KeyN" => Some(MappedKey::N),554- "KeyO" => Some(MappedKey::O),555- "KeyP" => Some(MappedKey::P),556- "KeyQ" => Some(MappedKey::Q),557- "KeyR" => Some(MappedKey::R),558- "KeyS" => Some(MappedKey::S),559- "KeyT" => Some(MappedKey::T),560- "KeyU" => Some(MappedKey::U),561- "KeyV" => Some(MappedKey::V),562- "KeyW" => Some(MappedKey::W),563- "KeyX" => Some(MappedKey::X),564- "KeyY" => Some(MappedKey::Y),565- "KeyZ" => Some(MappedKey::Z),566- "Digit0" => Some(MappedKey::Num0),567- "Digit1" => Some(MappedKey::Num1),568- "Digit2" => Some(MappedKey::Num2),569- "Digit3" => Some(MappedKey::Num3),570- "Digit4" => Some(MappedKey::Num4),571- "Digit5" => Some(MappedKey::Num5),572- "Digit6" => Some(MappedKey::Num6),573- "Digit7" => Some(MappedKey::Num7),574- "Digit8" => Some(MappedKey::Num8),575- "Digit9" => Some(MappedKey::Num9),576- "Minus" => Some(MappedKey::Minus),577- "Equal" => Some(MappedKey::Equal),578- "BracketLeft" => Some(MappedKey::LeftBrace),579- "BracketRight" => Some(MappedKey::RightBrace),580- "Backslash" => Some(MappedKey::Backslash),581- "IntlBackslash" => Some(MappedKey::NonUsBackslash),582- "IntlRo" => Some(MappedKey::Ro),583- "IntlYen" => Some(MappedKey::Yen),584- "Semicolon" => Some(MappedKey::Semicolon),585- "Quote" => Some(MappedKey::Apostrophe),586- "Backquote" => Some(MappedKey::Grave),587- "Comma" => Some(MappedKey::Comma),588- "Period" => Some(MappedKey::Dot),589- "Slash" => Some(MappedKey::Slash),590- "Enter" => Some(MappedKey::Enter),591- "Tab" => Some(MappedKey::Tab),592- "Space" => Some(MappedKey::Space),593- "Backspace" => Some(MappedKey::Backspace),594- "Escape" => Some(MappedKey::Escape),595- "ShiftLeft" => Some(MappedKey::LeftShift),596- "ShiftRight" => Some(MappedKey::RightShift),597- "ControlLeft" => Some(MappedKey::LeftCtrl),598- "ControlRight" => Some(MappedKey::RightCtrl),599- "AltLeft" => Some(MappedKey::LeftAlt),600- "AltRight" => Some(MappedKey::RightAlt),601- "MetaLeft" => Some(MappedKey::LeftMeta),602- "MetaRight" => Some(MappedKey::RightMeta),603- "Shift" => Some(MappedKey::LeftShift),604- "Control" => Some(MappedKey::LeftCtrl),605- "Alt" => Some(MappedKey::LeftAlt),606- "Meta" => Some(MappedKey::LeftMeta),607- "ArrowUp" => Some(MappedKey::Up),608- "ArrowDown" => Some(MappedKey::Down),609- "ArrowLeft" => Some(MappedKey::Left),610- "ArrowRight" => Some(MappedKey::Right),611- "Up" => Some(MappedKey::Up),612- "Down" => Some(MappedKey::Down),613- "Left" => Some(MappedKey::Left),614- "Right" => Some(MappedKey::Right),615- "Home" => Some(MappedKey::Home),616- "End" => Some(MappedKey::End),617- "PageUp" => Some(MappedKey::PageUp),618- "PageDown" => Some(MappedKey::PageDown),619- "Insert" => Some(MappedKey::Insert),620- "Delete" => Some(MappedKey::Delete),621- "PrintScreen" => Some(MappedKey::PrintScreen),622- "F1" => Some(MappedKey::F1),623- "F2" => Some(MappedKey::F2),624- "F3" => Some(MappedKey::F3),625- "F4" => Some(MappedKey::F4),626- "F5" => Some(MappedKey::F5),627- "F6" => Some(MappedKey::F6),628- "F7" => Some(MappedKey::F7),629- "F8" => Some(MappedKey::F8),630- "F9" => Some(MappedKey::F9),631- "F10" => Some(MappedKey::F10),632- "F11" => Some(MappedKey::F11),633- "F12" => Some(MappedKey::F12),634- _ => map_printable_us_key(value),635- }636-}637-638-#[cfg(any(target_os = "linux", test))]639-fn map_printable_us_key(value: &str) -> Option<MappedKey> {640- let mut chars = value.chars();641- let character = chars.next()?;642- if chars.next().is_some() || !character.is_ascii() {643- return None;644- }645- Some(match character {646- 'a' | 'A' => MappedKey::A,647- 'b' | 'B' => MappedKey::B,648- 'c' | 'C' => MappedKey::C,649- 'd' | 'D' => MappedKey::D,650- 'e' | 'E' => MappedKey::E,651- 'f' | 'F' => MappedKey::F,652- 'g' | 'G' => MappedKey::G,653- 'h' | 'H' => MappedKey::H,654- 'i' | 'I' => MappedKey::I,655- 'j' | 'J' => MappedKey::J,656- 'k' | 'K' => MappedKey::K,657- 'l' | 'L' => MappedKey::L,658- 'm' | 'M' => MappedKey::M,659- 'n' | 'N' => MappedKey::N,660- 'o' | 'O' => MappedKey::O,661- 'p' | 'P' => MappedKey::P,662- 'q' | 'Q' => MappedKey::Q,663- 'r' | 'R' => MappedKey::R,664- 's' | 'S' => MappedKey::S,665- 't' | 'T' => MappedKey::T,666- 'u' | 'U' => MappedKey::U,667- 'v' | 'V' => MappedKey::V,668- 'w' | 'W' => MappedKey::W,669- 'x' | 'X' => MappedKey::X,670- 'y' | 'Y' => MappedKey::Y,671- 'z' | 'Z' => MappedKey::Z,672- '0' | ')' => MappedKey::Num0,673- '1' | '!' => MappedKey::Num1,674- '2' | '@' => MappedKey::Num2,675- '3' | '#' => MappedKey::Num3,676- '4' | '$' => MappedKey::Num4,677- '5' | '%' => MappedKey::Num5,678- '6' | '^' => MappedKey::Num6,679- '7' | '&' => MappedKey::Num7,680- '8' | '*' => MappedKey::Num8,681- '9' | '(' => MappedKey::Num9,682- '-' | '_' => MappedKey::Minus,683- '=' | '+' => MappedKey::Equal,684- '[' | '{' => MappedKey::LeftBrace,685- ']' | '}' => MappedKey::RightBrace,686- '\\' | '|' => MappedKey::Backslash,687- ';' | ':' => MappedKey::Semicolon,688- '\'' | '"' => MappedKey::Apostrophe,689- '`' | '~' => MappedKey::Grave,690- ',' | '<' => MappedKey::Comma,691- '.' | '>' => MappedKey::Dot,692- '/' | '?' => MappedKey::Slash,693- ' ' => MappedKey::Space,694- _ => return None,695- })696-}697-698-#[cfg(any(target_os = "linux", test))]699-#[derive(Clone, Copy, Debug, PartialEq, Eq)]700-enum GamepadButton {701- A,702- B,703- X,704- Y,705- LeftShoulder,706- RightShoulder,707- Back,708- Start,709- Guide,710- LeftStick,711- RightStick,712-}713-714-#[cfg(any(target_os = "linux", test))]715-#[derive(Clone, Copy, Debug, PartialEq, Eq)]716-enum GamepadAxis {717- LeftX,718- LeftY,719- RightX,720- RightY,721- LeftTrigger,722- RightTrigger,723- DpadX,724- DpadY,725-}726-727-#[cfg(any(target_os = "linux", test))]728-#[derive(Clone, Copy, Debug, PartialEq, Eq)]729-struct GamepadReport {730- buttons: [(GamepadButton, bool); 11],731- axes: [(GamepadAxis, i32); 8],732-}733-734-#[cfg(any(target_os = "linux", test))]735-fn map_gamepad_state(state: &GamepadState) -> GamepadReport {736- GamepadReport {737- buttons: [738- (GamepadButton::A, state.a),739- (GamepadButton::B, state.b),740- (GamepadButton::X, state.x),741- (GamepadButton::Y, state.y),742- (GamepadButton::LeftShoulder, state.left_shoulder),743- (GamepadButton::RightShoulder, state.right_shoulder),744- (GamepadButton::Back, state.back),745- (GamepadButton::Start, state.start),746- (GamepadButton::Guide, state.guide),747- (GamepadButton::LeftStick, state.left_stick),748- (GamepadButton::RightStick, state.right_stick),749- ],750- axes: [751- (GamepadAxis::LeftX, i32::from(state.left_x)),752- (GamepadAxis::LeftY, i32::from(state.left_y)),753- (GamepadAxis::RightX, i32::from(state.right_x)),754- (GamepadAxis::RightY, i32::from(state.right_y)),755- (GamepadAxis::LeftTrigger, i32::from(state.left_trigger)),756- (GamepadAxis::RightTrigger, i32::from(state.right_trigger)),757- (758- GamepadAxis::DpadX,759- i32::from(state.dpad_right) - i32::from(state.dpad_left),760- ),761- (762- GamepadAxis::DpadY,763- i32::from(state.dpad_down) - i32::from(state.dpad_up),764- ),765- ],766- }767-}768-769-#[cfg(target_os = "linux")]770-mod linux {771- use std::collections::BTreeSet;772- use std::fs::{File, OpenOptions};773- use std::os::fd::AsRawFd;774- use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt};775-776- use anyhow::{Context, Result, ensure};777- use input_linux::{778- AbsoluteAxis, AbsoluteEvent, AbsoluteInfo, AbsoluteInfoSetup, EventKind, EventTime,779- InputId, InputProperty, Key, KeyEvent, KeyState, RelativeAxis, RelativeEvent,780- SynchronizeEvent, UInputHandle,781- };782- use sigil_protocol::{783- GAMEPAD_AXIS_MAX, GAMEPAD_AXIS_MIN, GAMEPAD_TRIGGER_MAX, GamepadState,784- InputEvent as ProtocolInputEvent,785- };786-787- use super::{788- ALL_MAPPED_KEYS, GAMEPAD_DEVICE_NAME, GAMEPAD_PRODUCT_ID, GamepadAxis, GamepadButton,789- InputDeviceClass, KEYBOARD_DEVICE_NAME, KEYBOARD_PRODUCT_ID, MappedKey,790- POINTER_DEVICE_NAME, POINTER_PRODUCT_ID, POSIX_ACL_REQUIRED_BYTES, PointerReportEvent,791- UINPUT_BUS_TYPE, UINPUT_DEVICE_VERSION, UINPUT_VENDOR_ID, input_device_class,792- map_gamepad_state, map_key, pointer_position_sync_report, validate_single_user_access_acl,793- };794- use crate::config::UinputConfig;795-796- const UINPUT_MAJOR: u32 = 10;797- const UINPUT_MINOR: u32 = 223;798- const MAX_POINTER_EVENTS: usize = 6;799- const MAX_KEYBOARD_EVENTS: usize = ALL_MAPPED_KEYS.len() + 1;800- const GAMEPAD_REPORT_EVENTS: usize = 20;801-802- pub struct UinputDevice {803- pointer: PointerDevice,804- keyboard: KeyboardDevice,805- gamepad: GamepadDevice,806- }807-808- impl UinputDevice {809- pub fn open(config: &UinputConfig) -> Result<Self> {810- // Each virtual device owns a distinct validated descriptor. This811- // keeps libinput/udev classification from merging keyboard keys812- // into the conventional relative pointer topology.813- let pointer = PointerDevice::open(config)?;814- let keyboard = KeyboardDevice::open(config)?;815- let gamepad = GamepadDevice::open(config)?;816-817- Ok(Self {818- pointer,819- keyboard,820- gamepad,821- })822- }823-824- pub fn apply(&mut self, event: &ProtocolInputEvent) -> Result<()> {825- match input_device_class(event) {826- Some(InputDeviceClass::Pointer) => self.pointer.apply(event),827- Some(InputDeviceClass::Keyboard) => self.keyboard.apply(event),828- Some(InputDeviceClass::Gamepad) => match event {829- ProtocolInputEvent::Gamepad { state } => self.gamepad.apply(state),830- _ => unreachable!("input device classification is exhaustive"),831- },832- None => Ok(()),833- }834- }835-836- pub fn release_all(&mut self) -> Result<()> {837- // Evaluate every release before combining results so one broken838- // descriptor cannot strand held state on either sibling device.839- let pointer_result = self.pointer.release_all();840- let keyboard_result = self.keyboard.release_all();841- let gamepad_result = self.gamepad.neutralize();842- pointer_result.and(keyboard_result).and(gamepad_result)843- }844- }845-846- struct PointerDevice {847- handle: UInputHandle<File>,848- pressed: BTreeSet<Key>,849- }850-851- impl PointerDevice {852- fn open(config: &UinputConfig) -> Result<Self> {853- let handle = open_validated_handle(config, "pointer")?;854- handle855- .set_propbit(InputProperty::Pointer)856- .context("enabling pointer property")?;857- handle858- .set_evbit(EventKind::Key)859- .context("enabling pointer button events")?;860- for key in [Key::ButtonLeft, Key::ButtonRight, Key::ButtonMiddle] {861- handle862- .set_keybit(key)863- .context("enabling a bounded pointer button capability")?;864- }865- handle866- .set_evbit(EventKind::Relative)867- .context("enabling relative pointer events")?;868- handle869- .set_relbit(RelativeAxis::X)870- .context("enabling relative X events")?;871- handle872- .set_relbit(RelativeAxis::Y)873- .context("enabling relative Y events")?;874- handle875- .set_relbit(RelativeAxis::Wheel)876- .context("enabling vertical wheel events")?;877- handle878- .set_relbit(RelativeAxis::HorizontalWheel)879- .context("enabling horizontal wheel events")?;880- handle881- .create(882- &InputId {883- bustype: UINPUT_BUS_TYPE,884- vendor: UINPUT_VENDOR_ID,885- product: POINTER_PRODUCT_ID,886- version: UINPUT_DEVICE_VERSION,887- },888- POINTER_DEVICE_NAME,889- 0,890- &[],891- )892- .context("registering Sigil virtual pointer")?;893-894- Ok(Self {895- handle,896- pressed: BTreeSet::new(),897- })898- }899-900- fn apply(&mut self, event: &ProtocolInputEvent) -> Result<()> {901- match event {902- ProtocolInputEvent::MouseMoveRelative { dx, dy } => {903- let mut events = [synchronize_event(); 3];904- let mut len = 0;905- if *dx != 0 {906- events[len] = relative_event(RelativeAxis::X, *dx);907- len += 1;908- }909- if *dy != 0 {910- events[len] = relative_event(RelativeAxis::Y, *dy);911- len += 1;912- }913- if len == 0 {914- return Ok(());915- }916- events[len] = synchronize_event();917- self.emit(&events[..=len])918- }919- ProtocolInputEvent::MousePositionSync { x, y } => {920- let events = pointer_position_sync_report(*x, *y).map(|event| match event {921- PointerReportEvent::RelativeX(value) => {922- relative_event(RelativeAxis::X, value)923- }924- PointerReportEvent::RelativeY(value) => {925- relative_event(RelativeAxis::Y, value)926- }927- PointerReportEvent::Synchronize => synchronize_event(),928- });929- self.emit(&events)930- }931- ProtocolInputEvent::MouseMove { .. } => anyhow::bail!(932- "absolute pointer movement is unavailable on the relative-only uinput device"933- ),934- ProtocolInputEvent::MouseClick { b } => {935- self.click(mouse_button(*b).context("unsupported mouse button")?)936- }937- ProtocolInputEvent::MouseDown { b } => {938- self.press(mouse_button(*b).context("unsupported mouse button")?)939- }940- ProtocolInputEvent::MouseUp { b } => {941- self.release(mouse_button(*b).context("unsupported mouse button")?)942- }943- ProtocolInputEvent::MouseScroll { dx, dy } => {944- let vertical = dy945- .checked_neg()946- .context("vertical wheel delta is out of range")?;947- let mut events = [synchronize_event(); 3];948- let mut len = 0;949- if *dx != 0 {950- events[len] = relative_event(RelativeAxis::HorizontalWheel, *dx);951- len += 1;952- }953- if vertical != 0 {954- events[len] = relative_event(RelativeAxis::Wheel, vertical);955- len += 1;956- }957- if len == 0 {958- return Ok(());959- }960- events[len] = synchronize_event();961- self.emit(&events[..=len])962- }963- _ => unreachable!("only pointer events are routed to the pointer device"),964- }965- }966-967- fn press(&mut self, key: Key) -> Result<()> {968- self.emit(&[key_event(key, KeyState::PRESSED), synchronize_event()])?;969- self.pressed.insert(key);970- Ok(())971- }972-973- fn release(&mut self, key: Key) -> Result<()> {974- self.emit(&[key_event(key, KeyState::RELEASED), synchronize_event()])?;975- self.pressed.remove(&key);976- Ok(())977- }978-979- fn click(&mut self, key: Key) -> Result<()> {980- ensure!(981- !self.pressed.contains(&key),982- "click transition is invalid while the key or button is held"983- );984- self.emit(&[985- key_event(key, KeyState::PRESSED),986- synchronize_event(),987- key_event(key, KeyState::RELEASED),988- synchronize_event(),989- ])990- }991-992- fn release_all(&mut self) -> Result<()> {993- if self.pressed.is_empty() {994- return Ok(());995- }996- let mut events = Vec::with_capacity(self.pressed.len() + 1);997- events.extend(998- self.pressed999- .iter()1000- .copied()1001- .map(|key| key_event(key, KeyState::RELEASED)),1002- );1003- events.push(synchronize_event());1004- ensure!(1005- events.len() <= MAX_POINTER_EVENTS,1006- "pointer release set exceeded its static bound"1007- );1008- self.emit(&events)?;1009- self.pressed.clear();1010- Ok(())1011- }1012-1013- fn emit(&self, events: &[input_linux::sys::input_event]) -> Result<()> {1014- ensure!(1015- !events.is_empty() && events.len() <= MAX_POINTER_EVENTS,1016- "pointer event batch is outside its static bound"1017- );1018- let written = self1019- .handle1020- .write(events)1021- .context("writing nonblocking pointer event batch")?;1022- ensure!(1023- written == events.len(),1024- "uinput accepted only {written} of {} pointer events",1025- events.len()1026- );1027- Ok(())1028- }1029- }1030-1031- impl Drop for PointerDevice {1032- fn drop(&mut self) {1033- let _ = self.release_all();1034- let _ = self.handle.dev_destroy();1035- }1036- }1037-1038- struct KeyboardDevice {1039- handle: UInputHandle<File>,1040- pressed: BTreeSet<Key>,1041- }1042-1043- impl KeyboardDevice {1044- fn open(config: &UinputConfig) -> Result<Self> {1045- let handle = open_validated_handle(config, "keyboard")?;1046- handle1047- .set_evbit(EventKind::Key)1048- .context("enabling keyboard key events")?;1049- for key in ALL_MAPPED_KEYS.iter().copied().map(linux_key) {1050- handle1051- .set_keybit(key)1052- .context("enabling a bounded keyboard key capability")?;1053- }1054- handle1055- .create(1056- &InputId {1057- bustype: UINPUT_BUS_TYPE,1058- vendor: UINPUT_VENDOR_ID,1059- product: KEYBOARD_PRODUCT_ID,1060- version: UINPUT_DEVICE_VERSION,1061- },1062- KEYBOARD_DEVICE_NAME,1063- 0,1064- &[],1065- )1066- .context("registering Sigil virtual keyboard")?;1067-1068- Ok(Self {1069- handle,1070- pressed: BTreeSet::new(),1071- })1072- }1073-1074- fn apply(&mut self, event: &ProtocolInputEvent) -> Result<()> {1075- match event {1076- ProtocolInputEvent::KeyDown { k } => {1077- self.press(linux_key(map_key(k).context("unsupported keyboard key")?))1078- }1079- ProtocolInputEvent::KeyUp { k } => {1080- self.release(linux_key(map_key(k).context("unsupported keyboard key")?))1081- }1082- ProtocolInputEvent::KeyClick { k } => {1083- self.click(linux_key(map_key(k).context("unsupported keyboard key")?))1084- }1085- _ => unreachable!("only keyboard events are routed to the keyboard device"),1086- }1087- }1088-1089- fn press(&mut self, key: Key) -> Result<()> {1090- self.emit(&[key_event(key, KeyState::PRESSED), synchronize_event()])?;1091- self.pressed.insert(key);1092- Ok(())1093- }1094-1095- fn release(&mut self, key: Key) -> Result<()> {1096- self.emit(&[key_event(key, KeyState::RELEASED), synchronize_event()])?;1097- self.pressed.remove(&key);1098- Ok(())1099- }1100-1101- fn click(&mut self, key: Key) -> Result<()> {1102- ensure!(1103- !self.pressed.contains(&key),1104- "click transition is invalid while the key is held"1105- );1106- self.emit(&[1107- key_event(key, KeyState::PRESSED),1108- synchronize_event(),1109- key_event(key, KeyState::RELEASED),1110- synchronize_event(),1111- ])1112- }1113-1114- fn release_all(&mut self) -> Result<()> {1115- if self.pressed.is_empty() {1116- return Ok(());1117- }1118- let mut events = Vec::with_capacity(self.pressed.len() + 1);1119- events.extend(1120- self.pressed1121- .iter()1122- .copied()1123- .map(|key| key_event(key, KeyState::RELEASED)),1124- );1125- events.push(synchronize_event());1126- ensure!(1127- events.len() <= MAX_KEYBOARD_EVENTS,1128- "keyboard release set exceeded its static bound"1129- );1130- self.emit(&events)?;1131- self.pressed.clear();1132- Ok(())1133- }1134-1135- fn emit(&self, events: &[input_linux::sys::input_event]) -> Result<()> {1136- ensure!(1137- !events.is_empty() && events.len() <= MAX_KEYBOARD_EVENTS,1138- "keyboard event batch is outside its static bound"1139- );1140- let written = self1141- .handle1142- .write(events)1143- .context("writing nonblocking keyboard event batch")?;1144- ensure!(1145- written == events.len(),1146- "uinput accepted only {written} of {} keyboard events",1147- events.len()1148- );1149- Ok(())1150- }1151- }1152-1153- impl Drop for KeyboardDevice {1154- fn drop(&mut self) {1155- let _ = self.release_all();1156- let _ = self.handle.dev_destroy();1157- }1158- }1159-1160- struct GamepadDevice {1161- handle: UInputHandle<File>,1162- }1163-1164- impl GamepadDevice {1165- fn open(config: &UinputConfig) -> Result<Self> {1166- let handle = open_validated_handle(config, "gamepad")?;1167-1168- handle1169- .set_evbit(EventKind::Key)1170- .context("enabling gamepad key events")?;1171- for (button, _) in map_gamepad_state(&GamepadState::default()).buttons {1172- handle1173- .set_keybit(linux_gamepad_button(button))1174- .context("enabling a bounded gamepad button capability")?;1175- }1176- handle1177- .set_evbit(EventKind::Absolute)1178- .context("enabling gamepad absolute-axis events")?;1179- for (axis, _) in map_gamepad_state(&GamepadState::default()).axes {1180- handle1181- .set_absbit(linux_gamepad_axis(axis))1182- .context("enabling a bounded gamepad axis capability")?;1183- }1184-1185- let axes = [1186- absolute_range(1187- AbsoluteAxis::X,1188- i32::from(GAMEPAD_AXIS_MIN),1189- i32::from(GAMEPAD_AXIS_MAX),1190- ),1191- absolute_range(1192- AbsoluteAxis::Y,1193- i32::from(GAMEPAD_AXIS_MIN),1194- i32::from(GAMEPAD_AXIS_MAX),1195- ),1196- absolute_range(1197- AbsoluteAxis::RX,1198- i32::from(GAMEPAD_AXIS_MIN),1199- i32::from(GAMEPAD_AXIS_MAX),1200- ),1201- absolute_range(1202- AbsoluteAxis::RY,1203- i32::from(GAMEPAD_AXIS_MIN),1204- i32::from(GAMEPAD_AXIS_MAX),1205- ),1206- absolute_range(AbsoluteAxis::Z, 0, i32::from(GAMEPAD_TRIGGER_MAX)),1207- absolute_range(AbsoluteAxis::RZ, 0, i32::from(GAMEPAD_TRIGGER_MAX)),1208- absolute_range(AbsoluteAxis::Hat0X, -1, 1),1209- absolute_range(AbsoluteAxis::Hat0Y, -1, 1),1210- ];1211- handle1212- .create(1213- &InputId {1214- bustype: UINPUT_BUS_TYPE,1215- vendor: UINPUT_VENDOR_ID,1216- product: GAMEPAD_PRODUCT_ID,1217- version: UINPUT_DEVICE_VERSION,1218- },1219- GAMEPAD_DEVICE_NAME,1220- 0,1221- &axes,1222- )1223- .context("registering Sigil virtual gamepad")?;1224- let device = Self { handle };1225- device.neutralize()?;1226- Ok(device)1227- }1228-1229- fn apply(&self, state: &GamepadState) -> Result<()> {1230- state.validate().context("validating gamepad snapshot")?;1231- let report = map_gamepad_state(state);1232- let mut events = [synchronize_event(); GAMEPAD_REPORT_EVENTS];1233- let mut index = 0;1234- for (button, pressed) in report.buttons {1235- events[index] = key_event(linux_gamepad_button(button), KeyState::pressed(pressed));1236- index += 1;1237- }1238- for (axis, value) in report.axes {1239- events[index] = absolute_event(linux_gamepad_axis(axis), value);1240- index += 1;1241- }1242- events[index] = synchronize_event();1243- index += 1;1244- ensure!(1245- index == GAMEPAD_REPORT_EVENTS,1246- "gamepad report violated its static event bound"1247- );1248- let written = self1249- .handle1250- .write(&events)1251- .context("writing nonblocking gamepad snapshot")?;1252- ensure!(1253- written == events.len(),1254- "uinput accepted only {written} of {} gamepad events",1255- events.len()1256- );1257- Ok(())1258- }1259-1260- fn neutralize(&self) -> Result<()> {1261- self.apply(&GamepadState::default())1262- .context("neutralizing virtual gamepad")1263- }1264- }1265-1266- impl Drop for GamepadDevice {1267- fn drop(&mut self) {1268- let _ = self.neutralize();1269- let _ = self.handle.dev_destroy();1270- }1271- }1272-1273- fn open_validated_handle(1274- config: &UinputConfig,1275- device_class: &str,1276- ) -> Result<UInputHandle<File>> {1277- let handle = UInputHandle::new(open_validated_device(config)?);1278- let version = handle1279- .version()1280- .with_context(|| format!("querying uinput interface version for {device_class}"))?;1281- ensure!(1282- version >= 5,1283- "uinput interface version {version} is too old for {device_class}"1284- );1285- Ok(handle)1286- }1287-1288- fn open_validated_device(config: &UinputConfig) -> Result<File> {1289- let mut options = OpenOptions::new();1290- options1291- .read(true)1292- .write(true)1293- .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK);1294- let file = options.open(&config.device_path).with_context(|| {1295- format!(1296- "opening configured uinput device {} without following symlinks",1297- config.device_path.display()1298- )1299- })?;1300- validate_open_device(&file, config)?;1301- Ok(file)1302- }1303-1304- fn validate_open_device(file: &File, config: &UinputConfig) -> Result<()> {1305- let metadata = file.metadata().with_context(|| {1306- format!(1307- "inspecting opened uinput device {}",1308- config.device_path.display()1309- )1310- })?;1311- ensure!(1312- metadata.file_type().is_char_device(),1313- "configured uinput path is not a character device"1314- );1315- ensure!(1316- metadata.uid() == config.expected_owner_uid,1317- "configured uinput owner UID changed"1318- );1319- ensure!(1320- metadata.gid() == config.expected_group_gid,1321- "configured uinput group GID changed"1322- );1323- ensure!(1324- metadata.mode() & 0o7777 == config.expected_mode,1325- "configured uinput permission mode changed"1326- );1327- let device = metadata.rdev();1328- ensure!(1329- libc::major(device) == UINPUT_MAJOR && libc::minor(device) == UINPUT_MINOR,1330- "configured character device is not the Linux uinput misc device"1331- );1332- validate_access_acl(file, config)?;1333- Ok(())1334- }1335-1336- fn validate_access_acl(file: &File, config: &UinputConfig) -> Result<()> {1337- if let Some(expected_uid) = config.expected_acl_user_uid {1338- let effective_uid = unsafe { libc::geteuid() };1339- ensure!(1340- expected_uid == effective_uid,1341- "configured uinput ACL user UID {expected_uid} does not match Sigil effective UID {effective_uid}"1342- );1343- }1344-1345- let attribute = c"system.posix_acl_access";1346- let mut bytes = [0_u8; POSIX_ACL_REQUIRED_BYTES];1347- let result = unsafe {1348- libc::fgetxattr(1349- file.as_raw_fd(),1350- attribute.as_ptr(),1351- bytes.as_mut_ptr().cast(),1352- bytes.len(),1353- )1354- };1355- if result >= 0 {1356- let Some(expected_uid) = config.expected_acl_user_uid else {1357- anyhow::bail!(1358- "configured uinput device must use the group-only scheme with no extended access ACL; set uinput.expected_acl_user_uid only for the explicit one-user ACL scheme"1359- );1360- };1361- let length = usize::try_from(result)1362- .context("converting configured uinput access ACL length")?;1363- return validate_single_user_access_acl(1364- bytes1365- .get(..length)1366- .context("configured uinput access ACL exceeded its fixed bound")?,1367- expected_uid,1368- config.expected_mode,1369- )1370- .context("validating configured uinput one-user access ACL");1371- }1372- let error = std::io::Error::last_os_error();1373- if error1374- .raw_os_error()1375- .is_some_and(|code| code == libc::ENODATA || code == libc::ENOTSUP)1376- {1377- ensure!(1378- config.expected_acl_user_uid.is_none(),1379- "configured uinput device is missing the explicitly required one-user access ACL"1380- );1381- return Ok(());1382- }1383- if error.raw_os_error() == Some(libc::ERANGE) {1384- if config.expected_acl_user_uid.is_some() {1385- anyhow::bail!("configured uinput access ACL exceeds the exact one-user ACL scheme");1386- }1387- anyhow::bail!(1388- "configured uinput device must use the group-only scheme with no extended access ACL"1389- );1390- }1391- Err(error).context("checking configured uinput device access ACL")1392- }1393-1394- fn absolute_range(axis: AbsoluteAxis, minimum: i32, maximum: i32) -> AbsoluteInfoSetup {1395- AbsoluteInfoSetup {1396- axis,1397- info: AbsoluteInfo {1398- minimum,1399- maximum,1400- ..AbsoluteInfo::default()1401- },1402- }1403- }1404-1405- fn mouse_button(button: u8) -> Option<Key> {1406- match button {1407- 1 => Some(Key::ButtonLeft),1408- 2 => Some(Key::ButtonRight),1409- 3 => Some(Key::ButtonMiddle),1410- _ => None,1411- }1412- }1413-1414- fn linux_gamepad_button(button: GamepadButton) -> Key {1415- match button {1416- GamepadButton::A => Key::ButtonSouth,1417- GamepadButton::B => Key::ButtonEast,1418- GamepadButton::X => Key::ButtonNorth,1419- GamepadButton::Y => Key::ButtonWest,1420- GamepadButton::LeftShoulder => Key::ButtonTL,1421- GamepadButton::RightShoulder => Key::ButtonTR,1422- GamepadButton::Back => Key::ButtonSelect,1423- GamepadButton::Start => Key::ButtonStart,1424- GamepadButton::Guide => Key::ButtonMode,1425- GamepadButton::LeftStick => Key::ButtonThumbl,1426- GamepadButton::RightStick => Key::ButtonThumbr,1427- }1428- }1429-1430- fn linux_gamepad_axis(axis: GamepadAxis) -> AbsoluteAxis {1431- match axis {1432- GamepadAxis::LeftX => AbsoluteAxis::X,1433- GamepadAxis::LeftY => AbsoluteAxis::Y,1434- GamepadAxis::RightX => AbsoluteAxis::RX,1435- GamepadAxis::RightY => AbsoluteAxis::RY,1436- GamepadAxis::LeftTrigger => AbsoluteAxis::Z,1437- GamepadAxis::RightTrigger => AbsoluteAxis::RZ,1438- GamepadAxis::DpadX => AbsoluteAxis::Hat0X,1439- GamepadAxis::DpadY => AbsoluteAxis::Hat0Y,1440- }1441- }1442-1443- fn key_event(key: Key, state: KeyState) -> input_linux::sys::input_event {1444- KeyEvent::new(EventTime::default(), key, state)1445- .into_event()1446- .into_raw()1447- }1448-1449- fn absolute_event(axis: AbsoluteAxis, value: i32) -> input_linux::sys::input_event {1450- AbsoluteEvent::new(EventTime::default(), axis, value)1451- .into_event()1452- .into_raw()1453- }1454-1455- fn relative_event(axis: RelativeAxis, value: i32) -> input_linux::sys::input_event {1456- RelativeEvent::new(EventTime::default(), axis, value)1457- .into_event()1458- .into_raw()1459- }1460-1461- fn synchronize_event() -> input_linux::sys::input_event {1462- SynchronizeEvent::report(EventTime::default())1463- .into_event()1464- .into_raw()1465- }1466-1467- pub(super) fn linux_key(key: MappedKey) -> Key {1468- match key {1469- MappedKey::A => Key::A,1470- MappedKey::B => Key::B,1471- MappedKey::C => Key::C,1472- MappedKey::D => Key::D,1473- MappedKey::E => Key::E,1474- MappedKey::F => Key::F,1475- MappedKey::G => Key::G,1476- MappedKey::H => Key::H,1477- MappedKey::I => Key::I,1478- MappedKey::J => Key::J,1479- MappedKey::K => Key::K,1480- MappedKey::L => Key::L,1481- MappedKey::M => Key::M,1482- MappedKey::N => Key::N,1483- MappedKey::O => Key::O,1484- MappedKey::P => Key::P,1485- MappedKey::Q => Key::Q,1486- MappedKey::R => Key::R,1487- MappedKey::S => Key::S,1488- MappedKey::T => Key::T,1489- MappedKey::U => Key::U,1490- MappedKey::V => Key::V,1491- MappedKey::W => Key::W,1492- MappedKey::X => Key::X,1493- MappedKey::Y => Key::Y,1494- MappedKey::Z => Key::Z,1495- MappedKey::Num0 => Key::Num0,1496- MappedKey::Num1 => Key::Num1,1497- MappedKey::Num2 => Key::Num2,1498- MappedKey::Num3 => Key::Num3,1499- MappedKey::Num4 => Key::Num4,1500- MappedKey::Num5 => Key::Num5,1501- MappedKey::Num6 => Key::Num6,1502- MappedKey::Num7 => Key::Num7,1503- MappedKey::Num8 => Key::Num8,1504- MappedKey::Num9 => Key::Num9,1505- MappedKey::Minus => Key::Minus,1506- MappedKey::Equal => Key::Equal,1507- MappedKey::LeftBrace => Key::LeftBrace,1508- MappedKey::RightBrace => Key::RightBrace,1509- MappedKey::Backslash => Key::Backslash,1510- MappedKey::Semicolon => Key::Semicolon,1511- MappedKey::Apostrophe => Key::Apostrophe,1512- MappedKey::Grave => Key::Grave,1513- MappedKey::Comma => Key::Comma,1514- MappedKey::Dot => Key::Dot,1515- MappedKey::Slash => Key::Slash,1516- MappedKey::NonUsBackslash => Key::NonUsBackslashAndPipe,1517- MappedKey::Ro => Key::Ro,1518- MappedKey::Yen => Key::Yen,1519- MappedKey::Enter => Key::Enter,1520- MappedKey::Tab => Key::Tab,1521- MappedKey::Space => Key::Space,1522- MappedKey::Backspace => Key::Backspace,1523- MappedKey::Escape => Key::Esc,1524- MappedKey::LeftShift => Key::LeftShift,1525- MappedKey::RightShift => Key::RightShift,1526- MappedKey::LeftCtrl => Key::LeftCtrl,1527- MappedKey::RightCtrl => Key::RightCtrl,1528- MappedKey::LeftAlt => Key::LeftAlt,1529- MappedKey::RightAlt => Key::RightAlt,1530- MappedKey::LeftMeta => Key::LeftMeta,1531- MappedKey::RightMeta => Key::RightMeta,1532- MappedKey::Up => Key::Up,1533- MappedKey::Down => Key::Down,1534- MappedKey::Left => Key::Left,1535- MappedKey::Right => Key::Right,1536- MappedKey::Home => Key::Home,1537- MappedKey::End => Key::End,1538- MappedKey::PageUp => Key::PageUp,1539- MappedKey::PageDown => Key::PageDown,1540- MappedKey::Insert => Key::Insert,1541- MappedKey::Delete => Key::Delete,1542- MappedKey::PrintScreen => Key::Sysrq,1543- MappedKey::F1 => Key::F1,1544- MappedKey::F2 => Key::F2,1545- MappedKey::F3 => Key::F3,1546- MappedKey::F4 => Key::F4,1547- MappedKey::F5 => Key::F5,1548- MappedKey::F6 => Key::F6,1549- MappedKey::F7 => Key::F7,1550- MappedKey::F8 => Key::F8,1551- MappedKey::F9 => Key::F9,1552- MappedKey::F10 => Key::F10,1553- MappedKey::F11 => Key::F11,1554- MappedKey::F12 => Key::F12,1555- }1556- }1557-}1558-1559-#[cfg(test)]1560-mod tests {1561- use std::panic::{AssertUnwindSafe, catch_unwind};1562- use std::sync::Arc;1563-1564- use super::*;1565-1566- fn acl_entry(tag: u16, permissions: u16, id: u32) -> [u8; POSIX_ACL_ENTRY_BYTES] {1567- let mut bytes = [0_u8; POSIX_ACL_ENTRY_BYTES];1568- bytes[0..2].copy_from_slice(&tag.to_le_bytes());1569- bytes[2..4].copy_from_slice(&permissions.to_le_bytes());1570- bytes[4..8].copy_from_slice(&id.to_le_bytes());1571- bytes1572- }1573-1574- fn one_user_access_acl(uid: u32, mode: u32) -> Vec<u8> {1575- let entries = [1576- acl_entry(1577- POSIX_ACL_USER_OBJ,1578- ((mode >> 6) & 0o7) as u16,1579- POSIX_ACL_UNDEFINED_ID,1580- ),1581- acl_entry(POSIX_ACL_USER, POSIX_ACL_READ_WRITE, uid),1582- acl_entry(1583- POSIX_ACL_GROUP_OBJ,1584- ((mode >> 3) & 0o7) as u16,1585- POSIX_ACL_UNDEFINED_ID,1586- ),1587- acl_entry(1588- POSIX_ACL_MASK,1589- ((mode >> 3) & 0o7) as u16,1590- POSIX_ACL_UNDEFINED_ID,1591- ),1592- acl_entry(POSIX_ACL_OTHER, (mode & 0o7) as u16, POSIX_ACL_UNDEFINED_ID),1593- ];1594- let mut bytes = Vec::with_capacity(POSIX_ACL_REQUIRED_BYTES);1595- bytes.extend_from_slice(&POSIX_ACL_XATTR_VERSION.to_le_bytes());1596- for entry in entries {1597- bytes.extend_from_slice(&entry);1598- }1599- bytes1600- }1601-1602- #[test]1603- fn exact_one_user_access_acl_is_accepted() {1604- let bytes = one_user_access_acl(1000, 0o660);1605- assert_eq!(bytes.len(), POSIX_ACL_REQUIRED_BYTES);1606- validate_single_user_access_acl(&bytes, 1000, 0o660).unwrap();1607- }1608-1609- #[test]1610- fn one_user_access_acl_rejects_wrong_principal_and_permissions() {1611- let bytes = one_user_access_acl(1000, 0o660);1612- assert!(validate_single_user_access_acl(&bytes, 1001, 0o660).is_err());1613-1614- for permission_offset in [6, 14, 22, 30, 38] {1615- let mut changed = bytes.clone();1616- changed[permission_offset] ^= 0x01;1617- assert!(validate_single_user_access_acl(&changed, 1000, 0o660).is_err());1618- }1619-1620- let mut invalid_permissions = bytes;1621- invalid_permissions[14] = 0x08;1622- assert!(validate_single_user_access_acl(&invalid_permissions, 1000, 0o660).is_err());1623- }1624-1625- #[test]1626- fn one_user_access_acl_rejects_extra_duplicate_and_named_group_entries() {1627- let bytes = one_user_access_acl(1000, 0o660);1628-1629- let mut extra = bytes.clone();1630- extra.extend_from_slice(&acl_entry(POSIX_ACL_USER, POSIX_ACL_READ_WRITE, 1001));1631- assert!(validate_single_user_access_acl(&extra, 1000, 0o660).is_err());1632-1633- let mut duplicate_user = bytes.clone();1634- duplicate_user[20..22].copy_from_slice(&POSIX_ACL_USER.to_le_bytes());1635- assert!(validate_single_user_access_acl(&duplicate_user, 1000, 0o660).is_err());1636-1637- let mut named_group = bytes;1638- named_group[20..22].copy_from_slice(&0x08_u16.to_le_bytes());1639- assert!(validate_single_user_access_acl(&named_group, 1000, 0o660).is_err());1640- }1641-1642- #[test]1643- fn one_user_access_acl_rejects_bad_version_and_non_exact_lengths() {1644- let bytes = one_user_access_acl(1000, 0o660);1645-1646- let mut bad_version = bytes.clone();1647- bad_version[0..4].copy_from_slice(&3_u32.to_le_bytes());1648- assert!(validate_single_user_access_acl(&bad_version, 1000, 0o660).is_err());1649- assert!(validate_single_user_access_acl(&bytes[..bytes.len() - 1], 1000, 0o660).is_err());1650-1651- let mut oversized = bytes;1652- oversized.push(0);1653- assert!(validate_single_user_access_acl(&oversized, 1000, 0o660).is_err());1654- }1655-1656- #[test]1657- fn virtual_input_topology_has_stable_distinct_identities() {1658- assert_eq!(UINPUT_BUS_TYPE, 0x06);1659- #[cfg(target_os = "linux")]1660- assert_eq!(UINPUT_BUS_TYPE, input_linux::sys::BUS_VIRTUAL);1661- assert_eq!(UINPUT_VENDOR_ID, 0x5347);1662- assert_eq!(UINPUT_DEVICE_VERSION, 1);1663- assert_eq!(POINTER_DEVICE_NAME, b"Sigil Spark Virtual Pointer");1664- assert_eq!(KEYBOARD_DEVICE_NAME, b"Sigil Spark Virtual Keyboard");1665- assert_eq!(GAMEPAD_DEVICE_NAME, b"Sigil Spark Virtual Gamepad");1666- assert_eq!(POINTER_PRODUCT_ID, 1);1667- assert_eq!(GAMEPAD_PRODUCT_ID, 2);1668- assert_eq!(KEYBOARD_PRODUCT_ID, 3);1669- assert_ne!(POINTER_PRODUCT_ID, KEYBOARD_PRODUCT_ID);1670- assert_ne!(POINTER_PRODUCT_ID, GAMEPAD_PRODUCT_ID);1671- assert_ne!(KEYBOARD_PRODUCT_ID, GAMEPAD_PRODUCT_ID);1672- }1673-1674- #[test]1675- fn session_reset_recovers_a_lock_poisoned_while_applying_input() {1676- #[derive(Debug, Default)]1677- struct DeviceState {1678- released: bool,1679- }1680-1681- let device = Arc::new(Mutex::new(DeviceState::default()));1682- let panic_device = Arc::clone(&device);1683- let panic_result = catch_unwind(AssertUnwindSafe(move || {1684- let _device = panic_device.lock().unwrap();1685- panic!("simulate panic while applying uinput");1686- }));1687- assert!(panic_result.is_err());1688- assert!(device.lock().is_err());1689-1690- reset_through_poisoned_lock(device.as_ref(), |device| {1691- device.released = true;1692- Ok(())1693- })1694- .unwrap();1695-1696- let device = device.lock().unwrap_err().into_inner();1697- assert!(device.released);1698- }1699-1700- #[test]1701- fn protocol_events_route_to_disjoint_virtual_devices() {1702- for event in [1703- InputEvent::MouseMove { x: 1, y: 2 },1704- InputEvent::MouseMoveRelative { dx: 1, dy: 2 },1705- InputEvent::MousePositionSync { x: 10, y: 20 },1706- InputEvent::MouseClick { b: 1 },1707- InputEvent::MouseDown { b: 2 },1708- InputEvent::MouseUp { b: 3 },1709- InputEvent::MouseScroll { dx: 1, dy: -1 },1710- ] {1711- assert_eq!(input_device_class(&event), Some(InputDeviceClass::Pointer));1712- }1713- for event in [1714- InputEvent::KeyDown { k: "a".into() },1715- InputEvent::KeyUp { k: "a".into() },1716- InputEvent::KeyClick { k: "a".into() },1717- ] {1718- assert_eq!(input_device_class(&event), Some(InputDeviceClass::Keyboard));1719- }1720- assert_eq!(1721- input_device_class(&InputEvent::Gamepad {1722- state: GamepadState::default(),1723- }),1724- Some(InputDeviceClass::Gamepad)1725- );1726- assert_eq!(1727- input_device_class(&InputEvent::Text {1728- s: "ignored".into()1729- }),1730- None1731- );1732- assert_eq!(input_device_class(&InputEvent::Probe), None);1733- }1734-1735- #[test]1736- fn content_free_probe_requires_ack_and_never_reaches_uinput() {1737- assert!(ensure_event_was_negotiated(&InputMode::Uinput, &InputEvent::Probe, &[]).is_err());1738- assert!(1739- ensure_event_was_negotiated(1740- &InputMode::Uinput,1741- &InputEvent::Probe,1742- &[Capability::InputAck]1743- )1744- .is_ok()1745- );1746- }1747-1748- #[test]1749- fn maps_client_named_keys_without_guessing() {1750- assert_eq!(map_key("Enter"), Some(MappedKey::Enter));1751- assert_eq!(map_key("Control"), Some(MappedKey::LeftCtrl));1752- assert_eq!(map_key("PageDown"), Some(MappedKey::PageDown));1753- assert_eq!(map_key("F13"), None);1754- assert_eq!(map_key("CapsLock"), None);1755- assert_eq!(map_key("Numpad1"), None);1756- assert_eq!(map_key(""), None);1757- }1758-1759- #[test]1760- fn maps_bounded_browser_physical_keys_for_games_and_layouts() {1761- for (value, expected) in [1762- ("KeyQ", MappedKey::Q),1763- ("Digit2", MappedKey::Num2),1764- ("BracketLeft", MappedKey::LeftBrace),1765- ("IntlBackslash", MappedKey::NonUsBackslash),1766- ("IntlRo", MappedKey::Ro),1767- ("IntlYen", MappedKey::Yen),1768- ("ShiftLeft", MappedKey::LeftShift),1769- ("ShiftRight", MappedKey::RightShift),1770- ("ControlLeft", MappedKey::LeftCtrl),1771- ("ControlRight", MappedKey::RightCtrl),1772- ("AltLeft", MappedKey::LeftAlt),1773- ("AltRight", MappedKey::RightAlt),1774- ("MetaLeft", MappedKey::LeftMeta),1775- ("MetaRight", MappedKey::RightMeta),1776- ("ArrowUp", MappedKey::Up),1777- ("Insert", MappedKey::Insert),1778- ("PrintScreen", MappedKey::PrintScreen),1779- ("F1", MappedKey::F1),1780- ("F12", MappedKey::F12),1781- ] {1782- assert_eq!(map_key(value), Some(expected), "{value}");1783- assert!(ALL_MAPPED_KEYS.contains(&expected), "{value}");1784- }1785- let unique = ALL_MAPPED_KEYS1786- .iter()1787- .copied()1788- .collect::<std::collections::BTreeSet<_>>();1789- assert_eq!(unique.len(), ALL_MAPPED_KEYS.len());1790- }1791-1792- #[cfg(target_os = "linux")]1793- #[test]1794- fn extended_physical_keys_translate_to_exact_linux_evdev_codes() {1795- for (mapped, expected) in [1796- (MappedKey::F1, input_linux::Key::F1),1797- (MappedKey::F12, input_linux::Key::F12),1798- (MappedKey::Insert, input_linux::Key::Insert),1799- (MappedKey::PrintScreen, input_linux::Key::Sysrq),1800- (MappedKey::RightShift, input_linux::Key::RightShift),1801- (MappedKey::RightCtrl, input_linux::Key::RightCtrl),1802- (MappedKey::RightAlt, input_linux::Key::RightAlt),1803- (MappedKey::RightMeta, input_linux::Key::RightMeta),1804- (1805- MappedKey::NonUsBackslash,1806- input_linux::Key::NonUsBackslashAndPipe,1807- ),1808- (MappedKey::Ro, input_linux::Key::Ro),1809- (MappedKey::Yen, input_linux::Key::Yen),1810- ] {1811- assert_eq!(linux::linux_key(mapped), expected);1812- }1813- }1814-1815- #[test]1816- fn maps_us_printable_keys_to_physical_keys() {1817- assert_eq!(map_key("a"), Some(MappedKey::A));1818- assert_eq!(map_key("A"), Some(MappedKey::A));1819- assert_eq!(map_key("!"), Some(MappedKey::Num1));1820- assert_eq!(map_key("?"), Some(MappedKey::Slash));1821- assert_eq!(map_key("é"), None);1822- assert_eq!(map_key("ab"), None);1823- }1824-1825- #[test]1826- fn relative_pointer_events_require_relative_pointer_negotiation() {1827- let movement = InputEvent::MouseMoveRelative { dx: 4, dy: -3 };1828- let synchronization = InputEvent::MousePositionSync { x: 123, y: 456 };1829- let click = InputEvent::MouseClick { b: 1 };1830- let legacy = InputEvent::MouseMove { x: 10, y: 20 };1831- for event in [&movement, &synchronization, &click] {1832- assert!(ensure_event_was_negotiated(&InputMode::Uinput, event, &[]).is_err());1833- assert!(1834- ensure_event_was_negotiated(1835- &InputMode::Uinput,1836- event,1837- &[Capability::AbsolutePointer]1838- )1839- .is_err()1840- );1841- assert!(1842- ensure_event_was_negotiated(1843- &InputMode::Uinput,1844- event,1845- &[Capability::RelativePointer]1846- )1847- .is_ok()1848- );1849- }1850- assert!(1851- ensure_event_was_negotiated(1852- &InputMode::Uinput,1853- &legacy,1854- &[Capability::RelativePointer]1855- )1856- .is_err()1857- );1858- }1859-1860- #[test]1861- fn pointer_position_sync_uses_two_ordered_relative_reports() {1862- assert_eq!(1863- pointer_position_sync_report(1_280, 800),1864- [1865- PointerReportEvent::RelativeX(-32_767),1866- PointerReportEvent::RelativeY(-32_767),1867- PointerReportEvent::Synchronize,1868- PointerReportEvent::RelativeX(1_280),1869- PointerReportEvent::RelativeY(800),1870- PointerReportEvent::Synchronize,1871- ]1872- );1873- }1874-1875- #[test]1876- fn maps_every_gamepad_control_into_one_bounded_report() {1877- let state = GamepadState {1878- a: true,1879- b: true,1880- x: true,1881- y: true,1882- left_shoulder: true,1883- right_shoulder: true,1884- back: true,1885- start: true,1886- guide: true,1887- left_stick: true,1888- right_stick: true,1889- dpad_up: true,1890- dpad_right: true,1891- left_x: -32_767,1892- left_y: 32_767,1893- right_x: -123,1894- right_y: 456,1895- left_trigger: 12_345,1896- right_trigger: 32_767,1897- ..GamepadState::default()1898- };1899- let report = map_gamepad_state(&state);1900- assert_eq!(report.buttons.len(), 11);1901- assert!(report.buttons.into_iter().all(|(_, pressed)| pressed));1902- assert_eq!(1903- report.axes,1904- [1905- (GamepadAxis::LeftX, -32_767),1906- (GamepadAxis::LeftY, 32_767),1907- (GamepadAxis::RightX, -123),1908- (GamepadAxis::RightY, 456),1909- (GamepadAxis::LeftTrigger, 12_345),1910- (GamepadAxis::RightTrigger, 32_767),1911- (GamepadAxis::DpadX, 1),1912- (GamepadAxis::DpadY, -1),1913- ]1914- );1915- }1916-1917- #[test]1918- fn neutral_gamepad_report_releases_buttons_and_axes() {1919- let report = map_gamepad_state(&GamepadState::default());1920- assert!(report.buttons.into_iter().all(|(_, pressed)| !pressed));1921- assert!(report.axes.into_iter().all(|(_, value)| value == 0));1922- }1923-1924- #[test]1925- fn uinput_rejects_unnegotiated_gamepad_snapshots() {1926- let event = InputEvent::Gamepad {1927- state: GamepadState::default(),1928- };1929- assert!(ensure_event_was_negotiated(&InputMode::Uinput, &event, &[]).is_err());1930- assert!(1931- ensure_event_was_negotiated(&InputMode::Uinput, &event, &[Capability::Gamepad]).is_ok()1932- );1933- assert!(ensure_event_was_negotiated(&InputMode::Log, &event, &[]).is_ok());1934- }1935-1936- #[test]1937- fn advertised_capabilities_follow_operational_mode() {1938- let disabled = InputBackend {1939- mode: InputMode::Disabled,1940- #[cfg(target_os = "linux")]1941- device: None,1942- };1943- assert_eq!(disabled.capabilities(), &[Capability::InputAck]);1944-1945- let log = InputBackend {1946- mode: InputMode::Log,1947- #[cfg(target_os = "linux")]1948- device: None,1949- };1950- assert_eq!(log.capabilities(), &[Capability::InputAck]);1951- assert!(!log.capabilities().contains(&Capability::Keyboard));1952- assert_eq!(1953- capabilities_for_mode(&InputMode::Uinput),1954- &[1955- Capability::RelativePointer,1956- Capability::Keyboard,1957- Capability::Gamepad,1958- Capability::InputAck,1959- ]1960- );1961- assert!(!capabilities_for_mode(&InputMode::Uinput).contains(&Capability::AbsolutePointer));1962- }1963-}
A crates/sigil-host/src/input/acl.rs +193 -0
@@ -0,0 +1,193 @@
1+#[cfg(any(target_os = "linux", test))]2+use std::mem::size_of;3+4+#[cfg(any(target_os = "linux", test))]5+use anyhow::{Result, bail};6+7+#[cfg(any(target_os = "linux", test))]8+const POSIX_ACL_XATTR_VERSION: u32 = 0x0002;9+#[cfg(any(target_os = "linux", test))]10+const POSIX_ACL_ENTRY_BYTES: usize = 8;11+#[cfg(any(target_os = "linux", test))]12+const POSIX_ACL_REQUIRED_ENTRIES: usize = 5;13+#[cfg(any(target_os = "linux", test))]14+pub(crate) const POSIX_ACL_REQUIRED_BYTES: usize =15+ size_of::<u32>() + POSIX_ACL_ENTRY_BYTES * POSIX_ACL_REQUIRED_ENTRIES;16+#[cfg(any(target_os = "linux", test))]17+const POSIX_ACL_UNDEFINED_ID: u32 = u32::MAX;18+#[cfg(any(target_os = "linux", test))]19+const POSIX_ACL_USER_OBJ: u16 = 0x01;20+#[cfg(any(target_os = "linux", test))]21+const POSIX_ACL_USER: u16 = 0x02;22+#[cfg(any(target_os = "linux", test))]23+const POSIX_ACL_GROUP_OBJ: u16 = 0x04;24+#[cfg(any(target_os = "linux", test))]25+const POSIX_ACL_MASK: u16 = 0x10;26+#[cfg(any(target_os = "linux", test))]27+const POSIX_ACL_OTHER: u16 = 0x20;28+#[cfg(any(target_os = "linux", test))]29+const POSIX_ACL_READ_WRITE: u16 = 0x06;30+31+/// Validate the only extended uinput ACL shape Sigil accepts.32+///33+/// Linux stores POSIX ACL xattrs as one little-endian version followed by34+/// fixed-size entries. Requiring the canonical five-entry form prevents a35+/// configured one-user exception from admitting extra users or groups.36+#[cfg(any(target_os = "linux", test))]37+pub(crate) fn validate_single_user_access_acl(38+ bytes: &[u8],39+ expected_uid: u32,40+ expected_mode: u32,41+) -> Result<()> {42+ if bytes.len() != POSIX_ACL_REQUIRED_BYTES {43+ bail!(44+ "configured uinput access ACL must contain exactly one named user and no named groups"45+ );46+ }47+ let version = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);48+ if version != POSIX_ACL_XATTR_VERSION {49+ bail!("configured uinput access ACL has an unsupported Linux ACL version");50+ }51+52+ let expected_entries = [53+ (54+ POSIX_ACL_USER_OBJ,55+ ((expected_mode >> 6) & 0o7) as u16,56+ POSIX_ACL_UNDEFINED_ID,57+ ),58+ (POSIX_ACL_USER, POSIX_ACL_READ_WRITE, expected_uid),59+ (60+ POSIX_ACL_GROUP_OBJ,61+ ((expected_mode >> 3) & 0o7) as u16,62+ POSIX_ACL_UNDEFINED_ID,63+ ),64+ (65+ POSIX_ACL_MASK,66+ ((expected_mode >> 3) & 0o7) as u16,67+ POSIX_ACL_UNDEFINED_ID,68+ ),69+ (70+ POSIX_ACL_OTHER,71+ (expected_mode & 0o7) as u16,72+ POSIX_ACL_UNDEFINED_ID,73+ ),74+ ];75+76+ for (index, (expected_tag, expected_permissions, expected_id)) in77+ expected_entries.into_iter().enumerate()78+ {79+ let offset = size_of::<u32>() + index * POSIX_ACL_ENTRY_BYTES;80+ let tag = u16::from_le_bytes([bytes[offset], bytes[offset + 1]]);81+ let permissions = u16::from_le_bytes([bytes[offset + 2], bytes[offset + 3]]);82+ let id = u32::from_le_bytes([83+ bytes[offset + 4],84+ bytes[offset + 5],85+ bytes[offset + 6],86+ bytes[offset + 7],87+ ]);88+ if permissions & !0o7 != 0 {89+ bail!("configured uinput access ACL contains invalid permission bits");90+ }91+ if tag != expected_tag || permissions != expected_permissions || id != expected_id {92+ bail!(93+ "configured uinput access ACL does not match the exact one-user owner/group/mode contract"94+ );95+ }96+ }97+ Ok(())98+}99+100+#[cfg(test)]101+mod tests {102+ use super::*;103+104+ fn acl_entry(tag: u16, permissions: u16, id: u32) -> [u8; POSIX_ACL_ENTRY_BYTES] {105+ let mut bytes = [0_u8; POSIX_ACL_ENTRY_BYTES];106+ bytes[0..2].copy_from_slice(&tag.to_le_bytes());107+ bytes[2..4].copy_from_slice(&permissions.to_le_bytes());108+ bytes[4..8].copy_from_slice(&id.to_le_bytes());109+ bytes110+ }111+112+ fn one_user_access_acl(uid: u32, mode: u32) -> Vec<u8> {113+ let entries = [114+ acl_entry(115+ POSIX_ACL_USER_OBJ,116+ ((mode >> 6) & 0o7) as u16,117+ POSIX_ACL_UNDEFINED_ID,118+ ),119+ acl_entry(POSIX_ACL_USER, POSIX_ACL_READ_WRITE, uid),120+ acl_entry(121+ POSIX_ACL_GROUP_OBJ,122+ ((mode >> 3) & 0o7) as u16,123+ POSIX_ACL_UNDEFINED_ID,124+ ),125+ acl_entry(126+ POSIX_ACL_MASK,127+ ((mode >> 3) & 0o7) as u16,128+ POSIX_ACL_UNDEFINED_ID,129+ ),130+ acl_entry(POSIX_ACL_OTHER, (mode & 0o7) as u16, POSIX_ACL_UNDEFINED_ID),131+ ];132+ let mut bytes = Vec::with_capacity(POSIX_ACL_REQUIRED_BYTES);133+ bytes.extend_from_slice(&POSIX_ACL_XATTR_VERSION.to_le_bytes());134+ for entry in entries {135+ bytes.extend_from_slice(&entry);136+ }137+ bytes138+ }139+140+ #[test]141+ fn exact_one_user_access_acl_is_accepted() {142+ let bytes = one_user_access_acl(1000, 0o660);143+ assert_eq!(bytes.len(), POSIX_ACL_REQUIRED_BYTES);144+ validate_single_user_access_acl(&bytes, 1000, 0o660).unwrap();145+ }146+147+ #[test]148+ fn one_user_access_acl_rejects_wrong_principal_and_permissions() {149+ let bytes = one_user_access_acl(1000, 0o660);150+ assert!(validate_single_user_access_acl(&bytes, 1001, 0o660).is_err());151+152+ for permission_offset in [6, 14, 22, 30, 38] {153+ let mut changed = bytes.clone();154+ changed[permission_offset] ^= 0x01;155+ assert!(validate_single_user_access_acl(&changed, 1000, 0o660).is_err());156+ }157+158+ let mut invalid_permissions = bytes;159+ invalid_permissions[14] = 0x08;160+ assert!(validate_single_user_access_acl(&invalid_permissions, 1000, 0o660).is_err());161+ }162+163+ #[test]164+ fn one_user_access_acl_rejects_extra_duplicate_and_named_group_entries() {165+ let bytes = one_user_access_acl(1000, 0o660);166+167+ let mut extra = bytes.clone();168+ extra.extend_from_slice(&acl_entry(POSIX_ACL_USER, POSIX_ACL_READ_WRITE, 1001));169+ assert!(validate_single_user_access_acl(&extra, 1000, 0o660).is_err());170+171+ let mut duplicate_user = bytes.clone();172+ duplicate_user[20..22].copy_from_slice(&POSIX_ACL_USER.to_le_bytes());173+ assert!(validate_single_user_access_acl(&duplicate_user, 1000, 0o660).is_err());174+175+ let mut named_group = bytes;176+ named_group[20..22].copy_from_slice(&0x08_u16.to_le_bytes());177+ assert!(validate_single_user_access_acl(&named_group, 1000, 0o660).is_err());178+ }179+180+ #[test]181+ fn one_user_access_acl_rejects_bad_version_and_non_exact_lengths() {182+ let bytes = one_user_access_acl(1000, 0o660);183+184+ let mut bad_version = bytes.clone();185+ bad_version[0..4].copy_from_slice(&3_u32.to_le_bytes());186+ assert!(validate_single_user_access_acl(&bad_version, 1000, 0o660).is_err());187+ assert!(validate_single_user_access_acl(&bytes[..bytes.len() - 1], 1000, 0o660).is_err());188+189+ let mut oversized = bytes;190+ oversized.push(0);191+ assert!(validate_single_user_access_acl(&oversized, 1000, 0o660).is_err());192+ }193+}
A crates/sigil-host/src/input/keymap.rs +745 -0
@@ -0,0 +1,745 @@
1+#[cfg(target_os = "linux")]2+use input_linux::Key;3+#[cfg(any(target_os = "linux", test))]4+use sigil_protocol::GamepadState;5+#[cfg(any(target_os = "linux", test))]6+use sigil_protocol::InputEvent;7+8+#[cfg(any(target_os = "linux", test))]9+#[derive(Clone, Copy, Debug, PartialEq, Eq)]10+pub(crate) enum InputDeviceClass {11+ Pointer,12+ Keyboard,13+ Gamepad,14+}15+16+#[cfg(any(target_os = "linux", test))]17+#[derive(Clone, Copy, Debug, PartialEq, Eq)]18+pub(crate) enum PointerReportEvent {19+ RelativeX(i32),20+ RelativeY(i32),21+ Synchronize,22+}23+24+#[cfg(any(target_os = "linux", test))]25+/// Emulate an absolute position on the relative-only appliance pointer.26+///27+/// This is correct only when Gamescope clamps the first report at the surface28+/// origin and applies neither acceleration nor scaling to either report. The29+/// hardware UAT verifies both the synchronized position and one subsequent30+/// relative delta through the Xwayland pointer tracker.31+pub(crate) fn pointer_position_sync_report(x: i32, y: i32) -> [PointerReportEvent; 6] {32+ [33+ PointerReportEvent::RelativeX(sigil_protocol::RELATIVE_POINTER_DELTA_MIN),34+ PointerReportEvent::RelativeY(sigil_protocol::RELATIVE_POINTER_DELTA_MIN),35+ PointerReportEvent::Synchronize,36+ PointerReportEvent::RelativeX(x),37+ PointerReportEvent::RelativeY(y),38+ PointerReportEvent::Synchronize,39+ ]40+}41+42+#[cfg(any(target_os = "linux", test))]43+pub(crate) fn input_device_class(event: &InputEvent) -> Option<InputDeviceClass> {44+ match event {45+ InputEvent::Probe => None,46+ InputEvent::MouseMove { .. }47+ | InputEvent::MouseMoveRelative { .. }48+ | InputEvent::MousePositionSync { .. }49+ | InputEvent::MouseClick { .. }50+ | InputEvent::MouseDown { .. }51+ | InputEvent::MouseUp { .. }52+ | InputEvent::MouseScroll { .. } => Some(InputDeviceClass::Pointer),53+ InputEvent::KeyDown { .. } | InputEvent::KeyUp { .. } | InputEvent::KeyClick { .. } => {54+ Some(InputDeviceClass::Keyboard)55+ }56+ InputEvent::Gamepad { .. } => Some(InputDeviceClass::Gamepad),57+ InputEvent::Text { .. } => None,58+ }59+}60+61+#[cfg(any(target_os = "linux", test))]62+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]63+pub(crate) enum MappedKey {64+ A,65+ B,66+ C,67+ D,68+ E,69+ F,70+ G,71+ H,72+ I,73+ J,74+ K,75+ L,76+ M,77+ N,78+ O,79+ P,80+ Q,81+ R,82+ S,83+ T,84+ U,85+ V,86+ W,87+ X,88+ Y,89+ Z,90+ Num0,91+ Num1,92+ Num2,93+ Num3,94+ Num4,95+ Num5,96+ Num6,97+ Num7,98+ Num8,99+ Num9,100+ Minus,101+ Equal,102+ LeftBrace,103+ RightBrace,104+ Backslash,105+ Semicolon,106+ Apostrophe,107+ Grave,108+ Comma,109+ Dot,110+ Slash,111+ NonUsBackslash,112+ Ro,113+ Yen,114+ Enter,115+ Tab,116+ Space,117+ Backspace,118+ Escape,119+ LeftShift,120+ RightShift,121+ LeftCtrl,122+ RightCtrl,123+ LeftAlt,124+ RightAlt,125+ LeftMeta,126+ RightMeta,127+ Up,128+ Down,129+ Left,130+ Right,131+ Home,132+ End,133+ PageUp,134+ PageDown,135+ Insert,136+ Delete,137+ PrintScreen,138+ F1,139+ F2,140+ F3,141+ F4,142+ F5,143+ F6,144+ F7,145+ F8,146+ F9,147+ F10,148+ F11,149+ F12,150+}151+152+#[cfg(any(target_os = "linux", test))]153+pub(crate) const ALL_MAPPED_KEYS: &[MappedKey] = &[154+ MappedKey::A,155+ MappedKey::B,156+ MappedKey::C,157+ MappedKey::D,158+ MappedKey::E,159+ MappedKey::F,160+ MappedKey::G,161+ MappedKey::H,162+ MappedKey::I,163+ MappedKey::J,164+ MappedKey::K,165+ MappedKey::L,166+ MappedKey::M,167+ MappedKey::N,168+ MappedKey::O,169+ MappedKey::P,170+ MappedKey::Q,171+ MappedKey::R,172+ MappedKey::S,173+ MappedKey::T,174+ MappedKey::U,175+ MappedKey::V,176+ MappedKey::W,177+ MappedKey::X,178+ MappedKey::Y,179+ MappedKey::Z,180+ MappedKey::Num0,181+ MappedKey::Num1,182+ MappedKey::Num2,183+ MappedKey::Num3,184+ MappedKey::Num4,185+ MappedKey::Num5,186+ MappedKey::Num6,187+ MappedKey::Num7,188+ MappedKey::Num8,189+ MappedKey::Num9,190+ MappedKey::Minus,191+ MappedKey::Equal,192+ MappedKey::LeftBrace,193+ MappedKey::RightBrace,194+ MappedKey::Backslash,195+ MappedKey::Semicolon,196+ MappedKey::Apostrophe,197+ MappedKey::Grave,198+ MappedKey::Comma,199+ MappedKey::Dot,200+ MappedKey::Slash,201+ MappedKey::NonUsBackslash,202+ MappedKey::Ro,203+ MappedKey::Yen,204+ MappedKey::Enter,205+ MappedKey::Tab,206+ MappedKey::Space,207+ MappedKey::Backspace,208+ MappedKey::Escape,209+ MappedKey::LeftShift,210+ MappedKey::RightShift,211+ MappedKey::LeftCtrl,212+ MappedKey::RightCtrl,213+ MappedKey::LeftAlt,214+ MappedKey::RightAlt,215+ MappedKey::LeftMeta,216+ MappedKey::RightMeta,217+ MappedKey::Up,218+ MappedKey::Down,219+ MappedKey::Left,220+ MappedKey::Right,221+ MappedKey::Home,222+ MappedKey::End,223+ MappedKey::PageUp,224+ MappedKey::PageDown,225+ MappedKey::Insert,226+ MappedKey::Delete,227+ MappedKey::PrintScreen,228+ MappedKey::F1,229+ MappedKey::F2,230+ MappedKey::F3,231+ MappedKey::F4,232+ MappedKey::F5,233+ MappedKey::F6,234+ MappedKey::F7,235+ MappedKey::F8,236+ MappedKey::F9,237+ MappedKey::F10,238+ MappedKey::F11,239+ MappedKey::F12,240+];241+242+#[cfg(any(target_os = "linux", test))]243+pub(crate) fn map_key(value: &str) -> Option<MappedKey> {244+ match value {245+ "KeyA" => Some(MappedKey::A),246+ "KeyB" => Some(MappedKey::B),247+ "KeyC" => Some(MappedKey::C),248+ "KeyD" => Some(MappedKey::D),249+ "KeyE" => Some(MappedKey::E),250+ "KeyF" => Some(MappedKey::F),251+ "KeyG" => Some(MappedKey::G),252+ "KeyH" => Some(MappedKey::H),253+ "KeyI" => Some(MappedKey::I),254+ "KeyJ" => Some(MappedKey::J),255+ "KeyK" => Some(MappedKey::K),256+ "KeyL" => Some(MappedKey::L),257+ "KeyM" => Some(MappedKey::M),258+ "KeyN" => Some(MappedKey::N),259+ "KeyO" => Some(MappedKey::O),260+ "KeyP" => Some(MappedKey::P),261+ "KeyQ" => Some(MappedKey::Q),262+ "KeyR" => Some(MappedKey::R),263+ "KeyS" => Some(MappedKey::S),264+ "KeyT" => Some(MappedKey::T),265+ "KeyU" => Some(MappedKey::U),266+ "KeyV" => Some(MappedKey::V),267+ "KeyW" => Some(MappedKey::W),268+ "KeyX" => Some(MappedKey::X),269+ "KeyY" => Some(MappedKey::Y),270+ "KeyZ" => Some(MappedKey::Z),271+ "Digit0" => Some(MappedKey::Num0),272+ "Digit1" => Some(MappedKey::Num1),273+ "Digit2" => Some(MappedKey::Num2),274+ "Digit3" => Some(MappedKey::Num3),275+ "Digit4" => Some(MappedKey::Num4),276+ "Digit5" => Some(MappedKey::Num5),277+ "Digit6" => Some(MappedKey::Num6),278+ "Digit7" => Some(MappedKey::Num7),279+ "Digit8" => Some(MappedKey::Num8),280+ "Digit9" => Some(MappedKey::Num9),281+ "Minus" => Some(MappedKey::Minus),282+ "Equal" => Some(MappedKey::Equal),283+ "BracketLeft" => Some(MappedKey::LeftBrace),284+ "BracketRight" => Some(MappedKey::RightBrace),285+ "Backslash" => Some(MappedKey::Backslash),286+ "IntlBackslash" => Some(MappedKey::NonUsBackslash),287+ "IntlRo" => Some(MappedKey::Ro),288+ "IntlYen" => Some(MappedKey::Yen),289+ "Semicolon" => Some(MappedKey::Semicolon),290+ "Quote" => Some(MappedKey::Apostrophe),291+ "Backquote" => Some(MappedKey::Grave),292+ "Comma" => Some(MappedKey::Comma),293+ "Period" => Some(MappedKey::Dot),294+ "Slash" => Some(MappedKey::Slash),295+ "Enter" => Some(MappedKey::Enter),296+ "Tab" => Some(MappedKey::Tab),297+ "Space" => Some(MappedKey::Space),298+ "Backspace" => Some(MappedKey::Backspace),299+ "Escape" => Some(MappedKey::Escape),300+ "ShiftLeft" => Some(MappedKey::LeftShift),301+ "ShiftRight" => Some(MappedKey::RightShift),302+ "ControlLeft" => Some(MappedKey::LeftCtrl),303+ "ControlRight" => Some(MappedKey::RightCtrl),304+ "AltLeft" => Some(MappedKey::LeftAlt),305+ "AltRight" => Some(MappedKey::RightAlt),306+ "MetaLeft" => Some(MappedKey::LeftMeta),307+ "MetaRight" => Some(MappedKey::RightMeta),308+ "Shift" => Some(MappedKey::LeftShift),309+ "Control" => Some(MappedKey::LeftCtrl),310+ "Alt" => Some(MappedKey::LeftAlt),311+ "Meta" => Some(MappedKey::LeftMeta),312+ "ArrowUp" => Some(MappedKey::Up),313+ "ArrowDown" => Some(MappedKey::Down),314+ "ArrowLeft" => Some(MappedKey::Left),315+ "ArrowRight" => Some(MappedKey::Right),316+ "Up" => Some(MappedKey::Up),317+ "Down" => Some(MappedKey::Down),318+ "Left" => Some(MappedKey::Left),319+ "Right" => Some(MappedKey::Right),320+ "Home" => Some(MappedKey::Home),321+ "End" => Some(MappedKey::End),322+ "PageUp" => Some(MappedKey::PageUp),323+ "PageDown" => Some(MappedKey::PageDown),324+ "Insert" => Some(MappedKey::Insert),325+ "Delete" => Some(MappedKey::Delete),326+ "PrintScreen" => Some(MappedKey::PrintScreen),327+ "F1" => Some(MappedKey::F1),328+ "F2" => Some(MappedKey::F2),329+ "F3" => Some(MappedKey::F3),330+ "F4" => Some(MappedKey::F4),331+ "F5" => Some(MappedKey::F5),332+ "F6" => Some(MappedKey::F6),333+ "F7" => Some(MappedKey::F7),334+ "F8" => Some(MappedKey::F8),335+ "F9" => Some(MappedKey::F9),336+ "F10" => Some(MappedKey::F10),337+ "F11" => Some(MappedKey::F11),338+ "F12" => Some(MappedKey::F12),339+ _ => map_printable_us_key(value),340+ }341+}342+343+#[cfg(any(target_os = "linux", test))]344+fn map_printable_us_key(value: &str) -> Option<MappedKey> {345+ let mut chars = value.chars();346+ let character = chars.next()?;347+ if chars.next().is_some() || !character.is_ascii() {348+ return None;349+ }350+ Some(match character {351+ 'a' | 'A' => MappedKey::A,352+ 'b' | 'B' => MappedKey::B,353+ 'c' | 'C' => MappedKey::C,354+ 'd' | 'D' => MappedKey::D,355+ 'e' | 'E' => MappedKey::E,356+ 'f' | 'F' => MappedKey::F,357+ 'g' | 'G' => MappedKey::G,358+ 'h' | 'H' => MappedKey::H,359+ 'i' | 'I' => MappedKey::I,360+ 'j' | 'J' => MappedKey::J,361+ 'k' | 'K' => MappedKey::K,362+ 'l' | 'L' => MappedKey::L,363+ 'm' | 'M' => MappedKey::M,364+ 'n' | 'N' => MappedKey::N,365+ 'o' | 'O' => MappedKey::O,366+ 'p' | 'P' => MappedKey::P,367+ 'q' | 'Q' => MappedKey::Q,368+ 'r' | 'R' => MappedKey::R,369+ 's' | 'S' => MappedKey::S,370+ 't' | 'T' => MappedKey::T,371+ 'u' | 'U' => MappedKey::U,372+ 'v' | 'V' => MappedKey::V,373+ 'w' | 'W' => MappedKey::W,374+ 'x' | 'X' => MappedKey::X,375+ 'y' | 'Y' => MappedKey::Y,376+ 'z' | 'Z' => MappedKey::Z,377+ '0' | ')' => MappedKey::Num0,378+ '1' | '!' => MappedKey::Num1,379+ '2' | '@' => MappedKey::Num2,380+ '3' | '#' => MappedKey::Num3,381+ '4' | '$' => MappedKey::Num4,382+ '5' | '%' => MappedKey::Num5,383+ '6' | '^' => MappedKey::Num6,384+ '7' | '&' => MappedKey::Num7,385+ '8' | '*' => MappedKey::Num8,386+ '9' | '(' => MappedKey::Num9,387+ '-' | '_' => MappedKey::Minus,388+ '=' | '+' => MappedKey::Equal,389+ '[' | '{' => MappedKey::LeftBrace,390+ ']' | '}' => MappedKey::RightBrace,391+ '\\' | '|' => MappedKey::Backslash,392+ ';' | ':' => MappedKey::Semicolon,393+ '\'' | '"' => MappedKey::Apostrophe,394+ '`' | '~' => MappedKey::Grave,395+ ',' | '<' => MappedKey::Comma,396+ '.' | '>' => MappedKey::Dot,397+ '/' | '?' => MappedKey::Slash,398+ ' ' => MappedKey::Space,399+ _ => return None,400+ })401+}402+403+#[cfg(any(target_os = "linux", test))]404+#[derive(Clone, Copy, Debug, PartialEq, Eq)]405+pub(crate) enum GamepadButton {406+ A,407+ B,408+ X,409+ Y,410+ LeftShoulder,411+ RightShoulder,412+ Back,413+ Start,414+ Guide,415+ LeftStick,416+ RightStick,417+}418+419+#[cfg(any(target_os = "linux", test))]420+#[derive(Clone, Copy, Debug, PartialEq, Eq)]421+pub(crate) enum GamepadAxis {422+ LeftX,423+ LeftY,424+ RightX,425+ RightY,426+ LeftTrigger,427+ RightTrigger,428+ DpadX,429+ DpadY,430+}431+432+#[cfg(any(target_os = "linux", test))]433+#[derive(Clone, Copy, Debug, PartialEq, Eq)]434+pub(crate) struct GamepadReport {435+ pub(crate) buttons: [(GamepadButton, bool); 11],436+ pub(crate) axes: [(GamepadAxis, i32); 8],437+}438+439+#[cfg(any(target_os = "linux", test))]440+pub(crate) fn map_gamepad_state(state: &GamepadState) -> GamepadReport {441+ GamepadReport {442+ buttons: [443+ (GamepadButton::A, state.a),444+ (GamepadButton::B, state.b),445+ (GamepadButton::X, state.x),446+ (GamepadButton::Y, state.y),447+ (GamepadButton::LeftShoulder, state.left_shoulder),448+ (GamepadButton::RightShoulder, state.right_shoulder),449+ (GamepadButton::Back, state.back),450+ (GamepadButton::Start, state.start),451+ (GamepadButton::Guide, state.guide),452+ (GamepadButton::LeftStick, state.left_stick),453+ (GamepadButton::RightStick, state.right_stick),454+ ],455+ axes: [456+ (GamepadAxis::LeftX, i32::from(state.left_x)),457+ (GamepadAxis::LeftY, i32::from(state.left_y)),458+ (GamepadAxis::RightX, i32::from(state.right_x)),459+ (GamepadAxis::RightY, i32::from(state.right_y)),460+ (GamepadAxis::LeftTrigger, i32::from(state.left_trigger)),461+ (GamepadAxis::RightTrigger, i32::from(state.right_trigger)),462+ (463+ GamepadAxis::DpadX,464+ i32::from(state.dpad_right) - i32::from(state.dpad_left),465+ ),466+ (467+ GamepadAxis::DpadY,468+ i32::from(state.dpad_down) - i32::from(state.dpad_up),469+ ),470+ ],471+ }472+}473+474+#[cfg(target_os = "linux")]475+pub(crate) fn linux_key(key: MappedKey) -> Key {476+ match key {477+ MappedKey::A => Key::A,478+ MappedKey::B => Key::B,479+ MappedKey::C => Key::C,480+ MappedKey::D => Key::D,481+ MappedKey::E => Key::E,482+ MappedKey::F => Key::F,483+ MappedKey::G => Key::G,484+ MappedKey::H => Key::H,485+ MappedKey::I => Key::I,486+ MappedKey::J => Key::J,487+ MappedKey::K => Key::K,488+ MappedKey::L => Key::L,489+ MappedKey::M => Key::M,490+ MappedKey::N => Key::N,491+ MappedKey::O => Key::O,492+ MappedKey::P => Key::P,493+ MappedKey::Q => Key::Q,494+ MappedKey::R => Key::R,495+ MappedKey::S => Key::S,496+ MappedKey::T => Key::T,497+ MappedKey::U => Key::U,498+ MappedKey::V => Key::V,499+ MappedKey::W => Key::W,500+ MappedKey::X => Key::X,501+ MappedKey::Y => Key::Y,502+ MappedKey::Z => Key::Z,503+ MappedKey::Num0 => Key::Num0,504+ MappedKey::Num1 => Key::Num1,505+ MappedKey::Num2 => Key::Num2,506+ MappedKey::Num3 => Key::Num3,507+ MappedKey::Num4 => Key::Num4,508+ MappedKey::Num5 => Key::Num5,509+ MappedKey::Num6 => Key::Num6,510+ MappedKey::Num7 => Key::Num7,511+ MappedKey::Num8 => Key::Num8,512+ MappedKey::Num9 => Key::Num9,513+ MappedKey::Minus => Key::Minus,514+ MappedKey::Equal => Key::Equal,515+ MappedKey::LeftBrace => Key::LeftBrace,516+ MappedKey::RightBrace => Key::RightBrace,517+ MappedKey::Backslash => Key::Backslash,518+ MappedKey::Semicolon => Key::Semicolon,519+ MappedKey::Apostrophe => Key::Apostrophe,520+ MappedKey::Grave => Key::Grave,521+ MappedKey::Comma => Key::Comma,522+ MappedKey::Dot => Key::Dot,523+ MappedKey::Slash => Key::Slash,524+ MappedKey::NonUsBackslash => Key::NonUsBackslashAndPipe,525+ MappedKey::Ro => Key::Ro,526+ MappedKey::Yen => Key::Yen,527+ MappedKey::Enter => Key::Enter,528+ MappedKey::Tab => Key::Tab,529+ MappedKey::Space => Key::Space,530+ MappedKey::Backspace => Key::Backspace,531+ MappedKey::Escape => Key::Esc,532+ MappedKey::LeftShift => Key::LeftShift,533+ MappedKey::RightShift => Key::RightShift,534+ MappedKey::LeftCtrl => Key::LeftCtrl,535+ MappedKey::RightCtrl => Key::RightCtrl,536+ MappedKey::LeftAlt => Key::LeftAlt,537+ MappedKey::RightAlt => Key::RightAlt,538+ MappedKey::LeftMeta => Key::LeftMeta,539+ MappedKey::RightMeta => Key::RightMeta,540+ MappedKey::Up => Key::Up,541+ MappedKey::Down => Key::Down,542+ MappedKey::Left => Key::Left,543+ MappedKey::Right => Key::Right,544+ MappedKey::Home => Key::Home,545+ MappedKey::End => Key::End,546+ MappedKey::PageUp => Key::PageUp,547+ MappedKey::PageDown => Key::PageDown,548+ MappedKey::Insert => Key::Insert,549+ MappedKey::Delete => Key::Delete,550+ MappedKey::PrintScreen => Key::Sysrq,551+ MappedKey::F1 => Key::F1,552+ MappedKey::F2 => Key::F2,553+ MappedKey::F3 => Key::F3,554+ MappedKey::F4 => Key::F4,555+ MappedKey::F5 => Key::F5,556+ MappedKey::F6 => Key::F6,557+ MappedKey::F7 => Key::F7,558+ MappedKey::F8 => Key::F8,559+ MappedKey::F9 => Key::F9,560+ MappedKey::F10 => Key::F10,561+ MappedKey::F11 => Key::F11,562+ MappedKey::F12 => Key::F12,563+ }564+}565+566+#[cfg(test)]567+mod tests {568+ use super::*;569+570+ #[test]571+ fn protocol_events_route_to_disjoint_virtual_devices() {572+ for event in [573+ InputEvent::MouseMove { x: 1, y: 2 },574+ InputEvent::MouseMoveRelative { dx: 1, dy: 2 },575+ InputEvent::MousePositionSync { x: 10, y: 20 },576+ InputEvent::MouseClick { b: 1 },577+ InputEvent::MouseDown { b: 2 },578+ InputEvent::MouseUp { b: 3 },579+ InputEvent::MouseScroll { dx: 1, dy: -1 },580+ ] {581+ assert_eq!(input_device_class(&event), Some(InputDeviceClass::Pointer));582+ }583+ for event in [584+ InputEvent::KeyDown { k: "a".into() },585+ InputEvent::KeyUp { k: "a".into() },586+ InputEvent::KeyClick { k: "a".into() },587+ ] {588+ assert_eq!(input_device_class(&event), Some(InputDeviceClass::Keyboard));589+ }590+ assert_eq!(591+ input_device_class(&InputEvent::Gamepad {592+ state: GamepadState::default(),593+ }),594+ Some(InputDeviceClass::Gamepad)595+ );596+ assert_eq!(597+ input_device_class(&InputEvent::Text {598+ s: "ignored".into()599+ }),600+ None601+ );602+ assert_eq!(input_device_class(&InputEvent::Probe), None);603+ }604+605+ #[test]606+ fn maps_client_named_keys_without_guessing() {607+ assert_eq!(map_key("Enter"), Some(MappedKey::Enter));608+ assert_eq!(map_key("Control"), Some(MappedKey::LeftCtrl));609+ assert_eq!(map_key("PageDown"), Some(MappedKey::PageDown));610+ assert_eq!(map_key("F13"), None);611+ assert_eq!(map_key("CapsLock"), None);612+ assert_eq!(map_key("Numpad1"), None);613+ assert_eq!(map_key(""), None);614+ }615+616+ #[test]617+ fn maps_bounded_browser_physical_keys_for_games_and_layouts() {618+ for (value, expected) in [619+ ("KeyQ", MappedKey::Q),620+ ("Digit2", MappedKey::Num2),621+ ("BracketLeft", MappedKey::LeftBrace),622+ ("IntlBackslash", MappedKey::NonUsBackslash),623+ ("IntlRo", MappedKey::Ro),624+ ("IntlYen", MappedKey::Yen),625+ ("ShiftLeft", MappedKey::LeftShift),626+ ("ShiftRight", MappedKey::RightShift),627+ ("ControlLeft", MappedKey::LeftCtrl),628+ ("ControlRight", MappedKey::RightCtrl),629+ ("AltLeft", MappedKey::LeftAlt),630+ ("AltRight", MappedKey::RightAlt),631+ ("MetaLeft", MappedKey::LeftMeta),632+ ("MetaRight", MappedKey::RightMeta),633+ ("ArrowUp", MappedKey::Up),634+ ("Insert", MappedKey::Insert),635+ ("PrintScreen", MappedKey::PrintScreen),636+ ("F1", MappedKey::F1),637+ ("F12", MappedKey::F12),638+ ] {639+ assert_eq!(map_key(value), Some(expected), "{value}");640+ assert!(ALL_MAPPED_KEYS.contains(&expected), "{value}");641+ }642+ let unique = ALL_MAPPED_KEYS643+ .iter()644+ .copied()645+ .collect::<std::collections::BTreeSet<_>>();646+ assert_eq!(unique.len(), ALL_MAPPED_KEYS.len());647+ }648+649+ #[cfg(target_os = "linux")]650+ #[test]651+ fn extended_physical_keys_translate_to_exact_linux_evdev_codes() {652+ for (mapped, expected) in [653+ (MappedKey::F1, input_linux::Key::F1),654+ (MappedKey::F12, input_linux::Key::F12),655+ (MappedKey::Insert, input_linux::Key::Insert),656+ (MappedKey::PrintScreen, input_linux::Key::Sysrq),657+ (MappedKey::RightShift, input_linux::Key::RightShift),658+ (MappedKey::RightCtrl, input_linux::Key::RightCtrl),659+ (MappedKey::RightAlt, input_linux::Key::RightAlt),660+ (MappedKey::RightMeta, input_linux::Key::RightMeta),661+ (662+ MappedKey::NonUsBackslash,663+ input_linux::Key::NonUsBackslashAndPipe,664+ ),665+ (MappedKey::Ro, input_linux::Key::Ro),666+ (MappedKey::Yen, input_linux::Key::Yen),667+ ] {668+ assert_eq!(linux_key(mapped), expected);669+ }670+ }671+672+ #[test]673+ fn maps_us_printable_keys_to_physical_keys() {674+ assert_eq!(map_key("a"), Some(MappedKey::A));675+ assert_eq!(map_key("A"), Some(MappedKey::A));676+ assert_eq!(map_key("!"), Some(MappedKey::Num1));677+ assert_eq!(map_key("?"), Some(MappedKey::Slash));678+ assert_eq!(map_key("é"), None);679+ assert_eq!(map_key("ab"), None);680+ }681+682+ #[test]683+ fn pointer_position_sync_uses_two_ordered_relative_reports() {684+ assert_eq!(685+ pointer_position_sync_report(1_280, 800),686+ [687+ PointerReportEvent::RelativeX(-32_767),688+ PointerReportEvent::RelativeY(-32_767),689+ PointerReportEvent::Synchronize,690+ PointerReportEvent::RelativeX(1_280),691+ PointerReportEvent::RelativeY(800),692+ PointerReportEvent::Synchronize,693+ ]694+ );695+ }696+697+ #[test]698+ fn maps_every_gamepad_control_into_one_bounded_report() {699+ let state = GamepadState {700+ a: true,701+ b: true,702+ x: true,703+ y: true,704+ left_shoulder: true,705+ right_shoulder: true,706+ back: true,707+ start: true,708+ guide: true,709+ left_stick: true,710+ right_stick: true,711+ dpad_up: true,712+ dpad_right: true,713+ left_x: -32_767,714+ left_y: 32_767,715+ right_x: -123,716+ right_y: 456,717+ left_trigger: 12_345,718+ right_trigger: 32_767,719+ ..GamepadState::default()720+ };721+ let report = map_gamepad_state(&state);722+ assert_eq!(report.buttons.len(), 11);723+ assert!(report.buttons.into_iter().all(|(_, pressed)| pressed));724+ assert_eq!(725+ report.axes,726+ [727+ (GamepadAxis::LeftX, -32_767),728+ (GamepadAxis::LeftY, 32_767),729+ (GamepadAxis::RightX, -123),730+ (GamepadAxis::RightY, 456),731+ (GamepadAxis::LeftTrigger, 12_345),732+ (GamepadAxis::RightTrigger, 32_767),733+ (GamepadAxis::DpadX, 1),734+ (GamepadAxis::DpadY, -1),735+ ]736+ );737+ }738+739+ #[test]740+ fn neutral_gamepad_report_releases_buttons_and_axes() {741+ let report = map_gamepad_state(&GamepadState::default());742+ assert!(report.buttons.into_iter().all(|(_, pressed)| !pressed));743+ assert!(report.axes.into_iter().all(|(_, value)| value == 0));744+ }745+}
A crates/sigil-host/src/input/linux.rs +691 -0
@@ -0,0 +1,691 @@
1+use std::collections::BTreeSet;2+use std::fs::{File, OpenOptions};3+use std::os::fd::AsRawFd;4+use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt};5+6+use anyhow::{Context, Result, ensure};7+use input_linux::{8+ AbsoluteAxis, AbsoluteEvent, AbsoluteInfo, AbsoluteInfoSetup, EventKind, EventTime, InputId,9+ InputProperty, Key, KeyEvent, KeyState, RelativeAxis, RelativeEvent, SynchronizeEvent,10+ UInputHandle,11+};12+use sigil_protocol::{13+ GAMEPAD_AXIS_MAX, GAMEPAD_AXIS_MIN, GAMEPAD_TRIGGER_MAX, GamepadState,14+ InputEvent as ProtocolInputEvent,15+};16+17+use crate::config::UinputConfig;18+use crate::input::acl::{POSIX_ACL_REQUIRED_BYTES, validate_single_user_access_acl};19+use crate::input::keymap::{20+ ALL_MAPPED_KEYS, GamepadAxis, GamepadButton, InputDeviceClass, PointerReportEvent,21+ input_device_class, linux_key, map_gamepad_state, map_key, pointer_position_sync_report,22+};23+use crate::input::{24+ GAMEPAD_DEVICE_NAME, GAMEPAD_PRODUCT_ID, KEYBOARD_DEVICE_NAME, KEYBOARD_PRODUCT_ID,25+ POINTER_DEVICE_NAME, POINTER_PRODUCT_ID, UINPUT_BUS_TYPE, UINPUT_DEVICE_VERSION,26+ UINPUT_VENDOR_ID,27+};28+29+const UINPUT_MAJOR: u32 = 10;30+const UINPUT_MINOR: u32 = 223;31+const MAX_POINTER_EVENTS: usize = 6;32+const MAX_KEYBOARD_EVENTS: usize = ALL_MAPPED_KEYS.len() + 1;33+const GAMEPAD_REPORT_EVENTS: usize = 20;34+35+pub struct UinputDevice {36+ pointer: PointerDevice,37+ keyboard: KeyboardDevice,38+ gamepad: GamepadDevice,39+}40+41+impl UinputDevice {42+ pub fn open(config: &UinputConfig) -> Result<Self> {43+ // Each virtual device owns a distinct validated descriptor. This44+ // keeps libinput/udev classification from merging keyboard keys45+ // into the conventional relative pointer topology.46+ let pointer = PointerDevice::open(config)?;47+ let keyboard = KeyboardDevice::open(config)?;48+ let gamepad = GamepadDevice::open(config)?;49+50+ Ok(Self {51+ pointer,52+ keyboard,53+ gamepad,54+ })55+ }56+57+ pub fn apply(&mut self, event: &ProtocolInputEvent) -> Result<()> {58+ match input_device_class(event) {59+ Some(InputDeviceClass::Pointer) => self.pointer.apply(event),60+ Some(InputDeviceClass::Keyboard) => self.keyboard.apply(event),61+ Some(InputDeviceClass::Gamepad) => match event {62+ ProtocolInputEvent::Gamepad { state } => self.gamepad.apply(state),63+ _ => unreachable!("input device classification is exhaustive"),64+ },65+ None => Ok(()),66+ }67+ }68+69+ pub fn release_all(&mut self) -> Result<()> {70+ // Evaluate every release before combining results so one broken71+ // descriptor cannot strand held state on either sibling device.72+ let pointer_result = self.pointer.release_all();73+ let keyboard_result = self.keyboard.release_all();74+ let gamepad_result = self.gamepad.neutralize();75+ pointer_result.and(keyboard_result).and(gamepad_result)76+ }77+}78+79+struct PointerDevice {80+ handle: UInputHandle<File>,81+ pressed: BTreeSet<Key>,82+}83+84+impl PointerDevice {85+ fn open(config: &UinputConfig) -> Result<Self> {86+ let handle = open_validated_handle(config, "pointer")?;87+ handle88+ .set_propbit(InputProperty::Pointer)89+ .context("enabling pointer property")?;90+ handle91+ .set_evbit(EventKind::Key)92+ .context("enabling pointer button events")?;93+ for key in [Key::ButtonLeft, Key::ButtonRight, Key::ButtonMiddle] {94+ handle95+ .set_keybit(key)96+ .context("enabling a bounded pointer button capability")?;97+ }98+ handle99+ .set_evbit(EventKind::Relative)100+ .context("enabling relative pointer events")?;101+ handle102+ .set_relbit(RelativeAxis::X)103+ .context("enabling relative X events")?;104+ handle105+ .set_relbit(RelativeAxis::Y)106+ .context("enabling relative Y events")?;107+ handle108+ .set_relbit(RelativeAxis::Wheel)109+ .context("enabling vertical wheel events")?;110+ handle111+ .set_relbit(RelativeAxis::HorizontalWheel)112+ .context("enabling horizontal wheel events")?;113+ handle114+ .create(115+ &InputId {116+ bustype: UINPUT_BUS_TYPE,117+ vendor: UINPUT_VENDOR_ID,118+ product: POINTER_PRODUCT_ID,119+ version: UINPUT_DEVICE_VERSION,120+ },121+ POINTER_DEVICE_NAME,122+ 0,123+ &[],124+ )125+ .context("registering Sigil virtual pointer")?;126+127+ Ok(Self {128+ handle,129+ pressed: BTreeSet::new(),130+ })131+ }132+133+ fn apply(&mut self, event: &ProtocolInputEvent) -> Result<()> {134+ match event {135+ ProtocolInputEvent::MouseMoveRelative { dx, dy } => {136+ let mut events = [synchronize_event(); 3];137+ let mut len = 0;138+ if *dx != 0 {139+ events[len] = relative_event(RelativeAxis::X, *dx);140+ len += 1;141+ }142+ if *dy != 0 {143+ events[len] = relative_event(RelativeAxis::Y, *dy);144+ len += 1;145+ }146+ if len == 0 {147+ return Ok(());148+ }149+ events[len] = synchronize_event();150+ self.emit(&events[..=len])151+ }152+ ProtocolInputEvent::MousePositionSync { x, y } => {153+ let events = pointer_position_sync_report(*x, *y).map(|event| match event {154+ PointerReportEvent::RelativeX(value) => relative_event(RelativeAxis::X, value),155+ PointerReportEvent::RelativeY(value) => relative_event(RelativeAxis::Y, value),156+ PointerReportEvent::Synchronize => synchronize_event(),157+ });158+ self.emit(&events)159+ }160+ ProtocolInputEvent::MouseMove { .. } => anyhow::bail!(161+ "absolute pointer movement is unavailable on the relative-only uinput device"162+ ),163+ ProtocolInputEvent::MouseClick { b } => {164+ self.click(mouse_button(*b).context("unsupported mouse button")?)165+ }166+ ProtocolInputEvent::MouseDown { b } => {167+ self.press(mouse_button(*b).context("unsupported mouse button")?)168+ }169+ ProtocolInputEvent::MouseUp { b } => {170+ self.release(mouse_button(*b).context("unsupported mouse button")?)171+ }172+ ProtocolInputEvent::MouseScroll { dx, dy } => {173+ let vertical = dy174+ .checked_neg()175+ .context("vertical wheel delta is out of range")?;176+ let mut events = [synchronize_event(); 3];177+ let mut len = 0;178+ if *dx != 0 {179+ events[len] = relative_event(RelativeAxis::HorizontalWheel, *dx);180+ len += 1;181+ }182+ if vertical != 0 {183+ events[len] = relative_event(RelativeAxis::Wheel, vertical);184+ len += 1;185+ }186+ if len == 0 {187+ return Ok(());188+ }189+ events[len] = synchronize_event();190+ self.emit(&events[..=len])191+ }192+ _ => unreachable!("only pointer events are routed to the pointer device"),193+ }194+ }195+196+ fn press(&mut self, key: Key) -> Result<()> {197+ self.emit(&[key_event(key, KeyState::PRESSED), synchronize_event()])?;198+ self.pressed.insert(key);199+ Ok(())200+ }201+202+ fn release(&mut self, key: Key) -> Result<()> {203+ self.emit(&[key_event(key, KeyState::RELEASED), synchronize_event()])?;204+ self.pressed.remove(&key);205+ Ok(())206+ }207+208+ fn click(&mut self, key: Key) -> Result<()> {209+ ensure!(210+ !self.pressed.contains(&key),211+ "click transition is invalid while the key or button is held"212+ );213+ self.emit(&[214+ key_event(key, KeyState::PRESSED),215+ synchronize_event(),216+ key_event(key, KeyState::RELEASED),217+ synchronize_event(),218+ ])219+ }220+221+ fn release_all(&mut self) -> Result<()> {222+ if self.pressed.is_empty() {223+ return Ok(());224+ }225+ let mut events = Vec::with_capacity(self.pressed.len() + 1);226+ events.extend(227+ self.pressed228+ .iter()229+ .copied()230+ .map(|key| key_event(key, KeyState::RELEASED)),231+ );232+ events.push(synchronize_event());233+ ensure!(234+ events.len() <= MAX_POINTER_EVENTS,235+ "pointer release set exceeded its static bound"236+ );237+ self.emit(&events)?;238+ self.pressed.clear();239+ Ok(())240+ }241+242+ fn emit(&self, events: &[input_linux::sys::input_event]) -> Result<()> {243+ ensure!(244+ !events.is_empty() && events.len() <= MAX_POINTER_EVENTS,245+ "pointer event batch is outside its static bound"246+ );247+ let written = self248+ .handle249+ .write(events)250+ .context("writing nonblocking pointer event batch")?;251+ ensure!(252+ written == events.len(),253+ "uinput accepted only {written} of {} pointer events",254+ events.len()255+ );256+ Ok(())257+ }258+}259+260+impl Drop for PointerDevice {261+ fn drop(&mut self) {262+ let _ = self.release_all();263+ let _ = self.handle.dev_destroy();264+ }265+}266+267+struct KeyboardDevice {268+ handle: UInputHandle<File>,269+ pressed: BTreeSet<Key>,270+}271+272+impl KeyboardDevice {273+ fn open(config: &UinputConfig) -> Result<Self> {274+ let handle = open_validated_handle(config, "keyboard")?;275+ handle276+ .set_evbit(EventKind::Key)277+ .context("enabling keyboard key events")?;278+ for key in ALL_MAPPED_KEYS.iter().copied().map(linux_key) {279+ handle280+ .set_keybit(key)281+ .context("enabling a bounded keyboard key capability")?;282+ }283+ handle284+ .create(285+ &InputId {286+ bustype: UINPUT_BUS_TYPE,287+ vendor: UINPUT_VENDOR_ID,288+ product: KEYBOARD_PRODUCT_ID,289+ version: UINPUT_DEVICE_VERSION,290+ },291+ KEYBOARD_DEVICE_NAME,292+ 0,293+ &[],294+ )295+ .context("registering Sigil virtual keyboard")?;296+297+ Ok(Self {298+ handle,299+ pressed: BTreeSet::new(),300+ })301+ }302+303+ fn apply(&mut self, event: &ProtocolInputEvent) -> Result<()> {304+ match event {305+ ProtocolInputEvent::KeyDown { k } => {306+ self.press(linux_key(map_key(k).context("unsupported keyboard key")?))307+ }308+ ProtocolInputEvent::KeyUp { k } => {309+ self.release(linux_key(map_key(k).context("unsupported keyboard key")?))310+ }311+ ProtocolInputEvent::KeyClick { k } => {312+ self.click(linux_key(map_key(k).context("unsupported keyboard key")?))313+ }314+ _ => unreachable!("only keyboard events are routed to the keyboard device"),315+ }316+ }317+318+ fn press(&mut self, key: Key) -> Result<()> {319+ self.emit(&[key_event(key, KeyState::PRESSED), synchronize_event()])?;320+ self.pressed.insert(key);321+ Ok(())322+ }323+324+ fn release(&mut self, key: Key) -> Result<()> {325+ self.emit(&[key_event(key, KeyState::RELEASED), synchronize_event()])?;326+ self.pressed.remove(&key);327+ Ok(())328+ }329+330+ fn click(&mut self, key: Key) -> Result<()> {331+ ensure!(332+ !self.pressed.contains(&key),333+ "click transition is invalid while the key is held"334+ );335+ self.emit(&[336+ key_event(key, KeyState::PRESSED),337+ synchronize_event(),338+ key_event(key, KeyState::RELEASED),339+ synchronize_event(),340+ ])341+ }342+343+ fn release_all(&mut self) -> Result<()> {344+ if self.pressed.is_empty() {345+ return Ok(());346+ }347+ let mut events = Vec::with_capacity(self.pressed.len() + 1);348+ events.extend(349+ self.pressed350+ .iter()351+ .copied()352+ .map(|key| key_event(key, KeyState::RELEASED)),353+ );354+ events.push(synchronize_event());355+ ensure!(356+ events.len() <= MAX_KEYBOARD_EVENTS,357+ "keyboard release set exceeded its static bound"358+ );359+ self.emit(&events)?;360+ self.pressed.clear();361+ Ok(())362+ }363+364+ fn emit(&self, events: &[input_linux::sys::input_event]) -> Result<()> {365+ ensure!(366+ !events.is_empty() && events.len() <= MAX_KEYBOARD_EVENTS,367+ "keyboard event batch is outside its static bound"368+ );369+ let written = self370+ .handle371+ .write(events)372+ .context("writing nonblocking keyboard event batch")?;373+ ensure!(374+ written == events.len(),375+ "uinput accepted only {written} of {} keyboard events",376+ events.len()377+ );378+ Ok(())379+ }380+}381+382+impl Drop for KeyboardDevice {383+ fn drop(&mut self) {384+ let _ = self.release_all();385+ let _ = self.handle.dev_destroy();386+ }387+}388+389+struct GamepadDevice {390+ handle: UInputHandle<File>,391+}392+393+impl GamepadDevice {394+ fn open(config: &UinputConfig) -> Result<Self> {395+ let handle = open_validated_handle(config, "gamepad")?;396+397+ handle398+ .set_evbit(EventKind::Key)399+ .context("enabling gamepad key events")?;400+ for (button, _) in map_gamepad_state(&GamepadState::default()).buttons {401+ handle402+ .set_keybit(linux_gamepad_button(button))403+ .context("enabling a bounded gamepad button capability")?;404+ }405+ handle406+ .set_evbit(EventKind::Absolute)407+ .context("enabling gamepad absolute-axis events")?;408+ for (axis, _) in map_gamepad_state(&GamepadState::default()).axes {409+ handle410+ .set_absbit(linux_gamepad_axis(axis))411+ .context("enabling a bounded gamepad axis capability")?;412+ }413+414+ let axes = [415+ absolute_range(416+ AbsoluteAxis::X,417+ i32::from(GAMEPAD_AXIS_MIN),418+ i32::from(GAMEPAD_AXIS_MAX),419+ ),420+ absolute_range(421+ AbsoluteAxis::Y,422+ i32::from(GAMEPAD_AXIS_MIN),423+ i32::from(GAMEPAD_AXIS_MAX),424+ ),425+ absolute_range(426+ AbsoluteAxis::RX,427+ i32::from(GAMEPAD_AXIS_MIN),428+ i32::from(GAMEPAD_AXIS_MAX),429+ ),430+ absolute_range(431+ AbsoluteAxis::RY,432+ i32::from(GAMEPAD_AXIS_MIN),433+ i32::from(GAMEPAD_AXIS_MAX),434+ ),435+ absolute_range(AbsoluteAxis::Z, 0, i32::from(GAMEPAD_TRIGGER_MAX)),436+ absolute_range(AbsoluteAxis::RZ, 0, i32::from(GAMEPAD_TRIGGER_MAX)),437+ absolute_range(AbsoluteAxis::Hat0X, -1, 1),438+ absolute_range(AbsoluteAxis::Hat0Y, -1, 1),439+ ];440+ handle441+ .create(442+ &InputId {443+ bustype: UINPUT_BUS_TYPE,444+ vendor: UINPUT_VENDOR_ID,445+ product: GAMEPAD_PRODUCT_ID,446+ version: UINPUT_DEVICE_VERSION,447+ },448+ GAMEPAD_DEVICE_NAME,449+ 0,450+ &axes,451+ )452+ .context("registering Sigil virtual gamepad")?;453+ let device = Self { handle };454+ device.neutralize()?;455+ Ok(device)456+ }457+458+ fn apply(&self, state: &GamepadState) -> Result<()> {459+ state.validate().context("validating gamepad snapshot")?;460+ let report = map_gamepad_state(state);461+ let mut events = [synchronize_event(); GAMEPAD_REPORT_EVENTS];462+ let mut index = 0;463+ for (button, pressed) in report.buttons {464+ events[index] = key_event(linux_gamepad_button(button), KeyState::pressed(pressed));465+ index += 1;466+ }467+ for (axis, value) in report.axes {468+ events[index] = absolute_event(linux_gamepad_axis(axis), value);469+ index += 1;470+ }471+ events[index] = synchronize_event();472+ index += 1;473+ ensure!(474+ index == GAMEPAD_REPORT_EVENTS,475+ "gamepad report violated its static event bound"476+ );477+ let written = self478+ .handle479+ .write(&events)480+ .context("writing nonblocking gamepad snapshot")?;481+ ensure!(482+ written == events.len(),483+ "uinput accepted only {written} of {} gamepad events",484+ events.len()485+ );486+ Ok(())487+ }488+489+ fn neutralize(&self) -> Result<()> {490+ self.apply(&GamepadState::default())491+ .context("neutralizing virtual gamepad")492+ }493+}494+495+impl Drop for GamepadDevice {496+ fn drop(&mut self) {497+ let _ = self.neutralize();498+ let _ = self.handle.dev_destroy();499+ }500+}501+502+fn open_validated_handle(config: &UinputConfig, device_class: &str) -> Result<UInputHandle<File>> {503+ let handle = UInputHandle::new(open_validated_device(config)?);504+ let version = handle505+ .version()506+ .with_context(|| format!("querying uinput interface version for {device_class}"))?;507+ ensure!(508+ version >= 5,509+ "uinput interface version {version} is too old for {device_class}"510+ );511+ Ok(handle)512+}513+514+fn open_validated_device(config: &UinputConfig) -> Result<File> {515+ let mut options = OpenOptions::new();516+ options517+ .read(true)518+ .write(true)519+ .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK);520+ let file = options.open(&config.device_path).with_context(|| {521+ format!(522+ "opening configured uinput device {} without following symlinks",523+ config.device_path.display()524+ )525+ })?;526+ validate_open_device(&file, config)?;527+ Ok(file)528+}529+530+fn validate_open_device(file: &File, config: &UinputConfig) -> Result<()> {531+ let metadata = file.metadata().with_context(|| {532+ format!(533+ "inspecting opened uinput device {}",534+ config.device_path.display()535+ )536+ })?;537+ ensure!(538+ metadata.file_type().is_char_device(),539+ "configured uinput path is not a character device"540+ );541+ ensure!(542+ metadata.uid() == config.expected_owner_uid,543+ "configured uinput owner UID changed"544+ );545+ ensure!(546+ metadata.gid() == config.expected_group_gid,547+ "configured uinput group GID changed"548+ );549+ ensure!(550+ metadata.mode() & 0o7777 == config.expected_mode,551+ "configured uinput permission mode changed"552+ );553+ let device = metadata.rdev();554+ ensure!(555+ libc::major(device) == UINPUT_MAJOR && libc::minor(device) == UINPUT_MINOR,556+ "configured character device is not the Linux uinput misc device"557+ );558+ validate_access_acl(file, config)?;559+ Ok(())560+}561+562+fn validate_access_acl(file: &File, config: &UinputConfig) -> Result<()> {563+ if let Some(expected_uid) = config.expected_acl_user_uid {564+ let effective_uid = unsafe { libc::geteuid() };565+ ensure!(566+ expected_uid == effective_uid,567+ "configured uinput ACL user UID {expected_uid} does not match Sigil effective UID {effective_uid}"568+ );569+ }570+571+ let attribute = c"system.posix_acl_access";572+ let mut bytes = [0_u8; POSIX_ACL_REQUIRED_BYTES];573+ let result = unsafe {574+ libc::fgetxattr(575+ file.as_raw_fd(),576+ attribute.as_ptr(),577+ bytes.as_mut_ptr().cast(),578+ bytes.len(),579+ )580+ };581+ if result >= 0 {582+ let Some(expected_uid) = config.expected_acl_user_uid else {583+ anyhow::bail!(584+ "configured uinput device must use the group-only scheme with no extended access ACL; set uinput.expected_acl_user_uid only for the explicit one-user ACL scheme"585+ );586+ };587+ let length =588+ usize::try_from(result).context("converting configured uinput access ACL length")?;589+ return validate_single_user_access_acl(590+ bytes591+ .get(..length)592+ .context("configured uinput access ACL exceeded its fixed bound")?,593+ expected_uid,594+ config.expected_mode,595+ )596+ .context("validating configured uinput one-user access ACL");597+ }598+ let error = std::io::Error::last_os_error();599+ if error600+ .raw_os_error()601+ .is_some_and(|code| code == libc::ENODATA || code == libc::ENOTSUP)602+ {603+ ensure!(604+ config.expected_acl_user_uid.is_none(),605+ "configured uinput device is missing the explicitly required one-user access ACL"606+ );607+ return Ok(());608+ }609+ if error.raw_os_error() == Some(libc::ERANGE) {610+ if config.expected_acl_user_uid.is_some() {611+ anyhow::bail!("configured uinput access ACL exceeds the exact one-user ACL scheme");612+ }613+ anyhow::bail!(614+ "configured uinput device must use the group-only scheme with no extended access ACL"615+ );616+ }617+ Err(error).context("checking configured uinput device access ACL")618+}619+620+fn absolute_range(axis: AbsoluteAxis, minimum: i32, maximum: i32) -> AbsoluteInfoSetup {621+ AbsoluteInfoSetup {622+ axis,623+ info: AbsoluteInfo {624+ minimum,625+ maximum,626+ ..AbsoluteInfo::default()627+ },628+ }629+}630+631+fn mouse_button(button: u8) -> Option<Key> {632+ match button {633+ 1 => Some(Key::ButtonLeft),634+ 2 => Some(Key::ButtonRight),635+ 3 => Some(Key::ButtonMiddle),636+ _ => None,637+ }638+}639+640+fn linux_gamepad_button(button: GamepadButton) -> Key {641+ match button {642+ GamepadButton::A => Key::ButtonSouth,643+ GamepadButton::B => Key::ButtonEast,644+ GamepadButton::X => Key::ButtonNorth,645+ GamepadButton::Y => Key::ButtonWest,646+ GamepadButton::LeftShoulder => Key::ButtonTL,647+ GamepadButton::RightShoulder => Key::ButtonTR,648+ GamepadButton::Back => Key::ButtonSelect,649+ GamepadButton::Start => Key::ButtonStart,650+ GamepadButton::Guide => Key::ButtonMode,651+ GamepadButton::LeftStick => Key::ButtonThumbl,652+ GamepadButton::RightStick => Key::ButtonThumbr,653+ }654+}655+656+fn linux_gamepad_axis(axis: GamepadAxis) -> AbsoluteAxis {657+ match axis {658+ GamepadAxis::LeftX => AbsoluteAxis::X,659+ GamepadAxis::LeftY => AbsoluteAxis::Y,660+ GamepadAxis::RightX => AbsoluteAxis::RX,661+ GamepadAxis::RightY => AbsoluteAxis::RY,662+ GamepadAxis::LeftTrigger => AbsoluteAxis::Z,663+ GamepadAxis::RightTrigger => AbsoluteAxis::RZ,664+ GamepadAxis::DpadX => AbsoluteAxis::Hat0X,665+ GamepadAxis::DpadY => AbsoluteAxis::Hat0Y,666+ }667+}668+669+fn key_event(key: Key, state: KeyState) -> input_linux::sys::input_event {670+ KeyEvent::new(EventTime::default(), key, state)671+ .into_event()672+ .into_raw()673+}674+675+fn absolute_event(axis: AbsoluteAxis, value: i32) -> input_linux::sys::input_event {676+ AbsoluteEvent::new(EventTime::default(), axis, value)677+ .into_event()678+ .into_raw()679+}680+681+fn relative_event(axis: RelativeAxis, value: i32) -> input_linux::sys::input_event {682+ RelativeEvent::new(EventTime::default(), axis, value)683+ .into_event()684+ .into_raw()685+}686+687+fn synchronize_event() -> input_linux::sys::input_event {688+ SynchronizeEvent::report(EventTime::default())689+ .into_event()690+ .into_raw()691+}
A crates/sigil-host/src/input/mod.rs +351 -0
@@ -0,0 +1,351 @@
1+use std::fmt;2+#[cfg(target_os = "linux")]3+use std::sync::Arc;4+#[cfg(any(target_os = "linux", test))]5+use std::sync::Mutex;6+7+#[cfg(target_os = "linux")]8+use anyhow::Context;9+use anyhow::{Result, bail};10+use sigil_protocol::{Capability, InputEvent};11+12+use crate::config::{HostConfig, InputMode};13+14+mod acl;15+mod keymap;16+#[cfg(target_os = "linux")]17+mod linux;18+19+const ACK_ONLY_CAPABILITIES: &[Capability] = &[Capability::InputAck];20+const UINPUT_CAPABILITIES: &[Capability] = &[21+ Capability::RelativePointer,22+ Capability::Keyboard,23+ Capability::Gamepad,24+ Capability::InputAck,25+];26+27+#[cfg(any(target_os = "linux", test))]28+// This Linux input identity tuple is an external ABI consumed by Gamescope,29+// udev/libinput rules, and hardware evidence. Preserve it exactly; see30+// docs/compatibility-identifiers.md.31+const UINPUT_BUS_TYPE: u16 = 0x06; // Linux BUS_VIRTUAL32+#[cfg(any(target_os = "linux", test))]33+const UINPUT_VENDOR_ID: u16 = 0x5347;34+#[cfg(any(target_os = "linux", test))]35+const UINPUT_DEVICE_VERSION: u16 = 1;36+#[cfg(any(target_os = "linux", test))]37+const POINTER_PRODUCT_ID: u16 = 1;38+#[cfg(any(target_os = "linux", test))]39+const GAMEPAD_PRODUCT_ID: u16 = 2;40+#[cfg(any(target_os = "linux", test))]41+const KEYBOARD_PRODUCT_ID: u16 = 3;42+#[cfg(any(target_os = "linux", test))]43+const POINTER_DEVICE_NAME: &[u8] = b"Sigil Spark Virtual Pointer";44+#[cfg(any(target_os = "linux", test))]45+const KEYBOARD_DEVICE_NAME: &[u8] = b"Sigil Spark Virtual Keyboard";46+#[cfg(any(target_os = "linux", test))]47+const GAMEPAD_DEVICE_NAME: &[u8] = b"Sigil Spark Virtual Gamepad";48+49+#[derive(Clone, Copy, Debug, PartialEq, Eq)]50+pub enum InputDisposition {51+ Probed,52+ Disabled,53+ Observed,54+ #[cfg(target_os = "linux")]55+ Injected,56+ TextIgnored,57+}58+59+#[derive(Clone)]60+pub struct InputBackend {61+ mode: InputMode,62+ #[cfg(target_os = "linux")]63+ device: Option<Arc<std::sync::Mutex<linux::UinputDevice>>>,64+}65+66+impl fmt::Debug for InputBackend {67+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {68+ formatter69+ .debug_struct("InputBackend")70+ .field("mode", &self.mode)71+ .finish_non_exhaustive()72+ }73+}74+75+impl InputBackend {76+ /// Construct the backend before the network endpoint is exposed. For77+ /// uinput this opens, validates, and registers the virtual device.78+ pub fn initialize(config: &HostConfig) -> Result<Self> {79+ match config.input_mode {80+ InputMode::Disabled | InputMode::Log => Ok(Self {81+ mode: config.input_mode.clone(),82+ #[cfg(target_os = "linux")]83+ device: None,84+ }),85+ InputMode::Uinput => {86+ #[cfg(target_os = "linux")]87+ {88+ let settings = config89+ .uinput90+ .as_ref()91+ .context("missing validated uinput configuration")?;92+ let device = linux::UinputDevice::open(settings)?;93+ Ok(Self {94+ mode: InputMode::Uinput,95+ device: Some(Arc::new(std::sync::Mutex::new(device))),96+ })97+ }98+ #[cfg(not(target_os = "linux"))]99+ {100+ let _ = config;101+ bail!("input_mode uinput is supported only on Linux")102+ }103+ }104+ }105+ }106+107+ pub fn capabilities(&self) -> &'static [Capability] {108+ capabilities_for_mode(&self.mode)109+ }110+111+ pub fn apply(&self, event: &InputEvent, negotiated: &[Capability]) -> Result<InputDisposition> {112+ ensure_event_was_negotiated(&self.mode, event, negotiated)?;113+ if matches!(event, InputEvent::Probe) {114+ return Ok(InputDisposition::Probed);115+ }116+ match self.mode {117+ InputMode::Disabled => Ok(InputDisposition::Disabled),118+ InputMode::Log => Ok(InputDisposition::Observed),119+ InputMode::Uinput => {120+ if matches!(event, InputEvent::Text { .. }) {121+ return Ok(InputDisposition::TextIgnored);122+ }123+ #[cfg(target_os = "linux")]124+ {125+ let device = self126+ .device127+ .as_ref()128+ .context("uinput backend was not initialized")?;129+ device130+ .lock()131+ .map_err(|_| anyhow::anyhow!("uinput backend lock poisoned"))?132+ .apply(event)?;133+ Ok(InputDisposition::Injected)134+ }135+ #[cfg(not(target_os = "linux"))]136+ {137+ let _ = event;138+ bail!("uinput backend is unavailable on this platform")139+ }140+ }141+ }142+ }143+144+ /// Release any transitions held by the ending input session so a dropped145+ /// connection cannot leave a key or mouse button pressed.146+ pub fn reset_session(&self) -> Result<()> {147+ #[cfg(target_os = "linux")]148+ if let Some(device) = &self.device {149+ reset_through_poisoned_lock(device, linux::UinputDevice::release_all)?;150+ }151+ Ok(())152+ }153+}154+155+#[cfg(any(target_os = "linux", test))]156+fn reset_through_poisoned_lock<T>(157+ device: &Mutex<T>,158+ reset: impl FnOnce(&mut T) -> Result<()>,159+) -> Result<()> {160+ let mut device = match device.lock() {161+ Ok(device) => device,162+ Err(poisoned) => {163+ // A panic while applying input poisons this mutex. Teardown must164+ // still recover the device solely to release held transitions;165+ // ordinary input continues to reject the poisoned lock.166+ tracing::warn!("recovering poisoned uinput lock for session reset");167+ poisoned.into_inner()168+ }169+ };170+ reset(&mut device)171+}172+173+fn ensure_event_was_negotiated(174+ mode: &InputMode,175+ event: &InputEvent,176+ negotiated: &[Capability],177+) -> Result<()> {178+ if !matches!(mode, InputMode::Uinput) {179+ return Ok(());180+ }181+ let required = match event {182+ InputEvent::Probe => Some(Capability::InputAck),183+ InputEvent::MouseMove { .. } => Some(Capability::AbsolutePointer),184+ InputEvent::MouseMoveRelative { .. }185+ | InputEvent::MousePositionSync { .. }186+ | InputEvent::MouseClick { .. }187+ | InputEvent::MouseDown { .. }188+ | InputEvent::MouseUp { .. }189+ | InputEvent::MouseScroll { .. } => Some(Capability::RelativePointer),190+ InputEvent::KeyDown { .. } | InputEvent::KeyUp { .. } | InputEvent::KeyClick { .. } => {191+ Some(Capability::Keyboard)192+ }193+ InputEvent::Gamepad { .. } => Some(Capability::Gamepad),194+ // Text remains an explicit content-free no-op for compatibility with195+ // clients that emit it alongside negotiated physical key transitions.196+ InputEvent::Text { .. } => None,197+ };198+ if required.is_some_and(|capability| !negotiated.contains(&capability)) {199+ bail!("input event class was not negotiated for this session");200+ }201+ Ok(())202+}203+204+fn capabilities_for_mode(mode: &InputMode) -> &'static [Capability] {205+ match mode {206+ InputMode::Disabled | InputMode::Log => ACK_ONLY_CAPABILITIES,207+ InputMode::Uinput => UINPUT_CAPABILITIES,208+ }209+}210+211+#[cfg(test)]212+mod tests {213+ use std::panic::{AssertUnwindSafe, catch_unwind};214+ use std::sync::Arc;215+216+ use sigil_protocol::GamepadState;217+218+ use super::*;219+220+ #[test]221+ fn virtual_input_topology_has_stable_distinct_identities() {222+ assert_eq!(UINPUT_BUS_TYPE, 0x06);223+ #[cfg(target_os = "linux")]224+ assert_eq!(UINPUT_BUS_TYPE, input_linux::sys::BUS_VIRTUAL);225+ assert_eq!(UINPUT_VENDOR_ID, 0x5347);226+ assert_eq!(UINPUT_DEVICE_VERSION, 1);227+ assert_eq!(POINTER_DEVICE_NAME, b"Sigil Spark Virtual Pointer");228+ assert_eq!(KEYBOARD_DEVICE_NAME, b"Sigil Spark Virtual Keyboard");229+ assert_eq!(GAMEPAD_DEVICE_NAME, b"Sigil Spark Virtual Gamepad");230+ assert_eq!(POINTER_PRODUCT_ID, 1);231+ assert_eq!(GAMEPAD_PRODUCT_ID, 2);232+ assert_eq!(KEYBOARD_PRODUCT_ID, 3);233+ assert_ne!(POINTER_PRODUCT_ID, KEYBOARD_PRODUCT_ID);234+ assert_ne!(POINTER_PRODUCT_ID, GAMEPAD_PRODUCT_ID);235+ assert_ne!(KEYBOARD_PRODUCT_ID, GAMEPAD_PRODUCT_ID);236+ }237+238+ #[test]239+ fn session_reset_recovers_a_lock_poisoned_while_applying_input() {240+ #[derive(Debug, Default)]241+ struct DeviceState {242+ released: bool,243+ }244+245+ let device = Arc::new(Mutex::new(DeviceState::default()));246+ let panic_device = Arc::clone(&device);247+ let panic_result = catch_unwind(AssertUnwindSafe(move || {248+ let _device = panic_device.lock().unwrap();249+ panic!("simulate panic while applying uinput");250+ }));251+ assert!(panic_result.is_err());252+ assert!(device.lock().is_err());253+254+ reset_through_poisoned_lock(device.as_ref(), |device| {255+ device.released = true;256+ Ok(())257+ })258+ .unwrap();259+260+ let device = device.lock().unwrap_err().into_inner();261+ assert!(device.released);262+ }263+264+ #[test]265+ fn content_free_probe_requires_ack_and_never_reaches_uinput() {266+ assert!(ensure_event_was_negotiated(&InputMode::Uinput, &InputEvent::Probe, &[]).is_err());267+ assert!(268+ ensure_event_was_negotiated(269+ &InputMode::Uinput,270+ &InputEvent::Probe,271+ &[Capability::InputAck]272+ )273+ .is_ok()274+ );275+ }276+277+ #[test]278+ fn relative_pointer_events_require_relative_pointer_negotiation() {279+ let movement = InputEvent::MouseMoveRelative { dx: 4, dy: -3 };280+ let synchronization = InputEvent::MousePositionSync { x: 123, y: 456 };281+ let click = InputEvent::MouseClick { b: 1 };282+ let legacy = InputEvent::MouseMove { x: 10, y: 20 };283+ for event in [&movement, &synchronization, &click] {284+ assert!(ensure_event_was_negotiated(&InputMode::Uinput, event, &[]).is_err());285+ assert!(286+ ensure_event_was_negotiated(287+ &InputMode::Uinput,288+ event,289+ &[Capability::AbsolutePointer]290+ )291+ .is_err()292+ );293+ assert!(294+ ensure_event_was_negotiated(295+ &InputMode::Uinput,296+ event,297+ &[Capability::RelativePointer]298+ )299+ .is_ok()300+ );301+ }302+ assert!(303+ ensure_event_was_negotiated(304+ &InputMode::Uinput,305+ &legacy,306+ &[Capability::RelativePointer]307+ )308+ .is_err()309+ );310+ }311+312+ #[test]313+ fn uinput_rejects_unnegotiated_gamepad_snapshots() {314+ let event = InputEvent::Gamepad {315+ state: GamepadState::default(),316+ };317+ assert!(ensure_event_was_negotiated(&InputMode::Uinput, &event, &[]).is_err());318+ assert!(319+ ensure_event_was_negotiated(&InputMode::Uinput, &event, &[Capability::Gamepad]).is_ok()320+ );321+ assert!(ensure_event_was_negotiated(&InputMode::Log, &event, &[]).is_ok());322+ }323+324+ #[test]325+ fn advertised_capabilities_follow_operational_mode() {326+ let disabled = InputBackend {327+ mode: InputMode::Disabled,328+ #[cfg(target_os = "linux")]329+ device: None,330+ };331+ assert_eq!(disabled.capabilities(), &[Capability::InputAck]);332+333+ let log = InputBackend {334+ mode: InputMode::Log,335+ #[cfg(target_os = "linux")]336+ device: None,337+ };338+ assert_eq!(log.capabilities(), &[Capability::InputAck]);339+ assert!(!log.capabilities().contains(&Capability::Keyboard));340+ assert_eq!(341+ capabilities_for_mode(&InputMode::Uinput),342+ &[343+ Capability::RelativePointer,344+ Capability::Keyboard,345+ Capability::Gamepad,346+ Capability::InputAck,347+ ]348+ );349+ assert!(!capabilities_for_mode(&InputMode::Uinput).contains(&Capability::AbsolutePointer));350+ }351+}
M crates/sigil-host/src/main.rs +63 -79
@@ -30,7 +30,7 @@ use iroh::{Endpoint, EndpointId};
3030 use moq_net::Origin;3131 use sigil_protocol::{3232 AUDIO_ALPN_V1, CONTROL_ALPN_V1, INPUT_ALPN_V1, InvitationGrants, MAX_INVITATION_TTL_SECS,33- MEDIA_ALPN_V1, MEDIA_ALPN_V2, MEDIA_ALPN_V3, MEDIA_FEEDBACK_ALPN_V1, SignedInvitation,33+ MEDIA_ALPN_V3, MEDIA_FEEDBACK_ALPN_V1, SignedInvitation,3434 };3535 use tracing::{info, warn};3636 use tracing_subscriber::EnvFilter;
@@ -43,7 +43,7 @@ use crate::cursor::PointerPositionTracker;
4343 use crate::input::InputBackend;4444 use crate::server::{4545 AudioHandler, AuthorizedMoqHandler, ControlHandler, InputHandler, MediaFeedbackHandler,46- MediaHandler, MediaV2Handler, MediaV3Handler, SessionRegistry,46+ MediaV3Handler, SessionRegistry,4747 };4848 4949 const CONNECTION_IDLE_TIMEOUT: Duration = Duration::from_secs(5);
@@ -837,6 +837,23 @@ fn validate_capture_probe_args(args: &CaptureProbeArgs, configured_framerate: u3
837837 Ok(())838838 }839839 840+/// Report the failing startup/shutdown step as a degraded appliance status841+/// before surfacing the error. Every fallible step between publisher creation842+/// and clean shutdown must flow through this helper so no failure class can843+/// exit the daemon without updating the published status.844+async fn degrade_on_error<T>(845+ publisher: Option<&appliance::RuntimePublisher>,846+ code: appliance::RuntimeErrorCode,847+ result: Result<T>,848+) -> Result<T> {849+ if result.is_err()850+ && let Some(publisher) = publisher851+ {852+ let _ = publisher.mark_degraded(code).await;853+ }854+ result855+}856+840857 async fn serve_command(args: ServeArgs) -> Result<()> {841858 validate_development_auth_bypass(args.dev_allow_unauthorized, args.max_runtime_seconds)?;842859 let configured_service = args.config.is_some();
@@ -874,17 +891,12 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
874891 "warning=development-authorization-bypass-active; do not expose or ship this host"875892 );876893 }877- let authorization = match authorization_result {878- Ok(authorization) => authorization,879- Err(error) => {880- if let Some(publisher) = publisher.as_ref() {881- let _ = publisher882- .mark_degraded(appliance::RuntimeErrorCode::AuthorizationState)883- .await;884- }885- return Err(error);886- }887- };894+ let authorization = degrade_on_error(895+ publisher.as_ref(),896+ appliance::RuntimeErrorCode::AuthorizationState,897+ authorization_result,898+ )899+ .await?;888900 889901 let preflight_result: Result<(InputBackend, Option<PointerPositionTracker>)> = async {890902 let pointer_surface_dimensions = if config.source == VideoSource::TestPattern {
@@ -950,41 +962,36 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
950962 Ok((input_backend, pointer_positions))951963 }952964 .await;953- let (input_backend, pointer_positions) = match preflight_result {954- Ok(preflight) => preflight,955- Err(error) => {956- if let Some(publisher) = publisher.as_ref() {957- let _ = publisher958- .mark_degraded(appliance::RuntimeErrorCode::Preflight)959- .await;960- }961- return Err(error);962- }963- };964-965- let idle_timeout = CONNECTION_IDLE_TIMEOUT966- .try_into()967- .context("converting bounded QUIC idle timeout")?;965+ let (input_backend, pointer_positions) = degrade_on_error(966+ publisher.as_ref(),967+ appliance::RuntimeErrorCode::Preflight,968+ preflight_result,969+ )970+ .await?;971+972+ let idle_timeout = degrade_on_error(973+ publisher.as_ref(),974+ appliance::RuntimeErrorCode::EndpointBind,975+ CONNECTION_IDLE_TIMEOUT976+ .try_into()977+ .context("converting bounded QUIC idle timeout"),978+ )979+ .await?;968980 let transport_config = QuicTransportConfig::builder()969981 .max_idle_timeout(Some(idle_timeout))970982 .keep_alive_interval(CONNECTION_KEEP_ALIVE_INTERVAL)971983 .build();972- let endpoint = match Endpoint::builder(presets::N0)973- .secret_key(secret)974- .transport_config(transport_config)975- .bind()976- .await977- {978- Ok(endpoint) => endpoint,979- Err(error) => {980- if let Some(publisher) = publisher.as_ref() {981- let _ = publisher982- .mark_degraded(appliance::RuntimeErrorCode::EndpointBind)983- .await;984- }985- return Err(error).context("binding iroh endpoint");986- }987- };984+ let endpoint = degrade_on_error(985+ publisher.as_ref(),986+ appliance::RuntimeErrorCode::EndpointBind,987+ Endpoint::builder(presets::N0)988+ .secret_key(secret)989+ .transport_config(transport_config)990+ .bind()991+ .await992+ .context("binding iroh endpoint"),993+ )994+ .await?;988995 let node_id = endpoint.id();989996 match tokio::time::timeout(Duration::from_secs(10), endpoint.online()).await {990997 Ok(()) => info!(%node_id, "iroh endpoint is online"),
@@ -1018,22 +1025,6 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
10181025 authorization: authorization.clone(),10191026 },10201027 )1021- .accept(1022- MEDIA_ALPN_V2,1023- MediaV2Handler {1024- config: config.clone(),1025- sessions: Arc::clone(&sessions),1026- authorization: authorization.clone(),1027- },1028- )1029- .accept(1030- MEDIA_ALPN_V1,1031- MediaHandler {1032- config: config.clone(),1033- sessions: Arc::clone(&sessions),1034- authorization: authorization.clone(),1035- },1036- )10371028 .accept(10381029 MEDIA_FEEDBACK_ALPN_V1,10391030 MediaFeedbackHandler {
@@ -1073,17 +1064,12 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
10731064 tokio::time::sleep(Duration::from_secs(seconds)).await;10741065 info!(seconds, "maximum runtime reached");10751066 } else {1076- let signal = match wait_for_shutdown_signal().await {1077- Ok(signal) => signal,1078- Err(error) => {1079- if let Some(publisher) = publisher.as_ref() {1080- let _ = publisher1081- .mark_degraded(appliance::RuntimeErrorCode::ShutdownSignal)1082- .await;1083- }1084- return Err(error);1085- }1086- };1067+ let signal = degrade_on_error(1068+ publisher.as_ref(),1069+ appliance::RuntimeErrorCode::ShutdownSignal,1070+ wait_for_shutdown_signal().await,1071+ )1072+ .await?;10871073 info!(signal, "shutdown signal received");10881074 }10891075
@@ -1092,14 +1078,12 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
10921078 {10931079 warn!(%error, "publishing stopping appliance status failed; continuing shutdown");10941080 }1095- if let Err(error) = router.shutdown().await {1096- if let Some(publisher) = publisher.as_ref() {1097- let _ = publisher1098- .mark_degraded(appliance::RuntimeErrorCode::RouterShutdown)1099- .await;1100- }1101- return Err(error).context("shutting down iroh router");1102- }1081+ degrade_on_error(1082+ publisher.as_ref(),1083+ appliance::RuntimeErrorCode::RouterShutdown,1084+ router.shutdown().await.context("shutting down iroh router"),1085+ )1086+ .await?;11031087 if let Some(publisher) = publisher11041088 && let Err(error) = publisher.finish_clean().await11051089 {
M crates/sigil-host/src/moq_catalog.rs +4 -30
@@ -1,34 +1,6 @@
11 use anyhow::{Context, Result, ensure};22 use moq_net::BroadcastProducer;3-use serde::{Deserialize, Serialize};4-use sigil_protocol::{MAX_MOQ_CATALOG_BYTES, MoqCatalogExtensionV1};5-6-#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]7-#[serde(rename_all = "camelCase")]8-struct GoqCatalogDocument {9- #[serde(flatten)]10- media: hang::Catalog,11- goq: MoqCatalogExtensionV1,12-}13-14-impl GoqCatalogDocument {15- fn video_h264() -> Self {16- Self {17- media: hang::Catalog::default(),18- goq: MoqCatalogExtensionV1::video_h264(),19- }20- }21-22- fn validate(&self) -> Result<()> {23- ensure!(24- self.media == hang::Catalog::default(),25- "Goq catalog must not advertise a standard Hang rendition for enveloped media"26- );27- self.goq28- .validate()29- .context("validating Goq catalog extension")30- }31-}3+use sigil_protocol::{GoqCatalogDocument, MAX_MOQ_CATALOG_BYTES};324 335 pub(crate) struct GoqCatalogProducer {346 producer: moq_json::Producer<GoqCatalogDocument>,
@@ -48,7 +20,9 @@ pub(crate) fn publish_goq_catalog(broadcast: &mut BroadcastProducer) -> Result<G
4820 .context("creating catalog.json track")?;4921 let mut producer = moq_json::Producer::new(track, moq_json::Config::default());5022 let document = GoqCatalogDocument::video_h264();51- document.validate()?;23+ document24+ .validate()25+ .context("validating Goq catalog document")?;5226 let snapshot = serde_json::to_vec(&document).context("serializing Goq catalog snapshot")?;5327 ensure!(5428 snapshot.len() <= MAX_MOQ_CATALOG_BYTES,
M crates/sigil-host/src/server/handlers.rs +0 -48
@@ -50,20 +50,6 @@ where
5050 }5151 }5252 53-#[derive(Clone, Debug)]54-pub struct MediaHandler {55- pub config: HostConfig,56- pub sessions: Arc<SessionRegistry>,57- pub authorization: AuthorizationPolicy,58-}59-60-#[derive(Clone, Debug)]61-pub struct MediaV2Handler {62- pub config: HostConfig,63- pub sessions: Arc<SessionRegistry>,64- pub authorization: AuthorizationPolicy,65-}66-6753 #[derive(Clone, Debug)]6854 pub struct MediaV3Handler {6955 pub config: HostConfig,
@@ -97,40 +83,6 @@ pub struct AuthorizedMoqHandler {
9783 pub origin: Origin,9884 }9985 100-impl ProtocolHandler for MediaHandler {101- async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {102- let remote = connection.remote_id();103- if let Err(error) = serve_media(104- connection,105- self.config.clone(),106- &self.sessions,107- &self.authorization,108- )109- .await110- {111- warn!(%remote, %error, "media connection ended");112- }113- Ok(())114- }115-}116-117-impl ProtocolHandler for MediaV2Handler {118- async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {119- let remote = connection.remote_id();120- if let Err(error) = serve_media_v2(121- connection,122- self.config.clone(),123- &self.sessions,124- &self.authorization,125- )126- .await127- {128- warn!(%remote, %error, "media v2 connection ended");129- }130- Ok(())131- }132-}133-13486 impl ProtocolHandler for MediaV3Handler {13587 async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {13688 let remote = connection.remote_id();
D crates/sigil-host/src/server/media_v2.rs +0 -821
@@ -1,821 +0,0 @@
1-use super::*;2-3-const MEDIA_WRITE_TIMEOUT: Duration = Duration::from_secs(5);4-const MEDIA_V2_PEER_ACK_TIMEOUT: Duration = Duration::from_secs(2);5-const MEDIA_V2_IN_FLIGHT_CAPACITY: usize = 4;6-const MEDIA_V2_KEYFRAME_PRIORITY: i32 = 10;7-const MEDIA_V2_DELTA_PRIORITY: i32 = 0;8-const MEDIA_V2_RESET_CODE: u32 = 0x5356;9-10-#[derive(Clone, Copy, Debug, PartialEq, Eq)]11-enum MediaActivity {12- SourceChanged,13- PeerDisconnected,14-}15-16-#[derive(Debug, PartialEq, Eq)]17-enum MediaV2ScheduleDecision {18- Send {19- discontinuity: bool,20- cancel_sequences: Vec<u64>,21- },22- SkipUntilKeyframe,23- EnterResync {24- cancel_sequences: Vec<u64>,25- },26-}27-28-#[derive(Debug)]29-struct MediaV2Scheduler {30- in_flight: Vec<u64>,31- last_scheduled_sequence: Option<u64>,32- waiting_for_keyframe: bool,33- discontinuity_pending: bool,34-}35-36-impl Default for MediaV2Scheduler {37- fn default() -> Self {38- Self {39- in_flight: Vec::with_capacity(MEDIA_V2_IN_FLIGHT_CAPACITY),40- last_scheduled_sequence: None,41- waiting_for_keyframe: true,42- discontinuity_pending: false,43- }44- }45-}46-47-impl MediaV2Scheduler {48- fn schedule(49- &mut self,50- sequence: u64,51- independently_decodable: bool,52- ) -> MediaV2ScheduleDecision {53- if self54- .last_scheduled_sequence55- .is_some_and(|last| sequence <= last)56- {57- return MediaV2ScheduleDecision::SkipUntilKeyframe;58- }59-60- let sequence_discontinuity = self61- .last_scheduled_sequence62- .is_some_and(|last| last.checked_add(1) != Some(sequence));63- if independently_decodable {64- let cancel_sequences = std::mem::take(&mut self.in_flight);65- let discontinuity = self.discontinuity_pending66- || sequence_discontinuity67- || !cancel_sequences.is_empty();68- self.in_flight.push(sequence);69- self.last_scheduled_sequence = Some(sequence);70- self.waiting_for_keyframe = false;71- self.discontinuity_pending = false;72- return MediaV2ScheduleDecision::Send {73- discontinuity,74- cancel_sequences,75- };76- }77-78- if self.waiting_for_keyframe {79- return MediaV2ScheduleDecision::SkipUntilKeyframe;80- }81- if sequence_discontinuity || self.in_flight.len() == MEDIA_V2_IN_FLIGHT_CAPACITY {82- return MediaV2ScheduleDecision::EnterResync {83- cancel_sequences: self.enter_resync(),84- };85- }86-87- self.in_flight.push(sequence);88- self.last_scheduled_sequence = Some(sequence);89- MediaV2ScheduleDecision::Send {90- discontinuity: false,91- cancel_sequences: Vec::new(),92- }93- }94-95- fn complete(&mut self, sequence: u64) {96- if let Some(index) = self.in_flight.iter().position(|value| *value == sequence) {97- self.in_flight.swap_remove(index);98- }99- }100-101- fn fail(&mut self, sequence: u64) -> Vec<u64> {102- let Some(index) = self.in_flight.iter().position(|value| *value == sequence) else {103- // A completion from an already-cancelled GOP must not poison the104- // newer GOP which replaced it.105- return Vec::new();106- };107- self.in_flight.swap_remove(index);108- self.enter_resync()109- }110-111- fn fail_all(&mut self) -> Vec<u64> {112- self.enter_resync()113- }114-115- fn enter_resync(&mut self) -> Vec<u64> {116- self.waiting_for_keyframe = true;117- self.discontinuity_pending = true;118- std::mem::take(&mut self.in_flight)119- }120-}121-122-struct ResetOnDropSendStream(Option<SendStream>);123-124-impl ResetOnDropSendStream {125- fn new(stream: SendStream) -> Self {126- Self(Some(stream))127- }128-129- fn stream(&self) -> &SendStream {130- self.0.as_ref().expect("send stream guard is armed")131- }132-133- fn stream_mut(&mut self) -> &mut SendStream {134- self.0.as_mut().expect("send stream guard is armed")135- }136-137- fn disarm(&mut self) {138- self.0.take();139- }140-}141-142-impl Drop for ResetOnDropSendStream {143- fn drop(&mut self) {144- if let Some(stream) = self.0.as_mut() {145- let _ = stream.reset(MEDIA_V2_RESET_CODE.into());146- }147- }148-}149-150-async fn wait_for_media_activity<T, F>(151- receiver: &mut tokio::sync::watch::Receiver<T>,152- peer_disconnected: Pin<&mut F>,153-) -> Result<MediaActivity>154-where155- T: Clone + Send + Sync,156- F: Future<Output = ()>,157-{158- tokio::select! {159- changed = receiver.changed() => {160- changed.context("encoded source stopped")?;161- Ok(MediaActivity::SourceChanged)162- }163- () = peer_disconnected => Ok(MediaActivity::PeerDisconnected),164- }165-}166-167-pub(super) async fn serve_media(168- connection: Connection,169- config: HostConfig,170- sessions: &Arc<SessionRegistry>,171- authorization: &AuthorizationPolicy,172-) -> Result<()> {173- let remote = connection.remote_id();174- let handshake_permit = sessions175- .pending_handshakes176- .try_acquire()177- .context("too many pending handshakes")?;178- let (mut send, mut recv) = tokio::time::timeout(HANDSHAKE_TIMEOUT, connection.accept_bi())179- .await180- .context("timed out accepting media stream")?181- .context("accepting media stream")?;182- let hello = receive_hello(&mut recv, Capability::VideoH264).await?;183- drop(handshake_permit);184- debug!(%remote, agent = %hello.agent, "media hello received");185-186- let grants = match authorization.authorize_or_redeem(187- remote,188- hello.invitation.as_deref(),189- unix_timestamp_now()?,190- ) {191- Ok(grants) => grants,192- Err(error) => {193- send_rejection(&mut send, "Portal peer is not authorized").await?;194- return Err(error.context("authorizing media peer"));195- }196- };197- ensure!(198- grants.contains(InvitationGrants::VIEW),199- "authorized media peer lacks view permission"200- );201-202- let lease = match sessions.claim(remote, hello.nonce, grants) {203- Ok(lease) => lease,204- Err(error) => {205- send_rejection(&mut send, "host already has an active client").await?;206- return Err(error);207- }208- };209-210- // `serve` has already completed the static executable and encoder211- // preflight. Resolve the live PipeWire node and create the bounded source212- // before accepting the session so plugin discovery can never sit behind an213- // accepted HostHello and the client's media-idle timeout.214- let source = match config.source {215- VideoSource::TestPattern => Ok(spawn_test_pattern(config.clone(), lease.session_clock)),216- VideoSource::GamescopePipewire => {217- let primary = spawn_gamescope_pipewire_after_static_preflight(218- config.clone(),219- lease.session_clock,220- )221- .await?;222- select_gamescope_startup_source(config.clone(), lease.session_clock, primary).await223- }224- };225- let EncodedSource {226- frames: frame_receiver,227- current_gop: mut current_gop_receiver,228- task: source_task,229- pointer_surface_dimensions,230- encoder_control,231- } = match source {232- Ok(source) => source,233- Err(error) => {234- send_rejection(&mut send, "video source is unavailable").await?;235- return Err(error);236- }237- };238- let source_task = SourceTaskGuard::new(source_task);239- sessions.install_encoder_control(remote, lease.session_id, encoder_control.clone())?;240- let _encoder_control = encoder_control;241-242- let mut media_hello = HostHello::accepted(243- lease.session_id,244- negotiated_capabilities(&hello, MEDIA_CAPABILITIES),245- );246- if let Some(dimensions) = pointer_surface_dimensions {247- media_hello = media_hello.with_pointer_surface_dimensions(dimensions);248- }249- write_host_hello(&mut send, &media_hello).await?;250- info!(%remote, session_id = lease.session_id, "media client accepted");251-252- let session_result: Result<()> = async {253- let maximum_replay_age = maximum_media_replay_age(config.framerate);254- let mut replay_cursor = MediaReplayCursor::default();255- let peer_disconnected = async {256- let result = connection.closed().await;257- debug!(%remote, ?result, "media connection closed");258- };259- tokio::pin!(peer_disconnected);260- loop {261- match wait_for_media_activity(&mut current_gop_receiver, peer_disconnected.as_mut())262- .await?263- {264- MediaActivity::PeerDisconnected => return Ok(()),265- MediaActivity::SourceChanged => {}266- }267- let Some(current_gop) = current_gop_receiver.borrow_and_update().clone() else {268- continue;269- };270- let initial_replay_started_at =271- replay_cursor.last_sequence.is_none().then(Instant::now);272- let replay_through_sequence = current_gop273- .frames274- .last()275- .map(|frame| frame.sequence)276- .context("current GOP snapshot is empty")?;277- for frame in new_current_gop_frames(current_gop, replay_cursor.last_sequence) {278- let discontinuity = match replay_cursor.classify(279- &frame,280- replay_through_sequence,281- initial_replay_started_at,282- Instant::now(),283- maximum_replay_age,284- ) {285- MediaReplayDecision::Send { discontinuity } => discontinuity,286- MediaReplayDecision::SkipUntilKeyframe => {287- debug!(288- sequence = frame.sequence,289- "waiting for keyframe with codec configuration"290- );291- continue;292- }293- MediaReplayDecision::DiscardStaleSuffix { through_sequence } => {294- debug!(295- sequence = frame.sequence,296- through_sequence,297- replay_age_micros = frame.observed_at.elapsed().as_micros(),298- maximum_replay_age_micros = maximum_replay_age.as_micros(),299- "discarding stale media suffix and waiting for keyframe"300- );301- break;302- }303- };304- let media_frame = media_frame_for_encoded(&config, &frame, discontinuity)?;305- tokio::time::timeout(306- MEDIA_WRITE_TIMEOUT,307- write_media_frame(&mut send, &media_frame),308- )309- .await310- .context("timed out writing media frame")??;311- replay_cursor.commit_sent(&frame);312- }313- }314- }315- .await;316-317- drop(current_gop_receiver);318- drop(frame_receiver);319- source_task.wait_or_abort(SOURCE_REAP_GRACE_TIMEOUT).await;320- drop(lease);321- info!(%remote, "media client released");322- session_result323-}324-325-pub(super) async fn serve_media_v2(326- connection: Connection,327- config: HostConfig,328- sessions: &Arc<SessionRegistry>,329- authorization: &AuthorizationPolicy,330-) -> Result<()> {331- let remote = connection.remote_id();332- let handshake_permit = sessions333- .pending_handshakes334- .try_acquire()335- .context("too many pending handshakes")?;336- let (mut send, mut recv) = tokio::time::timeout(HANDSHAKE_TIMEOUT, connection.accept_bi())337- .await338- .context("timed out accepting media v2 handshake stream")?339- .context("accepting media v2 handshake stream")?;340- let hello = receive_hello(&mut recv, Capability::VideoH264).await?;341- drop(handshake_permit);342- debug!(%remote, agent = %hello.agent, "media v2 hello received");343-344- let grants = match authorization.authorize_or_redeem(345- remote,346- hello.invitation.as_deref(),347- unix_timestamp_now()?,348- ) {349- Ok(grants) => grants,350- Err(error) => {351- send_rejection(&mut send, "Portal peer is not authorized").await?;352- return Err(error.context("authorizing media v2 peer"));353- }354- };355- ensure!(356- grants.contains(InvitationGrants::VIEW),357- "authorized media peer lacks view permission"358- );359-360- let lease = match sessions.claim(remote, hello.nonce, grants) {361- Ok(lease) => lease,362- Err(error) => {363- send_rejection(&mut send, "host already has an active client").await?;364- return Err(error);365- }366- };367-368- let source = match config.source {369- VideoSource::TestPattern => Ok(spawn_test_pattern(config.clone(), lease.session_clock)),370- VideoSource::GamescopePipewire => {371- let primary = spawn_gamescope_pipewire_after_static_preflight(372- config.clone(),373- lease.session_clock,374- )375- .await?;376- select_gamescope_startup_source(config.clone(), lease.session_clock, primary).await377- }378- };379- let EncodedSource {380- frames: frame_receiver,381- current_gop: mut current_gop_receiver,382- task: source_task,383- pointer_surface_dimensions,384- encoder_control,385- } = match source {386- Ok(source) => source,387- Err(error) => {388- send_rejection(&mut send, "video source is unavailable").await?;389- return Err(error);390- }391- };392- let source_task = SourceTaskGuard::new(source_task);393- sessions.install_encoder_control(remote, lease.session_id, encoder_control.clone())?;394- let _encoder_control = encoder_control;395-396- let mut media_hello = HostHello::accepted(397- lease.session_id,398- negotiated_capabilities(&hello, MEDIA_CAPABILITIES),399- );400- if let Some(dimensions) = pointer_surface_dimensions {401- media_hello = media_hello.with_pointer_surface_dimensions(dimensions);402- }403- write_host_hello(&mut send, &media_hello).await?;404- send.finish()405- .context("finishing media v2 handshake response")?;406- drop(send);407- drop(recv);408- info!(%remote, session_id = lease.session_id, "media v2 client accepted");409-410- let session_result =411- run_media_v2_session(&connection, &config, &mut current_gop_receiver, remote).await;412-413- drop(current_gop_receiver);414- drop(frame_receiver);415- source_task.wait_or_abort(SOURCE_REAP_GRACE_TIMEOUT).await;416- drop(lease);417- info!(%remote, "media v2 client released");418- session_result419-}420-421-async fn run_media_v2_session(422- connection: &Connection,423- config: &HostConfig,424- current_gop_receiver: &mut tokio::sync::watch::Receiver<Option<EncodedGop>>,425- remote: EndpointId,426-) -> Result<()> {427- let maximum_replay_age = maximum_media_replay_age(config.framerate);428- let mut replay_cursor = MediaReplayCursor::default();429- let mut scheduler = MediaV2Scheduler::default();430- let mut send_tasks = tokio::task::JoinSet::new();431-432- let result = loop {433- tokio::select! {434- closed = connection.closed() => {435- debug!(%remote, ?closed, "media v2 connection closed");436- break Ok(());437- }438- task = send_tasks.join_next(), if !send_tasks.is_empty() => {439- match task.expect("guarded by non-empty send task set") {440- Ok((sequence, Ok(()))) => scheduler.complete(sequence),441- Ok((sequence, Err(error))) => {442- warn!(sequence, %error, "media v2 object send failed; waiting for keyframe");443- if !scheduler.fail(sequence).is_empty() {444- send_tasks.abort_all();445- }446- }447- Err(error) if error.is_cancelled() => {}448- Err(error) => {449- warn!(%error, "media v2 object task failed; waiting for keyframe");450- if !scheduler.fail_all().is_empty() {451- send_tasks.abort_all();452- }453- }454- }455- }456- changed = current_gop_receiver.changed() => {457- if let Err(error) = changed {458- break Err(error).context("encoded source stopped");459- }460- let Some(current_gop) = current_gop_receiver.borrow_and_update().clone() else {461- continue;462- };463- let initial_replay_started_at =464- replay_cursor.last_sequence.is_none().then(Instant::now);465- let replay_through_sequence = current_gop466- .frames467- .last()468- .map(|frame| frame.sequence)469- .context("current GOP snapshot is empty")?;470-471- for frame in new_current_gop_frames(current_gop, replay_cursor.last_sequence) {472- let replay_discontinuity = match replay_cursor.classify(473- &frame,474- replay_through_sequence,475- initial_replay_started_at,476- Instant::now(),477- maximum_replay_age,478- ) {479- MediaReplayDecision::Send { discontinuity } => discontinuity,480- MediaReplayDecision::SkipUntilKeyframe => continue,481- MediaReplayDecision::DiscardStaleSuffix { .. } => break,482- };483- let independently_decodable = frame.keyframe && frame.codec_config;484- let (scheduler_discontinuity, cancel_sequences) =485- match scheduler.schedule(frame.sequence, independently_decodable) {486- MediaV2ScheduleDecision::Send {487- discontinuity,488- cancel_sequences,489- } => (discontinuity, cancel_sequences),490- MediaV2ScheduleDecision::SkipUntilKeyframe => continue,491- MediaV2ScheduleDecision::EnterResync { cancel_sequences } => {492- if !cancel_sequences.is_empty() {493- send_tasks.abort_all();494- }495- continue;496- }497- };498- if !cancel_sequences.is_empty() {499- debug!(500- sequence = frame.sequence,501- ?cancel_sequences,502- "keyframe superseding media v2 objects"503- );504- send_tasks.abort_all();505- }506-507- let media_frame = media_frame_for_encoded(508- config,509- &frame,510- replay_discontinuity || scheduler_discontinuity,511- )?;512- let sequence = frame.sequence;513- let keyframe = independently_decodable;514- // Reserve stream IDs in encoded-frame order. Opening them515- // inside the concurrent writer tasks would let task516- // scheduling reorder the QUIC object sequence even though517- // each object carries a monotonic media sequence number.518- let stream = match tokio::time::timeout(519- MEDIA_WRITE_TIMEOUT,520- connection.open_uni(),521- )522- .await523- {524- Ok(Ok(stream)) => stream,525- Ok(Err(error)) => {526- warn!(sequence, %error, "opening media v2 object stream failed");527- if !scheduler.fail(sequence).is_empty() {528- send_tasks.abort_all();529- }530- continue;531- }532- Err(_) => {533- warn!(sequence, "opening media v2 object stream timed out");534- if !scheduler.fail(sequence).is_empty() {535- send_tasks.abort_all();536- }537- continue;538- }539- };540- let stream = ResetOnDropSendStream::new(stream);541- if let Err(error) = stream542- .stream()543- .set_priority(media_v2_priority(keyframe))544- {545- warn!(sequence, %error, "setting media v2 object priority failed");546- if !scheduler.fail(sequence).is_empty() {547- send_tasks.abort_all();548- }549- continue;550- }551- send_tasks.spawn(async move {552- (553- sequence,554- send_media_v2_object(stream, media_frame).await,555- )556- });557- replay_cursor.commit_sent(&frame);558- }559- }560- }561- };562-563- send_tasks.abort_all();564- while send_tasks.join_next().await.is_some() {}565- result566-}567-568-pub(super) fn media_frame_for_encoded(569- _config: &HostConfig,570- frame: &EncodedFrame,571- discontinuity: bool,572-) -> Result<MediaFrame> {573- let mut flags = FrameFlags::NONE;574- if frame.keyframe {575- flags = flags.union(FrameFlags::KEYFRAME);576- }577- if frame.codec_config {578- flags = flags.union(FrameFlags::CODEC_CONFIG);579- }580- if discontinuity || frame.discontinuity {581- flags = flags.union(FrameFlags::DISCONTINUITY);582- }583- let header = MediaFrameHeader::h264(584- frame.width,585- frame.height,586- frame.data.len(),587- frame.sequence,588- frame.capture_timestamp_micros,589- frame.presentation_timestamp_micros,590- flags,591- )?;592- MediaFrame::new(header, frame.data.as_ref().to_vec()).map_err(Into::into)593-}594-595-async fn send_media_v2_object(mut stream: ResetOnDropSendStream, frame: MediaFrame) -> Result<()> {596- tokio::time::timeout(597- MEDIA_WRITE_TIMEOUT,598- write_media_frame(stream.stream_mut(), &frame),599- )600- .await601- .context("timed out writing media v2 object")??;602- stream603- .stream_mut()604- .finish()605- .context("finishing media v2 object stream")?;606- match tokio::time::timeout(MEDIA_V2_PEER_ACK_TIMEOUT, stream.stream().stopped())607- .await608- .context("timed out waiting for media v2 object acknowledgement")?609- .context("waiting for media v2 object acknowledgement")?610- {611- None => {612- stream.disarm();613- Ok(())614- }615- Some(code) => bail!("peer stopped media v2 object stream with code {code}"),616- }617-}618-619-fn media_v2_priority(keyframe: bool) -> i32 {620- if keyframe {621- MEDIA_V2_KEYFRAME_PRIORITY622- } else {623- MEDIA_V2_DELTA_PRIORITY624- }625-}626-627-#[cfg(test)]628-mod tests {629- use super::super::{DropNotify, endpoint};630- use super::*;631-632- #[test]633- fn media_v2_scheduler_is_bounded_and_drops_a_saturated_delta_suffix() {634- let mut scheduler = MediaV2Scheduler::default();635- assert_eq!(636- scheduler.schedule(10, true),637- MediaV2ScheduleDecision::Send {638- discontinuity: false,639- cancel_sequences: vec![],640- }641- );642- for sequence in 11..14 {643- assert!(matches!(644- scheduler.schedule(sequence, false),645- MediaV2ScheduleDecision::Send { .. }646- ));647- }648- assert_eq!(scheduler.in_flight.len(), MEDIA_V2_IN_FLIGHT_CAPACITY);649-650- assert_eq!(651- scheduler.schedule(14, false),652- MediaV2ScheduleDecision::EnterResync {653- cancel_sequences: vec![10, 11, 12, 13],654- }655- );656- assert!(scheduler.in_flight.is_empty());657- assert_eq!(658- scheduler.schedule(15, false),659- MediaV2ScheduleDecision::SkipUntilKeyframe660- );661- }662-663- #[test]664- fn blocked_old_streams_cannot_prevent_a_keyframe_decision() {665- let mut scheduler = MediaV2Scheduler::default();666- for sequence in 20..24 {667- assert!(matches!(668- scheduler.schedule(sequence, sequence == 20),669- MediaV2ScheduleDecision::Send { .. }670- ));671- }672- assert_eq!(scheduler.in_flight.len(), MEDIA_V2_IN_FLIGHT_CAPACITY);673-674- assert_eq!(675- scheduler.schedule(30, true),676- MediaV2ScheduleDecision::Send {677- discontinuity: true,678- cancel_sequences: vec![20, 21, 22, 23],679- }680- );681- assert_eq!(scheduler.in_flight, vec![30]);682- }683-684- #[test]685- fn media_v2_send_failure_cancels_dependents_and_resyncs_on_keyframe() {686- let mut scheduler = MediaV2Scheduler::default();687- assert!(matches!(688- scheduler.schedule(40, true),689- MediaV2ScheduleDecision::Send { .. }690- ));691- assert!(matches!(692- scheduler.schedule(41, false),693- MediaV2ScheduleDecision::Send { .. }694- ));695-696- assert_eq!(scheduler.fail(40), vec![41]);697- assert_eq!(698- scheduler.schedule(42, false),699- MediaV2ScheduleDecision::SkipUntilKeyframe700- );701- assert_eq!(702- scheduler.schedule(50, true),703- MediaV2ScheduleDecision::Send {704- discontinuity: true,705- cancel_sequences: vec![],706- }707- );708- scheduler.complete(50);709- assert!(scheduler.in_flight.is_empty());710- assert!(matches!(711- scheduler.schedule(51, false),712- MediaV2ScheduleDecision::Send {713- discontinuity: false,714- ..715- }716- ));717- }718-719- #[test]720- fn cancelled_old_completion_cannot_poison_the_replacement_gop() {721- let mut scheduler = MediaV2Scheduler::default();722- assert!(matches!(723- scheduler.schedule(60, true),724- MediaV2ScheduleDecision::Send { .. }725- ));726- assert!(matches!(727- scheduler.schedule(61, false),728- MediaV2ScheduleDecision::Send { .. }729- ));730- assert!(matches!(731- scheduler.schedule(70, true),732- MediaV2ScheduleDecision::Send {733- discontinuity: true,734- ..735- }736- ));737-738- assert!(scheduler.fail(60).is_empty());739- assert!(matches!(740- scheduler.schedule(71, false),741- MediaV2ScheduleDecision::Send {742- discontinuity: false,743- ..744- }745- ));746- }747-748- #[test]749- fn media_v2_keyframes_have_strictly_higher_transport_priority() {750- assert!(media_v2_priority(true) > media_v2_priority(false));751- }752-753- #[tokio::test]754- async fn no_frame_peer_drop_reaps_source_and_allows_reconnect() {755- let sessions = Arc::new(SessionRegistry::default());756- let remote = endpoint(1);757- let nonce = [7; 16];758- let media = sessions759- .claim(remote, nonce, InvitationGrants::ALL)760- .unwrap();761- let input = sessions.claim_input(remote, nonce).unwrap();762- let session_id = media.session_id;763-764- let input_sessions = Arc::clone(&sessions);765- let input_shutdown = tokio::spawn(async move {766- loop {767- let notified = input_sessions.session_changed.notified();768- if !input_sessions.is_active(remote, session_id) {769- break;770- }771- notified.await;772- }773- drop(input);774- });775-776- let (_frame_sender, mut frame_receiver) =777- tokio::sync::watch::channel(Option::<EncodedFrame>::None);778- let (source_started_tx, source_started_rx) = tokio::sync::oneshot::channel();779- let (source_reaped_tx, source_reaped_rx) = tokio::sync::oneshot::channel();780- let source_task = tokio::spawn(async move {781- let _notify = DropNotify(Some(source_reaped_tx));782- let _ = source_started_tx.send(());783- std::future::pending::<Result<()>>().await784- });785- let source_task = SourceTaskGuard::new(source_task);786- source_started_rx.await.unwrap();787-788- let (peer_alive, peer_disconnected) = tokio::sync::oneshot::channel::<()>();789- let disconnected = async move {790- let _ = peer_disconnected.await;791- };792- tokio::pin!(disconnected);793- drop(peer_alive);794-795- let activity = tokio::time::timeout(796- Duration::from_millis(100),797- wait_for_media_activity(&mut frame_receiver, disconnected.as_mut()),798- )799- .await800- .expect("no-frame media loop ignored peer disconnect")801- .unwrap();802- assert_eq!(activity, MediaActivity::PeerDisconnected);803-804- source_task.abort_and_wait().await;805- tokio::time::timeout(Duration::from_millis(100), source_reaped_rx)806- .await807- .expect("source task was not reaped after peer disconnect")808- .unwrap();809- drop(media);810- tokio::time::timeout(Duration::from_millis(100), input_shutdown)811- .await812- .expect("input lease did not observe media shutdown")813- .unwrap();814-815- assert!(816- sessions817- .claim(endpoint(2), [8; 16], InvitationGrants::ALL)818- .is_ok()819- );820- }821-}
M crates/sigil-host/src/server/media_v3.rs +1 -1

Not shown: this page already shows 6000 lines of diff.

M crates/sigil-host/src/server/mod.rs +2 -7

Not shown: this page already shows 6000 lines of diff.

M crates/sigil-host/src/server/moq.rs +27 -0

Not shown: this page already shows 6000 lines of diff.

M crates/sigil-protocol/Cargo.toml +1 -0

Not shown: this page already shows 6000 lines of diff.

M crates/sigil-protocol/src/lib.rs +4 -21

Not shown: this page already shows 6000 lines of diff.

M crates/sigil-protocol/src/media.rs +8 -146

Not shown: this page already shows 6000 lines of diff.

M crates/sigil-protocol/src/media_v3.rs +12 -3

Not shown: this page already shows 6000 lines of diff.

M crates/sigil-protocol/src/moq_catalog.rs +44 -0

Not shown: this page already shows 6000 lines of diff.

M decky/main.py +10 -1

Not shown: this page already shows 6000 lines of diff.

M decky/py_modules/goq_sigil/controller.py +45 -3

Not shown: this page already shows 6000 lines of diff.

M decky/py_modules/goq_sigil/errors.py +9 -1

Not shown: this page already shows 6000 lines of diff.

M decky/tests/backend/test_controller.py +35 -0

Not shown: this page already shows 6000 lines of diff.

M portal/audio-ui.mjs +5 -5

Not shown: this page already shows 6000 lines of diff.

M portal/audio-ui.test.mjs +6 -6

Not shown: this page already shows 6000 lines of diff.

M portal/connection-state.mjs +1 -3

Not shown: this page already shows 6000 lines of diff.

M portal/connection-state.test.mjs +0 -3

Not shown: this page already shows 6000 lines of diff.

M portal/enrollment-ui.test.mjs +0 -7

Not shown: this page already shows 6000 lines of diff.

M portal/frame-envelope.mjs +4 -2

Not shown: this page already shows 6000 lines of diff.

M portal/frame-envelope.test.mjs +13 -1

Not shown: this page already shows 6000 lines of diff.

M portal/frame-session.mjs +1 -37

Not shown: this page already shows 6000 lines of diff.

M portal/frame-session.test.mjs +0 -27

Not shown: this page already shows 6000 lines of diff.

M portal/index.html +1 -2

Not shown: this page already shows 6000 lines of diff.

M portal/input-state.mjs +14 -0

Not shown: this page already shows 6000 lines of diff.

M portal/input-state.test.mjs +20 -0

Not shown: this page already shows 6000 lines of diff.

M portal/main.js +19 -26

Not shown: this page already shows 6000 lines of diff.

M portal/mouse-input-wiring.test.mjs +16 -0

Not shown: this page already shows 6000 lines of diff.

A portal/runtime-files.txt +35 -0

Not shown: this page already shows 6000 lines of diff.

M portal/session-controls-layout.test.mjs +3 -1

Not shown: this page already shows 6000 lines of diff.

M portal/stream-runtime.mjs +0 -24

Not shown: this page already shows 6000 lines of diff.

M portal/stream-runtime.test.mjs +5 -22

Not shown: this page already shows 6000 lines of diff.

M portal/style.css +5 -5

Not shown: this page already shows 6000 lines of diff.

M portal/video-capability.mjs +1 -4

Not shown: this page already shows 6000 lines of diff.

M portal/video-capability.test.mjs +4 -7

Not shown: this page already shows 6000 lines of diff.

M portal/video-pipeline.mjs +77 -220

Not shown: this page already shows 6000 lines of diff.

M portal/video-pipeline.test.mjs +1 -229

Not shown: this page already shows 6000 lines of diff.

M scripts/loopback-proof.sh +19 -71

Not shown: this page already shows 6000 lines of diff.

M scripts/tests/compatibility-identifiers.sh +6 -6

Not shown: this page already shows 6000 lines of diff.

A scripts/tests/portal-runtime-payload.sh +75 -0

Not shown: this page already shows 6000 lines of diff.

M scripts/verify-demo-build.sh +0 -16

Not shown: this page already shows 6000 lines of diff.

M src-tauri/Cargo.toml +0 -3

Not shown: this page already shows 6000 lines of diff.

M src-tauri/build.rs +101 -0

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/commands/network.rs +1 -4

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/commands/state.rs +10 -116

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/media/audio_delivery.rs +2 -4

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/media/connect.rs +51 -98

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/media/frame_channel.rs +28 -48

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/media/input_delivery.rs +27 -79

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/media/moq_catalog.rs +5 -34

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/media/object_receiver.rs +4 -406

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/media/transport.rs +28 -143

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/media/video_delivery.rs +35 -297

Not shown: this page already shows 6000 lines of diff.

M src-tauri/tauri.conf.json +1 -1

Not shown: this page already shows 6000 lines of diff.