feat(client): implement Windows client (Phase 2)
- Persistent shell session (cmd.exe) preserving cd state between commands - Screenshot capture via Windows GDI (BGRA→RGBA→PNG→Base64) - Mouse click via SendInput with absolute screen coordinates - Text input via SendInput with Unicode (UTF-16) key events - Auto-reconnect with exponential backoff (max 30s) - Config stored in %APPDATA%/helios-remote/config.json - All Windows APIs under #[cfg(windows)] for cross-compile safety - CI: add Windows cross-compile job (x86_64-pc-windows-gnu) with artifact upload
This commit is contained in:
parent
c2ff818506
commit
04527ae1bf
6 changed files with 793 additions and 7 deletions
|
|
@ -1,7 +1,279 @@
|
|||
// helios-client — Phase 2 (not yet implemented)
|
||||
// See crates/client/README.md for the planned implementation.
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
fn main() {
|
||||
eprintln!("helios-client is not yet implemented. See crates/client/README.md.");
|
||||
std::process::exit(1);
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use helios_common::{ClientMessage, ServerMessage};
|
||||
|
||||
mod shell;
|
||||
mod screenshot;
|
||||
mod input;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Config {
|
||||
relay_url: String,
|
||||
relay_code: String,
|
||||
label: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
fn config_path() -> PathBuf {
|
||||
let base = dirs::config_dir()
|
||||
.or_else(|| dirs::home_dir())
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
base.join("helios-remote").join("config.json")
|
||||
}
|
||||
|
||||
fn load() -> Option<Self> {
|
||||
let path = Self::config_path();
|
||||
let data = std::fs::read_to_string(&path).ok()?;
|
||||
serde_json::from_str(&data).ok()
|
||||
}
|
||||
|
||||
fn save(&self) -> std::io::Result<()> {
|
||||
let path = Self::config_path();
|
||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||
let data = serde_json::to_string_pretty(self).unwrap();
|
||||
std::fs::write(&path, data)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_config() -> Config {
|
||||
let relay_url = {
|
||||
println!("Relay server URL [default: ws://46.225.185.232:8765/ws]: ");
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input).unwrap();
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
"ws://46.225.185.232:8765/ws".to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let relay_code = {
|
||||
println!("Enter relay code: ");
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input).unwrap();
|
||||
input.trim().to_string()
|
||||
};
|
||||
|
||||
let label = {
|
||||
println!("Label for this machine (optional, press Enter to skip): ");
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input).unwrap();
|
||||
let trimmed = input.trim().to_string();
|
||||
if trimmed.is_empty() { None } else { Some(trimmed) }
|
||||
};
|
||||
|
||||
Config { relay_url, relay_code, label }
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
std::env::var("RUST_LOG")
|
||||
.unwrap_or_else(|_| "helios_client=info".to_string()),
|
||||
)
|
||||
.init();
|
||||
|
||||
// Load or prompt for config
|
||||
let config = match Config::load() {
|
||||
Some(c) => {
|
||||
info!("Loaded config from {:?}", Config::config_path());
|
||||
c
|
||||
}
|
||||
None => {
|
||||
info!("No config found — prompting for setup");
|
||||
let c = prompt_config();
|
||||
if let Err(e) = c.save() {
|
||||
error!("Failed to save config: {e}");
|
||||
} else {
|
||||
info!("Config saved to {:?}", Config::config_path());
|
||||
}
|
||||
c
|
||||
}
|
||||
};
|
||||
|
||||
let config = Arc::new(config);
|
||||
let shell = Arc::new(Mutex::new(shell::PersistentShell::new()));
|
||||
|
||||
// Connect with exponential backoff
|
||||
let mut backoff = Duration::from_secs(1);
|
||||
const MAX_BACKOFF: Duration = Duration::from_secs(30);
|
||||
|
||||
loop {
|
||||
info!("Connecting to {}", config.relay_url);
|
||||
match connect_async(&config.relay_url).await {
|
||||
Ok((ws_stream, _)) => {
|
||||
info!("Connected!");
|
||||
backoff = Duration::from_secs(1); // reset on success
|
||||
|
||||
let (mut write, mut read) = ws_stream.split();
|
||||
|
||||
// Send Hello
|
||||
let hello = ClientMessage::Hello {
|
||||
label: config.label.clone(),
|
||||
};
|
||||
let hello_json = serde_json::to_string(&hello).unwrap();
|
||||
if let Err(e) = write.send(Message::Text(hello_json)).await {
|
||||
error!("Failed to send Hello: {e}");
|
||||
tokio::time::sleep(backoff).await;
|
||||
backoff = (backoff * 2).min(MAX_BACKOFF);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Shared write half
|
||||
let write = Arc::new(Mutex::new(write));
|
||||
|
||||
// Process messages
|
||||
while let Some(msg_result) = read.next().await {
|
||||
match msg_result {
|
||||
Ok(Message::Text(text)) => {
|
||||
let server_msg: ServerMessage = match serde_json::from_str(&text) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
warn!("Failed to parse server message: {e}\nRaw: {text}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let write_clone = Arc::clone(&write);
|
||||
let shell_clone = Arc::clone(&shell);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let response = handle_message(server_msg, shell_clone).await;
|
||||
let json = serde_json::to_string(&response).unwrap();
|
||||
let mut w = write_clone.lock().await;
|
||||
if let Err(e) = w.send(Message::Text(json)).await {
|
||||
error!("Failed to send response: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(Message::Ping(data)) => {
|
||||
let mut w = write.lock().await;
|
||||
let _ = w.send(Message::Pong(data)).await;
|
||||
}
|
||||
Ok(Message::Close(_)) => {
|
||||
info!("Server closed connection");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("WebSocket error: {e}");
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
warn!("Disconnected. Reconnecting in {:?}...", backoff);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Connection failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(backoff).await;
|
||||
backoff = (backoff * 2).min(MAX_BACKOFF);
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_message(
|
||||
msg: ServerMessage,
|
||||
shell: Arc<Mutex<shell::PersistentShell>>,
|
||||
) -> ClientMessage {
|
||||
match msg {
|
||||
ServerMessage::ScreenshotRequest { request_id } => {
|
||||
match screenshot::take_screenshot() {
|
||||
Ok((image_base64, width, height)) => ClientMessage::ScreenshotResponse {
|
||||
request_id,
|
||||
image_base64,
|
||||
width,
|
||||
height,
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Screenshot failed: {e}");
|
||||
ClientMessage::Error {
|
||||
request_id,
|
||||
message: format!("Screenshot failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ServerMessage::ExecRequest { request_id, command } => {
|
||||
info!("Exec: {command}");
|
||||
let mut sh = shell.lock().await;
|
||||
match sh.run(&command).await {
|
||||
Ok((stdout, stderr, exit_code)) => ClientMessage::ExecResponse {
|
||||
request_id,
|
||||
stdout,
|
||||
stderr,
|
||||
exit_code,
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Exec failed for command {:?}: {e}", command);
|
||||
ClientMessage::Error {
|
||||
request_id,
|
||||
message: format!(
|
||||
"Exec failed for command {:?}.\nError: {e}\nContext: persistent shell may have died.",
|
||||
command
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ServerMessage::ClickRequest { request_id, x, y, button } => {
|
||||
info!("Click: ({x},{y}) {:?}", button);
|
||||
match input::click(x, y, &button) {
|
||||
Ok(()) => ClientMessage::Ack { request_id },
|
||||
Err(e) => {
|
||||
error!("Click failed at ({x},{y}): {e}");
|
||||
ClientMessage::Error {
|
||||
request_id,
|
||||
message: format!("Click at ({x},{y}) failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ServerMessage::TypeRequest { request_id, text } => {
|
||||
info!("Type: {} chars", text.len());
|
||||
match input::type_text(&text) {
|
||||
Ok(()) => ClientMessage::Ack { request_id },
|
||||
Err(e) => {
|
||||
error!("Type failed: {e}");
|
||||
ClientMessage::Error {
|
||||
request_id,
|
||||
message: format!("Type failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ServerMessage::Ack { request_id } => {
|
||||
info!("Server ack for {request_id}");
|
||||
// Nothing to do - server acked something we sent
|
||||
ClientMessage::Ack { request_id }
|
||||
}
|
||||
|
||||
ServerMessage::Error { request_id, message } => {
|
||||
error!("Server error (req={request_id:?}): {message}");
|
||||
// No meaningful response needed but we need to return something
|
||||
// Use a dummy ack if we have a request_id
|
||||
if let Some(rid) = request_id {
|
||||
ClientMessage::Ack { request_id: rid }
|
||||
} else {
|
||||
ClientMessage::Hello { label: None }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue