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:
Helios 2026-03-06 01:55:28 +01:00
parent 5fd01a423d
commit 0b4a6de8ae
No known key found for this signature in database
GPG key ID: C8259547CD8309B5
14 changed files with 736 additions and 1180 deletions

View file

@ -1,27 +1,58 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Information about a single window on the client machine
/// Information about a single window on the client machine.
/// `label` is a human-readable, lowercase identifier (e.g. "google_chrome", "discord").
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WindowInfo {
pub id: u64,
pub title: String,
pub label: String,
pub visible: bool,
}
/// Validate a device/window label: lowercase, no whitespace, only a-z 0-9 - _
pub fn is_valid_label(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
}
/// Convert an arbitrary string into a valid label.
/// Lowercase, replace whitespace and invalid chars with '_', collapse runs.
pub fn sanitize_label(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut prev_underscore = false;
for c in s.chars() {
if c.is_ascii_alphanumeric() {
result.push(c.to_ascii_lowercase());
prev_underscore = false;
} else if c == '-' {
result.push('-');
prev_underscore = false;
} else {
// Replace whitespace and other chars with _
if !prev_underscore && !result.is_empty() {
result.push('_');
prev_underscore = true;
}
}
}
// Trim trailing _
result.trim_end_matches('_').to_string()
}
/// Messages sent from the relay server to a connected client
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerMessage {
/// Request a screenshot from the client
/// Request a full-screen screenshot
ScreenshotRequest { request_id: Uuid },
/// Capture a specific window by its HWND (works even if behind other windows)
/// Capture a specific window by its HWND
WindowScreenshotRequest { request_id: Uuid, window_id: u64 },
/// Fetch the last N lines of the client log file
LogsRequest { request_id: Uuid, lines: u32 },
/// Show a MessageBox on the client asking the user to do something.
/// Blocks until the user clicks OK — use this when you need the user
/// to perform a manual action before continuing.
/// Show a MessageBox on the client asking the user to do something
PromptRequest {
request_id: Uuid,
message: String,
@ -31,21 +62,8 @@ pub enum ServerMessage {
ExecRequest {
request_id: Uuid,
command: String,
/// Timeout in milliseconds. None = use client default (30s)
timeout_ms: Option<u64>,
},
/// Simulate a mouse click
ClickRequest {
request_id: Uuid,
x: i32,
y: i32,
button: MouseButton,
},
/// Type text on the client
TypeRequest {
request_id: Uuid,
text: String,
},
/// Acknowledge a client message
Ack { request_id: Uuid },
/// Server-side error response
@ -90,8 +108,8 @@ pub enum ServerMessage {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientMessage {
/// Client registers itself with optional display name
Hello { label: Option<String> },
/// Client registers itself with its device label
Hello { label: String },
/// Response to a screenshot request — base64-encoded PNG
ScreenshotResponse {
request_id: Uuid,
@ -106,7 +124,7 @@ pub enum ClientMessage {
stderr: String,
exit_code: i32,
},
/// Generic acknowledgement for click/type/minimize-all/focus/maximize
/// Generic acknowledgement
Ack { request_id: Uuid },
/// Client error response
Error {
@ -137,29 +155,33 @@ pub enum ClientMessage {
},
/// Response to a clipboard-get request
ClipboardGetResponse { request_id: Uuid, text: String },
/// Response to a prompt request — contains the user's typed answer
/// Response to a prompt request
PromptResponse { request_id: Uuid, answer: String },
}
/// Mouse button variants
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MouseButton {
Left,
Right,
Middle,
}
impl Default for MouseButton {
fn default() -> Self {
MouseButton::Left
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_labels() {
assert!(is_valid_label("moritz_pc"));
assert!(is_valid_label("my-desktop"));
assert!(is_valid_label("pc01"));
assert!(!is_valid_label("Moritz PC"));
assert!(!is_valid_label(""));
assert!(!is_valid_label("has spaces"));
assert!(!is_valid_label("UPPER"));
}
#[test]
fn test_sanitize_label() {
assert_eq!(sanitize_label("Moritz PC"), "moritz_pc");
assert_eq!(sanitize_label("My Desktop!!"), "my_desktop");
assert_eq!(sanitize_label("hello-world"), "hello-world");
assert_eq!(sanitize_label("DESKTOP-ABC123"), "desktop-abc123");
}
#[test]
fn test_server_message_serialization() {
let msg = ServerMessage::ExecRequest {
@ -174,25 +196,9 @@ mod tests {
#[test]
fn test_client_message_serialization() {
let msg = ClientMessage::Hello { label: Some("test-pc".into()) };
let msg = ClientMessage::Hello { label: "test-pc".into() };
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("hello"));
assert!(json.contains("test-pc"));
}
#[test]
fn test_roundtrip() {
let msg = ClientMessage::ExecResponse {
request_id: Uuid::nil(),
stdout: "hello\n".into(),
stderr: String::new(),
exit_code: 0,
};
let json = serde_json::to_string(&msg).unwrap();
let decoded: ClientMessage = serde_json::from_str(&json).unwrap();
match decoded {
ClientMessage::ExecResponse { exit_code, .. } => assert_eq!(exit_code, 0),
_ => panic!("wrong variant"),
}
}
}