use axum::body::Bytes;
use axum::extract::{Query, State};
use axum::http::{header, HeaderMap, StatusCode};
use axum::response::IntoResponse;
use axum::Json;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::server::{
DevServerState, RecordingSnapshot, RecordingStatus, DEFAULT_RECORDING_MAX_DURATION_SECONDS,
MAX_RECORDING_MAX_DURATION_SECONDS,
};
#[derive(Debug, Deserialize)]
pub struct ElementQuery {
pub selector: Option<String>,
pub text: Option<String>,
pub role: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AgentAction {
pub action: ActionType,
pub selector: String,
pub value: Option<String>,
pub coordinates: Option<(f64, f64)>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ActionType {
Click,
DoubleClick,
RightClick,
Type,
Fill,
Clear,
Scroll,
Hover,
Focus,
Select,
}
#[derive(Debug, Serialize)]
pub struct DomSnapshot {
pub html: String,
pub url: String,
pub title: String,
pub timestamp: String,
}
#[derive(Debug, Serialize)]
pub struct ElementInfo {
pub selector: String,
pub tag: String,
pub text: String,
pub attributes: HashMap<String, String>,
pub visible: bool,
pub enabled: bool,
pub role: Option<String>,
pub label: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ActionResult {
pub success: bool,
pub error: Option<String>,
pub side_effects: Vec<String>,
}
#[derive(Debug, Deserialize, Default)]
pub struct RecordingStartRequest {
pub fps: Option<u32>,
pub max_duration_seconds: Option<u64>,
}
#[derive(Debug, Deserialize, Default)]
pub struct RecordingStopRequest {
pub session_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct RecordingStartedRequest {
pub session_id: String,
pub mime_type: String,
}
#[derive(Debug, Deserialize)]
pub struct RecordingCompleteRequest {
pub session_id: String,
pub mime_type: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct RecordingErrorRequest {
pub session_id: String,
pub error: String,
}
pub async fn get_dom(State(state): State<DevServerState>) -> impl IntoResponse {
let snapshot = state.last_dom_snapshot.read().await;
let html = snapshot.clone().unwrap_or_else(|| {
r#"<!DOCTYPE html>
<html>
<head><title>rdesktop</title></head>
<body>
<p>No DOM snapshot available yet. Make sure the app is loaded in the browser.</p>
<p>The bridge script will send DOM updates automatically.</p>
</body>
</html>"#
.to_string()
});
let dom = DomSnapshot {
html,
url: "http://localhost".to_string(),
title: "rdesktop App".to_string(),
timestamp: timestamp(),
};
Json(dom).into_response()
}
pub async fn query_elements(
State(state): State<DevServerState>,
Query(query): Query<ElementQuery>,
) -> impl IntoResponse {
let snapshot = state.last_dom_snapshot.read().await;
let elements: Vec<ElementInfo> = if let Some(ref html) = *snapshot {
find_elements(html, &query)
} else {
vec![]
};
Json(serde_json::json!({
"query": {
"selector": query.selector,
"text": query.text,
"role": query.role,
},
"count": elements.len(),
"elements": elements,
}))
.into_response()
}
pub async fn execute_action(
State(state): State<DevServerState>,
Json(action): Json<AgentAction>,
) -> impl IntoResponse {
tracing::info!(
action = ?action.action,
selector = %action.selector,
"Agent action received"
);
state.pending_actions.lock().await.push(action.clone());
let result = ActionResult {
success: true,
error: None,
side_effects: vec![format!(
"Action {:?} on '{}' queued",
action.action, action.selector
)],
};
Json(result).into_response()
}
pub async fn pending_actions(State(state): State<DevServerState>) -> impl IntoResponse {
let mut actions = state.pending_actions.lock().await;
Json(std::mem::take(&mut *actions)).into_response()
}
pub async fn get_state(State(state): State<DevServerState>) -> impl IntoResponse {
let app_state = state.last_app_state.read().await;
match app_state.as_ref() {
Some(state) => Json(state.clone()).into_response(),
None => Json(serde_json::json!({
"message": "No application state available yet.",
"hint": "Use fetch('/__rdesktop__/state', { method: 'POST', body: JSON.stringify(state) }) from your app."
}))
.into_response(),
}
}
pub async fn send_ipc(
State(_state): State<DevServerState>,
Json(message): Json<serde_json::Value>,
) -> impl IntoResponse {
let cmd = message["cmd"].as_str().unwrap_or("unknown");
let payload = message["payload"].clone();
let id = message["id"].as_str().unwrap_or("0");
tracing::info!(cmd = cmd, "Agent IPC message received");
let response = match cmd {
"greet" => {
let name = payload["name"].as_str().unwrap_or("World");
serde_json::json!({
"id": id,
"success": true,
"data": { "message": format!("Hello, {}!", name) }
})
}
"ping" => {
serde_json::json!({
"id": id,
"success": true,
"data": { "pong": true }
})
}
_ => {
serde_json::json!({
"id": id,
"success": false,
"data": { "error": format!("Unknown command: {}", cmd) }
})
}
};
Json(response).into_response()
}
pub async fn take_screenshot(State(_state): State<DevServerState>) -> impl IntoResponse {
(
StatusCode::NOT_IMPLEMENTED,
Json(serde_json::json!({
"message": "Screenshot not implemented in browser mode.",
"hint": "Use Playwright's page.screenshot() directly."
})),
)
.into_response()
}
pub async fn get_recording(State(state): State<DevServerState>) -> impl IntoResponse {
Json(state.recording.snapshot().await).into_response()
}
pub async fn poll_recording(State(state): State<DevServerState>) -> impl IntoResponse {
Json(state.recording.snapshot().await).into_response()
}
pub async fn start_recording(
State(state): State<DevServerState>,
request: Option<Json<RecordingStartRequest>>,
) -> impl IntoResponse {
let request = request.map(|Json(request)| request).unwrap_or_default();
let fps = request.fps.unwrap_or(30).clamp(1, 60);
let max_duration_seconds = request
.max_duration_seconds
.unwrap_or(DEFAULT_RECORDING_MAX_DURATION_SECONDS)
.clamp(1, MAX_RECORDING_MAX_DURATION_SECONDS);
let max_duration = std::time::Duration::from_secs(max_duration_seconds);
match state.recording.start_with_options(fps, max_duration).await {
Ok((recording, reused)) => {
if !reused {
if let Some(session_id) = recording.session_id.clone() {
let recording_store = state.recording.clone();
tokio::spawn(async move {
tokio::time::sleep(max_duration).await;
if let Err(error) = recording_store.stop(Some(&session_id)).await {
tracing::warn!(%error, "recording auto-stop failed");
}
});
}
}
Json(serde_json::json!({
"ok": true,
"reused": reused,
"auto_stop_seconds": max_duration_seconds,
"recording": recording,
}))
.into_response()
}
Err(error) => json_error(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()),
}
}
pub async fn stop_recording(
State(state): State<DevServerState>,
request: Option<Json<RecordingStopRequest>>,
) -> impl IntoResponse {
let session_id = request.and_then(|Json(request)| request.session_id);
match state.recording.stop(session_id.as_deref()).await {
Ok(recording) => Json(serde_json::json!({
"ok": true,
"recording": recording,
}))
.into_response(),
Err(error) => json_error(StatusCode::CONFLICT, error.to_string()),
}
}
pub async fn recording_started(
State(state): State<DevServerState>,
Json(request): Json<RecordingStartedRequest>,
) -> impl IntoResponse {
match state
.recording
.mark_started(&request.session_id, &request.mime_type)
.await
{
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
Err(error) => json_error(StatusCode::CONFLICT, error.to_string()),
}
}
pub async fn recording_chunk(
State(state): State<DevServerState>,
headers: HeaderMap,
body: Bytes,
) -> impl IntoResponse {
let Some(session_id) = header_value(&headers, "x-rdesktop-recording-id") else {
return json_error(
StatusCode::BAD_REQUEST,
"missing recording session header".to_string(),
);
};
if body.is_empty() {
return Json(serde_json::json!({ "ok": true, "bytes": 0 })).into_response();
}
match state.recording.append_chunk(&session_id, &body).await {
Ok(bytes) => Json(serde_json::json!({ "ok": true, "bytes": bytes })).into_response(),
Err(error) => json_error(StatusCode::CONFLICT, error.to_string()),
}
}
pub async fn recording_complete(
State(state): State<DevServerState>,
Json(request): Json<RecordingCompleteRequest>,
) -> impl IntoResponse {
match state
.recording
.complete(&request.session_id, request.mime_type.as_deref())
.await
{
Ok(recording) => recording_response(recording),
Err(error) => json_error(StatusCode::CONFLICT, error.to_string()),
}
}
pub async fn recording_error(
State(state): State<DevServerState>,
Json(request): Json<RecordingErrorRequest>,
) -> impl IntoResponse {
match state
.recording
.fail(&request.session_id, request.error)
.await
{
Ok(recording) => recording_response(recording),
Err(error) => json_error(StatusCode::CONFLICT, error.to_string()),
}
}
pub async fn recording_file(State(state): State<DevServerState>) -> impl IntoResponse {
let recording = state.recording.snapshot().await;
if recording.status != RecordingStatus::Completed {
return json_error(
StatusCode::NOT_FOUND,
format!("recording is not complete: {:?}", recording.status),
);
}
match tokio::fs::read(&recording.path).await {
Ok(bytes) => axum::response::Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
recording.mime_type.as_deref().unwrap_or("video/webm"),
)
.header(
header::CONTENT_DISPOSITION,
if recording
.mime_type
.as_deref()
.map(|mime| mime.starts_with("video/mp4"))
.unwrap_or(false)
{
"attachment; filename=recording.mp4"
} else {
"attachment; filename=recording.webm"
},
)
.body(axum::body::Body::from(bytes))
.expect("recording response is valid")
.into_response(),
Err(error) => json_error(StatusCode::NOT_FOUND, error.to_string()),
}
}
fn recording_response(recording: RecordingSnapshot) -> axum::response::Response {
Json(serde_json::json!({
"ok": recording.status == RecordingStatus::Completed,
"recording": recording,
}))
.into_response()
}
fn header_value(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.map(str::to_owned)
}
fn json_error(status: StatusCode, error: String) -> axum::response::Response {
(
status,
Json(serde_json::json!({ "ok": false, "error": error })),
)
.into_response()
}
fn timestamp() -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
format!("{}", now.as_secs())
}
fn find_elements(html: &str, query: &ElementQuery) -> Vec<ElementInfo> {
let mut elements = vec![];
if let Some(ref selector) = query.selector {
let tag = selector.trim_start_matches('<').trim_end_matches('>');
let open_tag = format!("<{}", tag);
let mut start = 0;
while let Some(pos) = html[start..].find(&open_tag) {
let abs_pos = start + pos;
let end = html[abs_pos..].find('>').unwrap_or(0);
let _tag_content = &html[abs_pos..abs_pos + end + 1];
let close_tag = format!("</{}>", tag);
let text_start = abs_pos + end + 1;
let text = if let Some(text_end) = html[text_start..].find(&close_tag) {
html[text_start..text_start + text_end].trim().to_string()
} else {
String::new()
};
elements.push(ElementInfo {
selector: format!("{}:nth-of-type({})", tag, elements.len() + 1),
tag: tag.to_string(),
text,
attributes: HashMap::new(),
visible: true,
enabled: true,
role: None,
label: None,
});
start = abs_pos + end + 1;
}
}
if let Some(ref text_query) = query.text {
let lower_html = html.to_lowercase();
let lower_query = text_query.to_lowercase();
if lower_html.contains(&lower_query) {
elements.push(ElementInfo {
selector: format!("*:contains(\"{}\")", text_query),
tag: "*".to_string(),
text: text_query.clone(),
attributes: HashMap::new(),
visible: true,
enabled: true,
role: None,
label: None,
});
}
}
elements
}