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:
Helios 2026-03-02 18:32:55 +01:00
parent c2ff818506
commit 04527ae1bf
No known key found for this signature in database
GPG key ID: C8259547CD8309B5
6 changed files with 793 additions and 7 deletions

View file

@ -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 }
}
}
}
}