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:
@@ -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();
|
||||
|
||||
223
core/archipelago/src/api/handler/remote_input.rs
Normal file
223
core/archipelago/src/api/handler/remote_input.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user