Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions dist/60-moonshine.rules
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", TAG+="uaccess", GROUP="input", MODE="0660"
KERNEL=="uhid", TAG+="uaccess", GROUP="input", MODE="0660"
SUBSYSTEM=="hidraw", KERNELS=="uhid", TAG+="uaccess", GROUP="input", MODE="0660"
SUBSYSTEMS=="input", ATTRS{name}=="Moonshine *", TAG+="uaccess", GROUP="input", MODE="0660"
SUBSYSTEM=="hidraw", KERNELS=="uhid", ATTRS{name}=="Moonshine *", ENV{ID_SEAT}="seat9", MODE="0666"
SUBSYSTEMS=="input", ATTRS{name}=="Moonshine *", ENV{ID_SEAT}="seat9", MODE="0666"
79 changes: 49 additions & 30 deletions moonshine-core/src/crypto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,44 +3,63 @@ use aes::cipher::BlockModeEncrypt;
use aes::cipher::KeyIvInit;
use aes_gcm::{
Aes128Gcm, Key, Nonce,
aead::{Aead, KeyInit},
aead::{AeadInPlace, KeyInit, generic_array::GenericArray},
};
use inout::block_padding::Pkcs7;

pub(crate) fn encrypt(plaintext: &[u8], key: &[u8], iv: &[u8], tag: &mut [u8]) -> Result<Vec<u8>, aes_gcm::Error> {
let key = Key::<Aes128Gcm>::from_slice(key);
let nonce = Nonce::from_slice(iv);
let cipher = Aes128Gcm::new(key);

// In OpenSSL, encrypting with GCM returns ciphertext usually without tag appended if you use `tag()` to retrieve it separate.
// aes-gcm crate append tag to ciphertext.
let mut ciphertext = cipher.encrypt(nonce, plaintext)?;
/// An AES-128-GCM cipher cached across calls, keyed by the control stream's
/// input key.
///
/// The control stream encrypts feedback and decrypts input using
/// `remote_input_key`, which only changes on key rotation (tracked by
/// `remote_input_key_id`). Rebuilding the cipher — a full AES key schedule plus
/// GHASH table — on every packet showed up on the per-input-event path, so we
/// cache it and only rebuild when the key id changes.
pub(crate) struct GcmCipher {
cipher: Option<Aes128Gcm>,
key_id: i64,
}

// Split tag from ciphertext
let tag_len = 16;
let len = ciphertext.len();
if len < tag_len {
return Err(aes_gcm::Error);
impl GcmCipher {
pub fn new() -> Self {
Self {
cipher: None,
key_id: i64::MIN,
}
}
let actual_ciphertext_len = len - tag_len;

tag.copy_from_slice(&ciphertext[actual_ciphertext_len..]);
ciphertext.truncate(actual_ciphertext_len);

Ok(ciphertext)
}

pub(crate) fn decrypt(ciphertext: &[u8], key: &[u8], iv: &[u8], tag: &[u8]) -> Result<Vec<u8>, aes_gcm::Error> {
let key = Key::<Aes128Gcm>::from_slice(key);
let nonce = Nonce::from_slice(iv);
let cipher = Aes128Gcm::new(key);
/// Return the cached cipher, rebuilding it if the key has rotated.
fn get(&mut self, key: &[u8], key_id: i64) -> Result<&Aes128Gcm, ()> {
if self.cipher.is_none() || self.key_id != key_id {
if key.len() != 16 {
tracing::warn!("Control key must be 16 bytes, got {}.", key.len());
self.cipher = None;
return Err(());
}
self.cipher = Some(Aes128Gcm::new(Key::<Aes128Gcm>::from_slice(key)));
self.key_id = key_id;
}
self.cipher.as_ref().ok_or(())
}

// Append tag to ciphertext for aes-gcm crate
let mut payload = Vec::with_capacity(ciphertext.len() + tag.len());
payload.extend_from_slice(ciphertext);
payload.extend_from_slice(tag);
/// Encrypt `buffer` in place, returning the detached 16-byte tag.
pub fn encrypt(&mut self, key: &[u8], key_id: i64, iv: &[u8], buffer: &mut [u8]) -> Result<[u8; 16], ()> {
let cipher = self.get(key, key_id)?;
let tag = cipher
.encrypt_in_place_detached(Nonce::from_slice(iv), b"", buffer)
.map_err(|e| tracing::warn!("Failed to encrypt control data: {e}"))?;
let mut out = [0u8; 16];
out.copy_from_slice(&tag);
Ok(out)
}

cipher.decrypt(nonce, payload.as_ref())
/// Decrypt `buffer` in place using the detached 16-byte tag.
pub fn decrypt(&mut self, key: &[u8], key_id: i64, iv: &[u8], tag: &[u8; 16], buffer: &mut [u8]) -> Result<(), ()> {
let cipher = self.get(key, key_id)?;
cipher
.decrypt_in_place_detached(Nonce::from_slice(iv), b"", buffer, GenericArray::from_slice(tag))
.map_err(|e| tracing::warn!("Failed to decrypt control message: {e}"))
}
}

pub(crate) fn encrypt_cbc(data: &[u8], key: &[u8], iv: &[u8]) -> Result<Vec<u8>, String> {
Expand Down
28 changes: 28 additions & 0 deletions moonshine-core/src/session/compositor/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,34 @@ impl MoonshineCompositor {
self.clear_dropdowns();
}

// Raise the focused window to the top of the stack.
//
// Stacking order is otherwise fixed at map time and never revisited,
// so focus and z-order can disagree indefinitely: a window mapped
// earlier keeps covering the focused one. Launching a game from Steam
// Big Picture hits this — Big Picture stays mapped and on top, and
// blanks its own surface while the game starts, so the composited
// output is black even though the game renders correctly underneath.
//
// `activate: false` reorders the stack without touching activation
// state, preserving the `_NET_WM_STATE_FOCUSED` handling that Wine
// depends on for input delivery (see `mapped_override_redirect_window`).
self.space.raise_element(best, false);

// Overlays and dropdowns belong above the focused window — gamescope
// layers base < overlay < external overlay. Re-raise them so the line
// above cannot bury the Steam overlay behind the game.
for layer in [
self.overlay_window.clone(),
self.external_overlay_window.clone(),
self.override_window.clone(),
]
.into_iter()
.flatten()
{
self.space.raise_element(&layer, false);
}

// Find the overlay window with input_focus_mode != 0.
// Gamescope: looks for overlayWindow with inputFocusMode set.
let overlay_with_input_focus: Option<Window> = self.overlay_window.as_ref().and_then(|w| {
Expand Down
12 changes: 6 additions & 6 deletions moonshine-core/src/session/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -371,14 +371,14 @@ impl SessionManager {
// Resume (reconnect): the streams are already running, so PLAY is a
// no-op — the client picks up the existing streams once it PINGs.
// The reconnecting client is a fresh Moonlight session that expects
// frame numbers to start at 1, so reset the video frame counters and
// force an IDR; otherwise it sees the running counter as a huge frame
// gap and reports a poor connection.
// frame numbers to start at 1, so arm a video stream reset (frame-counter
// reset + forced IDR); otherwise it sees the running counter as a huge
// frame gap and reports a poor connection. The reset fires once the packet
// handler re-learns the client's (usually new) address from its first PING,
// so the forced IDR isn't sent to the stale previous address.
active.reset_video_stream();
guard.session = Some(SessionState::Active(active));
tracing::info!(
"Resuming active session: resetting video frame counter and treating PLAY as no-op."
);
tracing::info!("Resuming active session: arming video stream reset and treating PLAY as no-op.");
return Ok(());
},
None => {
Expand Down
4 changes: 3 additions & 1 deletion moonshine-core/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,9 @@ impl ActiveSession {
&self.context
}

/// Reset the video stream's frame counters and force an IDR for a resuming client.
/// Arm a video stream reset (frame-counter reset + forced IDR) for a resuming client.
/// The reset fires once the packet handler re-learns the client's address from its
/// first PING; see [`VideoStreamHandle::request_reset`].
pub(crate) fn reset_video_stream(&self) {
self.video_handle.request_reset();
}
Expand Down
56 changes: 43 additions & 13 deletions moonshine-core/src/session/stream/control/input/gamepad.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,15 +144,22 @@ impl GamepadInfo {
}
}

// Moonlight touch lifecycle event types (LI_TOUCH_EVENT_*).
const TOUCH_EVENT_DOWN: u8 = 0x01;
const TOUCH_EVENT_UP: u8 = 0x02;
const TOUCH_EVENT_MOVE: u8 = 0x03;
const TOUCH_EVENT_CANCEL: u8 = 0x04;
const TOUCH_EVENT_CANCEL_ALL: u8 = 0x07;

#[derive(Debug)]
pub(crate) struct GamepadTouch {
pub index: u8,
_event_type: u8,
event_type: u8,
// zero: [u8; 2], // Alignment/reserved
pointer_id: u32,
pub x: f32,
pub y: f32,
pub pressure: f32,
_pressure: f32,
}

impl GamepadTouch {
Expand All @@ -177,12 +184,12 @@ impl GamepadTouch {

Ok(Self {
index: buffer[0],
_event_type: buffer[1],
event_type: buffer[1],
// zero: u16::from_le_bytes(buffer[2..4].try_into().unwrap()),
pointer_id: u32::from_le_bytes(buffer[4..8].try_into().unwrap()),
x: f32::from_le_bytes(buffer[8..12].try_into().unwrap()).clamp(0.0, 1.0),
y: f32::from_le_bytes(buffer[12..16].try_into().unwrap()).clamp(0.0, 1.0),
pressure: f32::from_le_bytes(buffer[16..20].try_into().unwrap()).clamp(0.0, 1.0),
_pressure: f32::from_le_bytes(buffer[16..20].try_into().unwrap()).clamp(0.0, 1.0),
})
}
}
Expand Down Expand Up @@ -344,6 +351,10 @@ pub(crate) struct Gamepad {
/// The underlying inputtino joypad, used to inject button presses, stick
/// positions, triggers, touchpad events, and motion data.
gamepad: inputtino::Joypad,

/// Active touchpad pointer ids, tracked so CancelAll can release them.
/// DualSense reports at most two concurrent touch points, so a Vec stays tiny.
touch_points: Vec<u32>,
}

impl Gamepad {
Expand Down Expand Up @@ -461,7 +472,10 @@ impl Gamepad {
}
});

Ok(Self { gamepad })
Ok(Self {
gamepad,
touch_points: Vec::new(),
})
}

/// Apply button flags to the gamepad.
Expand All @@ -482,14 +496,30 @@ impl Gamepad {

pub fn touch(&mut self, touch: &GamepadTouch) {
if let Joypad::PS5(gamepad) = &self.gamepad {
if touch.pressure > 0.5 {
gamepad.place_finger(
touch.pointer_id,
(touch.x * PS5Joypad::TOUCHPAD_WIDTH as f32) as u16,
(touch.y * PS5Joypad::TOUCHPAD_HEIGHT as f32) as u16,
);
} else {
gamepad.release_finger(touch.pointer_id);
// Drive the touchpad from Moonlight's explicit touch lifecycle event
// rather than inferring up/down from pressure, which clients don't
// reliably populate (the DualSense touchpad has no pressure sensor).
match touch.event_type {
TOUCH_EVENT_DOWN | TOUCH_EVENT_MOVE => {
gamepad.place_finger(
touch.pointer_id,
(touch.x * PS5Joypad::TOUCHPAD_WIDTH as f32) as u16,
(touch.y * PS5Joypad::TOUCHPAD_HEIGHT as f32) as u16,
);
if !self.touch_points.contains(&touch.pointer_id) {
self.touch_points.push(touch.pointer_id);
}
},
TOUCH_EVENT_UP | TOUCH_EVENT_CANCEL => {
gamepad.release_finger(touch.pointer_id);
self.touch_points.retain(|id| *id != touch.pointer_id);
},
TOUCH_EVENT_CANCEL_ALL => {
for pointer_id in self.touch_points.drain(..) {
gamepad.release_finger(pointer_id);
}
},
_ => {},
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion moonshine-core/src/session/stream/control/input/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,9 @@ impl InputHandler {
stop_session_manager: ShutdownManager<SessionShutdownReason>,
gamepad_config: GamepadConfig,
) -> Result<Self, ()> {
let (gamepad_tx, gamepad_rx) = mpsc::channel(10);
// Sized generously: the control loop awaits sends into this channel, so a
// full channel stalls all input processing and ENet servicing.
let (gamepad_tx, gamepad_rx) = mpsc::channel(64);

std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
Expand Down
Loading