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
|
|
@ -11,27 +11,23 @@ use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::Message, Con
|
|||
|
||||
use base64::Engine;
|
||||
use helios_common::{ClientMessage, ServerMessage};
|
||||
use uuid::Uuid;
|
||||
use helios_common::protocol::{is_valid_label, sanitize_label};
|
||||
|
||||
mod display;
|
||||
mod logger;
|
||||
mod shell;
|
||||
mod screenshot;
|
||||
mod input;
|
||||
mod windows_mgmt;
|
||||
|
||||
// Re-export trunc for use in this file
|
||||
use display::trunc;
|
||||
|
||||
fn banner() {
|
||||
println!();
|
||||
// Use same column layout as info_line: 2sp + emoji_cell(2w) + 2sp + name(14) + 2sp + value
|
||||
// ☀ is 1-wide → emoji_cell pads to 2 → need 1 extra space to match
|
||||
println!(" {} {}", "☀ ".yellow().bold(), "HELIOS REMOTE".bold());
|
||||
display::info_line("🔗", "commit:", &env!("GIT_COMMIT").dimmed().to_string());
|
||||
}
|
||||
|
||||
fn print_session_info(label: &str, sid: &uuid::Uuid) {
|
||||
fn print_device_info(label: &str) {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let admin = is_admin();
|
||||
|
|
@ -46,7 +42,6 @@ fn print_session_info(label: &str, sid: &uuid::Uuid) {
|
|||
display::info_line("👤", "privileges:", &"no admin".dimmed().to_string());
|
||||
|
||||
display::info_line("🖥", "device:", &label.dimmed().to_string());
|
||||
display::info_line("🪪", "session:", &sid.to_string().dimmed().to_string());
|
||||
println!();
|
||||
}
|
||||
|
||||
|
|
@ -72,14 +67,69 @@ fn enable_ansi() {
|
|||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// ── Single instance enforcement ─────────────────────────────────────────────
|
||||
|
||||
fn lock_file_path() -> PathBuf {
|
||||
let base = dirs::config_dir()
|
||||
.or_else(|| dirs::home_dir())
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
base.join("helios-remote").join("instance.lock")
|
||||
}
|
||||
|
||||
/// Try to acquire a single-instance lock. Returns true if we got it.
|
||||
fn acquire_instance_lock() -> bool {
|
||||
let path = lock_file_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
// Check if another instance is running
|
||||
if path.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
if let Ok(pid) = content.trim().parse::<u32>() {
|
||||
// Check if process is still alive
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use windows::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
|
||||
let alive = unsafe {
|
||||
OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid).is_ok()
|
||||
};
|
||||
if alive {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
use std::process::Command;
|
||||
let alive = Command::new("kill")
|
||||
.args(["-0", &pid.to_string()])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if alive {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write our PID
|
||||
let pid = std::process::id();
|
||||
std::fs::write(&path, pid.to_string()).is_ok()
|
||||
}
|
||||
|
||||
fn release_instance_lock() {
|
||||
let _ = std::fs::remove_file(lock_file_path());
|
||||
}
|
||||
|
||||
// ── Config ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct Config {
|
||||
relay_url: String,
|
||||
api_key: String,
|
||||
label: Option<String>,
|
||||
session_id: Option<String>, // persistent UUID
|
||||
label: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
|
|
@ -131,39 +181,73 @@ fn prompt_config() -> Config {
|
|||
};
|
||||
|
||||
let label = {
|
||||
let default_label = hostname();
|
||||
print!(" {} Label for this PC [{}]: ", "→".cyan().bold(), default_label);
|
||||
std::io::stdout().flush().unwrap();
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input).unwrap();
|
||||
let trimmed = input.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
Some(default_label)
|
||||
} else {
|
||||
Some(trimmed)
|
||||
let default_label = sanitize_label(&hostname());
|
||||
loop {
|
||||
print!(" {} Device label [{}]: ", "→".cyan().bold(), default_label);
|
||||
std::io::stdout().flush().unwrap();
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input).unwrap();
|
||||
let trimmed = input.trim();
|
||||
let candidate = if trimmed.is_empty() {
|
||||
default_label.clone()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
};
|
||||
|
||||
if is_valid_label(&candidate) {
|
||||
break candidate;
|
||||
}
|
||||
|
||||
println!(" {} Label must be lowercase, no spaces. Only a-z, 0-9, '-', '_'.",
|
||||
"✗".red().bold());
|
||||
println!(" Suggestion: {}", sanitize_label(&candidate).cyan());
|
||||
}
|
||||
};
|
||||
|
||||
Config { relay_url, api_key, label, session_id: None }
|
||||
Config { relay_url, api_key, label }
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Enable ANSI color codes on Windows (required when running as admin)
|
||||
#[cfg(windows)]
|
||||
enable_ansi();
|
||||
logger::init();
|
||||
|
||||
// Suppress tracing output by default
|
||||
if std::env::var("RUST_LOG").is_err() {
|
||||
unsafe { std::env::set_var("RUST_LOG", "off"); }
|
||||
}
|
||||
|
||||
banner();
|
||||
|
||||
// Single instance check
|
||||
if !acquire_instance_lock() {
|
||||
display::err("❌", "Another instance of helios-remote is already running.");
|
||||
display::err("", "Only one instance per device is allowed.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Clean up lock on exit
|
||||
let _guard = scopeguard::guard((), |_| release_instance_lock());
|
||||
|
||||
// Load or prompt for config
|
||||
let config = match Config::load() {
|
||||
Some(c) => c,
|
||||
Some(c) => {
|
||||
// Validate existing label
|
||||
if !is_valid_label(&c.label) {
|
||||
let new_label = sanitize_label(&c.label);
|
||||
display::info_line("⚠", "migrate:", &format!(
|
||||
"Label '{}' is invalid, migrating to '{}'", c.label, new_label
|
||||
));
|
||||
let mut cfg = c;
|
||||
cfg.label = new_label;
|
||||
if let Err(e) = cfg.save() {
|
||||
display::err("❌", &format!("Failed to save config: {e}"));
|
||||
}
|
||||
cfg
|
||||
} else {
|
||||
c
|
||||
}
|
||||
}
|
||||
None => {
|
||||
display::info_line("ℹ", "setup:", "No config found — first-time setup");
|
||||
println!();
|
||||
|
|
@ -178,22 +262,8 @@ async fn main() {
|
|||
}
|
||||
};
|
||||
|
||||
// Resolve or generate persistent session UUID
|
||||
let sid: Uuid = match &config.session_id {
|
||||
Some(id) => Uuid::parse_str(id).unwrap_or_else(|_| Uuid::new_v4()),
|
||||
None => {
|
||||
let id = Uuid::new_v4();
|
||||
let mut cfg = config.clone();
|
||||
cfg.session_id = Some(id.to_string());
|
||||
if let Err(e) = cfg.save() {
|
||||
display::err("❌", &format!("Failed to save session_id: {e}"));
|
||||
}
|
||||
id
|
||||
}
|
||||
};
|
||||
|
||||
let label = config.label.clone().unwrap_or_else(|| hostname());
|
||||
print_session_info(&label, &sid);
|
||||
let label = config.label.clone();
|
||||
print_device_info(&label);
|
||||
|
||||
let config = Arc::new(config);
|
||||
let shell = Arc::new(Mutex::new(shell::PersistentShell::new()));
|
||||
|
|
@ -225,9 +295,9 @@ async fn main() {
|
|||
|
||||
let (mut write, mut read) = ws_stream.split();
|
||||
|
||||
// Send Hello
|
||||
// Send Hello with device label
|
||||
let hello = ClientMessage::Hello {
|
||||
label: config.label.clone(),
|
||||
label: label.clone(),
|
||||
};
|
||||
let hello_json = serde_json::to_string(&hello).unwrap();
|
||||
if let Err(e) = write.send(Message::Text(hello_json)).await {
|
||||
|
|
@ -254,9 +324,6 @@ async fn main() {
|
|||
let shell_clone = Arc::clone(&shell);
|
||||
|
||||
tokio::spawn(async move {
|
||||
// tokio isolates panics per task — a panic here won't kill
|
||||
// the main loop. handle_message uses map_err everywhere so
|
||||
// it should never panic in practice.
|
||||
let response = handle_message(server_msg, shell_clone).await;
|
||||
let json = match serde_json::to_string(&response) {
|
||||
Ok(j) => j,
|
||||
|
|
@ -343,14 +410,14 @@ async fn handle_message(
|
|||
}
|
||||
|
||||
ServerMessage::ScreenshotRequest { request_id } => {
|
||||
display::cmd_start("📷", "screenshot", "");
|
||||
display::cmd_start("📷", "screenshot", "screen");
|
||||
match screenshot::take_screenshot() {
|
||||
Ok((image_base64, width, height)) => {
|
||||
display::cmd_done("📷", "screenshot", "", true, &format!("{width}×{height}"));
|
||||
display::cmd_done("📷", "screenshot", "screen", true, &format!("{width}×{height}"));
|
||||
ClientMessage::ScreenshotResponse { request_id, image_base64, width, height }
|
||||
}
|
||||
Err(e) => {
|
||||
display::cmd_done("📷", "screenshot", "", false, &format!("{e}"));
|
||||
display::cmd_done("📷", "screenshot", "screen", false, &format!("{e}"));
|
||||
ClientMessage::Error { request_id, message: format!("Screenshot failed: {e}") }
|
||||
}
|
||||
}
|
||||
|
|
@ -358,7 +425,6 @@ async fn handle_message(
|
|||
|
||||
ServerMessage::PromptRequest { request_id, message, title: _ } => {
|
||||
display::prompt_waiting(&message);
|
||||
// Read user input from stdin (blocking)
|
||||
let answer = tokio::task::spawn_blocking(|| {
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input).ok();
|
||||
|
|
@ -375,8 +441,6 @@ async fn handle_message(
|
|||
match sh.run(&command, timeout_ms).await {
|
||||
Ok((stdout, stderr, exit_code)) => {
|
||||
let result = if exit_code != 0 {
|
||||
// For errors: use first non-empty stderr line (actual error message),
|
||||
// ignoring PowerShell boilerplate like "+ CategoryInfo", "In Zeile:", etc.
|
||||
let err_line = stderr.lines()
|
||||
.map(|l| l.trim())
|
||||
.find(|l| !l.is_empty()
|
||||
|
|
@ -388,7 +452,6 @@ async fn handle_message(
|
|||
.to_string();
|
||||
err_line
|
||||
} else {
|
||||
// Success: first stdout line, no exit code
|
||||
stdout.trim().lines().next().unwrap_or("").to_string()
|
||||
};
|
||||
display::cmd_done("⚡", "execute", &payload, exit_code == 0, &result);
|
||||
|
|
@ -401,36 +464,6 @@ async fn handle_message(
|
|||
}
|
||||
}
|
||||
|
||||
ServerMessage::ClickRequest { request_id, x, y, button } => {
|
||||
let payload = format!("({x}, {y}) {button:?}");
|
||||
display::cmd_start("🖱", "click", &payload);
|
||||
match input::click(x, y, &button) {
|
||||
Ok(()) => {
|
||||
display::cmd_done("🖱", "click", &payload, true, "done");
|
||||
ClientMessage::Ack { request_id }
|
||||
}
|
||||
Err(e) => {
|
||||
display::cmd_done("🖱", "click", &payload, false, &format!("{e}"));
|
||||
ClientMessage::Error { request_id, message: format!("Click at ({x},{y}) failed: {e}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ServerMessage::TypeRequest { request_id, text } => {
|
||||
let payload = format!("{} chars", text.len());
|
||||
display::cmd_start("⌨", "type", &payload);
|
||||
match input::type_text(&text) {
|
||||
Ok(()) => {
|
||||
display::cmd_done("⌨", "type", &payload, true, "done");
|
||||
ClientMessage::Ack { request_id }
|
||||
}
|
||||
Err(e) => {
|
||||
display::cmd_done("⌨", "type", &payload, false, &format!("{e}"));
|
||||
ClientMessage::Error { request_id, message: format!("Type failed: {e}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ServerMessage::ListWindowsRequest { request_id } => {
|
||||
display::cmd_start("🪟", "list windows", "");
|
||||
match windows_mgmt::list_windows() {
|
||||
|
|
@ -610,7 +643,7 @@ async fn handle_message(
|
|||
if let Some(rid) = request_id {
|
||||
ClientMessage::Ack { request_id: rid }
|
||||
} else {
|
||||
ClientMessage::Hello { label: None }
|
||||
ClientMessage::Hello { label: String::new() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue