refactor: enforce device labels, unify screenshot, remove deprecated commands, session-id-less design
- Device labels: lowercase, no whitespace, only a-z 0-9 - _ (enforced at config time) - Session IDs removed: device label is the sole identifier - Routes changed: /sessions/:id → /devices/:label - Removed commands: click, type, find-window, wait-for-window, label, old version, server-version - Renamed: status → version (compares relay/remote.py/client commits) - Unified screenshot: takes 'screen' or a window label as argument - Windows listed with human-readable labels (same format as device labels) - Single instance enforcement via PID lock file - Removed input.rs (click/type functionality) - All docs and code in English - Protocol: Hello.label is now required (String, not Option<String>) - Client auto-migrates invalid labels on startup
This commit is contained in:
parent
5fd01a423d
commit
0b4a6de8ae
14 changed files with 736 additions and 1180 deletions
|
|
@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
|
|||
use uuid::Uuid;
|
||||
use tracing::error;
|
||||
|
||||
use helios_common::protocol::{ClientMessage, MouseButton, ServerMessage};
|
||||
use helios_common::protocol::{ClientMessage, ServerMessage};
|
||||
use crate::AppState;
|
||||
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
|
@ -21,33 +21,29 @@ pub struct ErrorBody {
|
|||
pub error: String,
|
||||
}
|
||||
|
||||
fn not_found(session_id: &str) -> (StatusCode, Json<ErrorBody>) {
|
||||
fn not_found(label: &str) -> (StatusCode, Json<ErrorBody>) {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorBody {
|
||||
error: format!("Session '{session_id}' not found or not connected"),
|
||||
error: format!("Device '{label}' not found or not connected"),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn timeout_error(session_id: &str, op: &str) -> (StatusCode, Json<ErrorBody>) {
|
||||
fn timeout_error(label: &str, op: &str) -> (StatusCode, Json<ErrorBody>) {
|
||||
(
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
Json(ErrorBody {
|
||||
error: format!(
|
||||
"Timed out waiting for client response (session='{session_id}', op='{op}')"
|
||||
),
|
||||
error: format!("Timed out waiting for client response (device='{label}', op='{op}')"),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn send_error(session_id: &str, op: &str) -> (StatusCode, Json<ErrorBody>) {
|
||||
fn send_error(label: &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}')"
|
||||
),
|
||||
error: format!("Failed to send command to client — may have disconnected (device='{label}', op='{op}')"),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -56,19 +52,19 @@ fn send_error(session_id: &str, op: &str) -> (StatusCode, Json<ErrorBody>) {
|
|||
|
||||
async fn dispatch<F>(
|
||||
state: &AppState,
|
||||
session_id: &str,
|
||||
label: &str,
|
||||
op: &str,
|
||||
make_msg: F,
|
||||
) -> Result<ClientMessage, (StatusCode, Json<ErrorBody>)>
|
||||
where
|
||||
F: FnOnce(Uuid) -> ServerMessage,
|
||||
{
|
||||
dispatch_with_timeout(state, session_id, op, make_msg, REQUEST_TIMEOUT).await
|
||||
dispatch_with_timeout(state, label, op, make_msg, REQUEST_TIMEOUT).await
|
||||
}
|
||||
|
||||
async fn dispatch_with_timeout<F>(
|
||||
state: &AppState,
|
||||
session_id: &str,
|
||||
label: &str,
|
||||
op: &str,
|
||||
make_msg: F,
|
||||
timeout: Duration,
|
||||
|
|
@ -76,50 +72,62 @@ async fn dispatch_with_timeout<F>(
|
|||
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))?;
|
||||
.get_cmd_tx(label)
|
||||
.ok_or_else(|| not_found(label))?;
|
||||
|
||||
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)
|
||||
error!("Channel send failed for device={label}, op={op}: {e}");
|
||||
send_error(label, op)
|
||||
})?;
|
||||
|
||||
match tokio::time::timeout(timeout, rx).await {
|
||||
Ok(Ok(response)) => Ok(response),
|
||||
Ok(Err(_)) => Err(send_error(session_id, op)),
|
||||
Err(_) => Err(timeout_error(session_id, op)),
|
||||
Ok(Err(_)) => Err(send_error(label, op)),
|
||||
Err(_) => Err(timeout_error(label, 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 }))
|
||||
/// GET /devices — list all connected clients
|
||||
pub async fn list_devices(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
let devices = state.sessions.list();
|
||||
Json(serde_json::json!({ "devices": devices }))
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/windows/:window_id/screenshot
|
||||
pub async fn window_screenshot(
|
||||
Path((session_id, window_id)): Path<(String, u64)>,
|
||||
/// POST /devices/:label/screenshot — full screen screenshot
|
||||
pub async fn request_screenshot(
|
||||
Path(label): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "window_screenshot", |rid| {
|
||||
match dispatch(&state, &label, "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" }))).into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /devices/:label/windows/:window_id/screenshot
|
||||
pub async fn window_screenshot(
|
||||
Path((label, window_id)): Path<(String, u64)>,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &label, "window_screenshot", |rid| {
|
||||
ServerMessage::WindowScreenshotRequest { request_id: rid, window_id }
|
||||
}).await {
|
||||
Ok(ClientMessage::ScreenshotResponse { image_base64, width, height, .. }) => (
|
||||
|
|
@ -135,14 +143,14 @@ pub async fn window_screenshot(
|
|||
}
|
||||
}
|
||||
|
||||
/// GET /sessions/:id/logs?lines=100
|
||||
/// GET /devices/:label/logs?lines=100
|
||||
pub async fn logs(
|
||||
Path(session_id): Path<String>,
|
||||
Path(label): Path<String>,
|
||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
let lines: u32 = params.get("lines").and_then(|v| v.parse().ok()).unwrap_or(100);
|
||||
match dispatch(&state, &session_id, "logs", |rid| {
|
||||
match dispatch(&state, &label, "logs", |rid| {
|
||||
ServerMessage::LogsRequest { request_id: rid, lines }
|
||||
}).await {
|
||||
Ok(ClientMessage::LogsResponse { content, log_path, .. }) => (
|
||||
|
|
@ -158,216 +166,95 @@ pub async fn logs(
|
|||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// POST /devices/:label/exec
|
||||
#[derive(Deserialize)]
|
||||
pub struct ExecBody {
|
||||
pub command: String,
|
||||
/// Optional timeout in milliseconds (default: 30000). Use higher values for
|
||||
/// long-running commands like downloads.
|
||||
pub timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
pub async fn request_exec(
|
||||
Path(session_id): Path<String>,
|
||||
Path(label): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<ExecBody>,
|
||||
) -> impl IntoResponse {
|
||||
// Server-side wait must be at least as long as the client timeout + buffer
|
||||
let server_timeout = body.timeout_ms
|
||||
.map(|ms| std::time::Duration::from_millis(ms + 5_000))
|
||||
.map(|ms| Duration::from_millis(ms + 5_000))
|
||||
.unwrap_or(REQUEST_TIMEOUT);
|
||||
|
||||
match dispatch_with_timeout(&state, &session_id, "exec", |rid| ServerMessage::ExecRequest {
|
||||
match dispatch_with_timeout(&state, &label, "exec", |rid| ServerMessage::ExecRequest {
|
||||
request_id: rid,
|
||||
command: body.command.clone(),
|
||||
timeout_ms: body.timeout_ms,
|
||||
}, server_timeout)
|
||||
.await
|
||||
{
|
||||
Ok(ClientMessage::ExecResponse {
|
||||
stdout,
|
||||
stderr,
|
||||
exit_code,
|
||||
..
|
||||
}) => (
|
||||
}, server_timeout).await {
|
||||
Ok(ClientMessage::ExecResponse { stdout, stderr, exit_code, .. }) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"exit_code": exit_code,
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
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(),
|
||||
).into_response(),
|
||||
Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /sessions/:id/windows
|
||||
/// GET /devices/:label/windows
|
||||
pub async fn list_windows(
|
||||
Path(session_id): Path<String>,
|
||||
Path(label): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "list_windows", |rid| {
|
||||
match dispatch(&state, &label, "list_windows", |rid| {
|
||||
ServerMessage::ListWindowsRequest { request_id: rid }
|
||||
})
|
||||
.await
|
||||
{
|
||||
}).await {
|
||||
Ok(ClientMessage::ListWindowsResponse { windows, .. }) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({ "windows": windows })),
|
||||
)
|
||||
.into_response(),
|
||||
).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(),
|
||||
).into_response(),
|
||||
Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/windows/minimize-all
|
||||
/// POST /devices/:label/windows/minimize-all
|
||||
pub async fn minimize_all(
|
||||
Path(session_id): Path<String>,
|
||||
Path(label): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "minimize_all", |rid| {
|
||||
match dispatch(&state, &label, "minimize_all", |rid| {
|
||||
ServerMessage::MinimizeAllRequest { request_id: rid }
|
||||
})
|
||||
.await
|
||||
{
|
||||
}).await {
|
||||
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/windows/:window_id/focus
|
||||
/// POST /devices/:label/windows/:window_id/focus
|
||||
pub async fn focus_window(
|
||||
Path((session_id, window_id)): Path<(String, u64)>,
|
||||
Path((label, window_id)): Path<(String, u64)>,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "focus_window", |rid| {
|
||||
match dispatch(&state, &label, "focus_window", |rid| {
|
||||
ServerMessage::FocusWindowRequest { request_id: rid, window_id }
|
||||
})
|
||||
.await
|
||||
{
|
||||
}).await {
|
||||
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/windows/:window_id/maximize
|
||||
/// POST /devices/:label/windows/:window_id/maximize
|
||||
pub async fn maximize_and_focus(
|
||||
Path((session_id, window_id)): Path<(String, u64)>,
|
||||
Path((label, window_id)): Path<(String, u64)>,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "maximize_and_focus", |rid| {
|
||||
match dispatch(&state, &label, "maximize_and_focus", |rid| {
|
||||
ServerMessage::MaximizeAndFocusRequest { request_id: rid, window_id }
|
||||
})
|
||||
.await
|
||||
{
|
||||
}).await {
|
||||
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
|
|
@ -376,41 +263,32 @@ pub async fn maximize_and_focus(
|
|||
/// GET /version — server version (public, no auth)
|
||||
pub async fn server_version() -> impl IntoResponse {
|
||||
Json(serde_json::json!({
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"commit": env!("GIT_COMMIT"),
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /sessions/:id/version — client version
|
||||
/// GET /devices/:label/version — client version
|
||||
pub async fn client_version(
|
||||
Path(session_id): Path<String>,
|
||||
Path(label): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "version", |rid| {
|
||||
match dispatch(&state, &label, "version", |rid| {
|
||||
ServerMessage::VersionRequest { request_id: rid }
|
||||
})
|
||||
.await
|
||||
{
|
||||
}).await {
|
||||
Ok(ClientMessage::VersionResponse { version, commit, .. }) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({ "version": version, "commit": commit })),
|
||||
)
|
||||
.into_response(),
|
||||
).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(),
|
||||
).into_response(),
|
||||
Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/upload
|
||||
/// POST /devices/:label/upload
|
||||
#[derive(Deserialize)]
|
||||
pub struct UploadBody {
|
||||
pub path: String,
|
||||
|
|
@ -418,59 +296,49 @@ pub struct UploadBody {
|
|||
}
|
||||
|
||||
pub async fn upload_file(
|
||||
Path(session_id): Path<String>,
|
||||
Path(label): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<UploadBody>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "upload", |rid| ServerMessage::UploadRequest {
|
||||
match dispatch(&state, &label, "upload", |rid| ServerMessage::UploadRequest {
|
||||
request_id: rid,
|
||||
path: body.path.clone(),
|
||||
content_base64: body.content_base64.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
}).await {
|
||||
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /sessions/:id/download?path=...
|
||||
/// GET /devices/:label/download?path=...
|
||||
#[derive(Deserialize)]
|
||||
pub struct DownloadQuery {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
pub async fn download_file(
|
||||
Path(session_id): Path<String>,
|
||||
Path(label): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<DownloadQuery>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "download", |rid| ServerMessage::DownloadRequest {
|
||||
match dispatch(&state, &label, "download", |rid| ServerMessage::DownloadRequest {
|
||||
request_id: rid,
|
||||
path: query.path.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
}).await {
|
||||
Ok(ClientMessage::DownloadResponse { content_base64, size, .. }) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({ "content_base64": content_base64, "size": size })),
|
||||
)
|
||||
.into_response(),
|
||||
).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(),
|
||||
).into_response(),
|
||||
Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/run
|
||||
/// POST /devices/:label/run
|
||||
#[derive(Deserialize)]
|
||||
pub struct RunBody {
|
||||
pub program: String,
|
||||
|
|
@ -479,73 +347,61 @@ pub struct RunBody {
|
|||
}
|
||||
|
||||
pub async fn run_program(
|
||||
Path(session_id): Path<String>,
|
||||
Path(label): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<RunBody>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "run", |rid| ServerMessage::RunRequest {
|
||||
match dispatch(&state, &label, "run", |rid| ServerMessage::RunRequest {
|
||||
request_id: rid,
|
||||
program: body.program.clone(),
|
||||
args: body.args.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
}).await {
|
||||
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /sessions/:id/clipboard
|
||||
/// GET /devices/:label/clipboard
|
||||
pub async fn clipboard_get(
|
||||
Path(session_id): Path<String>,
|
||||
Path(label): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "clipboard_get", |rid| {
|
||||
match dispatch(&state, &label, "clipboard_get", |rid| {
|
||||
ServerMessage::ClipboardGetRequest { request_id: rid }
|
||||
})
|
||||
.await
|
||||
{
|
||||
}).await {
|
||||
Ok(ClientMessage::ClipboardGetResponse { text, .. }) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({ "text": text })),
|
||||
)
|
||||
.into_response(),
|
||||
).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(),
|
||||
).into_response(),
|
||||
Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/clipboard
|
||||
/// POST /devices/:label/clipboard
|
||||
#[derive(Deserialize)]
|
||||
pub struct ClipboardSetBody {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
pub async fn clipboard_set(
|
||||
Path(session_id): Path<String>,
|
||||
Path(label): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<ClipboardSetBody>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "clipboard_set", |rid| {
|
||||
match dispatch(&state, &label, "clipboard_set", |rid| {
|
||||
ServerMessage::ClipboardSetRequest { request_id: rid, text: body.text.clone() }
|
||||
})
|
||||
.await
|
||||
{
|
||||
}).await {
|
||||
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/prompt
|
||||
/// POST /devices/:label/prompt
|
||||
#[derive(Deserialize)]
|
||||
pub struct PromptBody {
|
||||
pub message: String,
|
||||
|
|
@ -553,17 +409,15 @@ pub struct PromptBody {
|
|||
}
|
||||
|
||||
pub async fn prompt_user(
|
||||
Path(session_id): Path<String>,
|
||||
Path(label): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<PromptBody>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "prompt", |rid| ServerMessage::PromptRequest {
|
||||
match dispatch(&state, &label, "prompt", |rid| ServerMessage::PromptRequest {
|
||||
request_id: rid,
|
||||
message: body.message.clone(),
|
||||
title: body.title.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
}).await {
|
||||
Ok(ClientMessage::PromptResponse { answer, .. }) => {
|
||||
(StatusCode::OK, Json(serde_json::json!({ "ok": true, "answer": answer }))).into_response()
|
||||
}
|
||||
|
|
@ -571,32 +425,3 @@ pub async fn prompt_user(
|
|||
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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
.init();
|
||||
|
||||
const GIT_COMMIT: &str = env!("GIT_COMMIT");
|
||||
info!("helios-server v{} ({})", env!("CARGO_PKG_VERSION"), GIT_COMMIT);
|
||||
info!("helios-server ({GIT_COMMIT})");
|
||||
|
||||
let api_key = std::env::var("HELIOS_API_KEY")
|
||||
.unwrap_or_else(|_| "dev-secret".to_string());
|
||||
|
|
@ -45,25 +45,22 @@ async fn main() -> anyhow::Result<()> {
|
|||
};
|
||||
|
||||
let protected = Router::new()
|
||||
.route("/sessions", get(api::list_sessions))
|
||||
.route("/sessions/:id/screenshot", post(api::request_screenshot))
|
||||
.route("/sessions/:id/exec", post(api::request_exec))
|
||||
.route("/sessions/:id/click", post(api::request_click))
|
||||
.route("/sessions/:id/type", post(api::request_type))
|
||||
.route("/sessions/:id/label", post(api::set_label))
|
||||
.route("/sessions/:id/prompt", post(api::prompt_user))
|
||||
.route("/sessions/:id/windows", get(api::list_windows))
|
||||
.route("/sessions/:id/windows/minimize-all", post(api::minimize_all))
|
||||
.route("/sessions/:id/logs", get(api::logs))
|
||||
.route("/sessions/:id/windows/:window_id/screenshot", post(api::window_screenshot))
|
||||
.route("/sessions/:id/windows/:window_id/focus", post(api::focus_window))
|
||||
.route("/sessions/:id/windows/:window_id/maximize", post(api::maximize_and_focus))
|
||||
.route("/sessions/:id/version", get(api::client_version))
|
||||
.route("/sessions/:id/upload", post(api::upload_file))
|
||||
.route("/sessions/:id/download", get(api::download_file))
|
||||
.route("/sessions/:id/run", post(api::run_program))
|
||||
.route("/sessions/:id/clipboard", get(api::clipboard_get))
|
||||
.route("/sessions/:id/clipboard", post(api::clipboard_set))
|
||||
.route("/devices", get(api::list_devices))
|
||||
.route("/devices/:label/screenshot", post(api::request_screenshot))
|
||||
.route("/devices/:label/exec", post(api::request_exec))
|
||||
.route("/devices/:label/prompt", post(api::prompt_user))
|
||||
.route("/devices/:label/windows", get(api::list_windows))
|
||||
.route("/devices/:label/windows/minimize-all", post(api::minimize_all))
|
||||
.route("/devices/:label/logs", get(api::logs))
|
||||
.route("/devices/:label/windows/:window_id/screenshot", post(api::window_screenshot))
|
||||
.route("/devices/:label/windows/:window_id/focus", post(api::focus_window))
|
||||
.route("/devices/:label/windows/:window_id/maximize", post(api::maximize_and_focus))
|
||||
.route("/devices/:label/version", get(api::client_version))
|
||||
.route("/devices/:label/upload", post(api::upload_file))
|
||||
.route("/devices/:label/download", get(api::download_file))
|
||||
.route("/devices/:label/run", post(api::run_program))
|
||||
.route("/devices/:label/clipboard", get(api::clipboard_get))
|
||||
.route("/devices/:label/clipboard", post(api::clipboard_set))
|
||||
.layer(middleware::from_fn_with_state(state.clone(), require_api_key));
|
||||
|
||||
let app = Router::new()
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ use uuid::Uuid;
|
|||
use serde::Serialize;
|
||||
use helios_common::protocol::{ClientMessage, ServerMessage};
|
||||
|
||||
/// Represents one connected remote client
|
||||
/// Represents one connected remote client.
|
||||
/// The device label is the sole identifier — no session UUIDs exposed externally.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Session {
|
||||
pub id: Uuid,
|
||||
pub label: Option<String>,
|
||||
pub label: String,
|
||||
/// Channel to send commands to the WS handler for this session
|
||||
pub cmd_tx: mpsc::Sender<ServerMessage>,
|
||||
}
|
||||
|
|
@ -16,22 +16,20 @@ pub struct Session {
|
|||
/// Serializable view of a session for the REST API
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SessionInfo {
|
||||
pub id: Uuid,
|
||||
pub label: Option<String>,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
impl From<&Session> for SessionInfo {
|
||||
fn from(s: &Session) -> Self {
|
||||
SessionInfo {
|
||||
id: s.id,
|
||||
label: s.label.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SessionStore {
|
||||
/// Active sessions by ID
|
||||
sessions: DashMap<Uuid, Session>,
|
||||
/// Active sessions keyed by device label
|
||||
sessions: DashMap<String, Session>,
|
||||
/// Pending request callbacks by request_id
|
||||
pending: DashMap<Uuid, oneshot::Sender<ClientMessage>>,
|
||||
}
|
||||
|
|
@ -45,24 +43,15 @@ impl SessionStore {
|
|||
}
|
||||
|
||||
pub fn insert(&self, session: Session) {
|
||||
self.sessions.insert(session.id, session);
|
||||
self.sessions.insert(session.label.clone(), session);
|
||||
}
|
||||
|
||||
pub fn remove(&self, id: &Uuid) {
|
||||
self.sessions.remove(id);
|
||||
pub fn remove(&self, label: &str) {
|
||||
self.sessions.remove(label);
|
||||
}
|
||||
|
||||
pub fn get_cmd_tx(&self, id: &Uuid) -> Option<mpsc::Sender<ServerMessage>> {
|
||||
self.sessions.get(id).map(|s| s.cmd_tx.clone())
|
||||
}
|
||||
|
||||
pub fn set_label(&self, id: &Uuid, label: String) -> bool {
|
||||
if let Some(mut s) = self.sessions.get_mut(id) {
|
||||
s.label = Some(label);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
pub fn get_cmd_tx(&self, label: &str) -> Option<mpsc::Sender<ServerMessage>> {
|
||||
self.sessions.get(label).map(|s| s.cmd_tx.clone())
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Vec<SessionInfo> {
|
||||
|
|
@ -77,7 +66,6 @@ impl SessionStore {
|
|||
}
|
||||
|
||||
/// Deliver a client response to the waiting request handler.
|
||||
/// Returns true if the request was found and resolved.
|
||||
pub fn resolve_pending(&self, request_id: Uuid, msg: ClientMessage) -> bool {
|
||||
if let Some((_, tx)) = self.pending.remove(&request_id) {
|
||||
let _ = tx.send(msg);
|
||||
|
|
|
|||
|
|
@ -19,32 +19,57 @@ pub async fn ws_upgrade(
|
|||
}
|
||||
|
||||
async fn handle_socket(socket: WebSocket, state: AppState) {
|
||||
let session_id = Uuid::new_v4();
|
||||
let (cmd_tx, mut cmd_rx) = mpsc::channel::<ServerMessage>(64);
|
||||
let (mut ws_tx, mut ws_rx) = socket.split();
|
||||
|
||||
// Register session (label filled in on Hello)
|
||||
// Wait for the Hello message to get the device label
|
||||
let label = loop {
|
||||
match ws_rx.next().await {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
match serde_json::from_str::<ClientMessage>(&text) {
|
||||
Ok(ClientMessage::Hello { label }) => {
|
||||
if label.is_empty() {
|
||||
warn!("Client sent empty label, disconnecting");
|
||||
return;
|
||||
}
|
||||
break label;
|
||||
}
|
||||
Ok(_) => {
|
||||
warn!("Expected Hello as first message, got something else");
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Invalid JSON on handshake: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => return,
|
||||
_ => continue,
|
||||
}
|
||||
};
|
||||
|
||||
// Register session by label
|
||||
let session = Session {
|
||||
id: session_id,
|
||||
label: None,
|
||||
label: label.clone(),
|
||||
cmd_tx,
|
||||
};
|
||||
state.sessions.insert(session);
|
||||
info!("Client connected: session={session_id}");
|
||||
|
||||
let (mut ws_tx, mut ws_rx) = socket.split();
|
||||
info!("Client connected: device={label}");
|
||||
|
||||
// Spawn task: forward server commands → WS
|
||||
let label_clone = label.clone();
|
||||
let send_task = tokio::spawn(async move {
|
||||
while let Some(msg) = cmd_rx.recv().await {
|
||||
match serde_json::to_string(&msg) {
|
||||
Ok(json) => {
|
||||
if let Err(e) = ws_tx.send(Message::Text(json.into())).await {
|
||||
error!("WS send error for session={session_id}: {e}");
|
||||
error!("WS send error for device={label_clone}: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Serialization error for session={session_id}: {e}");
|
||||
error!("Serialization error for device={label_clone}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -55,36 +80,33 @@ async fn handle_socket(socket: WebSocket, state: AppState) {
|
|||
match result {
|
||||
Ok(Message::Text(text)) => {
|
||||
match serde_json::from_str::<ClientMessage>(&text) {
|
||||
Ok(msg) => handle_client_message(session_id, msg, &state).await,
|
||||
Ok(msg) => handle_client_message(&label, msg, &state).await,
|
||||
Err(e) => {
|
||||
warn!("Invalid JSON from session={session_id}: {e} | raw={text}");
|
||||
warn!("Invalid JSON from device={label}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Message::Close(_)) => {
|
||||
info!("Client disconnected gracefully: session={session_id}");
|
||||
info!("Client disconnected gracefully: device={label}");
|
||||
break;
|
||||
}
|
||||
Ok(Message::Ping(_)) | Ok(Message::Pong(_)) | Ok(Message::Binary(_)) => {}
|
||||
Err(e) => {
|
||||
error!("WS receive error for session={session_id}: {e}");
|
||||
error!("WS receive error for device={label}: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
send_task.abort();
|
||||
state.sessions.remove(&session_id);
|
||||
info!("Session cleaned up: session={session_id}");
|
||||
state.sessions.remove(&label);
|
||||
info!("Session cleaned up: device={label}");
|
||||
}
|
||||
|
||||
async fn handle_client_message(session_id: Uuid, msg: ClientMessage, state: &AppState) {
|
||||
async fn handle_client_message(label: &str, msg: ClientMessage, state: &AppState) {
|
||||
match &msg {
|
||||
ClientMessage::Hello { label } => {
|
||||
if let Some(lbl) = label {
|
||||
state.sessions.set_label(&session_id, lbl.clone());
|
||||
}
|
||||
debug!("Hello from session={session_id}, label={label:?}");
|
||||
ClientMessage::Hello { .. } => {
|
||||
debug!("Duplicate Hello from device={label}, ignoring");
|
||||
}
|
||||
ClientMessage::ScreenshotResponse { request_id, .. }
|
||||
| ClientMessage::ExecResponse { request_id, .. }
|
||||
|
|
@ -98,7 +120,7 @@ async fn handle_client_message(session_id: Uuid, msg: ClientMessage, state: &App
|
|||
| ClientMessage::Error { request_id, .. } => {
|
||||
let rid = *request_id;
|
||||
if !state.sessions.resolve_pending(rid, msg) {
|
||||
warn!("No pending request for request_id={rid} (session={session_id})");
|
||||
warn!("No pending request for request_id={rid} (device={label})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue