use forgewright::{cdp, selector};
#[cfg(target_os = "windows")]
use forgewright::uia;
#[cfg(target_os = "windows")]
use forgewright::win32;
use forgewright::{burst, gate, macro_compiler, spooler};
#[cfg(target_os = "windows")]
use forgewright::dxgi;
use std::env;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Duration;
use serde_json::{json, Value};
const DEFAULT_PORT: u16 = 9333;
const DEFAULT_URL: &str = "about:blank";
#[derive(Debug)]
enum Target {
Cdp { host: String, port: u16 },
Uia { window: String },
Win32 { window: String },
Wright { addr: String }, }
#[tokio::main]
async fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
print_usage();
std::process::exit(2);
}
let result: Result<(), Box<dyn std::error::Error>> = match args[1].as_str() {
"spawn" => cmd_spawn(&args[2..]),
"drive" => cmd_drive(&args[2..]).await,
"login" => cmd_login(&args[2..]).await,
"--help" | "-h" | "help" => {
print_usage();
Ok(())
}
other => {
eprintln!("forgewright: unknown subcommand '{}'", other);
print_usage();
std::process::exit(2);
}
};
if let Err(e) = result {
eprintln!("forgewright: error: {}", e);
std::process::exit(1);
}
}
fn print_usage() {
eprintln!("forgewright — standalone UI automation (CDP + UIA)");
eprintln!();
eprintln!("USAGE:");
eprintln!(" forgewright spawn [--port PORT] [--url URL] [--headless]");
eprintln!(" Launch Chromium with --remote-debugging-port exposed.");
eprintln!();
eprintln!(" forgewright drive <action> [args...] [--port PORT] [--target TARGET]");
eprintln!(" One-shot UI action. Prints JSON to stdout.");
eprintln!();
eprintln!(" Targets:");
eprintln!(" --target cdp://HOST:PORT Chrome DevTools Protocol (default)");
eprintln!(" --target uia://WINDOW Windows UI Automation");
eprintln!(" uia://AppName exact title match");
eprintln!(" uia://*AppName* wildcard title");
eprintln!(" uia://pid:12345 process ID");
eprintln!(" uia://class:Notepad window class");
eprintln!();
eprintln!(" CDP actions:");
eprintln!(" url current URL");
eprintln!(" title document.title");
eprintln!(" goto <URL> navigate");
eprintln!(" reload Page.reload");
eprintln!(" screenshot [PATH] capture PNG");
eprintln!(" shot-element <SEL> [PATH] element screenshot");
eprintln!(" click <SELECTOR> click CSS selector");
eprintln!(" type <SELECTOR> <TEXT> fill input");
eprintln!(" eval <JS> evaluate JavaScript");
eprintln!(" query <SELECTOR> describe element");
eprintln!(" wait-for <SEL> [--timeout MS] poll until visible");
eprintln!(" listpages list page targets");
eprintln!();
eprintln!(" UIA actions:");
eprintln!(" click <SELECTOR> click (invoke/toggle/mouse)");
eprintln!(" type <SELECTOR> <TEXT> set value or send keystrokes");
eprintln!(" query <SELECTOR> describe element properties");
eprintln!(" screenshot [PATH] capture window to BMP");
eprintln!(" tree [SELECTOR] [--depth N] dump element tree");
eprintln!(" list-windows list visible windows");
eprintln!(" wait-for <SEL> [--timeout MS] poll until element exists");
eprintln!(" focus <SELECTOR> set focus");
eprintln!(" raw-keys <TEXT> send raw keystrokes");
eprintln!(" expand <SELECTOR> expand combobox/tree node");
eprintln!();
eprintln!(" UIA selector syntax:");
eprintln!(" name:Business Number Name property");
eprintln!(" aid:txtBN AutomationId");
eprintln!(" type:Edit ControlType");
eprintln!(" class:TextBox ClassName");
eprintln!(" name:Save&type:Button AND conditions");
eprintln!(" name:Window > type:Edit tree descent");
eprintln!(" type:Edit[2] index (0-based)");
eprintln!();
eprintln!(" forgewright login [--port PORT]");
eprintln!(" Interactive browser login (email+password, hidden input).");
eprintln!();
eprintln!("DEFAULT PORT: {}", DEFAULT_PORT);
}
fn parse_port(args: &[String]) -> u16 {
let mut i = 0;
while i + 1 < args.len() {
if args[i] == "--port" {
if let Ok(p) = args[i + 1].parse::<u16>() {
return p;
}
}
i += 1;
}
DEFAULT_PORT
}
fn parse_target(args: &[String]) -> Target {
let mut i = 0;
while i + 1 < args.len() {
if args[i] == "--target" {
let spec = &args[i + 1];
if let Some(ws_spec) = spec.strip_prefix("ws://") {
return Target::Wright {
addr: format!("ws://{}", ws_spec),
};
}
if let Some(w32_spec) = spec.strip_prefix("w32://") {
return Target::Win32 {
window: w32_spec.to_string(),
};
}
if let Some(uia_spec) = spec.strip_prefix("uia://") {
return Target::Uia {
window: uia_spec.to_string(),
};
}
if let Some(cdp_spec) = spec.strip_prefix("cdp://") {
let parts: Vec<&str> = cdp_spec.rsplitn(2, ':').collect();
let port = parts
.first()
.and_then(|p| p.parse::<u16>().ok())
.unwrap_or(DEFAULT_PORT);
let host = if parts.len() > 1 {
parts[1].to_string()
} else {
"127.0.0.1".to_string()
};
return Target::Cdp { host, port };
}
return Target::Uia {
window: spec.to_string(),
};
}
i += 1;
}
Target::Cdp {
host: "127.0.0.1".to_string(),
port: parse_port(args),
}
}
fn parse_flag<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
let mut i = 0;
while i + 1 < args.len() {
if args[i] == flag {
return Some(args[i + 1].as_str());
}
i += 1;
}
None
}
fn positional<'a>(args: &'a [String]) -> Vec<&'a str> {
let mut out = Vec::new();
let mut i = 0;
while i < args.len() {
if args[i].starts_with("--") {
i += 2;
continue;
}
out.push(args[i].as_str());
i += 1;
}
out
}
fn cmd_spawn(args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
let port = parse_port(args);
let url = parse_flag(args, "--url").unwrap_or(DEFAULT_URL);
let headless = args.iter().any(|a| a == "--headless");
let candidates = [
"chromium",
"chromium-browser",
"google-chrome",
"google-chrome-stable",
"chrome",
];
let binary = candidates
.iter()
.find_map(|name| which(name))
.ok_or("no chromium binary found in PATH")?;
#[cfg(target_os = "windows")]
let home = env::var("USERPROFILE").unwrap_or_else(|_| "C:\\Users\\Public".to_string());
#[cfg(not(target_os = "windows"))]
let home = env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
let profile_dir = format!("{}/.forgewright/profile", home);
std::fs::create_dir_all(&profile_dir).ok();
eprintln!(
"[forgewright] spawn: {} --remote-debugging-port={} --url={} headless={}",
binary, port, url, headless
);
let mut cmd = Command::new(&binary);
cmd.args([
&format!("--remote-debugging-port={}", port),
"--remote-debugging-address=127.0.0.1",
&format!("--user-data-dir={}", profile_dir),
"--no-first-run",
"--no-default-browser-check",
"--disable-background-networking",
"--disable-features=TranslateUI",
]);
if headless {
cmd.args([
"--headless=new",
"--disable-gpu",
"--no-sandbox",
"--disable-dev-shm-usage",
"--window-size=1440,900",
]);
} else {
cmd.arg("--start-maximized");
}
cmd.arg(url);
cmd.stdout(Stdio::null()).stderr(Stdio::null());
let child = cmd.spawn()?;
let pid = child.id();
println!(
"{}",
json!({
"status": "ok",
"pid": pid,
"port": port,
"cdp_url": format!("http://127.0.0.1:{}", port),
"binary": binary,
"profile_dir": profile_dir,
"url": url,
})
);
std::mem::forget(child);
Ok(())
}
fn which(name: &str) -> Option<String> {
let output = Command::new("which").arg(name).output().ok()?;
if !output.status.success() {
return None;
}
let s = String::from_utf8(output.stdout).ok()?;
let trimmed = s.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
async fn cmd_drive(args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
let target = parse_target(args);
let pos = positional(args);
if pos.is_empty() {
return Err("drive: missing action".into());
}
if pos[0] == "visual-gate" {
return cmd_visual_gate(&pos, args);
}
if pos[0] == "spooler-write" {
return cmd_spooler_write(args);
}
if pos[0] == "spooler-status" {
return cmd_spooler_status();
}
match target {
Target::Cdp { host: _, port } => drive_cdp(port, &pos, args).await,
Target::Uia { window } => drive_uia(&window, &pos, args),
Target::Win32 { window } => drive_win32(&window, &pos, args),
Target::Wright { addr } => drive_wright(&addr, &pos, args).await,
}
}
async fn drive_cdp(
port: u16,
pos: &[&str],
args: &[String],
) -> Result<(), Box<dyn std::error::Error>> {
let action = pos[0];
let mut cdp_conn = cdp::Cdp::connect(port).await?;
let mut result = serde_json::Map::new();
result.insert("status".to_string(), json!("ok"));
result.insert("action".to_string(), json!(action));
result.insert("port".to_string(), json!(port));
result.insert("backend".to_string(), json!("cdp"));
match action {
"url" => {
let v = cdp_conn.evaluate("window.location.href").await?;
result.insert("url".to_string(), v);
}
"title" => {
let v = cdp_conn.evaluate("document.title").await?;
result.insert("title".to_string(), v);
}
"goto" => {
let url = pos.get(1).ok_or("goto: missing URL")?;
cdp_conn.navigate(url).await?;
tokio::time::sleep(Duration::from_millis(1500)).await;
let v = cdp_conn.evaluate("window.location.href").await?;
result.insert("url".to_string(), v);
}
"reload" => {
cdp_conn.send("Page.reload", json!({})).await?;
tokio::time::sleep(Duration::from_millis(800)).await;
let v = cdp_conn.evaluate("window.location.href").await?;
result.insert("url".to_string(), v);
}
"wait-for" => {
let sel = pos.get(1).ok_or("wait-for: missing selector")?;
let timeout_ms: u64 = parse_flag(args, "--timeout")
.and_then(|s| s.parse().ok())
.unwrap_or(5000);
let start = std::time::Instant::now();
let mut found = false;
let js = format!(
r#"(function() {{
const el = document.querySelector({sel});
if (!el) return false;
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
}})()"#,
sel = serde_json::to_string(sel)?
);
while start.elapsed().as_millis() < timeout_ms as u128 {
let v = cdp_conn.evaluate(&js).await?;
if v.as_bool() == Some(true) {
found = true;
break;
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
result.insert("found".to_string(), json!(found));
result.insert("selector".to_string(), json!(sel));
result.insert(
"elapsed_ms".to_string(),
json!(start.elapsed().as_millis() as u64),
);
if !found {
result.insert("status".to_string(), json!("timeout"));
}
}
"shot-element" => {
let sel = pos.get(1).ok_or("shot-element: missing selector")?;
let path = pos
.get(2)
.map(|s| s.to_string())
.unwrap_or_else(default_screenshot_path);
if let Some(parent) = Path::new(&path).parent() {
std::fs::create_dir_all(parent).ok();
}
let js = format!(
r#"(function() {{
const el = document.querySelector({sel});
if (!el) return null;
el.scrollIntoView({{block: "center"}});
const r = el.getBoundingClientRect();
return {{x: r.x, y: r.y, w: r.width, h: r.height}};
}})()"#,
sel = serde_json::to_string(sel)?
);
let rect = cdp_conn.evaluate(&js).await?;
if rect.is_null() {
result.insert("status".to_string(), json!("error"));
result.insert(
"error".to_string(),
json!(format!("element not found: {}", sel)),
);
} else {
tokio::time::sleep(Duration::from_millis(150)).await;
let clip = json!({
"x": rect.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0),
"y": rect.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0),
"width": rect.get("w").and_then(|v| v.as_f64()).unwrap_or(0.0),
"height": rect.get("h").and_then(|v| v.as_f64()).unwrap_or(0.0),
"scale": 1.0,
});
let response = cdp_conn
.send(
"Page.captureScreenshot",
json!({ "format": "png", "clip": clip }),
)
.await?;
let data = response
.get("data")
.and_then(|d| d.as_str())
.ok_or("no screenshot data in CDP response")?;
let bytes = cdp::base64_decode(data)?;
std::fs::write(&path, bytes)?;
result.insert("screenshot".to_string(), json!(path));
result.insert("selector".to_string(), json!(sel));
}
}
"screenshot" | "fullshot" => {
let path = pos
.get(1)
.map(|s| s.to_string())
.unwrap_or_else(default_screenshot_path);
if let Some(parent) = Path::new(&path).parent() {
std::fs::create_dir_all(parent).ok();
}
cdp_conn.screenshot(Path::new(&path)).await?;
result.insert("screenshot".to_string(), json!(path));
}
"click" => {
let sel = pos.get(1).ok_or("click: missing selector")?;
let js = format!(
r#"(function() {{
const el = document.querySelector({sel});
if (!el) return "not found";
el.scrollIntoView({{block:"center"}});
el.click();
return "clicked";
}})()"#,
sel = serde_json::to_string(sel)?
);
let v = cdp_conn.evaluate(&js).await?;
result.insert("result".to_string(), v);
}
"type" => {
let sel = pos.get(1).ok_or("type: missing selector")?;
let text = pos.get(2).ok_or("type: missing text")?;
let js = format!(
r#"(function() {{
const el = document.querySelector({sel});
if (!el) return "not found";
el.focus();
const proto = el.tagName === "TEXTAREA" ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, "value").set;
setter.call(el, {text});
el.dispatchEvent(new Event("input", {{bubbles: true}}));
el.dispatchEvent(new Event("change", {{bubbles: true}}));
return "filled";
}})()"#,
sel = serde_json::to_string(sel)?,
text = serde_json::to_string(text)?
);
let v = cdp_conn.evaluate(&js).await?;
result.insert("result".to_string(), v);
}
"eval" => {
let expr = pos.get(1).ok_or("eval: missing expression")?;
let v = cdp_conn.evaluate(expr).await?;
result.insert("value".to_string(), v);
}
"query" => {
let sel = pos.get(1).ok_or("query: missing selector")?;
let js = format!(
r#"(function() {{
const el = document.querySelector({sel});
if (!el) return null;
const r = el.getBoundingClientRect();
return {{
tag: el.tagName,
text: (el.innerText || "").slice(0, 200),
classes: el.className || "",
visible: r.width > 0 && r.height > 0,
x: r.x, y: r.y, w: r.width, h: r.height,
}};
}})()"#,
sel = serde_json::to_string(sel)?
);
let v = cdp_conn.evaluate(&js).await?;
result.insert("element".to_string(), v);
}
"listpages" => {
let body = cdp::http_get("127.0.0.1", port, "/json/list").await?;
let targets: Value = serde_json::from_str(&body)?;
result.insert("pages".to_string(), targets);
}
other => {
result.insert("status".to_string(), json!("error"));
result.insert(
"error".to_string(),
json!(format!("unknown CDP action: {}", other)),
);
}
}
println!("{}", serde_json::to_string(&Value::Object(result))?);
Ok(())
}
#[cfg(target_os = "windows")]
fn drive_uia(
window_spec: &str,
pos: &[&str],
args: &[String],
) -> Result<(), Box<dyn std::error::Error>> {
let action = pos[0];
let mut result = serde_json::Map::new();
result.insert("status".to_string(), json!("ok"));
result.insert("action".to_string(), json!(action));
result.insert("target".to_string(), json!(format!("uia://{}", window_spec)));
result.insert("backend".to_string(), json!("uia"));
let driver = uia::Uia::connect(window_spec)?;
match action {
"click" => {
let sel = pos.get(1).ok_or("click: missing selector")?;
let v = driver.click(sel)?;
result.insert("result".to_string(), v);
}
"type" => {
let sel = pos.get(1).ok_or("type: missing selector")?;
let text = pos.get(2).ok_or("type: missing text")?;
let v = driver.type_text(sel, text)?;
result.insert("result".to_string(), v);
}
"query" => {
let sel = pos.get(1).ok_or("query: missing selector")?;
let v = driver.query(sel)?;
result.insert("element".to_string(), v);
}
"screenshot" => {
let path = pos
.get(1)
.map(|s| s.to_string())
.unwrap_or_else(default_screenshot_path);
let path = path.replace(".png", ".bmp");
driver.screenshot(Path::new(&path))?;
result.insert("screenshot".to_string(), json!(path));
}
"tree" => {
let sel = pos.get(1);
let depth: u32 = parse_flag(args, "--depth")
.and_then(|s| s.parse().ok())
.unwrap_or(3);
let v = driver.tree(sel.copied(), depth)?;
result.insert("tree".to_string(), v);
}
"list-windows" => {
let v = driver.list_windows()?;
result.insert("windows".to_string(), v);
}
"wait-for" => {
let sel = pos.get(1).ok_or("wait-for: missing selector")?;
let timeout_ms: u64 = parse_flag(args, "--timeout")
.and_then(|s| s.parse().ok())
.unwrap_or(5000);
let v = driver.wait_for(sel, timeout_ms)?;
result.insert("result".to_string(), v);
}
"focus" => {
let sel = pos.get(1).ok_or("focus: missing selector")?;
let v = driver.focus(sel)?;
result.insert("result".to_string(), v);
}
"raw-keys" => {
let keys = pos.get(1).ok_or("raw-keys: missing text")?;
let v = driver.raw_keys(keys)?;
result.insert("result".to_string(), v);
}
"expand" => {
let sel = pos.get(1).ok_or("expand: missing selector")?;
let v = driver.expand(sel)?;
result.insert("result".to_string(), v);
}
other => {
result.insert("status".to_string(), json!("error"));
result.insert(
"error".to_string(),
json!(format!("unknown UIA action: {}", other)),
);
}
}
println!("{}", serde_json::to_string(&Value::Object(result))?);
Ok(())
}
#[cfg(not(target_os = "windows"))]
fn drive_uia(
window_spec: &str,
pos: &[&str],
args: &[String],
) -> Result<(), Box<dyn std::error::Error>> {
let exe = env::var("FORGEWRIGHT_WIN_EXE").unwrap_or_else(|_| {
r"F:\repos\forgewright\target\release\forgewright.exe".to_string()
});
let mut win_args = vec!["drive".to_string()];
win_args.push("--target".to_string());
win_args.push(format!("uia://{}", window_spec));
for p in pos {
win_args.push(p.to_string());
}
for flag in &["--timeout", "--depth"] {
if let Some(val) = parse_flag(args, flag) {
win_args.push(flag.to_string());
win_args.push(val.to_string());
}
}
let escaped: Vec<String> = win_args
.iter()
.map(|a| format!("'{}'", a.replace('\'', "''")))
.collect();
let ps_cmd = format!("& '{}' {}", exe, escaped.join(" "));
let output = Command::new("powershell.exe")
.args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("not recognized") || stderr.contains("Cannot find") {
return Err(format!(
"forgewright.exe not found at '{}'. Build with:\n \
cargo build --release --target x86_64-pc-windows-msvc\n \
Or set FORGEWRIGHT_WIN_EXE env var.",
exe
)
.into());
}
return Err(format!("forgewright.exe failed: {}", stderr).into());
}
let stdout = String::from_utf8_lossy(&output.stdout);
print!("{}", stdout);
Ok(())
}
#[cfg(target_os = "windows")]
fn drive_win32(
window_spec: &str,
pos: &[&str],
args: &[String],
) -> Result<(), Box<dyn std::error::Error>> {
let action = pos[0];
let mut result = serde_json::Map::new();
result.insert("status".to_string(), json!("ok"));
result.insert("action".to_string(), json!(action));
result.insert("target".to_string(), json!(format!("w32://{}", window_spec)));
result.insert("backend".to_string(), json!("win32"));
let driver = win32::Win32::connect(window_spec)?;
match action {
"children" => {
let v = driver.children()?;
result.insert("controls".to_string(), v);
}
"set-text" => {
let sel = pos.get(1).ok_or("set-text: missing selector")?;
let text = pos.get(2).ok_or("set-text: missing text")?;
let v = driver.set_text(sel, text)?;
result.insert("result".to_string(), v);
}
"get-text" => {
let sel = pos.get(1).ok_or("get-text: missing selector")?;
let v = driver.get_text(sel)?;
result.insert("result".to_string(), v);
}
"click" => {
let sel = pos.get(1).ok_or("click: missing selector")?;
let v = driver.click(sel)?;
result.insert("result".to_string(), v);
}
"set-combo" => {
let sel = pos.get(1).ok_or("set-combo: missing selector")?;
let text = pos.get(2).ok_or("set-combo: missing text")?;
let v = driver.set_combo(sel, text)?;
result.insert("result".to_string(), v);
}
"read-combo" => {
let sel = pos.get(1).ok_or("read-combo: missing selector")?;
let v = driver.read_combo(sel)?;
result.insert("result".to_string(), v);
}
"screenshot" => {
let path = pos.get(1).map(|s| s.to_string())
.unwrap_or_else(default_screenshot_path).replace(".png", ".bmp");
let dxgi_result = dxgi::capture_to_json(Path::new(&path));
if dxgi_result.get("status").and_then(|v| v.as_str()) == Some("ok") {
if let Some(fb) = dxgi_result.get("fallback") {
result.insert("fallback".to_string(), fb.clone());
}
result.insert("screenshot".to_string(), json!(path));
} else {
driver.screenshot(Path::new(&path))?;
result.insert("screenshot".to_string(), json!(path));
}
}
"wait-for" => {
let sel = pos.get(1).ok_or("wait-for: missing selector")?;
let timeout_ms: u64 = parse_flag(args, "--timeout")
.and_then(|s| s.parse().ok()).unwrap_or(5000);
let v = driver.wait_for(sel, timeout_ms)?;
result.insert("result".to_string(), v);
}
"raw-keys" => {
let keys = pos.get(1).ok_or("raw-keys: missing text")?;
let v = driver.raw_keys(keys)?;
result.insert("result".to_string(), v);
}
"list-windows" => {
let v = driver.list_windows()?;
result.insert("windows".to_string(), v);
}
other => {
result.insert("status".to_string(), json!("error"));
result.insert("error".to_string(), json!(format!("unknown win32 action: {}", other)));
}
}
println!("{}", serde_json::to_string(&Value::Object(result))?);
Ok(())
}
#[cfg(not(target_os = "windows"))]
fn drive_win32(
window_spec: &str,
pos: &[&str],
args: &[String],
) -> Result<(), Box<dyn std::error::Error>> {
let exe = env::var("FORGEWRIGHT_WIN_EXE").unwrap_or_else(|_| {
r"F:\repos\forgewright\target\x86_64-pc-windows-gnu\release\forgewright.exe".to_string()
});
let mut win_args = vec!["drive".to_string(), "--target".to_string(), format!("w32://{}", window_spec)];
for p in pos { win_args.push(p.to_string()); }
for flag in &["--timeout", "--depth"] {
if let Some(val) = parse_flag(args, flag) {
win_args.push(flag.to_string());
win_args.push(val.to_string());
}
}
let escaped: Vec<String> = win_args.iter()
.map(|a| format!("'{}'", a.replace('\'', "''"))).collect();
let ps_cmd = format!("& '{}' {}", exe, escaped.join(" "));
let output = Command::new("powershell.exe")
.args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("forgewright.exe failed: {}", stderr).into());
}
print!("{}", String::from_utf8_lossy(&output.stdout));
Ok(())
}
async fn cmd_login(args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
let port = parse_port(args);
eprintln!("{}", "=".repeat(60));
eprintln!("ForgeWright Secure Login");
eprintln!("Credentials never logged or transmitted to any cloud.");
eprintln!("{}", "=".repeat(60));
eprint!("Email / user ID: ");
io::stderr().flush().ok();
let mut email = String::new();
io::stdin().read_line(&mut email)?;
let email = email.trim().to_string();
if email.is_empty() {
return Err("no email provided".into());
}
let password = rpassword::prompt_password("Password (hidden): ")?;
if password.is_empty() {
return Err("no password provided".into());
}
eprintln!("\n[1/5] Connecting to CDP on port {}...", port);
let mut cdp_conn = cdp::Cdp::connect(port).await?;
let url = cdp_conn.evaluate("window.location.href").await?;
eprintln!(
"[2/5] Current URL: {}",
url.as_str().unwrap_or("(unknown)")
);
eprintln!("[3/5] Typing email...");
let fill_email = format!(
r#"(function() {{
const sel = "input[name='Email'], input[type='email'], input[id*='Email'], input[autocomplete='username']";
const el = document.querySelector(sel);
if (!el) return "no email field found";
el.focus();
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
setter.call(el, {email});
el.dispatchEvent(new Event('input', {{bubbles: true}}));
el.dispatchEvent(new Event('change', {{bubbles: true}}));
return "ok";
}})()"#,
email = serde_json::to_string(&email)?
);
let r = cdp_conn.evaluate(&fill_email).await?;
eprintln!(" -> {}", r);
tokio::time::sleep(Duration::from_millis(400)).await;
eprintln!("[4/5] Clicking sign in / continue...");
let click_continue = r#"(function() {
const btns = [...document.querySelectorAll("button, input[type='submit']")];
const btn = btns.find(b => /sign in|continue|next/i.test(b.innerText || b.value || ""));
if (btn) { btn.click(); return "clicked"; }
return "no sign-in button found";
})()"#;
let r = cdp_conn.evaluate(click_continue).await?;
eprintln!(" -> {}", r);
tokio::time::sleep(Duration::from_secs(2)).await;
eprintln!("[5/5] Looking for password field (up to 8s)...");
let fill_password = format!(
r#"(async function() {{
for (let i = 0; i < 16; i++) {{
const el = document.querySelector("input[type='password']");
if (el) {{
el.focus();
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
setter.call(el, {pw});
el.dispatchEvent(new Event('input', {{bubbles: true}}));
el.dispatchEvent(new Event('change', {{bubbles: true}}));
await new Promise(r => setTimeout(r, 300));
const btns = [...document.querySelectorAll("button, input[type='submit']")];
const btn = btns.find(b => /sign in|continue|submit/i.test(b.innerText || b.value || ""));
if (btn) btn.click();
return "password submitted";
}}
await new Promise(r => setTimeout(r, 500));
}}
return "password field never appeared";
}})()"#,
pw = serde_json::to_string(&password)?
);
let r = cdp_conn.evaluate(&fill_password).await?;
eprintln!(" -> {}", r);
drop(password);
tokio::time::sleep(Duration::from_secs(2)).await;
let final_url = cdp_conn.evaluate("window.location.href").await?;
let title = cdp_conn.evaluate("document.title").await?;
eprintln!("\n{}", "=".repeat(60));
eprintln!("URL: {}", final_url.as_str().unwrap_or("(unknown)"));
eprintln!("Title: {}", title.as_str().unwrap_or("(unknown)"));
eprintln!("{}", "=".repeat(60));
eprintln!("If 2FA is required, handle it in the browser window.");
Ok(())
}
fn cmd_visual_gate(
_pos: &[&str],
args: &[String],
) -> Result<(), Box<dyn std::error::Error>> {
let frames: u32 = parse_flag(args, "--frames")
.and_then(|s| s.parse().ok())
.unwrap_or(1);
let interval: u32 = parse_flag(args, "--interval")
.and_then(|s| s.parse().ok())
.unwrap_or(100);
let tile_size: u32 = parse_flag(args, "--tile-size")
.and_then(|s| s.parse().ok())
.unwrap_or(16);
let gif = args.iter().any(|a| a == "--gif");
let output_dir = parse_flag(args, "--output-dir")
.map(PathBuf::from)
.unwrap_or_else(|| {
if Path::new("F:\\output").is_dir() {
PathBuf::from(r"F:\output\screenshots")
} else {
PathBuf::from("screenshots")
}
});
let burst_config = burst::BurstConfig {
frame_count: frames,
interval_ms: interval,
output_format: if gif {
burst::BurstFormat::Gif
} else {
burst::BurstFormat::FrameSequence
},
output_dir,
max_captures: 50,
max_bytes: 100 * 1024 * 1024,
};
let max_changed = parse_flag(args, "--max-changed-tiles")
.and_then(|s| s.parse().ok());
let gamepad_required = args.iter().any(|a| a == "--gamepad-required");
let mouse_region = parse_flag(args, "--mouse-region").and_then(|s| {
let parts: Vec<i32> = s.split(',').filter_map(|p| p.parse().ok()).collect();
if parts.len() == 4 {
Some(gate::Rect {
x: parts[0],
y: parts[1],
width: parts[2],
height: parts[3],
})
} else {
None
}
});
let expectations = if max_changed.is_some() || mouse_region.is_some() || gamepad_required {
Some(gate::GateExpectations {
max_changed_tiles: max_changed,
mouse_region,
gamepad_required,
})
} else {
None
};
let config = gate::GateConfig {
burst: burst_config,
tile_size,
expectations,
};
let report = gate::run_gate(config)?;
println!("{}", serde_json::to_string_pretty(&report)?);
Ok(())
}
fn cmd_spooler_write(args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
let macro_path = parse_flag(args, "--macro")
.ok_or("spooler-write: missing --macro <path> argument")?;
let macro_json = std::fs::read_to_string(macro_path)?;
let skill: macro_compiler::SkillMacro = serde_json::from_str(¯o_json)?;
let frames = macro_compiler::compile_macro(&skill);
#[cfg(target_os = "windows")]
{
let mut spooler = spooler::shared_mem::InputSpooler::create()?;
let mut state = spooler.state();
state.write_macro(1, &frames).map_err(|e| -> Box<dyn std::error::Error> {
Box::new(e)
})?;
}
let result = json!({
"status": "ok",
"action": "spooler-write",
"macro_name": skill.name,
"steps": skill.steps.len(),
"compiled_frames": frames.len(),
});
println!("{}", serde_json::to_string_pretty(&result)?);
Ok(())
}
fn cmd_spooler_status() -> Result<(), Box<dyn std::error::Error>> {
#[cfg(target_os = "windows")]
{
match spooler::shared_mem::InputSpooler::open() {
Ok(mut sp) => {
let state = sp.state();
let result = json!({
"status": "ok",
"action": "spooler-status",
"is_agent_active": state.is_agent_active(),
"current_macro_id": state.current_macro_id(),
"head_index": state.head_index(),
"tail_index": state.tail_index(),
"pending_frames": state.head_index().wrapping_sub(state.tail_index()),
});
println!("{}", serde_json::to_string_pretty(&result)?);
}
Err(e) => {
let result = json!({
"status": "error",
"action": "spooler-status",
"error": format!("spooler not available: {}", e),
});
println!("{}", serde_json::to_string_pretty(&result)?);
}
}
}
#[cfg(not(target_os = "windows"))]
{
let result = json!({
"status": "error",
"action": "spooler-status",
"error": "spooler requires Windows (shared memory via CreateFileMappingW)",
});
println!("{}", serde_json::to_string_pretty(&result)?);
}
Ok(())
}
fn default_screenshot_path() -> String {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let dir = if Path::new("F:\\output").is_dir() {
"F:\\output\\screenshots".to_string()
} else if Path::new("D:\\forgewright-captures").is_dir() || std::fs::create_dir_all("D:\\forgewright-captures").is_ok() {
"D:\\forgewright-captures".to_string()
} else if let Ok(home) = std::env::var("USERPROFILE") {
format!("{}\\Desktop\\forgewright-captures", home)
} else {
"/tmp/forgewright-screenshots".to_string()
};
std::fs::create_dir_all(&dir).ok();
format!("{}/fw-{}.png", dir, ts)
}
async fn drive_wright(
addr: &str,
pos: &[&str],
_args: &[String],
) -> Result<(), Box<dyn std::error::Error>> {
use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::connect_async;
let action = pos.get(0).copied().unwrap_or("wright-read");
let value = pos.get(1).copied().unwrap_or("");
let cmd = match action {
"wright-read" => {
serde_json::json!({"cmd": "wright-read", "vars": value}).to_string()
}
"wright-schema" => {
if value.is_empty() {
serde_json::json!({"cmd": "wright-schema"}).to_string()
} else {
serde_json::json!({"cmd": "wright-schema", "subsystem": value}).to_string()
}
}
other => {
serde_json::json!({"cmd": other, "value": value}).to_string()
}
};
let connect_future = connect_async(addr);
let (mut ws_stream, _) = match tokio::time::timeout(
std::time::Duration::from_secs(2),
connect_future,
).await {
Ok(Ok(conn)) => conn,
Ok(Err(e)) => {
println!("{}", serde_json::json!({"error": "app not running", "detail": e.to_string()}));
return Ok(());
}
Err(_) => {
println!("{}", serde_json::json!({"error": "app not running", "detail": "connection timeout"}));
return Ok(());
}
};
ws_stream.send(tokio_tungstenite::tungstenite::Message::Text(cmd.into())).await?;
match tokio::time::timeout(
std::time::Duration::from_secs(2),
ws_stream.next(),
).await {
Ok(Some(Ok(tokio_tungstenite::tungstenite::Message::Text(response)))) => {
println!("{}", response);
}
Ok(Some(Ok(_))) => {
println!("{}", serde_json::json!({"error": "unexpected message type"}));
}
Ok(Some(Err(e))) => {
println!("{}", serde_json::json!({"error": e.to_string()}));
}
Ok(None) => {
println!("{}", serde_json::json!({"error": "connection closed"}));
}
Err(_) => {
println!("{}", serde_json::json!({"error": "response timeout"}));
}
}
Ok(())
}