feat(nescope): open the compositor

A headless Wayland compositor for a single fullscreen client, and the second
component into this repo. Imported as a tree from `nestrilabs/nescope` for the
same reason as the last one: the upstream repo is private, its history has never
been reviewed for publication, and a squash is what keeps that history from
becoming permanent here.

Wired to the workspace — versions from the root, `nesprotocol` by path instead
of a sibling directory. 8 tests pass.

It knows a lot about Steam, and all of it stays. `steam_app_*` window classes,
a launcher that exits before the game it started, a client that shows a login
screen with no Vulkan frames in it: that is third-party behaviour a compositor
for games has to handle, and describing it reveals nothing about how we are put
together. The rule is about topology, not vocabulary.

Two comments still name the transport by its old name and are left for the
commit that renames it, so that rename reads as one change rather than as
noise spread across four imports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wanjohi
2026-08-26 18:01:41 +03:00
parent 938f5e6544
commit 37dd985810
21 changed files with 6744 additions and 0 deletions

394
apps/nescope/src/input.rs Normal file
View File

@@ -0,0 +1,394 @@
//! Programmatic input injection into the Smithay seat.
//!
//! Unlike the proxy version, nescope does **not** receive input from a host
//! compositor. Instead callers (e.g. a streaming server, a test harness, or
//! a control socket handler) send [`InputEvent`]s over a
//! [`calloop::channel::Channel`] that is registered in the event loop.
//!
//! # Usage
//!
//! ```ignore
//! // Obtained when calling NescopeState::new()
//! let input_tx: calloop::channel::Sender<InputEvent> = ...;
//!
//! // From any thread:
//! input_tx.send(InputEvent::KeyDown { keycode: 28 }).unwrap(); // evdev KEY_ENTER
//! ```
//!
//! Keycodes follow the Linux evdev convention (the same as used in
//! Moonshine's `CompositorInputEvent`). The compositor adds 8 to convert
//! them to X11/xkbcommon keycodes before passing them to Smithay.
#![allow(dead_code)]
use smithay::backend::input::{Axis, AxisSource, ButtonState, KeyState};
use smithay::input::keyboard::{FilterResult, Keycode};
use smithay::input::pointer::{AxisFrame, ButtonEvent, MotionEvent, RelativeMotionEvent};
use smithay::utils::{Logical, Point, SERIAL_COUNTER};
use smithay::wayland::pointer_constraints::{PointerConstraint, with_pointer_constraint};
use smithay::wayland::seat::WaylandFocus;
use nesprotocol::input::{self as nestri_input, DecodedInput};
use crate::focus::KeyboardFocusTarget;
use crate::state::NescopeState;
// ── Wire protocol constants (mirrors nestri-protocol/src/input.rs) ──
const WIRE_INPUT_KEY: u8 = 0;
const WIRE_INPUT_MOUSE_MOVE: u8 = 1;
const WIRE_INPUT_MOUSE_BUTTON: u8 = 2;
const WIRE_INPUT_MOUSE_WHEEL: u8 = 3;
const WIRE_KEY_DOWN: u8 = 1;
const WIRE_BTN_LEFT: u32 = 0x110; // BTN_LEFT
const WIRE_BTN_MIDDLE: u32 = 0x112; // BTN_MIDDLE
const WIRE_BTN_RIGHT: u32 = 0x111; // BTN_RIGHT
// ---------------------------------------------------------------------------
// Public event type
// ---------------------------------------------------------------------------
/// Events that can be injected into the compositor seat programmatically.
///
/// All coordinates are in logical (output-space) pixels.
/// Keycodes are Linux evdev keycodes (NOT X11 keycodes).
#[derive(Debug, Clone)]
pub enum InputEvent {
// ── Keyboard ─────────────────────────────────────────────────────────
/// Key pressed. `keycode` is a Linux evdev keycode (8 will be added
/// internally to produce an X11 keycode).
KeyDown { keycode: u32 },
/// Key released.
KeyUp { keycode: u32 },
// ── Pointer — absolute ───────────────────────────────────────────────
/// Absolute pointer position. Coordinates are in the Moonlight client's
/// coordinate space; they are scaled to the compositor output.
MouseMoveAbsolute {
x: f64,
y: f64,
/// Client screen width used for coordinate mapping.
screen_width: f64,
/// Client screen height used for coordinate mapping.
screen_height: f64,
},
// ── Pointer — relative ───────────────────────────────────────────────
/// Relative pointer delta in logical pixels.
MouseMoveRelative { dx: f64, dy: f64 },
// ── Pointer — buttons ────────────────────────────────────────────────
/// Mouse button pressed. `button` is a Linux button code
/// (e.g. `BTN_LEFT = 0x110`).
MouseButtonDown { button: u32 },
/// Mouse button released.
MouseButtonUp { button: u32 },
// ── Pointer — scroll ─────────────────────────────────────────────────
/// Vertical scroll. Positive = up, negative = down.
ScrollVertical { amount: f64 },
/// Horizontal scroll. Positive = right, negative = left.
ScrollHorizontal { amount: f64 },
}
// ---------------------------------------------------------------------------
// Injection
// ---------------------------------------------------------------------------
/// Process a single [`InputEvent`] injected from outside the compositor.
///
/// Called from the calloop idle callback after draining the input channel.
pub fn process_input(event: InputEvent, state: &mut NescopeState) {
let serial = SERIAL_COUNTER.next_serial();
let time = state.clock.now().as_millis();
// Track pointer activity for cursor inactivity timer.
match event {
InputEvent::KeyDown { .. } | InputEvent::KeyUp { .. } => {}
_ => state.last_pointer_activity = std::time::Instant::now(),
}
// One-time X11 focus reset when the gamescope WSI surface is active.
if state.override_surface.is_some() && state.x11_focus_needs_reset {
state.sync_x11_focus();
}
match event {
InputEvent::KeyDown { keycode } => {
if let Some(kb) = state.seat.get_keyboard() {
// Auto-focus the topmost window if nothing has focus yet.
if kb.current_focus().is_none() {
state.determine_and_apply_focus();
}
// evdev → X11/xkbcommon keycode: add 8.
kb.input::<(), _>(
state,
Keycode::from(keycode + 8),
KeyState::Pressed,
serial,
time,
|_, _, _| FilterResult::Forward,
);
}
}
InputEvent::KeyUp { keycode } => {
if let Some(kb) = state.seat.get_keyboard() {
if kb.current_focus().is_none() {
state.determine_and_apply_focus();
}
kb.input::<(), _>(
state,
Keycode::from(keycode + 8),
KeyState::Released,
serial,
time,
|_, _, _| FilterResult::Forward,
);
}
}
InputEvent::MouseMoveAbsolute {
x,
y,
screen_width,
screen_height,
} => {
let output_size = state
.output
.current_mode()
.map(|m| m.size)
.unwrap_or((state.width as i32, state.height as i32).into());
let new_x = if screen_width > 0.0 {
x / screen_width * output_size.w as f64
} else {
x
};
let new_y = if screen_height > 0.0 {
y / screen_height * output_size.h as f64
} else {
y
};
state.cursor_position = Point::from((new_x, new_y));
clamp_cursor(state);
let under = surface_under(state);
let pointer = state.seat.get_pointer().unwrap();
pointer.motion(
state,
under,
&MotionEvent {
location: state.cursor_position,
serial,
time,
},
);
pointer.frame(state);
}
InputEvent::MouseMoveRelative { dx, dy } => {
if !state.cursor_initialized {
let size = state
.output
.current_mode()
.map(|m| m.size)
.unwrap_or((state.width as i32, state.height as i32).into());
state.cursor_position = Point::from((size.w as f64 / 2.0, size.h as f64 / 2.0));
state.cursor_initialized = true;
tracing::debug!("cursor initialized to center: {:?}", state.cursor_position);
}
let delta = Point::from((dx, dy));
let pointer = state.seat.get_pointer().unwrap();
// Check for a pointer lock constraint.
let mut locked = false;
let under = surface_under(state);
if let Some((ref target, _)) = under {
if let Some(surf) = target.wl_surface() {
with_pointer_constraint(&surf, &pointer, |c| {
if let Some(c) = c {
if c.is_active() {
if let PointerConstraint::Locked(_) = &*c {
locked = true;
}
}
}
});
}
}
pointer.relative_motion(
state,
under.clone(),
&RelativeMotionEvent {
delta,
delta_unaccel: delta,
utime: time as u64,
},
);
state.cursor_position += delta;
clamp_cursor(state);
if locked {
pointer.frame(state);
return;
}
pointer.motion(
state,
under.clone(),
&MotionEvent {
location: state.cursor_position,
serial,
time,
},
);
pointer.frame(state);
}
InputEvent::MouseButtonDown { button } => {
let pointer = state.seat.get_pointer().unwrap();
pointer.button(
state,
&ButtonEvent {
serial,
time,
button,
state: ButtonState::Pressed,
},
);
pointer.frame(state);
}
InputEvent::MouseButtonUp { button } => {
let pointer = state.seat.get_pointer().unwrap();
pointer.button(
state,
&ButtonEvent {
serial,
time,
button,
state: ButtonState::Released,
},
);
pointer.frame(state);
}
InputEvent::ScrollVertical { amount } => {
let pointer = state.seat.get_pointer().unwrap();
pointer.axis(
state,
AxisFrame::new(time)
.source(AxisSource::Wheel)
.value(Axis::Vertical, -amount),
);
pointer.frame(state);
}
InputEvent::ScrollHorizontal { amount } => {
let pointer = state.seat.get_pointer().unwrap();
pointer.axis(
state,
AxisFrame::new(time)
.source(AxisSource::Wheel)
.value(Axis::Horizontal, amount),
);
pointer.frame(state);
}
}
}
// ---------------------------------------------------------------------------
// Wire-protocol decoder
// ---------------------------------------------------------------------------
/// Decode a single input event from the nestri guest-hub wire protocol
/// and convert it to an [`InputEvent`] for the compositor seat.
///
/// Uses the shared [`nesprotocol::input::decode_input_event`] and maps:
/// - Button 0 → `BTN_LEFT` (0x110), 1 → `BTN_MIDDLE` (0x112), 2 → `BTN_RIGHT` (0x111)
/// - Mouse wheel `dy` → `ScrollVertical`
///
/// Returns `None` if the buffer cannot be decoded.
pub fn decode_wire_event(data: &[u8]) -> Option<InputEvent> {
match nestri_input::decode_input_event(data)? {
DecodedInput::Key { down, keycode } => Some(if down {
InputEvent::KeyDown {
keycode: keycode as u32,
}
} else {
InputEvent::KeyUp {
keycode: keycode as u32,
}
}),
DecodedInput::MouseMove { dx, dy } => Some(InputEvent::MouseMoveRelative {
dx: dx as f64,
dy: dy as f64,
}),
DecodedInput::MouseButton { button, down } => {
let btn = match button {
0 => 0x110, // BTN_LEFT
1 => 0x112, // BTN_MIDDLE
2 => 0x111, // BTN_RIGHT
_ => return None,
};
Some(if down {
InputEvent::MouseButtonDown { button: btn }
} else {
InputEvent::MouseButtonUp { button: btn }
})
}
DecodedInput::MouseWheel { dx: _dx, dy } => {
Some(InputEvent::ScrollVertical { amount: dy as f64 })
}
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Clamp the cursor to the output bounds.
fn clamp_cursor(state: &mut NescopeState) {
let size = state
.output
.current_mode()
.map(|m| m.size)
.unwrap_or((state.width as i32, state.height as i32).into());
state.cursor_position.x = state.cursor_position.x.clamp(0.0, (size.w - 1) as f64);
state.cursor_position.y = state.cursor_position.y.clamp(0.0, (size.h - 1) as f64);
}
/// Find the focused target under the current cursor position.
pub fn surface_under(state: &NescopeState) -> Option<(KeyboardFocusTarget, Point<f64, Logical>)> {
if state.override_surface.is_some() {
if let Some(wid) = state.focused_x11_window {
for window in state.space.elements() {
if let Some(x11) = window.x11_surface() {
if x11.window_id() == wid {
let loc = state.space.element_geometry(window)?.loc;
return Some((KeyboardFocusTarget::Window(window.clone()), loc.to_f64()));
}
}
}
}
let (window, loc) = state.space.element_under(state.cursor_position)?;
return Some((KeyboardFocusTarget::Window(window.clone()), loc.to_f64()));
}
// Try element_under first
if let Some((window, loc)) = state.space.element_under(state.cursor_position) {
return Some((KeyboardFocusTarget::Window(window.clone()), loc.to_f64()));
}
// Fallback: use the keyboard-focused window if any window is mapped
if let Some(w) = state.space.elements().next() {
if let Some(geo) = state.space.element_geometry(w) {
return Some((KeyboardFocusTarget::Window(w.clone()), geo.loc.to_f64()));
}
}
None
}