diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 90210e3..dd83fe4 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -35,4 +35,5 @@ windows = { version = "0.54", features = [ "Win32_UI_Input_KeyboardAndMouse", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", + "Win32_UI_Shell", ] } diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs index 19b435b..b867fb3 100644 --- a/crates/client/src/main.rs +++ b/crates/client/src/main.rs @@ -23,9 +23,26 @@ mod windows_mgmt; fn banner() { println!(); println!(" {} HELIOS REMOTE v{} ({})", "☀".yellow().bold(), env!("CARGO_PKG_VERSION"), env!("GIT_COMMIT")); + #[cfg(windows)] + { + let admin = is_admin(); + let admin_str = if admin { + "admin".green().bold() + } else { + "user (no admin)".yellow() + }; + println!(" {} running as {}", "─".repeat(20).dimmed(), admin_str); + } + #[cfg(not(windows))] println!(" {}", "─".repeat(45).dimmed()); } +#[cfg(windows)] +fn is_admin() -> bool { + use windows::Win32::UI::Shell::IsUserAnAdmin; + unsafe { IsUserAnAdmin().as_bool() } +} + macro_rules! log_status { ($($arg:tt)*) => { println!(" {} {}", "→".cyan().bold(), format!($($arg)*)); @@ -325,6 +342,37 @@ async fn handle_message( } } + ServerMessage::PromptRequest { request_id, message, title } => { + let title = title.unwrap_or_else(|| "Helios Remote".to_string()); + log_cmd!("prompt › {}", &message[..message.len().min(60)]); + #[cfg(windows)] + { + use windows::core::PCWSTR; + use windows::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_OK, MB_ICONINFORMATION, HWND_DESKTOP}; + let msg_wide: Vec = message.encode_utf16().chain(std::iter::once(0)).collect(); + let title_wide: Vec = title.encode_utf16().chain(std::iter::once(0)).collect(); + // Run blocking MessageBox in a thread so we don't block the async runtime + let msg_clone = message.clone(); + tokio::task::spawn_blocking(move || { + unsafe { + MessageBoxW( + HWND_DESKTOP, + PCWSTR(msg_wide.as_ptr()), + PCWSTR(title_wide.as_ptr()), + MB_OK | MB_ICONINFORMATION, + ); + } + }).await.ok(); + log_ok!("User confirmed: {}", &msg_clone[..msg_clone.len().min(40)]); + } + #[cfg(not(windows))] + { + // On non-Windows just log it + println!(" [PROMPT] {}", message); + } + ClientMessage::Ack { request_id } + } + ServerMessage::ExecRequest { request_id, command } => { // Truncate long commands for display let cmd_display = if command.len() > 60 { diff --git a/crates/common/src/protocol.rs b/crates/common/src/protocol.rs index 70120bc..a239176 100644 --- a/crates/common/src/protocol.rs +++ b/crates/common/src/protocol.rs @@ -15,6 +15,14 @@ pub struct WindowInfo { pub enum ServerMessage { /// Request a screenshot from the client ScreenshotRequest { request_id: Uuid }, + /// 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. + PromptRequest { + request_id: Uuid, + message: String, + title: Option, + }, /// Execute a shell command on the client ExecRequest { request_id: Uuid, diff --git a/crates/server/src/api.rs b/crates/server/src/api.rs index a6269d0..4d75031 100644 --- a/crates/server/src/api.rs +++ b/crates/server/src/api.rs @@ -479,6 +479,30 @@ pub async fn clipboard_set( } } +/// POST /sessions/:id/prompt +#[derive(Deserialize)] +pub struct PromptBody { + pub message: String, + pub title: Option, +} + +pub async fn prompt_user( + Path(session_id): Path, + State(state): State, + Json(body): Json, +) -> impl IntoResponse { + match dispatch(&state, &session_id, "prompt", |rid| ServerMessage::PromptRequest { + request_id: rid, + message: body.message.clone(), + title: body.title.clone(), + }) + .await + { + Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(), + Err(e) => e.into_response(), + } +} + /// POST /sessions/:id/label #[derive(Deserialize)] pub struct LabelBody { diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 1f8d35f..8fbfc01 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -51,6 +51,7 @@ async fn main() -> anyhow::Result<()> { .route("/sessions/:id/click", post(api::request_click)) .route("/sessions/:id/type", post(api::request_type)) .route("/sessions/:id/label", post(api::set_label)) + .route("/sessions/:id/prompt", post(api::prompt_user)) .route("/sessions/:id/windows", get(api::list_windows)) .route("/sessions/:id/windows/minimize-all", post(api::minimize_all)) .route("/sessions/:id/windows/:window_id/focus", post(api::focus_window)) diff --git a/skills/remote.py b/skills/remote.py index 801a5dc..65373fa 100644 --- a/skills/remote.py +++ b/skills/remote.py @@ -287,6 +287,18 @@ def cmd_find_window(args): print(f"{wid:<20} {title}") +def cmd_prompt(args): + """Show a MessageBox on the remote PC asking the user to do something. + Blocks until the user clicks OK — use this when the AI needs the user + to perform a manual action (e.g. click a button, confirm a dialog).""" + sid = resolve_session(args.session_id) + body = {"message": args.message} + if args.title: + body["title"] = args.title + _req("POST", f"/sessions/{sid}/prompt", json=body) + print(f"User confirmed prompt on session {sid!r}.") + + def cmd_run(args): """Launch a program on the remote session (fire-and-forget).""" sid = resolve_session(args.session_id) @@ -374,6 +386,12 @@ def build_parser() -> argparse.ArgumentParser: fwp.add_argument("session_id") fwp.add_argument("title", help="Substring to search for (case-insensitive)") + pp = sub.add_parser("prompt", help="Show a MessageBox asking the user to do something manually") + pp.add_argument("session_id") + pp.add_argument("message", help="What to ask the user (e.g. 'Please click Save, then OK')") + pp.add_argument("--title", default=None, help="Dialog title (default: Helios Remote)") + pp.set_defaults(func=cmd_prompt) + rp = sub.add_parser("run", help="Launch a program on the remote session (fire-and-forget)") rp.add_argument("session_id") rp.add_argument("program", help="Program to launch (e.g. notepad.exe)")