feat: NES portrait controller, remote input handler updates

- NESPortraitController layout for vertical phone use
- Updated NESController and NESKeyboard components
- Remote input WebSocket handler and API route registration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-01 23:37:55 +01:00
parent 5c429f9571
commit d2dc803920
6 changed files with 744 additions and 329 deletions

View File

@@ -2,6 +2,7 @@ mod content;
mod dwn;
mod node_message;
mod proxy;
mod remote_input;
mod websocket;
use crate::api::rpc::RpcHandler;
@@ -140,6 +141,15 @@ impl ApiHandler {
return Self::handle_websocket(req, self.state_manager.clone(), self.metrics_store.clone()).await;
}
// Remote input WebSocket — companion app sends keyboard/mouse events
if method == Method::GET && path == "/ws/remote-input" {
if !self.is_authenticated(req.headers()).await {
tracing::warn!("401 WebSocket /ws/remote-input — session invalid or missing");
return Ok(Self::unauthorized());
}
return Self::handle_remote_input(req).await;
}
// Convert body to bytes for non-WS routes
let headers = req.headers().clone();
let (parts, body) = req.into_parts();

View File

@@ -0,0 +1,223 @@
use anyhow::{Context, Result};
use futures_util::{SinkExt, StreamExt};
use hyper::{Request, Response};
use hyper_ws_listener::WsStream;
use serde::Deserialize;
use std::time::Instant;
use tokio::process::Command;
use tokio_tungstenite::tungstenite::Message;
use tracing::{debug, info, warn};
use super::ApiHandler;
/// Allowed xdotool key names. Only these pass validation.
const ALLOWED_KEYS: &[&str] = &[
// Letters
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m",
"n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z",
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M",
"N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z",
// Numbers
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
// Navigation
"Up", "Down", "Left", "Right",
"Return", "Escape", "Tab", "BackSpace", "Delete",
"Home", "End", "Prior", "Next", // Prior=PageUp, Next=PageDown
// Modifiers (for combos like shift+a)
"space", "minus", "equal", "bracketleft", "bracketright",
"backslash", "semicolon", "apostrophe", "grave", "comma",
"period", "slash",
// Function keys
"F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12",
// Symbols — xdotool names
"exclam", "at", "numbersign", "dollar", "percent", "asciicircum",
"ampersand", "asterisk", "parenleft", "parenright", "underscore",
"plus", "braceleft", "braceright", "bar", "colon", "quotedbl",
"less", "greater", "question", "asciitilde",
];
/// Validate a key name against the whitelist.
/// Also allows "shift+X" combos where X is in the whitelist.
fn validate_key(key: &str) -> bool {
if ALLOWED_KEYS.contains(&key) {
return true;
}
// Allow modifier combos: "shift+a", "ctrl+c", etc.
if let Some((modifier, base)) = key.split_once('+') {
let valid_modifiers = ["shift", "ctrl", "alt", "super"];
return valid_modifiers.contains(&modifier) && ALLOWED_KEYS.contains(&base);
}
false
}
#[derive(Deserialize)]
#[serde(tag = "t")]
enum InputCommand {
#[serde(rename = "k")]
Key { k: String },
#[serde(rename = "m")]
MouseMove { x: i32, y: i32 },
#[serde(rename = "c")]
Click { b: u8 },
#[serde(rename = "s")]
Scroll { y: i32 },
#[serde(rename = "p")]
Ping,
}
async fn xdotool(args: &[&str]) -> Result<()> {
let output = Command::new("xdotool")
.env("DISPLAY", ":0")
.args(args)
.output()
.await
.context("xdotool execution failed")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
debug!("xdotool error: {}", stderr);
}
Ok(())
}
async fn handle_input(msg: &str) -> Result<Option<String>> {
let cmd: InputCommand = serde_json::from_str(msg)
.context("invalid input command")?;
match cmd {
InputCommand::Key { ref k } => {
if !validate_key(k) {
warn!("rejected key: {}", k);
return Ok(Some(r#"{"t":"e","m":"invalid key"}"#.to_string()));
}
xdotool(&["key", "--clearmodifiers", k]).await?;
}
InputCommand::MouseMove { x, y } => {
let x = x.clamp(-50, 50);
let y = y.clamp(-50, 50);
let xs = x.to_string();
let ys = y.to_string();
xdotool(&["mousemove_relative", "--", &xs, &ys]).await?;
}
InputCommand::Click { b } => {
let b = b.clamp(1, 3);
let bs = b.to_string();
xdotool(&["click", &bs]).await?;
}
InputCommand::Scroll { y } => {
// xdotool: button 4 = scroll up, button 5 = scroll down
let btn = if y < 0 { "4" } else { "5" };
let count = y.unsigned_abs().clamp(1, 10).to_string();
xdotool(&["click", "--repeat", &count, btn]).await?;
}
InputCommand::Ping => {
return Ok(Some(r#"{"t":"p"}"#.to_string()));
}
}
Ok(None)
}
impl ApiHandler {
pub(super) async fn handle_remote_input(
req: Request<hyper::Body>,
) -> Result<Response<hyper::Body>> {
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
if let Some(ws_fut) = ws_fut_opt {
tokio::spawn(async move {
let ws_stream: WsStream = match ws_fut.await {
Ok(Ok(s)) => s,
Ok(Err(e)) => {
debug!("Remote input WS handshake failed: {}", e);
return;
}
Err(e) => {
debug!("Remote input WS task join failed: {}", e);
return;
}
};
info!("Remote input connected");
let (mut tx, mut rx) = ws_stream.split();
// Send ready message
let _ = tx.send(Message::Text(r#"{"t":"ok"}"#.to_string())).await;
let ping_interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
tokio::pin!(ping_interval);
let mut last_activity = Instant::now();
let mut msg_count: u64 = 0;
let mut rate_window_start = Instant::now();
let mut rate_count: u32 = 0;
const MAX_RATE: u32 = 120; // messages per second
const INACTIVITY_TIMEOUT: u64 = 300;
loop {
tokio::select! {
_ = ping_interval.tick() => {
if last_activity.elapsed().as_secs() >= INACTIVITY_TIMEOUT {
info!("Remote input inactive, closing");
let _ = tx.send(Message::Close(None)).await;
break;
}
if tx.send(Message::Ping(vec![])).await.is_err() {
break;
}
}
msg = rx.next() => {
match msg {
Some(Ok(Message::Text(text))) => {
last_activity = Instant::now();
msg_count += 1;
// Rate limiting
if rate_window_start.elapsed().as_millis() >= 1000 {
rate_window_start = Instant::now();
rate_count = 0;
}
rate_count += 1;
if rate_count > MAX_RATE {
continue; // silently drop
}
match handle_input(&text).await {
Ok(Some(reply)) => {
let _ = tx.send(Message::Text(reply)).await;
}
Ok(None) => {}
Err(e) => {
debug!("Input error: {}", e);
let err = format!(r#"{{"t":"e","m":"{}"}}"#,
e.to_string().replace('"', "'"));
let _ = tx.send(Message::Text(err)).await;
}
}
}
Some(Ok(Message::Pong(_))) => {
last_activity = Instant::now();
}
Some(Ok(Message::Ping(data))) => {
last_activity = Instant::now();
let _ = tx.send(Message::Pong(data)).await;
}
Some(Ok(Message::Close(_))) | None => break,
Some(Ok(_)) => { last_activity = Instant::now(); }
Some(Err(e)) => {
debug!("Remote input stream error: {}", e);
break;
}
}
}
}
}
info!("Remote input disconnected ({} messages processed)", msg_count);
});
}
Ok(response)
}
}