Initial implementation: relay server + common protocol + client stub
This commit is contained in:
commit
7285a33cff
17 changed files with 926 additions and 0 deletions
263
crates/server/src/api.rs
Normal file
263
crates/server/src/api.rs
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
use std::time::Duration;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
use tracing::error;
|
||||
|
||||
use helios_common::protocol::{ClientMessage, MouseButton, ServerMessage};
|
||||
use crate::AppState;
|
||||
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
// ── Response types ──────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ErrorBody {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
fn not_found(session_id: &str) -> (StatusCode, Json<ErrorBody>) {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorBody {
|
||||
error: format!("Session '{session_id}' not found or not connected"),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn timeout_error(session_id: &str, op: &str) -> (StatusCode, Json<ErrorBody>) {
|
||||
(
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
Json(ErrorBody {
|
||||
error: format!(
|
||||
"Timed out waiting for client response (session='{session_id}', op='{op}')"
|
||||
),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn send_error(session_id: &str, op: &str) -> (StatusCode, Json<ErrorBody>) {
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(ErrorBody {
|
||||
error: format!(
|
||||
"Failed to send command to client — client may have disconnected (session='{session_id}', op='{op}')"
|
||||
),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Helper to send a command and await the response ─────────────────────────
|
||||
|
||||
async fn dispatch<F>(
|
||||
state: &AppState,
|
||||
session_id: &str,
|
||||
op: &str,
|
||||
make_msg: F,
|
||||
) -> Result<ClientMessage, (StatusCode, Json<ErrorBody>)>
|
||||
where
|
||||
F: FnOnce(Uuid) -> ServerMessage,
|
||||
{
|
||||
let id = session_id.parse::<Uuid>().map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorBody {
|
||||
error: format!("Invalid session id: '{session_id}'"),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let tx = state
|
||||
.sessions
|
||||
.get_cmd_tx(&id)
|
||||
.ok_or_else(|| not_found(session_id))?;
|
||||
|
||||
let request_id = Uuid::new_v4();
|
||||
let rx = state.sessions.register_pending(request_id);
|
||||
let msg = make_msg(request_id);
|
||||
|
||||
tx.send(msg).await.map_err(|e| {
|
||||
error!("Channel send failed for session={session_id}, op={op}: {e}");
|
||||
send_error(session_id, op)
|
||||
})?;
|
||||
|
||||
match tokio::time::timeout(REQUEST_TIMEOUT, rx).await {
|
||||
Ok(Ok(response)) => Ok(response),
|
||||
Ok(Err(_)) => Err(send_error(session_id, op)),
|
||||
Err(_) => Err(timeout_error(session_id, op)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// GET /sessions — list all connected clients
|
||||
pub async fn list_sessions(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
let sessions = state.sessions.list();
|
||||
Json(serde_json::json!({ "sessions": sessions }))
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/screenshot
|
||||
pub async fn request_screenshot(
|
||||
Path(session_id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "screenshot", |rid| {
|
||||
ServerMessage::ScreenshotRequest { request_id: rid }
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(ClientMessage::ScreenshotResponse {
|
||||
image_base64,
|
||||
width,
|
||||
height,
|
||||
..
|
||||
}) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"image_base64": image_base64,
|
||||
"width": width,
|
||||
"height": height,
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(ClientMessage::Error { message, .. }) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": message })),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(_) => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(serde_json::json!({ "error": "Unexpected response from client" })),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/exec
|
||||
#[derive(Deserialize)]
|
||||
pub struct ExecBody {
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
pub async fn request_exec(
|
||||
Path(session_id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<ExecBody>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "exec", |rid| ServerMessage::ExecRequest {
|
||||
request_id: rid,
|
||||
command: body.command.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(ClientMessage::ExecResponse {
|
||||
stdout,
|
||||
stderr,
|
||||
exit_code,
|
||||
..
|
||||
}) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"exit_code": exit_code,
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(ClientMessage::Error { message, .. }) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": message })),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(_) => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(serde_json::json!({ "error": "Unexpected response from client" })),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/click
|
||||
#[derive(Deserialize)]
|
||||
pub struct ClickBody {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
#[serde(default)]
|
||||
pub button: MouseButton,
|
||||
}
|
||||
|
||||
pub async fn request_click(
|
||||
Path(session_id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<ClickBody>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "click", |rid| ServerMessage::ClickRequest {
|
||||
request_id: rid,
|
||||
x: body.x,
|
||||
y: body.y,
|
||||
button: body.button.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/type
|
||||
#[derive(Deserialize)]
|
||||
pub struct TypeBody {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
pub async fn request_type(
|
||||
Path(session_id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<TypeBody>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "type", |rid| ServerMessage::TypeRequest {
|
||||
request_id: rid,
|
||||
text: body.text.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/label
|
||||
#[derive(Deserialize)]
|
||||
pub struct LabelBody {
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
pub async fn set_label(
|
||||
Path(session_id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<LabelBody>,
|
||||
) -> impl IntoResponse {
|
||||
let id = match session_id.parse::<Uuid>() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": format!("Invalid session id: '{session_id}'") })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if state.sessions.set_label(&id, body.label.clone()) {
|
||||
(StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response()
|
||||
} else {
|
||||
not_found(&session_id).into_response()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue