* Native Rust rewrite of agent-browser daemon

Single-binary Rust implementation replacing the Node.js/Playwright daemon
with direct CDP (Chrome DevTools Protocol) communication. Includes full
command parity, WebDriver/Safari/iOS backend routing, request tracking,
frame context management, CDP protocol codegen, and comprehensive tests.

* improvements

* fix ci

* fixes

* faster builds
This commit is contained in:
Chris Tate
2026-03-03 15:15:57 -06:00
committed by GitHub
parent 857c0b25df
commit 51f5fa484c
52 changed files with 55160 additions and 278 deletions
+80 -53
View File
@@ -114,10 +114,12 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
let mut nav_cmd = json!({ "id": id, "action": "navigate", "url": url });
// If --headers flag is set, include headers (scoped to this origin)
if let Some(ref headers_json) = flags.headers {
let headers = serde_json::from_str::<serde_json::Value>(headers_json)
.map_err(|_| ParseError::InvalidValue {
message: format!("Invalid JSON for --headers: {}", headers_json),
usage: "open <url> --headers '{\"Key\": \"Value\"}'",
let headers =
serde_json::from_str::<serde_json::Value>(headers_json).map_err(|_| {
ParseError::InvalidValue {
message: format!("Invalid JSON for --headers: {}", headers_json),
usage: "open <url> --headers '{\"Key\": \"Value\"}'",
}
})?;
nav_cmd["headers"] = headers;
}
@@ -290,7 +292,9 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
usage: "keyboard inserttext <text>",
});
}
Ok(json!({ "id": id, "action": "keyboard", "subaction": "insertText", "text": text }))
Ok(
json!({ "id": id, "action": "keyboard", "subaction": "insertText", "text": text }),
)
}
_ => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
@@ -585,13 +589,33 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
let mut j = 2;
while j < rest.len() {
match rest[j].as_ref() {
"--url" => { url = rest.get(j + 1).cloned(); j += 1; }
"--username" => { username = rest.get(j + 1).cloned(); j += 1; }
"--password" => { password = rest.get(j + 1).cloned(); j += 1; }
"--password-stdin" => { password_stdin = true; }
"--username-selector" => { username_selector = rest.get(j + 1).cloned(); j += 1; }
"--password-selector" => { password_selector = rest.get(j + 1).cloned(); j += 1; }
"--submit-selector" => { submit_selector = rest.get(j + 1).cloned(); j += 1; }
"--url" => {
url = rest.get(j + 1).cloned();
j += 1;
}
"--username" => {
username = rest.get(j + 1).cloned();
j += 1;
}
"--password" => {
password = rest.get(j + 1).cloned();
j += 1;
}
"--password-stdin" => {
password_stdin = true;
}
"--username-selector" => {
username_selector = rest.get(j + 1).cloned();
j += 1;
}
"--password-selector" => {
password_selector = rest.get(j + 1).cloned();
j += 1;
}
"--submit-selector" => {
submit_selector = rest.get(j + 1).cloned();
j += 1;
}
other => {
if other.starts_with("--") {
return Err(ParseError::InvalidValue {
@@ -1093,9 +1117,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
})?;
Ok(json!({ "id": id, "action": "state_load", "path": path }))
}
Some("list") => {
Ok(json!({ "id": id, "action": "state_list" }))
}
Some("list") => Ok(json!({ "id": id, "action": "state_list" })),
Some("clear") => {
let mut session_name: Option<&str> = None;
let mut all = false;
@@ -1116,7 +1138,9 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
if let Some(name) = session_name {
if !is_valid_session_name(name) {
return Err(ParseError::InvalidSessionName { name: name.to_string() });
return Err(ParseError::InvalidSessionName {
name: name.to_string(),
});
}
}
@@ -1170,13 +1194,19 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
let new_name = new_name.trim_end_matches(".json");
if !is_valid_session_name(old_name) {
return Err(ParseError::InvalidSessionName { name: old_name.to_string() });
return Err(ParseError::InvalidSessionName {
name: old_name.to_string(),
});
}
if !is_valid_session_name(new_name) {
return Err(ParseError::InvalidSessionName { name: new_name.to_string() });
return Err(ParseError::InvalidSessionName {
name: new_name.to_string(),
});
}
Ok(json!({ "id": id, "action": "state_rename", "oldName": old_name, "newName": new_name }))
Ok(
json!({ "id": id, "action": "state_rename", "oldName": old_name, "newName": new_name }),
)
}
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
@@ -1285,7 +1315,10 @@ fn parse_diff(rest: &[&str], id: &str, flags: &Flags) -> Result<Value, ParseErro
}
Err(_) => {
return Err(ParseError::InvalidValue {
message: format!("Depth must be a non-negative integer, got: {}", d),
message: format!(
"Depth must be a non-negative integer, got: {}",
d
),
usage: "diff snapshot --depth <n>",
});
}
@@ -1351,7 +1384,10 @@ fn parse_diff(rest: &[&str], id: &str, flags: &Flags) -> Result<Value, ParseErro
}
Ok(n) => {
return Err(ParseError::InvalidValue {
message: format!("Threshold must be between 0 and 1, got {}", n),
message: format!(
"Threshold must be between 0 and 1, got {}",
n
),
usage: "diff screenshot --threshold <0-1>",
});
}
@@ -1468,7 +1504,10 @@ fn parse_diff(rest: &[&str], id: &str, flags: &Flags) -> Result<Value, ParseErro
}
Err(_) => {
return Err(ParseError::InvalidValue {
message: format!("Depth must be a non-negative integer, got: {}", d),
message: format!(
"Depth must be a non-negative integer, got: {}",
d
),
usage: "diff url <url1> <url2> --depth <n>",
});
}
@@ -2070,7 +2109,7 @@ mod tests {
action_policy: None,
confirm_actions: None,
confirm_interactive: false,
native: false,
}
}
@@ -2374,16 +2413,12 @@ mod tests {
)
.unwrap();
assert_eq!(cmd["action"], "navigate");
assert_eq!(
cmd["url"],
"chrome-extension://abcdefghijklmnop/popup.html"
);
assert_eq!(cmd["url"], "chrome-extension://abcdefghijklmnop/popup.html");
}
#[test]
fn test_navigate_chrome_url() {
let cmd =
parse_command(&args("open chrome://extensions"), &default_flags()).unwrap();
let cmd = parse_command(&args("open chrome://extensions"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "navigate");
assert_eq!(cmd["url"], "chrome://extensions");
}
@@ -3236,8 +3271,11 @@ mod tests {
#[test]
fn test_diff_snapshot_baseline() {
let cmd =
parse_command(&args("diff snapshot --baseline before.txt"), &default_flags()).unwrap();
let cmd = parse_command(
&args("diff snapshot --baseline before.txt"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "diff_snapshot");
assert_eq!(cmd["baseline"], "before.txt");
}
@@ -3257,9 +3295,11 @@ mod tests {
#[test]
fn test_diff_snapshot_short_flags() {
let cmd =
parse_command(&args("diff snapshot -b snap.txt -s .content -c -d 2"), &default_flags())
.unwrap();
let cmd = parse_command(
&args("diff snapshot -b snap.txt -s .content -c -d 2"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "diff_snapshot");
assert_eq!(cmd["baseline"], "snap.txt");
assert_eq!(cmd["selector"], ".content");
@@ -3307,8 +3347,7 @@ mod tests {
fn test_diff_screenshot_global_full_flag() {
let mut flags = default_flags();
flags.full = true;
let cmd =
parse_command(&args("diff screenshot --baseline b.png"), &flags).unwrap();
let cmd = parse_command(&args("diff screenshot --baseline b.png"), &flags).unwrap();
assert_eq!(cmd["action"], "diff_screenshot");
assert_eq!(cmd["fullPage"], true);
}
@@ -3352,8 +3391,7 @@ mod tests {
fn test_diff_url_global_full_flag() {
let mut flags = default_flags();
flags.full = true;
let cmd =
parse_command(&args("diff url https://a.com https://b.com"), &flags).unwrap();
let cmd = parse_command(&args("diff url https://a.com https://b.com"), &flags).unwrap();
assert_eq!(cmd["fullPage"], true);
}
@@ -3676,11 +3714,7 @@ mod tests {
#[test]
fn test_scroll_with_selector_short_flag() {
let cmd = parse_command(
&args("scroll left 100 -s .sidebar"),
&default_flags(),
)
.unwrap();
let cmd = parse_command(&args("scroll left 100 -s .sidebar"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "scroll");
assert_eq!(cmd["direction"], "left");
assert_eq!(cmd["amount"], 100);
@@ -3689,11 +3723,8 @@ mod tests {
#[test]
fn test_scroll_selector_before_positional() {
let cmd = parse_command(
&args("scroll --selector .panel down 400"),
&default_flags(),
)
.unwrap();
let cmd =
parse_command(&args("scroll --selector .panel down 400"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "scroll");
assert_eq!(cmd["direction"], "down");
assert_eq!(cmd["amount"], 400);
@@ -3702,11 +3733,7 @@ mod tests {
#[test]
fn test_scroll_selector_only() {
let cmd = parse_command(
&args("scroll --selector .content"),
&default_flags(),
)
.unwrap();
let cmd = parse_command(&args("scroll --selector .content"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "scroll");
assert_eq!(cmd["direction"], "down");
assert_eq!(cmd["amount"], 300);
+99 -60
View File
@@ -232,6 +232,7 @@ pub struct DaemonOptions<'a> {
pub allowed_domains: Option<&'a [String]>,
pub action_policy: Option<&'a str>,
pub confirm_actions: Option<&'a str>,
pub native: bool,
}
fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
@@ -294,10 +295,7 @@ fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
}
}
pub fn ensure_daemon(
session: &str,
opts: &DaemonOptions,
) -> Result<DaemonResult, String> {
pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult, String> {
// Check if daemon is running AND responsive
if is_daemon_running(session) && daemon_ready(session) {
// Double-check it's actually responsive by waiting and checking again
@@ -366,72 +364,113 @@ pub fn ensure_daemon(
exe_path
}
};
let exe_dir = exe_path.parent().unwrap();
let mut daemon_paths = vec![
exe_dir.join("daemon.js"),
exe_dir.join("../dist/daemon.js"),
PathBuf::from("dist/daemon.js"),
];
if opts.native {
// Native mode: spawn self as daemon (Rust/CDP, no Node.js needed)
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
// Check AGENT_BROWSER_HOME environment variable
if let Ok(home) = env::var("AGENT_BROWSER_HOME") {
let home_path = PathBuf::from(&home);
daemon_paths.insert(0, home_path.join("dist/daemon.js"));
daemon_paths.insert(1, home_path.join("daemon.js"));
}
let mut cmd = Command::new(&exe_path);
cmd.env("AGENT_BROWSER_DAEMON", "1");
apply_daemon_env(&mut cmd, session, opts);
let daemon_path = daemon_paths
.iter()
.find(|p| p.exists())
.ok_or("Daemon not found. Set AGENT_BROWSER_HOME environment variable or run from project directory.")?;
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
// Spawn daemon as a fully detached background process
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let mut cmd = Command::new("node");
cmd.arg(daemon_path);
apply_daemon_env(&mut cmd, session, opts);
// Create new process group and session to fully detach
unsafe {
cmd.pre_exec(|| {
// Create new session (detach from terminal)
libc::setsid();
Ok(())
});
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to start native daemon: {}", e))?;
}
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?;
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
let mut cmd = Command::new(&exe_path);
cmd.env("AGENT_BROWSER_DAEMON", "1");
apply_daemon_env(&mut cmd, session, opts);
// Use node.exe explicitly to avoid Git Bash/MSYS2 shell wrapper resolution
let mut cmd = Command::new("node.exe");
cmd.arg(daemon_path)
.env("MSYS_NO_PATHCONV", "1")
.env("MSYS2_ARG_CONV_EXCL", "*");
apply_daemon_env(&mut cmd, session, opts);
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to start native daemon: {}", e))?;
}
} else {
// Default mode: spawn Node.js daemon (Playwright)
let exe_dir = exe_path.parent().unwrap();
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?;
let mut daemon_paths = vec![
exe_dir.join("daemon.js"),
exe_dir.join("../dist/daemon.js"),
PathBuf::from("dist/daemon.js"),
];
if let Ok(home) = env::var("AGENT_BROWSER_HOME") {
let home_path = PathBuf::from(&home);
daemon_paths.insert(0, home_path.join("dist/daemon.js"));
daemon_paths.insert(1, home_path.join("daemon.js"));
}
let daemon_path = daemon_paths
.iter()
.find(|p| p.exists())
.ok_or("Daemon not found. Set AGENT_BROWSER_HOME environment variable or run from project directory.")?;
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let mut cmd = Command::new("node");
cmd.arg(daemon_path);
apply_daemon_env(&mut cmd, session, opts);
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?;
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
// Use node.exe explicitly to avoid Git Bash/MSYS2 shell wrapper resolution
let mut cmd = Command::new("node.exe");
cmd.arg(daemon_path)
.env("MSYS_NO_PATHCONV", "1")
.env("MSYS2_ARG_CONV_EXCL", "*");
apply_daemon_env(&mut cmd, session, opts);
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?;
}
}
for _ in 0..50 {
+95 -49
View File
@@ -41,6 +41,7 @@ pub struct Config {
pub action_policy: Option<String>,
pub confirm_actions: Option<String>,
pub confirm_interactive: Option<bool>,
pub native: Option<bool>,
}
impl Config {
@@ -82,6 +83,7 @@ impl Config {
action_policy: other.action_policy.or(self.action_policy),
confirm_actions: other.confirm_actions.or(self.confirm_actions),
confirm_interactive: other.confirm_interactive.or(self.confirm_interactive),
native: other.native.or(self.native),
}
}
}
@@ -180,8 +182,7 @@ pub fn load_config(args: &[String]) -> Result<Config, String> {
});
if let Some((source, maybe_path)) = explicit {
let path_str =
maybe_path.ok_or_else(|| format!("{} requires a file path", source))?;
let path_str = maybe_path.ok_or_else(|| format!("{} requires a file path", source))?;
let path = PathBuf::from(&path_str);
if !path.exists() {
return Err(format!("config file not found: {}", path_str));
@@ -234,6 +235,7 @@ pub struct Flags {
pub action_policy: Option<String>,
pub confirm_actions: Option<String>,
pub confirm_interactive: bool,
pub native: bool,
// Track which launch-time options were explicitly passed via CLI
// (as opposed to being set only via environment variables)
@@ -273,66 +275,72 @@ pub fn parse_flags(args: &[String]) -> Flags {
};
let mut flags = Flags {
json: env_var_is_truthy("AGENT_BROWSER_JSON")
|| config.json.unwrap_or(false),
full: env_var_is_truthy("AGENT_BROWSER_FULL")
|| config.full.unwrap_or(false),
headed: env_var_is_truthy("AGENT_BROWSER_HEADED")
|| config.headed.unwrap_or(false),
debug: env_var_is_truthy("AGENT_BROWSER_DEBUG")
|| config.debug.unwrap_or(false),
session: env::var("AGENT_BROWSER_SESSION").ok()
json: env_var_is_truthy("AGENT_BROWSER_JSON") || config.json.unwrap_or(false),
full: env_var_is_truthy("AGENT_BROWSER_FULL") || config.full.unwrap_or(false),
headed: env_var_is_truthy("AGENT_BROWSER_HEADED") || config.headed.unwrap_or(false),
debug: env_var_is_truthy("AGENT_BROWSER_DEBUG") || config.debug.unwrap_or(false),
session: env::var("AGENT_BROWSER_SESSION")
.ok()
.or(config.session)
.unwrap_or_else(|| "default".to_string()),
headers: config.headers,
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok()
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH")
.ok()
.or(config.executable_path),
cdp: config.cdp,
extensions,
profile: env::var("AGENT_BROWSER_PROFILE").ok()
.or(config.profile),
state: env::var("AGENT_BROWSER_STATE").ok()
.or(config.state),
proxy: env::var("AGENT_BROWSER_PROXY").ok()
.or(config.proxy),
proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS").ok()
profile: env::var("AGENT_BROWSER_PROFILE").ok().or(config.profile),
state: env::var("AGENT_BROWSER_STATE").ok().or(config.state),
proxy: env::var("AGENT_BROWSER_PROXY").ok().or(config.proxy),
proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS")
.ok()
.or(config.proxy_bypass),
args: env::var("AGENT_BROWSER_ARGS").ok()
.or(config.args),
user_agent: env::var("AGENT_BROWSER_USER_AGENT").ok()
args: env::var("AGENT_BROWSER_ARGS").ok().or(config.args),
user_agent: env::var("AGENT_BROWSER_USER_AGENT")
.ok()
.or(config.user_agent),
provider: env::var("AGENT_BROWSER_PROVIDER").ok()
.or(config.provider),
provider: env::var("AGENT_BROWSER_PROVIDER").ok().or(config.provider),
ignore_https_errors: env_var_is_truthy("AGENT_BROWSER_IGNORE_HTTPS_ERRORS")
|| config.ignore_https_errors.unwrap_or(false),
allow_file_access: env_var_is_truthy("AGENT_BROWSER_ALLOW_FILE_ACCESS")
|| config.allow_file_access.unwrap_or(false),
device: env::var("AGENT_BROWSER_IOS_DEVICE").ok()
.or(config.device),
device: env::var("AGENT_BROWSER_IOS_DEVICE").ok().or(config.device),
auto_connect: env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT")
|| config.auto_connect.unwrap_or(false),
session_name: env::var("AGENT_BROWSER_SESSION_NAME").ok()
session_name: env::var("AGENT_BROWSER_SESSION_NAME")
.ok()
.or(config.session_name),
annotate: env_var_is_truthy("AGENT_BROWSER_ANNOTATE")
|| config.annotate.unwrap_or(false),
color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME").ok()
annotate: env_var_is_truthy("AGENT_BROWSER_ANNOTATE") || config.annotate.unwrap_or(false),
color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME")
.ok()
.or(config.color_scheme),
download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH").ok()
download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH")
.ok()
.or(config.download_path),
content_boundaries: env_var_is_truthy("AGENT_BROWSER_CONTENT_BOUNDARIES")
|| config.content_boundaries.unwrap_or(false),
max_output: env::var("AGENT_BROWSER_MAX_OUTPUT").ok()
max_output: env::var("AGENT_BROWSER_MAX_OUTPUT")
.ok()
.and_then(|s| s.parse().ok())
.or(config.max_output),
allowed_domains: env::var("AGENT_BROWSER_ALLOWED_DOMAINS").ok()
.map(|s| s.split(',').map(|d| d.trim().to_lowercase()).filter(|d| !d.is_empty()).collect())
allowed_domains: env::var("AGENT_BROWSER_ALLOWED_DOMAINS")
.ok()
.map(|s| {
s.split(',')
.map(|d| d.trim().to_lowercase())
.filter(|d| !d.is_empty())
.collect()
})
.or(config.allowed_domains),
action_policy: env::var("AGENT_BROWSER_ACTION_POLICY").ok()
action_policy: env::var("AGENT_BROWSER_ACTION_POLICY")
.ok()
.or(config.action_policy),
confirm_actions: env::var("AGENT_BROWSER_CONFIRM_ACTIONS").ok()
confirm_actions: env::var("AGENT_BROWSER_CONFIRM_ACTIONS")
.ok()
.or(config.confirm_actions),
confirm_interactive: env_var_is_truthy("AGENT_BROWSER_CONFIRM_INTERACTIVE")
|| config.confirm_interactive.unwrap_or(false),
native: env_var_is_truthy("AGENT_BROWSER_NATIVE") || config.native.unwrap_or(false),
cli_executable_path: false,
cli_extensions: false,
cli_profile: false,
@@ -352,22 +360,30 @@ pub fn parse_flags(args: &[String]) -> Flags {
"--json" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.json = val;
if consumed { i += 1; }
if consumed {
i += 1;
}
}
"--full" | "-f" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.full = val;
if consumed { i += 1; }
if consumed {
i += 1;
}
}
"--headed" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.headed = val;
if consumed { i += 1; }
if consumed {
i += 1;
}
}
"--debug" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.debug = val;
if consumed { i += 1; }
if consumed {
i += 1;
}
}
"--session" => {
if let Some(s) = args.get(i + 1) {
@@ -452,13 +468,17 @@ pub fn parse_flags(args: &[String]) -> Flags {
"--ignore-https-errors" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.ignore_https_errors = val;
if consumed { i += 1; }
if consumed {
i += 1;
}
}
"--allow-file-access" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.allow_file_access = val;
flags.cli_allow_file_access = true;
if consumed { i += 1; }
if consumed {
i += 1;
}
}
"--device" => {
if let Some(d) = args.get(i + 1) {
@@ -469,7 +489,9 @@ pub fn parse_flags(args: &[String]) -> Flags {
"--auto-connect" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.auto_connect = val;
if consumed { i += 1; }
if consumed {
i += 1;
}
}
"--session-name" => {
if let Some(s) = args.get(i + 1) {
@@ -481,7 +503,9 @@ pub fn parse_flags(args: &[String]) -> Flags {
let (val, consumed) = parse_bool_arg(args, i);
flags.annotate = val;
flags.cli_annotate = true;
if consumed { i += 1; }
if consumed {
i += 1;
}
}
"--color-scheme" => {
if let Some(s) = args.get(i + 1) {
@@ -499,7 +523,9 @@ pub fn parse_flags(args: &[String]) -> Flags {
"--content-boundaries" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.content_boundaries = val;
if consumed { i += 1; }
if consumed {
i += 1;
}
}
"--max-output" => {
if let Some(s) = args.get(i + 1) {
@@ -512,7 +538,10 @@ pub fn parse_flags(args: &[String]) -> Flags {
"--allowed-domains" => {
if let Some(s) = args.get(i + 1) {
flags.allowed_domains = Some(
s.split(',').map(|d| d.trim().to_lowercase()).filter(|d| !d.is_empty()).collect()
s.split(',')
.map(|d| d.trim().to_lowercase())
.filter(|d| !d.is_empty())
.collect(),
);
i += 1;
}
@@ -532,7 +561,16 @@ pub fn parse_flags(args: &[String]) -> Flags {
"--confirm-interactive" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.confirm_interactive = val;
if consumed { i += 1; }
if consumed {
i += 1;
}
}
"--native" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.native = val;
if consumed {
i += 1;
}
}
"--config" => {
// Already handled by load_config(); skip the value
@@ -561,6 +599,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
"--annotate",
"--content-boundaries",
"--confirm-interactive",
"--native",
];
// Global flags that always take a value (need to skip the next arg too)
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &[
@@ -835,7 +874,10 @@ mod tests {
assert_eq!(config.session.as_deref(), Some("test-session"));
assert_eq!(config.session_name.as_deref(), Some("my-app"));
assert_eq!(config.executable_path.as_deref(), Some("/usr/bin/chromium"));
assert_eq!(config.extensions, Some(vec!["/ext1".to_string(), "/ext2".to_string()]));
assert_eq!(
config.extensions,
Some(vec!["/ext1".to_string(), "/ext2".to_string()])
);
assert_eq!(config.profile.as_deref(), Some("/tmp/profile"));
assert_eq!(config.state.as_deref(), Some("/tmp/state.json"));
assert_eq!(config.proxy.as_deref(), Some("http://proxy:8080"));
@@ -1144,7 +1186,11 @@ mod tests {
let merged = user.merge(project);
assert_eq!(
merged.extensions,
Some(vec!["/ext1".to_string(), "/ext2".to_string(), "/ext3".to_string()])
Some(vec![
"/ext1".to_string(),
"/ext2".to_string(),
"/ext3".to_string()
])
);
}
+44 -8
View File
@@ -3,6 +3,7 @@ mod commands;
mod connection;
mod flags;
mod install;
mod native;
mod output;
mod validation;
@@ -20,7 +21,9 @@ use commands::{gen_id, parse_command, ParseError};
use connection::{ensure_daemon, get_socket_dir, send_command, DaemonOptions};
use flags::{clean_args, parse_flags};
use install::run_install;
use output::{print_command_help, print_help, print_response_with_opts, print_version, OutputOptions};
use output::{
print_command_help, print_help, print_response_with_opts, print_version, OutputOptions,
};
use std::path::PathBuf;
use std::process::Command as ProcessCommand;
@@ -120,7 +123,10 @@ fn run_auth_cli(cmd: &serde_json::Value, json_mode: bool) -> ! {
}
Err(e) => {
if json_mode {
println!(r#"{{"success":false,"error":"Failed to run auth-cli: {}"}}"#, e);
println!(
r#"{{"success":false,"error":"Failed to run auth-cli: {}"}}"#,
e
);
} else {
eprintln!("{} Failed to run auth-cli: {}", color::error_indicator(), e);
}
@@ -249,6 +255,14 @@ fn main() {
env::set_var("MSYS2_ARG_CONV_EXCL", "*");
}
// Native daemon mode: when AGENT_BROWSER_DAEMON is set, run as the daemon process
if env::var("AGENT_BROWSER_DAEMON").is_ok() {
let session = env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string());
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
rt.block_on(native::daemon::run_daemon(&session));
return;
}
let args: Vec<String> = env::args().skip(1).collect();
let flags = parse_flags(&args);
let clean = clean_args(&args);
@@ -320,10 +334,17 @@ fn main() {
color::warning_indicator()
);
}
if cmd.get("passwordStdin").and_then(|v| v.as_bool()).unwrap_or(false) {
if cmd
.get("passwordStdin")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
let mut pass = String::new();
if std::io::stdin().read_line(&mut pass).is_err() || pass.is_empty() {
eprintln!("{} Failed to read password from stdin", color::error_indicator());
eprintln!(
"{} Failed to read password from stdin",
color::error_indicator()
);
exit(1);
}
let pass = pass.trim_end_matches('\n').trim_end_matches('\r');
@@ -339,7 +360,10 @@ fn main() {
// Handle local auth commands without starting the daemon.
// These don't need a browser, so we avoid sending passwords through the socket.
if let Some(action) = cmd.get("action").and_then(|v| v.as_str()) {
if matches!(action, "auth_save" | "auth_list" | "auth_show" | "auth_delete") {
if matches!(
action,
"auth_save" | "auth_list" | "auth_show" | "auth_delete"
) {
run_auth_cli(&cmd, flags.json);
}
}
@@ -379,6 +403,7 @@ fn main() {
allowed_domains: flags.allowed_domains.as_deref(),
action_policy: flags.action_policy.as_deref(),
confirm_actions: flags.confirm_actions.as_deref(),
native: flags.native,
};
let daemon_result = match ensure_daemon(&flags.session, &daemon_opts) {
Ok(result) => result,
@@ -436,6 +461,7 @@ fn main() {
flags.ignore_https_errors.then_some("--ignore-https-errors"),
flags.cli_allow_file_access.then_some("--allow-file-access"),
flags.cli_download_path.then_some("--download-path"),
flags.native.then_some("--native"),
]
.into_iter()
.flatten()
@@ -788,10 +814,20 @@ fn main() {
// Handle interactive confirmation
if flags.confirm_interactive {
if let Some(data) = &resp.data {
if data.get("confirmation_required").and_then(|v| v.as_bool()).unwrap_or(false) {
let desc = data.get("description").and_then(|v| v.as_str()).unwrap_or("unknown action");
if data
.get("confirmation_required")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
let desc = data
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("unknown action");
let category = data.get("category").and_then(|v| v.as_str()).unwrap_or("");
let cid = data.get("confirmation_id").and_then(|v| v.as_str()).unwrap_or("");
let cid = data
.get("confirmation_id")
.and_then(|v| v.as_str())
.unwrap_or("");
eprintln!("[agent-browser] Action requires confirmation:");
eprintln!(" {}: {}", category, desc);
File diff suppressed because it is too large Load Diff
+315
View File
@@ -0,0 +1,315 @@
use aes_gcm::{aead::Aead, aead::KeyInit, Aes256Gcm};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthProfile {
pub name: String,
pub url: String,
pub username: String,
pub password: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub username_selector: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub password_selector: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub submit_selector: Option<String>,
}
// Keep legacy Credential alias for backward compatibility
pub type Credential = AuthProfile;
fn validate_profile_name(name: &str) -> Result<(), String> {
if name.is_empty()
|| !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Err(format!(
"Invalid profile name '{}'. Must match /^[a-zA-Z0-9_-]+$/",
name
));
}
Ok(())
}
fn get_auth_dir() -> PathBuf {
if let Some(home) = dirs::home_dir() {
home.join(".agent-browser").join("auth")
} else {
std::env::temp_dir().join("agent-browser").join("auth")
}
}
fn get_profile_path(name: &str) -> PathBuf {
get_auth_dir().join(format!("{}.json", name))
}
fn derive_encryption_key() -> Vec<u8> {
let hostname = std::env::var("HOSTNAME")
.or_else(|_| std::env::var("COMPUTERNAME"))
.unwrap_or_else(|_| {
#[cfg(unix)]
{
let mut buf = [0u8; 256];
let len = unsafe { libc::gethostname(buf.as_mut_ptr() as *mut _, buf.len()) };
if len == 0 {
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
String::from_utf8_lossy(&buf[..end]).to_string()
} else {
"unknown-host".to_string()
}
}
#[cfg(not(unix))]
{
"unknown-host".to_string()
}
});
let username = std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "unknown-user".to_string());
let mut hasher = Sha256::new();
hasher.update(format!("agent-browser:{}:{}", hostname, username).as_bytes());
hasher.finalize().to_vec()
}
fn encrypt_profile(profile: &AuthProfile) -> Result<Vec<u8>, String> {
let key = derive_encryption_key();
let cipher =
Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Encryption key error: {}", e))?;
let plaintext = serde_json::to_string(profile)
.map_err(|e| format!("Failed to serialize profile: {}", e))?;
let mut nonce = [0u8; 12];
getrandom::getrandom(&mut nonce).map_err(|e| format!("Failed to generate nonce: {}", e))?;
let ciphertext = cipher
.encrypt(aes_gcm::Nonce::from_slice(&nonce), plaintext.as_bytes())
.map_err(|e| format!("Encryption failed: {}", e))?;
let mut result = Vec::with_capacity(12 + ciphertext.len());
result.extend_from_slice(&nonce);
result.extend_from_slice(&ciphertext);
Ok(result)
}
fn decrypt_profile(data: &[u8]) -> Result<AuthProfile, String> {
if data.len() < 13 {
return Err("Encrypted data too short".to_string());
}
let (nonce_bytes, ciphertext) = data.split_at(12);
let key = derive_encryption_key();
let cipher =
Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Decryption key error: {}", e))?;
let plaintext = cipher
.decrypt(aes_gcm::Nonce::from_slice(nonce_bytes), ciphertext)
.map_err(|e| format!("Decryption failed: {}", e))?;
let json_str = String::from_utf8(plaintext)
.map_err(|e| format!("Decrypted data is not valid UTF-8: {}", e))?;
serde_json::from_str(&json_str).map_err(|e| format!("Invalid profile data: {}", e))
}
fn save_profile(profile: &AuthProfile) -> Result<(), String> {
let dir = get_auth_dir();
let _ = fs::create_dir_all(&dir);
let encrypted = encrypt_profile(profile)?;
let path = get_profile_path(&profile.name);
fs::write(&path, &encrypted).map_err(|e| format!("Failed to write profile: {}", e))
}
fn load_profile(name: &str) -> Result<AuthProfile, String> {
let path = get_profile_path(name);
if !path.exists() {
return Err(format!("Auth profile '{}' not found", name));
}
let data = fs::read(&path).map_err(|e| format!("Failed to read profile: {}", e))?;
decrypt_profile(&data)
}
pub fn credentials_set(
name: &str,
username: &str,
password: &str,
url: Option<&str>,
) -> Result<Value, String> {
validate_profile_name(name)?;
let profile = AuthProfile {
name: name.to_string(),
url: url.unwrap_or("").to_string(),
username: username.to_string(),
password: password.to_string(),
username_selector: None,
password_selector: None,
submit_selector: None,
};
save_profile(&profile)?;
Ok(json!({ "saved": name }))
}
pub fn auth_save(
name: &str,
url: &str,
username: &str,
password: &str,
username_selector: Option<&str>,
password_selector: Option<&str>,
submit_selector: Option<&str>,
) -> Result<Value, String> {
validate_profile_name(name)?;
let profile = AuthProfile {
name: name.to_string(),
url: url.to_string(),
username: username.to_string(),
password: password.to_string(),
username_selector: username_selector.map(String::from),
password_selector: password_selector.map(String::from),
submit_selector: submit_selector.map(String::from),
};
save_profile(&profile)?;
Ok(json!({ "saved": name }))
}
pub fn credentials_get(name: &str) -> Result<Value, String> {
let profile = load_profile(name)?;
Ok(json!({
"name": profile.name,
"username": profile.username,
"url": profile.url,
"hasPassword": true,
}))
}
pub fn credentials_get_full(name: &str) -> Result<AuthProfile, String> {
load_profile(name)
}
pub fn credentials_delete(name: &str) -> Result<Value, String> {
validate_profile_name(name)?;
let path = get_profile_path(name);
if !path.exists() {
return Err(format!("Auth profile '{}' not found", name));
}
fs::remove_file(&path).map_err(|e| format!("Failed to delete profile: {}", e))?;
Ok(json!({ "deleted": name }))
}
pub fn credentials_list() -> Result<Value, String> {
let dir = get_auth_dir();
if !dir.exists() {
return Ok(json!({ "profiles": [] }));
}
let mut profiles = Vec::new();
if let Ok(entries) = fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let name = path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
match load_profile(&name) {
Ok(profile) => {
profiles.push(json!({
"name": profile.name,
"username": profile.username,
"url": profile.url,
}));
}
Err(_) => {
profiles.push(json!({
"name": name,
"error": "Failed to decrypt",
}));
}
}
}
}
Ok(json!({ "profiles": profiles }))
}
pub fn auth_show(name: &str) -> Result<Value, String> {
validate_profile_name(name)?;
let profile = load_profile(name)?;
Ok(json!({
"profile": {
"name": profile.name,
"url": profile.url,
"username": profile.username,
"usernameSelector": profile.username_selector,
"passwordSelector": profile.password_selector,
"submitSelector": profile.submit_selector,
}
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_profile_name() {
assert!(validate_profile_name("github").is_ok());
assert!(validate_profile_name("my-app").is_ok());
assert!(validate_profile_name("test_123").is_ok());
assert!(validate_profile_name("").is_err());
assert!(validate_profile_name("has space").is_err());
assert!(validate_profile_name("../evil").is_err());
assert!(validate_profile_name("foo/bar").is_err());
}
#[test]
fn test_auth_profile_serialization() {
let profile = AuthProfile {
name: "test".to_string(),
url: "https://example.com".to_string(),
username: "user".to_string(),
password: "pass".to_string(),
username_selector: None,
password_selector: None,
submit_selector: Some("button[type=submit]".to_string()),
};
let json = serde_json::to_string(&profile).unwrap();
let parsed: AuthProfile = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.name, "test");
assert_eq!(
parsed.submit_selector,
Some("button[type=submit]".to_string())
);
assert!(parsed.username_selector.is_none());
}
#[test]
fn test_encrypt_decrypt_roundtrip() {
let profile = AuthProfile {
name: "roundtrip".to_string(),
url: "https://example.com".to_string(),
username: "user".to_string(),
password: "s3cret!".to_string(),
username_selector: None,
password_selector: None,
submit_selector: None,
};
let encrypted = encrypt_profile(&profile).unwrap();
let decrypted = decrypt_profile(&encrypted).unwrap();
assert_eq!(decrypted.name, "roundtrip");
assert_eq!(decrypted.password, "s3cret!");
}
#[test]
fn test_derive_encryption_key_is_stable() {
let k1 = derive_encryption_key();
let k2 = derive_encryption_key();
assert_eq!(k1, k2);
assert_eq!(k1.len(), 32);
}
}
File diff suppressed because it is too large Load Diff
+417
View File
@@ -0,0 +1,417 @@
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::Duration;
use super::types::BrowserVersionInfo;
pub struct ChromeProcess {
child: Child,
pub ws_url: String,
}
impl ChromeProcess {
pub fn kill(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Drop for ChromeProcess {
fn drop(&mut self) {
self.kill();
}
}
pub struct LaunchOptions {
pub headless: bool,
pub executable_path: Option<String>,
pub proxy: Option<String>,
pub proxy_bypass: Option<String>,
pub profile: Option<String>,
pub args: Vec<String>,
pub allow_file_access: bool,
pub extensions: Option<Vec<String>>,
pub storage_state: Option<String>,
pub user_agent: Option<String>,
pub ignore_https_errors: bool,
pub color_scheme: Option<String>,
pub download_path: Option<String>,
}
impl Default for LaunchOptions {
fn default() -> Self {
Self {
headless: true,
executable_path: None,
proxy: None,
proxy_bypass: None,
profile: None,
args: Vec::new(),
allow_file_access: false,
extensions: None,
storage_state: None,
user_agent: None,
ignore_https_errors: false,
color_scheme: None,
download_path: None,
}
}
}
pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
let chrome_path = match &options.executable_path {
Some(p) => PathBuf::from(p),
None => {
find_chrome().ok_or("Chrome not found. Install Chrome or use --executable-path.")?
}
};
let mut args = vec![
"--remote-debugging-port=0".to_string(),
"--no-first-run".to_string(),
"--no-default-browser-check".to_string(),
"--disable-background-networking".to_string(),
"--disable-backgrounding-occluded-windows".to_string(),
"--disable-component-update".to_string(),
"--disable-default-apps".to_string(),
"--disable-hang-monitor".to_string(),
"--disable-popup-blocking".to_string(),
"--disable-prompt-on-repost".to_string(),
"--disable-sync".to_string(),
"--enable-features=NetworkService,NetworkServiceInProcess".to_string(),
"--metrics-recording-only".to_string(),
"--password-store=basic".to_string(),
"--use-mock-keychain".to_string(),
];
if options.headless {
args.push("--headless=new".to_string());
}
if let Some(ref proxy) = options.proxy {
args.push(format!("--proxy-server={}", proxy));
}
if let Some(ref bypass) = options.proxy_bypass {
args.push(format!("--proxy-bypass-list={}", bypass));
}
if let Some(ref profile) = options.profile {
let expanded = expand_tilde(profile);
args.push(format!("--user-data-dir={}", expanded));
}
if options.allow_file_access {
args.push("--allow-file-access-from-files".to_string());
args.push("--allow-file-access".to_string());
}
if let Some(ref exts) = options.extensions {
if !exts.is_empty() {
let ext_list = exts.join(",");
args.push(format!("--load-extension={}", ext_list));
args.push(format!("--disable-extensions-except={}", ext_list));
}
}
// Check if user args set window size (skip viewport override)
let has_window_size = options
.args
.iter()
.any(|a| a.starts_with("--start-maximized") || a.starts_with("--window-size="));
if !has_window_size && options.headless {
args.push("--window-size=1280,720".to_string());
}
args.extend(options.args.iter().cloned());
let mut child = Command::new(&chrome_path)
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to launch Chrome at {:?}: {}", chrome_path, e))?;
let stderr = child
.stderr
.take()
.ok_or("Failed to capture Chrome stderr")?;
let reader = BufReader::new(stderr);
let ws_url = wait_for_ws_url(reader)?;
Ok(ChromeProcess { child, ws_url })
}
fn wait_for_ws_url(reader: BufReader<std::process::ChildStderr>) -> Result<String, String> {
let deadline = std::time::Instant::now() + Duration::from_secs(30);
let prefix = "DevTools listening on ";
for line in reader.lines() {
if std::time::Instant::now() > deadline {
return Err("Timeout waiting for Chrome DevTools URL".to_string());
}
let line = line.map_err(|e| format!("Failed to read Chrome stderr: {}", e))?;
if let Some(url) = line.strip_prefix(prefix) {
return Ok(url.trim().to_string());
}
}
Err("Chrome exited before providing DevTools URL".to_string())
}
pub fn find_chrome() -> Option<PathBuf> {
#[cfg(target_os = "macos")]
{
let candidates = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
];
for c in &candidates {
let p = PathBuf::from(c);
if p.exists() {
return Some(p);
}
}
}
#[cfg(target_os = "linux")]
{
let candidates = [
"google-chrome",
"google-chrome-stable",
"chromium-browser",
"chromium",
];
for name in &candidates {
if let Ok(output) = Command::new("which").arg(name).output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Some(PathBuf::from(path));
}
}
}
}
}
#[cfg(target_os = "windows")]
{
let candidates = [
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
];
if let Ok(local) = std::env::var("LOCALAPPDATA") {
let p = PathBuf::from(&local).join(r"Google\Chrome\Application\chrome.exe");
if p.exists() {
return Some(p);
}
}
for c in &candidates {
let p = PathBuf::from(c);
if p.exists() {
return Some(p);
}
}
}
None
}
pub async fn discover_cdp_url(port: u16) -> Result<String, String> {
let url = format!("http://127.0.0.1:{}/json/version", port);
let body = tokio::time::timeout(Duration::from_secs(2), async {
reqwest_get_string(&url).await
})
.await
.map_err(|_| format!("Timeout connecting to CDP on port {}", port))?
.map_err(|e| format!("Failed to connect to CDP on port {}: {}", port, e))?;
let info: BrowserVersionInfo = serde_json::from_str(&body)
.map_err(|e| format!("Invalid /json/version response: {}", e))?;
info.web_socket_debugger_url
.ok_or_else(|| format!("No webSocketDebuggerUrl in /json/version on port {}", port))
}
async fn reqwest_get_string(url: &str) -> Result<String, String> {
let client = tokio::net::TcpStream::connect(
url.strip_prefix("http://")
.unwrap_or(url)
.split('/')
.next()
.unwrap_or("127.0.0.1:9222"),
)
.await
.map_err(|e| e.to_string())?;
let path = url
.find('/')
.and_then(|i| url[i..].find('/').map(|j| &url[i + j..]))
.unwrap_or("/json/version");
let host = url
.strip_prefix("http://")
.unwrap_or(url)
.split('/')
.next()
.unwrap_or("127.0.0.1");
let request = format!(
"GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
path, host
);
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut client = client;
client
.write_all(request.as_bytes())
.await
.map_err(|e| e.to_string())?;
let mut response = Vec::new();
client
.read_to_end(&mut response)
.await
.map_err(|e| e.to_string())?;
let response_str = String::from_utf8_lossy(&response);
let body = response_str
.split("\r\n\r\n")
.nth(1)
.unwrap_or("")
.to_string();
Ok(body)
}
pub fn read_devtools_active_port(user_data_dir: &Path) -> Option<(u16, String)> {
let path = user_data_dir.join("DevToolsActivePort");
let content = std::fs::read_to_string(&path).ok()?;
let mut lines = content.lines();
let port: u16 = lines.next()?.trim().parse().ok()?;
let ws_path = lines
.next()
.unwrap_or("/devtools/browser")
.trim()
.to_string();
Some((port, ws_path))
}
pub async fn auto_connect_cdp() -> Result<String, String> {
let user_data_dirs = get_chrome_user_data_dirs();
for dir in &user_data_dirs {
if let Some((port, ws_path)) = read_devtools_active_port(dir) {
// Try HTTP endpoint first (pre-M144)
if let Ok(ws_url) = discover_cdp_url(port).await {
return Ok(ws_url);
}
// M144+: direct WebSocket
let ws_url = format!("ws://127.0.0.1:{}{}", port, ws_path);
return Ok(ws_url);
}
}
// Fallback: probe common ports
for port in [9222u16, 9229] {
if let Ok(ws_url) = discover_cdp_url(port).await {
return Ok(ws_url);
}
}
Err("No running Chrome instance found. Launch Chrome with --remote-debugging-port or use --cdp.".to_string())
}
fn get_chrome_user_data_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
#[cfg(target_os = "macos")]
{
if let Some(home) = dirs::home_dir() {
let base = home.join("Library/Application Support");
for name in ["Google/Chrome", "Google/Chrome Canary", "Chromium"] {
dirs.push(base.join(name));
}
}
}
#[cfg(target_os = "linux")]
{
if let Some(home) = dirs::home_dir() {
let config = home.join(".config");
for name in ["google-chrome", "google-chrome-unstable", "chromium"] {
dirs.push(config.join(name));
}
}
}
#[cfg(target_os = "windows")]
{
if let Ok(local) = std::env::var("LOCALAPPDATA") {
let base = PathBuf::from(local);
for name in [
r"Google\Chrome\User Data",
r"Google\Chrome SxS\User Data",
r"Chromium\User Data",
] {
dirs.push(base.join(name));
}
}
}
dirs
}
fn expand_tilde(path: &str) -> String {
if let Some(rest) = path.strip_prefix('~') {
if let Some(home) = dirs::home_dir() {
return home
.join(rest.strip_prefix('/').unwrap_or(rest))
.to_string_lossy()
.to_string();
}
}
path.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_find_chrome_returns_some_on_host() {
// This test only makes sense on systems with Chrome installed
if cfg!(target_os = "macos") || cfg!(target_os = "linux") {
let result = find_chrome();
// Don't assert Some -- CI may not have Chrome
if let Some(path) = result {
assert!(path.exists());
}
}
}
#[test]
fn test_expand_tilde() {
let expanded = expand_tilde("~/test/path");
assert!(!expanded.starts_with('~'));
assert!(expanded.ends_with("test/path"));
}
#[test]
fn test_expand_tilde_no_tilde() {
assert_eq!(expand_tilde("/absolute/path"), "/absolute/path");
}
#[test]
fn test_read_devtools_active_port_missing() {
let result = read_devtools_active_port(Path::new("/nonexistent"));
assert!(result.is_none());
}
}
+163
View File
@@ -0,0 +1,163 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use futures_util::{SinkExt, StreamExt};
use serde_json::Value;
use tokio::sync::{broadcast, oneshot, Mutex};
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message;
use super::types::{CdpCommand, CdpEvent, CdpMessage};
type PendingMap = Arc<Mutex<HashMap<u64, oneshot::Sender<CdpMessage>>>>;
pub struct CdpClient {
ws_tx: Arc<
Mutex<
futures_util::stream::SplitSink<
tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
Message,
>,
>,
>,
next_id: AtomicU64,
pending: PendingMap,
event_tx: broadcast::Sender<CdpEvent>,
_reader_handle: tokio::task::JoinHandle<()>,
}
impl CdpClient {
pub async fn connect(url: &str) -> Result<Self, String> {
let (ws_stream, _) = connect_async(url)
.await
.map_err(|e| format!("CDP WebSocket connect failed: {}", e))?;
let (ws_tx, mut ws_rx) = ws_stream.split();
let ws_tx = Arc::new(Mutex::new(ws_tx));
let pending: PendingMap = Arc::new(Mutex::new(HashMap::new()));
let (event_tx, _) = broadcast::channel(256);
let pending_clone = pending.clone();
let event_tx_clone = event_tx.clone();
let reader_handle = tokio::spawn(async move {
while let Some(msg) = ws_rx.next().await {
let msg = match msg {
Ok(Message::Text(text)) => text,
Ok(Message::Close(_)) => break,
Ok(_) => continue,
Err(_) => break,
};
let parsed: CdpMessage = match serde_json::from_str(&msg) {
Ok(m) => m,
Err(_) => continue,
};
if let Some(id) = parsed.id {
// Response to a command
let mut pending = pending_clone.lock().await;
if let Some(tx) = pending.remove(&id) {
let _ = tx.send(parsed);
}
} else if let Some(ref method) = parsed.method {
// Event
let event = CdpEvent {
method: method.clone(),
params: parsed.params.clone().unwrap_or(Value::Null),
session_id: parsed.session_id.clone(),
};
let _ = event_tx_clone.send(event);
}
}
});
Ok(Self {
ws_tx,
next_id: AtomicU64::new(1),
pending,
event_tx,
_reader_handle: reader_handle,
})
}
pub async fn send_command(
&self,
method: &str,
params: Option<Value>,
session_id: Option<&str>,
) -> Result<Value, String> {
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
let cmd = CdpCommand {
id,
method: method.to_string(),
params,
session_id: session_id.map(|s| s.to_string()),
};
let json = serde_json::to_string(&cmd)
.map_err(|e| format!("Failed to serialize CDP command: {}", e))?;
let (tx, rx) = oneshot::channel();
{
let mut pending = self.pending.lock().await;
pending.insert(id, tx);
}
{
let mut ws_tx = self.ws_tx.lock().await;
ws_tx
.send(Message::Text(json))
.await
.map_err(|e| format!("Failed to send CDP command: {}", e))?;
}
let response = match tokio::time::timeout(std::time::Duration::from_secs(30), rx).await {
Ok(Ok(resp)) => resp,
Ok(Err(_)) => return Err("CDP response channel closed".to_string()),
Err(_) => {
self.pending.lock().await.remove(&id);
return Err(format!("CDP command timed out: {}", method));
}
};
if let Some(error) = response.error {
return Err(format!("CDP error ({}): {}", method, error));
}
Ok(response.result.unwrap_or(Value::Null))
}
pub fn subscribe(&self) -> broadcast::Receiver<CdpEvent> {
self.event_tx.subscribe()
}
pub async fn send_command_typed<P: serde::Serialize, R: serde::de::DeserializeOwned>(
&self,
method: &str,
params: &P,
session_id: Option<&str>,
) -> Result<R, String> {
let params_value = serde_json::to_value(params)
.map_err(|e| format!("Failed to serialize params: {}", e))?;
let result = self
.send_command(method, Some(params_value), session_id)
.await?;
serde_json::from_value(result)
.map_err(|e| format!("Failed to deserialize CDP response for {}: {}", method, e))
}
pub async fn send_command_no_params(
&self,
method: &str,
session_id: Option<&str>,
) -> Result<Value, String> {
self.send_command(method, None, session_id).await
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod chrome;
pub mod client;
pub mod types;
+537
View File
@@ -0,0 +1,537 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
// ---------------------------------------------------------------------------
// CDP message envelope
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CdpCommand {
pub id: u64,
pub method: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub params: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CdpMessage {
pub id: Option<u64>,
pub result: Option<Value>,
pub error: Option<CdpError>,
pub method: Option<String>,
pub params: Option<Value>,
pub session_id: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CdpError {
pub code: Option<i64>,
pub message: String,
pub data: Option<String>,
}
impl std::fmt::Display for CdpError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
// ---------------------------------------------------------------------------
// CDP events (broadcast to subscribers)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct CdpEvent {
pub method: String,
pub params: Value,
pub session_id: Option<String>,
}
// ---------------------------------------------------------------------------
// Target domain
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TargetInfo {
pub target_id: String,
#[serde(rename = "type")]
pub target_type: String,
pub title: String,
pub url: String,
pub attached: Option<bool>,
pub browser_context_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetTargetsResult {
pub target_infos: Vec<TargetInfo>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AttachToTargetParams {
pub target_id: String,
pub flatten: bool,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AttachToTargetResult {
pub session_id: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SetDiscoverTargetsParams {
pub discover: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateTargetParams {
pub url: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateTargetResult {
pub target_id: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CloseTargetParams {
pub target_id: String,
}
// Target events
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TargetCreatedEvent {
pub target_info: TargetInfo,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TargetDestroyedEvent {
pub target_id: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TargetInfoChangedEvent {
pub target_info: TargetInfo,
}
// ---------------------------------------------------------------------------
// Page domain
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PageNavigateParams {
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub referrer: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PageNavigateResult {
pub frame_id: String,
pub loader_id: Option<String>,
pub error_text: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FrameNavigatedEvent {
pub frame: FrameInfo,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FrameInfo {
pub id: String,
pub url: String,
pub parent_id: Option<String>,
pub name: Option<String>,
}
// Page.javascriptDialogOpening
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct JavascriptDialogOpeningEvent {
pub url: String,
pub message: String,
#[serde(rename = "type")]
pub dialog_type: String,
pub default_prompt: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HandleJavaScriptDialogParams {
pub accept: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_text: Option<String>,
}
// ---------------------------------------------------------------------------
// Runtime domain
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EvaluateParams {
pub expression: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub return_by_value: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub await_promise: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EvaluateResult {
pub result: RemoteObject,
pub exception_details: Option<ExceptionDetails>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoteObject {
#[serde(rename = "type")]
pub object_type: String,
pub subtype: Option<String>,
pub value: Option<Value>,
pub description: Option<String>,
pub object_id: Option<String>,
pub class_name: Option<String>,
pub unserializable_value: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExceptionDetails {
pub text: String,
pub exception: Option<RemoteObject>,
pub line_number: Option<i64>,
pub column_number: Option<i64>,
}
// Runtime.consoleAPICalled
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsoleApiCalledEvent {
#[serde(rename = "type")]
pub call_type: String,
pub args: Vec<RemoteObject>,
pub timestamp: Option<f64>,
}
// Runtime.exceptionThrown
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExceptionThrownEvent {
pub timestamp: f64,
pub exception_details: ExceptionDetails,
}
// ---------------------------------------------------------------------------
// Accessibility domain
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetFullAXTreeResult {
pub nodes: Vec<AXNode>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AXNode {
pub node_id: String,
pub role: Option<AXValue>,
pub name: Option<AXValue>,
pub value: Option<AXValue>,
pub description: Option<AXValue>,
pub properties: Option<Vec<AXProperty>>,
pub child_ids: Option<Vec<String>>,
pub backend_d_o_m_node_id: Option<i64>,
pub ignored: Option<bool>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AXValue {
#[serde(rename = "type")]
pub value_type: String,
pub value: Option<Value>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AXProperty {
pub name: String,
pub value: AXValue,
}
// ---------------------------------------------------------------------------
// Network domain (minimal for Phase 1)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RequestWillBeSentEvent {
pub request_id: String,
pub request: NetworkRequest,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NetworkRequest {
pub url: String,
pub method: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LoadingFinishedEvent {
pub request_id: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LoadingFailedEvent {
pub request_id: String,
}
// ---------------------------------------------------------------------------
// DOM domain
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DomResolveNodeParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub backend_node_id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub node_id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub object_group: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DomResolveNodeResult {
pub object: RemoteObject,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DomGetBoxModelParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub backend_node_id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub node_id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub object_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DomGetBoxModelResult {
pub model: BoxModel,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BoxModel {
pub content: Vec<f64>,
pub padding: Vec<f64>,
pub border: Vec<f64>,
pub margin: Vec<f64>,
pub width: i64,
pub height: i64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DomQuerySelectorParams {
pub node_id: i64,
pub selector: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DomQuerySelectorResult {
pub node_id: i64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DomGetDocumentParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub depth: Option<i32>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DomGetDocumentResult {
pub root: DomNode,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DomNode {
pub node_id: i64,
pub backend_node_id: Option<i64>,
pub node_type: Option<i64>,
pub node_name: Option<String>,
pub children: Option<Vec<DomNode>>,
}
// ---------------------------------------------------------------------------
// Input domain
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DispatchMouseEventParams {
#[serde(rename = "type")]
pub event_type: String,
pub x: f64,
pub y: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub button: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub buttons: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub click_count: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delta_x: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delta_y: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub modifiers: Option<i32>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DispatchKeyEventParams {
#[serde(rename = "type")]
pub event_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unmodified_text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub windows_virtual_key_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub native_virtual_key_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub modifiers: Option<i32>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InsertTextParams {
pub text: String,
}
// ---------------------------------------------------------------------------
// Page.captureScreenshot
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CaptureScreenshotParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quality: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub clip: Option<Viewport>,
#[serde(skip_serializing_if = "Option::is_none")]
pub from_surface: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub capture_beyond_viewport: Option<bool>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Viewport {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
pub scale: f64,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CaptureScreenshotResult {
pub data: String,
}
// ---------------------------------------------------------------------------
// Runtime.callFunctionOn
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CallFunctionOnParams {
pub function_declaration: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub object_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub arguments: Option<Vec<CallArgument>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub return_by_value: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub await_promise: Option<bool>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CallArgument {
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub object_id: Option<String>,
}
// ---------------------------------------------------------------------------
// Version info (from /json/version)
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BrowserVersionInfo {
#[serde(rename = "webSocketDebuggerUrl")]
pub web_socket_debugger_url: Option<String>,
#[serde(rename = "Browser")]
pub browser: Option<String>,
}
/// Auto-generated CDP types from protocol JSON files in `cdp-protocol/`.
///
/// To populate: download `browser_protocol.json` and `js_protocol.json` from
/// <https://github.com/nicolo-ribaudo/nicolo-ribaudo.github.io/> (or any
/// Chromium source) into `cli/cdp-protocol/` and rebuild.
///
/// Usage: `use super::cdp::types::generated::cdp_page::*;`
pub mod generated {
include!(concat!(env!("OUT_DIR"), "/cdp_generated.rs"));
}
+87
View File
@@ -0,0 +1,87 @@
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use super::cdp::client::CdpClient;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Cookie {
pub name: String,
pub value: String,
pub domain: String,
pub path: String,
#[serde(default)]
pub expires: f64,
#[serde(default)]
pub size: i64,
#[serde(default)]
pub http_only: bool,
#[serde(default)]
pub secure: bool,
#[serde(default)]
pub session: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub same_site: Option<String>,
}
pub async fn get_cookies(
client: &CdpClient,
session_id: &str,
urls: Option<Vec<String>>,
) -> Result<Vec<Cookie>, String> {
let params = match urls {
Some(ref u) if !u.is_empty() => json!({ "urls": u }),
_ => json!({}),
};
let result = client
.send_command("Network.getCookies", Some(params), Some(session_id))
.await?;
let cookies: Vec<Cookie> = result
.get("cookies")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
Ok(cookies)
}
pub async fn set_cookies(
client: &CdpClient,
session_id: &str,
cookies: Vec<Value>,
current_url: Option<&str>,
) -> Result<(), String> {
let cookies: Vec<Value> = cookies
.into_iter()
.map(|mut c| {
// Auto-fill url if no domain/path/url provided
if c.get("url").is_none() && c.get("domain").is_none() && current_url.is_some() {
c.as_object_mut().map(|m| {
m.insert(
"url".to_string(),
Value::String(current_url.unwrap().to_string()),
)
});
}
c
})
.collect();
client
.send_command(
"Network.setCookies",
Some(json!({ "cookies": cookies })),
Some(session_id),
)
.await?;
Ok(())
}
pub async fn clear_cookies(client: &CdpClient, session_id: &str) -> Result<(), String> {
client
.send_command_no_params("Network.clearBrowserCookies", Some(session_id))
.await?;
Ok(())
}
+266
View File
@@ -0,0 +1,266 @@
use serde_json::Value;
use std::env;
use std::fs;
use std::path::PathBuf;
use std::process;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::signal;
use super::actions::{execute_command, DaemonState};
use super::state;
pub async fn run_daemon(session: &str) {
let socket_dir = get_daemon_socket_dir();
if !socket_dir.exists() {
let _ = fs::create_dir_all(&socket_dir);
}
let pid_path = socket_dir.join(format!("{}.pid", session));
let _ = fs::write(&pid_path, process::id().to_string());
let socket_path = socket_dir.join(format!("{}.sock", session));
if socket_path.exists() {
let _ = fs::remove_file(&socket_path);
}
if let Ok(days_str) = env::var("AGENT_BROWSER_STATE_EXPIRE_DAYS") {
if let Ok(days) = days_str.parse::<u64>() {
if days > 0 {
let _ = state::state_clean(days);
}
}
}
let result = run_socket_server(&socket_path, session).await;
let _ = fs::remove_file(&socket_path);
let _ = fs::remove_file(&pid_path);
let stream_path = socket_dir.join(format!("{}.stream", session));
let _ = fs::remove_file(&stream_path);
if let Err(e) = result {
eprintln!("Daemon error: {}", e);
process::exit(1);
}
}
#[cfg(unix)]
async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(), String> {
use tokio::net::UnixListener;
let listener =
UnixListener::bind(socket_path).map_err(|e| format!("Failed to bind socket: {}", e))?;
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> =
std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new()));
loop {
tokio::select! {
accept_result = listener.accept() => {
match accept_result {
Ok((stream, _)) => {
let state = state.clone();
tokio::spawn(async move {
handle_connection(stream, state).await;
});
}
Err(e) => {
eprintln!("Accept error: {}", e);
}
}
}
_ = shutdown_signal() => {
let mut s = state.lock().await;
if let Some(ref mut mgr) = s.browser {
let _ = mgr.close().await;
}
break;
}
}
}
Ok(())
}
#[cfg(windows)]
async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), String> {
use tokio::net::TcpListener;
let port = get_port_for_session(session);
let listener = TcpListener::bind(format!("127.0.0.1:{}", port))
.await
.map_err(|e| format!("Failed to bind TCP: {}", e))?;
let socket_dir = socket_path.parent().unwrap_or(std::path::Path::new("."));
let port_path = socket_dir.join(format!("{}.port", session));
let _ = fs::write(&port_path, port.to_string());
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> =
std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new()));
loop {
tokio::select! {
accept_result = listener.accept() => {
match accept_result {
Ok((stream, _)) => {
let state = state.clone();
tokio::spawn(async move {
handle_connection(stream, state).await;
});
}
Err(e) => {
eprintln!("Accept error: {}", e);
}
}
}
_ = shutdown_signal() => {
let mut s = state.lock().await;
if let Some(ref mut mgr) = s.browser {
let _ = mgr.close().await;
}
let _ = fs::remove_file(&port_path);
break;
}
}
}
Ok(())
}
async fn handle_connection<S>(stream: S, state: std::sync::Arc<tokio::sync::Mutex<DaemonState>>)
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
let (reader, mut writer) = tokio::io::split(stream);
let mut buf_reader = BufReader::new(reader);
let mut line = String::new();
loop {
line.clear();
match buf_reader.read_line(&mut line).await {
Ok(0) => break,
Ok(_) => {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if looks_like_http(trimmed) {
break;
}
let cmd: Value = match serde_json::from_str(trimmed) {
Ok(v) => v,
Err(e) => {
let err = serde_json::json!({
"success": false,
"error": format!("Invalid JSON: {}", e),
});
let mut resp = serde_json::to_string(&err).unwrap_or_default();
resp.push('\n');
let _ = writer.write_all(resp.as_bytes()).await;
continue;
}
};
let is_close = cmd.get("action").and_then(|v| v.as_str()) == Some("close");
let response = {
let mut s = state.lock().await;
execute_command(&cmd, &mut s).await
};
let mut resp = serde_json::to_string(&response).unwrap_or_default();
resp.push('\n');
if writer.write_all(resp.as_bytes()).await.is_err() {
break;
}
if is_close {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
process::exit(0);
}
}
Err(_) => break,
}
}
}
fn looks_like_http(line: &str) -> bool {
let prefixes = [
"GET ", "POST ", "PUT ", "DELETE ", "PATCH ", "HEAD ", "OPTIONS ", "CONNECT ", "TRACE ",
];
prefixes.iter().any(|p| line.starts_with(p))
}
async fn shutdown_signal() {
#[cfg(unix)]
{
let mut sigint = match signal::unix::signal(signal::unix::SignalKind::interrupt()) {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to install SIGINT handler: {}", e);
process::exit(1);
}
};
let mut sigterm = match signal::unix::signal(signal::unix::SignalKind::terminate()) {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to install SIGTERM handler: {}", e);
process::exit(1);
}
};
let mut sighup = match signal::unix::signal(signal::unix::SignalKind::hangup()) {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to install SIGHUP handler: {}", e);
process::exit(1);
}
};
tokio::select! {
_ = sigint.recv() => {}
_ = sigterm.recv() => {}
_ = sighup.recv() => {}
}
}
#[cfg(windows)]
{
if let Err(e) = signal::ctrl_c().await {
eprintln!("Failed to install Ctrl+C handler: {}", e);
process::exit(1);
}
}
}
fn get_daemon_socket_dir() -> PathBuf {
if let Ok(dir) = env::var("AGENT_BROWSER_SOCKET_DIR") {
if !dir.is_empty() {
return PathBuf::from(dir);
}
}
if let Ok(xdg) = env::var("XDG_RUNTIME_DIR") {
if !xdg.is_empty() {
return PathBuf::from(xdg).join("agent-browser");
}
}
if let Some(home) = dirs::home_dir() {
return home.join(".agent-browser");
}
std::env::temp_dir().join("agent-browser")
}
#[cfg(windows)]
fn get_port_for_session(session: &str) -> u16 {
let mut hash: i64 = 0;
for b in session.bytes() {
hash = hash.wrapping_mul(31).wrapping_add(b as i64);
}
49152 + (hash.unsigned_abs() % 16383) as u16
}
+195
View File
@@ -0,0 +1,195 @@
use serde_json::{json, Value};
use similar::{ChangeTag, TextDiff};
pub struct ScreenshotDiffResult {
pub total_pixels: u64,
pub different_pixels: u64,
pub mismatch_percentage: f64,
pub matched: bool,
pub diff_image: Option<Vec<u8>>,
pub dimension_mismatch: Option<Value>,
}
pub struct SnapshotDiffResult {
pub diff: String,
pub additions: usize,
pub removals: usize,
pub unchanged: usize,
pub changed: bool,
}
pub fn diff_screenshot(
baseline: &[u8],
current: &[u8],
threshold: f64,
) -> Result<ScreenshotDiffResult, String> {
let img_a = image::load_from_memory(baseline)
.map_err(|e| format!("Failed to decode baseline image: {}", e))?;
let img_b = image::load_from_memory(current)
.map_err(|e| format!("Failed to decode current image: {}", e))?;
let (wa, ha) = (img_a.width(), img_a.height());
let (wb, hb) = (img_b.width(), img_b.height());
if wa != wb || ha != hb {
return Ok(ScreenshotDiffResult {
total_pixels: (wa as u64) * (ha as u64),
different_pixels: (wa as u64) * (ha as u64),
mismatch_percentage: 100.0,
matched: false,
diff_image: None,
dimension_mismatch: Some(json!({
"expected": { "width": wa, "height": ha },
"actual": { "width": wb, "height": hb },
})),
});
}
let rgba_a = img_a.to_rgba8();
let rgba_b = img_b.to_rgba8();
let total = (wa as u64) * (ha as u64);
let max_color_distance = threshold * 255.0 * (3.0_f64).sqrt();
let mut different = 0u64;
let mut diff_img = image::RgbaImage::new(wa, ha);
for y in 0..ha {
for x in 0..wa {
let pa = rgba_a.get_pixel(x, y);
let pb = rgba_b.get_pixel(x, y);
let dr = (pa[0] as f64) - (pb[0] as f64);
let dg = (pa[1] as f64) - (pb[1] as f64);
let db = (pa[2] as f64) - (pb[2] as f64);
let dist = (dr * dr + dg * dg + db * db).sqrt();
if dist > max_color_distance {
different += 1;
diff_img.put_pixel(x, y, image::Rgba([255, 0, 0, 255]));
} else {
let gray = ((pa[0] as u16 + pa[1] as u16 + pa[2] as u16) / 3) as u8;
let dimmed = (gray as f64 * 0.3) as u8;
diff_img.put_pixel(x, y, image::Rgba([dimmed, dimmed, dimmed, 255]));
}
}
}
let mismatch = if total > 0 {
(different as f64 / total as f64) * 100.0
} else {
0.0
};
let diff_bytes = if different > 0 {
let mut buf = std::io::Cursor::new(Vec::new());
diff_img
.write_to(&mut buf, image::ImageFormat::Png)
.map_err(|e| format!("Failed to encode diff image: {}", e))?;
Some(buf.into_inner())
} else {
None
};
Ok(ScreenshotDiffResult {
total_pixels: total,
different_pixels: different,
mismatch_percentage: mismatch,
matched: different == 0,
diff_image: diff_bytes,
dimension_mismatch: None,
})
}
/// Compute a snapshot diff using the Myers algorithm via the `similar` crate.
pub fn diff_snapshots(before: &str, after: &str) -> SnapshotDiffResult {
let text_diff = TextDiff::from_lines(before, after);
let mut additions = 0usize;
let mut removals = 0usize;
let mut unchanged = 0usize;
for change in text_diff.iter_all_changes() {
match change.tag() {
ChangeTag::Insert => additions += 1,
ChangeTag::Delete => removals += 1,
ChangeTag::Equal => unchanged += 1,
}
}
let changed = additions > 0 || removals > 0;
let diff = text_diff
.unified_diff()
.context_radius(3)
.header("before", "after")
.to_string();
SnapshotDiffResult {
diff,
additions,
removals,
unchanged,
changed,
}
}
/// Legacy JSON diff output for backwards compatibility.
pub fn diff_text(a: &str, b: &str) -> Value {
let result = diff_snapshots(a, b);
json!({
"identical": !result.changed,
"additions": result.additions,
"removals": result.removals,
"deletions": result.removals,
"unchanged": result.unchanged,
"changed": result.changed,
})
}
pub fn diff_unified(a: &str, b: &str) -> String {
diff_snapshots(a, b).diff
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_diff_identical() {
let result = diff_text("hello\nworld", "hello\nworld");
assert_eq!(result.get("identical").unwrap(), true);
assert_eq!(result.get("changed").unwrap(), false);
assert_eq!(result.get("unchanged").unwrap(), 2);
}
#[test]
fn test_diff_additions() {
let result = diff_text("hello\n", "hello\nworld\n");
assert_eq!(result.get("identical").unwrap(), false);
assert_eq!(result.get("changed").unwrap(), true);
assert!(result.get("additions").unwrap().as_i64().unwrap() > 0);
}
#[test]
fn test_diff_deletions() {
let result = diff_text("hello\nworld\n", "hello\n");
assert_eq!(result.get("identical").unwrap(), false);
assert!(result.get("removals").unwrap().as_i64().unwrap() > 0);
}
#[test]
fn test_diff_unified_output() {
let output = diff_unified("a\nb\nc\n", "a\nx\nc\n");
assert!(output.contains("---"));
assert!(output.contains("+++"));
}
#[test]
fn test_snapshot_diff_struct() {
let result = diff_snapshots("line1\nline2\n", "line1\nline3\n");
assert!(result.changed);
assert_eq!(result.additions, 1);
assert_eq!(result.removals, 1);
assert_eq!(result.unchanged, 1);
assert!(!result.diff.is_empty());
}
}
File diff suppressed because it is too large Load Diff
+718
View File
@@ -0,0 +1,718 @@
use std::collections::HashMap;
use serde_json::Value;
use super::cdp::client::CdpClient;
use super::cdp::types::*;
#[derive(Debug, Clone)]
pub struct RefEntry {
pub backend_node_id: Option<i64>,
pub role: String,
pub name: String,
pub nth: Option<usize>,
pub selector: Option<String>,
}
pub struct RefMap {
map: HashMap<String, RefEntry>,
next_ref: usize,
}
impl RefMap {
pub fn new() -> Self {
Self {
map: HashMap::new(),
next_ref: 1,
}
}
pub fn add(
&mut self,
ref_id: String,
backend_node_id: Option<i64>,
role: &str,
name: &str,
nth: Option<usize>,
) {
self.map.insert(
ref_id,
RefEntry {
backend_node_id,
role: role.to_string(),
name: name.to_string(),
nth,
selector: None,
},
);
}
pub fn get(&self, ref_id: &str) -> Option<&RefEntry> {
self.map.get(ref_id)
}
pub fn clear(&mut self) {
self.map.clear();
self.next_ref = 1;
}
pub fn next_ref_num(&self) -> usize {
self.next_ref
}
pub fn set_next_ref_num(&mut self, n: usize) {
self.next_ref = n;
}
}
pub fn parse_ref(input: &str) -> Option<String> {
let trimmed = input.trim();
if let Some(stripped) = trimmed.strip_prefix('@') {
if stripped.starts_with('e') && stripped[1..].chars().all(|c| c.is_ascii_digit()) {
return Some(stripped.to_string());
}
}
if let Some(stripped) = trimmed.strip_prefix("ref=") {
if stripped.starts_with('e') && stripped[1..].chars().all(|c| c.is_ascii_digit()) {
return Some(stripped.to_string());
}
}
if trimmed.starts_with('e')
&& trimmed.len() > 1
&& trimmed[1..].chars().all(|c| c.is_ascii_digit())
{
return Some(trimmed.to_string());
}
None
}
pub async fn resolve_element_center(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
) -> Result<(f64, f64), String> {
if let Some(ref_id) = parse_ref(selector_or_ref) {
let entry = ref_map
.get(&ref_id)
.ok_or_else(|| format!("Unknown ref: {}", ref_id))?;
if let Some(backend_node_id) = entry.backend_node_id {
let result: DomGetBoxModelResult = client
.send_command_typed(
"DOM.getBoxModel",
&DomGetBoxModelParams {
backend_node_id: Some(backend_node_id),
node_id: None,
object_id: None,
},
Some(session_id),
)
.await?;
return Ok(box_model_center(&result.model));
}
// Fallback: use role/name to find via JS
return resolve_by_role_name(client, session_id, &entry.role, &entry.name, entry.nth).await;
}
// CSS selector
resolve_by_selector(client, session_id, selector_or_ref).await
}
pub async fn resolve_element_object_id(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
) -> Result<String, String> {
if let Some(ref_id) = parse_ref(selector_or_ref) {
let entry = ref_map
.get(&ref_id)
.ok_or_else(|| format!("Unknown ref: {}", ref_id))?;
if let Some(backend_node_id) = entry.backend_node_id {
let result: DomResolveNodeResult = client
.send_command_typed(
"DOM.resolveNode",
&DomResolveNodeParams {
backend_node_id: Some(backend_node_id),
node_id: None,
object_group: Some("agent-browser".to_string()),
},
Some(session_id),
)
.await?;
return result
.object
.object_id
.ok_or_else(|| format!("No objectId for ref {}", ref_id));
}
}
// CSS selector fallback
let js = format!(
"document.querySelector({})",
serde_json::to_string(selector_or_ref).unwrap_or_default()
);
let result: EvaluateResult = client
.send_command_typed(
"Runtime.evaluate",
&EvaluateParams {
expression: js,
return_by_value: Some(false),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
result
.result
.object_id
.ok_or_else(|| format!("Element not found: {}", selector_or_ref))
}
async fn resolve_by_role_name(
client: &CdpClient,
session_id: &str,
role: &str,
name: &str,
nth: Option<usize>,
) -> Result<(f64, f64), String> {
let nth_index = nth.unwrap_or(0);
let js = format!(
r#"(() => {{
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
const matches = [];
let node;
while (node = walker.nextNode()) {{
const r = node.getAttribute('role') || node.tagName.toLowerCase();
const n = node.getAttribute('aria-label') || node.textContent.trim().slice(0, 100);
if (r === {role} && n === {name}) matches.push(node);
}}
const el = matches[{nth}];
if (!el) return null;
const rect = el.getBoundingClientRect();
return {{ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }};
}})()"#,
role = serde_json::to_string(role).unwrap_or_default(),
name = serde_json::to_string(name).unwrap_or_default(),
nth = nth_index,
);
let result: EvaluateResult = client
.send_command_typed(
"Runtime.evaluate",
&EvaluateParams {
expression: js,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
let val = result.result.value.unwrap_or(Value::Null);
let x = val.get("x").and_then(|v| v.as_f64());
let y = val.get("y").and_then(|v| v.as_f64());
match (x, y) {
(Some(x), Some(y)) => Ok((x, y)),
_ => Err(format!(
"Could not locate element with role={} name={}",
role, name
)),
}
}
async fn resolve_by_selector(
client: &CdpClient,
session_id: &str,
selector: &str,
) -> Result<(f64, f64), String> {
let js = format!(
r#"(() => {{
const el = document.querySelector({sel});
if (!el) return null;
const rect = el.getBoundingClientRect();
return {{ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }};
}})()"#,
sel = serde_json::to_string(selector).unwrap_or_default(),
);
let result: EvaluateResult = client
.send_command_typed(
"Runtime.evaluate",
&EvaluateParams {
expression: js,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
let val = result.result.value.unwrap_or(Value::Null);
let x = val.get("x").and_then(|v| v.as_f64());
let y = val.get("y").and_then(|v| v.as_f64());
match (x, y) {
(Some(x), Some(y)) => Ok((x, y)),
_ => Err(format!("Element not found: {}", selector)),
}
}
fn box_model_center(model: &BoxModel) -> (f64, f64) {
// content quad: [x1,y1, x2,y2, x3,y3, x4,y4]
if model.content.len() >= 8 {
let x = (model.content[0] + model.content[2] + model.content[4] + model.content[6]) / 4.0;
let y = (model.content[1] + model.content[3] + model.content[5] + model.content[7]) / 4.0;
(x, y)
} else {
(0.0, 0.0)
}
}
pub async fn get_element_text(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
) -> Result<String, String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let result: EvaluateResult = client
.send_command_typed(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration:
"function() { return this.innerText || this.textContent || ''; }".to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
Ok(result
.result
.value
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_default())
}
pub async fn get_element_attribute(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
attribute: &str,
) -> Result<Value, String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let result: EvaluateResult = client
.send_command_typed(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: format!(
"function() {{ return this.getAttribute({}); }}",
serde_json::to_string(attribute).unwrap_or_default()
),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
Ok(result.result.value.unwrap_or(Value::Null))
}
pub async fn is_element_visible(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
) -> Result<bool, String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let result: EvaluateResult = client
.send_command_typed(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: r#"function() {
const rect = this.getBoundingClientRect();
const style = window.getComputedStyle(this);
return rect.width > 0 && rect.height > 0 &&
style.visibility !== 'hidden' &&
style.display !== 'none' &&
parseFloat(style.opacity) > 0;
}"#
.to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
Ok(result
.result
.value
.and_then(|v| v.as_bool())
.unwrap_or(false))
}
pub async fn is_element_enabled(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
) -> Result<bool, String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let result: EvaluateResult = client
.send_command_typed(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: "function() { return !this.disabled; }".to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
Ok(result
.result
.value
.and_then(|v| v.as_bool())
.unwrap_or(true))
}
pub async fn is_element_checked(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
) -> Result<bool, String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let result: EvaluateResult = client
.send_command_typed(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: "function() { return !!this.checked; }".to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
Ok(result
.result
.value
.and_then(|v| v.as_bool())
.unwrap_or(false))
}
pub async fn get_element_inner_text(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
) -> Result<String, String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let result: EvaluateResult = client
.send_command_typed(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: "function() { return this.innerText || ''; }".to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
Ok(result
.result
.value
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_default())
}
pub async fn get_element_inner_html(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
) -> Result<String, String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let result: EvaluateResult = client
.send_command_typed(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: "function() { return this.innerHTML || ''; }".to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
Ok(result
.result
.value
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_default())
}
pub async fn get_element_input_value(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
) -> Result<String, String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let result: EvaluateResult = client
.send_command_typed(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration:
"function() { return typeof this.value === 'string' ? this.value : ''; }"
.to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
Ok(result
.result
.value
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_default())
}
pub async fn set_element_value(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
value: &str,
) -> Result<(), String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let js = format!(
"function() {{ this.value = {}; this.dispatchEvent(new Event('input', {{bubbles: true}})); this.dispatchEvent(new Event('change', {{bubbles: true}})); }}",
serde_json::to_string(value).unwrap_or_default()
);
client
.send_command_typed::<_, EvaluateResult>(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: js,
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
Ok(())
}
pub async fn get_element_bounding_box(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
) -> Result<Value, String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let result: EvaluateResult = client
.send_command_typed(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: r#"function() {
const r = this.getBoundingClientRect();
return { x: r.x, y: r.y, width: r.width, height: r.height };
}"#
.to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
result
.result
.value
.ok_or_else(|| format!("Could not get bounding box for: {}", selector_or_ref))
}
pub async fn get_element_count(
client: &CdpClient,
session_id: &str,
selector: &str,
) -> Result<i64, String> {
let js = format!(
"document.querySelectorAll({}).length",
serde_json::to_string(selector).unwrap_or_default()
);
let result: EvaluateResult = client
.send_command_typed(
"Runtime.evaluate",
&EvaluateParams {
expression: js,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
Ok(result.result.value.and_then(|v| v.as_i64()).unwrap_or(0))
}
pub async fn get_element_styles(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
properties: Option<Vec<String>>,
) -> Result<Value, String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let js = match properties {
Some(props) => {
let props_json = serde_json::to_string(&props).unwrap_or("[]".to_string());
format!(
r#"function() {{
const s = window.getComputedStyle(this);
const props = {};
const result = {{}};
for (const p of props) result[p] = s.getPropertyValue(p);
return result;
}}"#,
props_json
)
}
None => r#"function() {
const s = window.getComputedStyle(this);
const result = {};
for (let i = 0; i < s.length; i++) {
const p = s[i];
result[p] = s.getPropertyValue(p);
}
return result;
}"#
.to_string(),
};
let result: EvaluateResult = client
.send_command_typed(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: js,
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
Ok(result.result.value.unwrap_or(Value::Null))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_ref_at_prefix() {
assert_eq!(parse_ref("@e1"), Some("e1".to_string()));
assert_eq!(parse_ref("@e123"), Some("e123".to_string()));
}
#[test]
fn test_parse_ref_equals_prefix() {
assert_eq!(parse_ref("ref=e1"), Some("e1".to_string()));
}
#[test]
fn test_parse_ref_bare() {
assert_eq!(parse_ref("e1"), Some("e1".to_string()));
assert_eq!(parse_ref("e42"), Some("e42".to_string()));
}
#[test]
fn test_parse_ref_invalid() {
assert_eq!(parse_ref("button"), None);
assert_eq!(parse_ref("e"), None);
assert_eq!(parse_ref("1"), None);
assert_eq!(parse_ref(""), None);
}
#[test]
fn test_ref_map_basic() {
let mut map = RefMap::new();
map.add("e1".to_string(), Some(42), "button", "Submit", None);
assert!(map.get("e1").is_some());
assert_eq!(map.get("e1").unwrap().role, "button");
assert!(map.get("e2").is_none());
}
#[test]
fn test_box_model_center() {
let model = BoxModel {
content: vec![10.0, 20.0, 110.0, 20.0, 110.0, 60.0, 10.0, 60.0],
padding: vec![],
border: vec![],
margin: vec![],
width: 100,
height: 40,
};
let (x, y) = box_model_center(&model);
assert!((x - 60.0).abs() < 0.01);
assert!((y - 40.0).abs() < 0.01);
}
}
+707
View File
@@ -0,0 +1,707 @@
use serde_json::Value;
use super::cdp::client::CdpClient;
use super::cdp::types::*;
use super::element::{resolve_element_center, resolve_element_object_id, RefMap};
pub async fn click(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
button: &str,
click_count: i32,
) -> Result<(), String> {
let (x, y) = resolve_element_center(client, session_id, ref_map, selector_or_ref).await?;
dispatch_click(client, session_id, x, y, button, click_count).await
}
pub async fn dblclick(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
) -> Result<(), String> {
click(client, session_id, ref_map, selector_or_ref, "left", 2).await
}
pub async fn hover(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
) -> Result<(), String> {
let (x, y) = resolve_element_center(client, session_id, ref_map, selector_or_ref).await?;
client
.send_command_typed::<_, Value>(
"Input.dispatchMouseEvent",
&DispatchMouseEventParams {
event_type: "mouseMoved".to_string(),
x,
y,
button: None,
buttons: None,
click_count: None,
delta_x: None,
delta_y: None,
modifiers: None,
},
Some(session_id),
)
.await?;
Ok(())
}
pub async fn fill(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
value: &str,
) -> Result<(), String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
// Focus the element
client
.send_command_typed::<_, Value>(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: "function() { this.focus(); }".to_string(),
object_id: Some(object_id.clone()),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
// Select all + delete to clear
client
.send_command_typed::<_, Value>(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: r#"function() {
this.select && this.select();
this.value = '';
this.dispatchEvent(new Event('input', { bubbles: true }));
}"#
.to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
// Insert text
client
.send_command_typed::<_, Value>(
"Input.insertText",
&InsertTextParams {
text: value.to_string(),
},
Some(session_id),
)
.await?;
Ok(())
}
pub async fn type_text(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
text: &str,
clear: bool,
delay_ms: Option<u64>,
) -> Result<(), String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
// Focus
client
.send_command_typed::<_, Value>(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: "function() { this.focus(); }".to_string(),
object_id: Some(object_id.clone()),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
if clear {
client
.send_command_typed::<_, Value>(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: r#"function() {
this.select && this.select();
this.value = '';
this.dispatchEvent(new Event('input', { bubbles: true }));
}"#
.to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
}
let delay = delay_ms.unwrap_or(0);
for ch in text.chars() {
let text_str = ch.to_string();
let (key, code, key_code) = char_to_key_info(ch);
client
.send_command_typed::<_, Value>(
"Input.dispatchKeyEvent",
&DispatchKeyEventParams {
event_type: "keyDown".to_string(),
key: Some(key.clone()),
code: Some(code.clone()),
text: Some(text_str.clone()),
unmodified_text: Some(text_str.clone()),
windows_virtual_key_code: Some(key_code),
native_virtual_key_code: Some(key_code),
modifiers: None,
},
Some(session_id),
)
.await?;
client
.send_command_typed::<_, Value>(
"Input.dispatchKeyEvent",
&DispatchKeyEventParams {
event_type: "keyUp".to_string(),
key: Some(key),
code: Some(code),
text: None,
unmodified_text: None,
windows_virtual_key_code: Some(key_code),
native_virtual_key_code: Some(key_code),
modifiers: None,
},
Some(session_id),
)
.await?;
if delay > 0 {
tokio::time::sleep(tokio::time::Duration::from_millis(delay)).await;
}
}
Ok(())
}
pub async fn press_key(client: &CdpClient, session_id: &str, key: &str) -> Result<(), String> {
let (key_name, code, key_code) = named_key_info(key);
client
.send_command_typed::<_, Value>(
"Input.dispatchKeyEvent",
&DispatchKeyEventParams {
event_type: "keyDown".to_string(),
key: Some(key_name.clone()),
code: Some(code.clone()),
text: None,
unmodified_text: None,
windows_virtual_key_code: Some(key_code),
native_virtual_key_code: Some(key_code),
modifiers: None,
},
Some(session_id),
)
.await?;
client
.send_command_typed::<_, Value>(
"Input.dispatchKeyEvent",
&DispatchKeyEventParams {
event_type: "keyUp".to_string(),
key: Some(key_name),
code: Some(code),
text: None,
unmodified_text: None,
windows_virtual_key_code: Some(key_code),
native_virtual_key_code: Some(key_code),
modifiers: None,
},
Some(session_id),
)
.await?;
Ok(())
}
pub async fn scroll(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: Option<&str>,
delta_x: f64,
delta_y: f64,
) -> Result<(), String> {
if let Some(sel) = selector_or_ref {
let object_id = resolve_element_object_id(client, session_id, ref_map, sel).await?;
let js = "function(dx, dy) { this.scrollBy(dx, dy); }".to_string();
client
.send_command_typed::<_, Value>(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: js,
object_id: Some(object_id),
arguments: Some(vec![
CallArgument {
value: Some(serde_json::json!(delta_x)),
object_id: None,
},
CallArgument {
value: Some(serde_json::json!(delta_y)),
object_id: None,
},
]),
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
} else {
let js = format!("window.scrollBy({}, {})", delta_x, delta_y);
client
.send_command_typed::<_, Value>(
"Runtime.evaluate",
&EvaluateParams {
expression: js,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
}
Ok(())
}
pub async fn select_option(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
values: &[String],
) -> Result<(), String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let js = r#"function(vals) {
const options = Array.from(this.options);
for (const opt of options) {
opt.selected = vals.includes(opt.value) || vals.includes(opt.textContent.trim());
}
this.dispatchEvent(new Event('change', { bubbles: true }));
}"#
.to_string();
client
.send_command_typed::<_, Value>(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: js,
object_id: Some(object_id),
arguments: Some(vec![CallArgument {
value: Some(serde_json::json!(values)),
object_id: None,
}]),
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
Ok(())
}
pub async fn check(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
) -> Result<(), String> {
let is_checked =
super::element::is_element_checked(client, session_id, ref_map, selector_or_ref).await?;
if !is_checked {
click(client, session_id, ref_map, selector_or_ref, "left", 1).await?;
}
Ok(())
}
pub async fn uncheck(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
) -> Result<(), String> {
let is_checked =
super::element::is_element_checked(client, session_id, ref_map, selector_or_ref).await?;
if is_checked {
click(client, session_id, ref_map, selector_or_ref, "left", 1).await?;
}
Ok(())
}
pub async fn focus(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
) -> Result<(), String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
client
.send_command_typed::<_, Value>(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: "function() { this.focus(); }".to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
Ok(())
}
pub async fn clear(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
) -> Result<(), String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
client
.send_command_typed::<_, Value>(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: r#"function() {
this.focus();
this.value = '';
this.dispatchEvent(new Event('input', { bubbles: true }));
this.dispatchEvent(new Event('change', { bubbles: true }));
}"#
.to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
Ok(())
}
pub async fn select_all(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
) -> Result<(), String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
client
.send_command_typed::<_, Value>(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: r#"function() {
this.focus();
if (typeof this.select === 'function') {
this.select();
} else {
const range = document.createRange();
range.selectNodeContents(this);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
}"#
.to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
Ok(())
}
pub async fn scroll_into_view(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
) -> Result<(), String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
client
.send_command_typed::<_, Value>(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration:
"function() { this.scrollIntoView({ block: 'center', inline: 'center' }); }"
.to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
Ok(())
}
pub async fn dispatch_event(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
event_type: &str,
event_init: Option<&Value>,
) -> Result<(), String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
let init_json = event_init
.map(|v| serde_json::to_string(v).unwrap_or("{}".to_string()))
.unwrap_or_else(|| "{ bubbles: true }".to_string());
let js = format!(
"function() {{ this.dispatchEvent(new Event({}, {})); }}",
serde_json::to_string(event_type).unwrap_or_default(),
init_json
);
client
.send_command_typed::<_, Value>(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: js,
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
Ok(())
}
pub async fn highlight(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
) -> Result<(), String> {
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
client
.send_command_typed::<_, Value>(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: r#"function() {
this.style.outline = '2px solid red';
this.style.outlineOffset = '2px';
const el = this;
setTimeout(() => {
el.style.outline = '';
el.style.outlineOffset = '';
}, 3000);
}"#
.to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
Ok(())
}
pub async fn tap_touch(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
) -> Result<(), String> {
let (x, y) = resolve_element_center(client, session_id, ref_map, selector_or_ref).await?;
client
.send_command(
"Input.dispatchTouchEvent",
Some(serde_json::json!({
"type": "touchStart",
"touchPoints": [{ "x": x, "y": y }],
})),
Some(session_id),
)
.await?;
client
.send_command(
"Input.dispatchTouchEvent",
Some(serde_json::json!({
"type": "touchEnd",
"touchPoints": [],
})),
Some(session_id),
)
.await?;
Ok(())
}
async fn dispatch_click(
client: &CdpClient,
session_id: &str,
x: f64,
y: f64,
button: &str,
click_count: i32,
) -> Result<(), String> {
// Move
client
.send_command_typed::<_, Value>(
"Input.dispatchMouseEvent",
&DispatchMouseEventParams {
event_type: "mouseMoved".to_string(),
x,
y,
button: None,
buttons: None,
click_count: None,
delta_x: None,
delta_y: None,
modifiers: None,
},
Some(session_id),
)
.await?;
let button_value = match button {
"right" => 2,
"middle" => 4,
_ => 1,
};
// Press
client
.send_command_typed::<_, Value>(
"Input.dispatchMouseEvent",
&DispatchMouseEventParams {
event_type: "mousePressed".to_string(),
x,
y,
button: Some(button.to_string()),
buttons: Some(button_value),
click_count: Some(click_count),
delta_x: None,
delta_y: None,
modifiers: None,
},
Some(session_id),
)
.await?;
// Release
client
.send_command_typed::<_, Value>(
"Input.dispatchMouseEvent",
&DispatchMouseEventParams {
event_type: "mouseReleased".to_string(),
x,
y,
button: Some(button.to_string()),
buttons: Some(0),
click_count: Some(click_count),
delta_x: None,
delta_y: None,
modifiers: None,
},
Some(session_id),
)
.await?;
Ok(())
}
fn char_to_key_info(ch: char) -> (String, String, i32) {
match ch {
'\n' | '\r' => ("Enter".to_string(), "Enter".to_string(), 13),
'\t' => ("Tab".to_string(), "Tab".to_string(), 9),
' ' => (" ".to_string(), "Space".to_string(), 32),
_ => {
let key = ch.to_string();
let code = if ch.is_ascii_alphabetic() {
format!("Key{}", ch.to_uppercase())
} else if ch.is_ascii_digit() {
format!("Digit{}", ch)
} else {
String::new()
};
let key_code = ch as i32;
(key, code, key_code)
}
}
}
fn named_key_info(key: &str) -> (String, String, i32) {
match key.to_lowercase().as_str() {
"enter" | "return" => ("Enter".to_string(), "Enter".to_string(), 13),
"tab" => ("Tab".to_string(), "Tab".to_string(), 9),
"escape" | "esc" => ("Escape".to_string(), "Escape".to_string(), 27),
"backspace" => ("Backspace".to_string(), "Backspace".to_string(), 8),
"delete" => ("Delete".to_string(), "Delete".to_string(), 46),
"arrowup" | "up" => ("ArrowUp".to_string(), "ArrowUp".to_string(), 38),
"arrowdown" | "down" => ("ArrowDown".to_string(), "ArrowDown".to_string(), 40),
"arrowleft" | "left" => ("ArrowLeft".to_string(), "ArrowLeft".to_string(), 37),
"arrowright" | "right" => ("ArrowRight".to_string(), "ArrowRight".to_string(), 39),
"home" => ("Home".to_string(), "Home".to_string(), 36),
"end" => ("End".to_string(), "End".to_string(), 35),
"pageup" => ("PageUp".to_string(), "PageUp".to_string(), 33),
"pagedown" => ("PageDown".to_string(), "PageDown".to_string(), 34),
"space" | " " => (" ".to_string(), "Space".to_string(), 32),
_ => {
if key.len() == 1 {
let ch = key.chars().next().unwrap();
char_to_key_info(ch)
} else {
(key.to_string(), key.to_string(), 0)
}
}
}
}
+45
View File
@@ -0,0 +1,45 @@
#[allow(dead_code)]
pub mod actions;
#[allow(dead_code)]
pub mod auth;
#[allow(dead_code)]
pub mod browser;
#[allow(dead_code)]
pub mod cdp;
#[allow(dead_code)]
pub mod cookies;
#[allow(dead_code)]
pub mod daemon;
#[allow(dead_code)]
pub mod diff;
#[allow(dead_code)]
pub mod element;
#[allow(dead_code)]
pub mod interaction;
#[allow(dead_code)]
pub mod network;
#[allow(dead_code)]
pub mod policy;
#[allow(dead_code)]
pub mod providers;
#[allow(dead_code)]
pub mod recording;
#[allow(dead_code)]
pub mod screenshot;
#[allow(dead_code)]
pub mod snapshot;
#[allow(dead_code)]
pub mod state;
#[allow(dead_code)]
pub mod storage;
#[allow(dead_code)]
pub mod stream;
#[allow(dead_code)]
pub mod tracing;
#[allow(dead_code)]
pub mod webdriver;
#[cfg(test)]
mod e2e_tests;
#[cfg(test)]
mod parity_tests;
+399
View File
@@ -0,0 +1,399 @@
use serde_json::{json, Value};
use std::collections::HashMap;
use super::cdp::client::CdpClient;
pub async fn set_extra_headers(
client: &CdpClient,
session_id: &str,
headers: &HashMap<String, String>,
) -> Result<(), String> {
let headers_value: Value = headers
.iter()
.map(|(k, v)| (k.clone(), Value::String(v.clone())))
.collect::<serde_json::Map<String, Value>>()
.into();
client
.send_command(
"Network.setExtraHTTPHeaders",
Some(json!({ "headers": headers_value })),
Some(session_id),
)
.await?;
Ok(())
}
pub async fn set_offline(
client: &CdpClient,
session_id: &str,
offline: bool,
) -> Result<(), String> {
client
.send_command(
"Network.emulateNetworkConditions",
Some(json!({
"offline": offline,
"latency": 0,
"downloadThroughput": -1,
"uploadThroughput": -1,
})),
Some(session_id),
)
.await?;
Ok(())
}
pub async fn set_content(client: &CdpClient, session_id: &str, html: &str) -> Result<(), String> {
// Get current frame ID
let tree_result = client
.send_command_no_params("Page.getFrameTree", Some(session_id))
.await?;
let frame_id = tree_result
.get("frameTree")
.and_then(|t| t.get("frame"))
.and_then(|f| f.get("id"))
.and_then(|id| id.as_str())
.ok_or("Could not determine frame ID")?;
client
.send_command(
"Page.setDocumentContent",
Some(json!({
"frameId": frame_id,
"html": html,
})),
Some(session_id),
)
.await?;
Ok(())
}
// ---------------------------------------------------------------------------
// Domain filter
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct DomainFilter {
pub allowed_domains: Vec<String>,
}
impl DomainFilter {
pub fn new(domains: &str) -> Self {
let allowed = parse_domain_list(domains);
Self {
allowed_domains: allowed,
}
}
pub fn is_allowed(&self, hostname: &str) -> bool {
if self.allowed_domains.is_empty() {
return true;
}
let hostname = hostname.to_lowercase();
for pattern in &self.allowed_domains {
if let Some(suffix) = pattern.strip_prefix("*.") {
if hostname == suffix || hostname.ends_with(&format!(".{}", suffix)) {
return true;
}
} else if hostname == *pattern {
return true;
}
}
false
}
pub fn check_url(&self, url: &str) -> Result<(), String> {
if self.allowed_domains.is_empty() {
return Ok(());
}
let parsed = url::Url::parse(url).map_err(|_| format!("Invalid URL: {}", url))?;
let hostname = parsed
.host_str()
.ok_or_else(|| format!("No hostname in URL: {}", url))?;
if self.is_allowed(hostname) {
Ok(())
} else {
Err(format!(
"Domain '{}' is not in the allowed domains list",
hostname
))
}
}
}
fn parse_domain_list(input: &str) -> Vec<String> {
input
.split(',')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect()
}
pub async fn sanitize_existing_pages(
client: &CdpClient,
pages: &[super::browser::PageInfo],
filter: &DomainFilter,
) {
for page in pages {
if page.url.is_empty() || page.url == "about:blank" {
continue;
}
if let Ok(parsed) = url::Url::parse(&page.url) {
if let Some(hostname) = parsed.host_str() {
if !filter.is_allowed(hostname) {
let _ = client
.send_command(
"Page.navigate",
Some(json!({ "url": "about:blank" })),
Some(&page.session_id),
)
.await;
}
}
}
}
}
pub async fn install_domain_filter_script(
client: &CdpClient,
session_id: &str,
allowed_domains: &[String],
) -> Result<(), String> {
if allowed_domains.is_empty() {
return Ok(());
}
let domains_json = serde_json::to_string(allowed_domains).unwrap_or("[]".to_string());
let script = format!(
r#"(() => {{
const _allowed = {};
function _isDomainAllowed(hostname) {{
hostname = hostname.toLowerCase();
for (const p of _allowed) {{
if (p.startsWith('*.')) {{
const suffix = p.slice(2);
if (hostname === suffix || hostname.endsWith('.' + suffix)) return true;
}} else if (hostname === p) return true;
}}
return false;
}}
const OrigWS = window.WebSocket;
window.WebSocket = function(url, protocols) {{
try {{
const u = new URL(url);
if (!_isDomainAllowed(u.hostname)) throw new DOMException('WebSocket blocked: ' + u.hostname, 'SecurityError');
}} catch(e) {{ if (e instanceof DOMException) throw e; }}
return new OrigWS(url, protocols);
}};
window.WebSocket.prototype = OrigWS.prototype;
const OrigES = window.EventSource;
if (OrigES) {{
window.EventSource = function(url, opts) {{
try {{
const u = new URL(url, location.href);
if (!_isDomainAllowed(u.hostname)) throw new DOMException('EventSource blocked: ' + u.hostname, 'SecurityError');
}} catch(e) {{ if (e instanceof DOMException) throw e; }}
return new OrigES(url, opts);
}};
window.EventSource.prototype = OrigES.prototype;
}}
const origBeacon = navigator.sendBeacon;
if (origBeacon) {{
navigator.sendBeacon = function(url, data) {{
try {{
const u = new URL(url, location.href);
if (!_isDomainAllowed(u.hostname)) return false;
}} catch(e) {{ return false; }}
return origBeacon.call(navigator, url, data);
}};
}}
}})()"#,
domains_json,
);
client
.send_command(
"Page.addScriptToEvaluateOnNewDocument",
Some(json!({ "source": script })),
Some(session_id),
)
.await?;
Ok(())
}
/// Enable Fetch-based network interception for domain filtering.
/// This intercepts all requests and checks them against the allowed domains list.
/// The actual handling of `Fetch.requestPaused` events happens in
/// `resolve_fetch_paused` in the actions module.
pub async fn install_domain_filter_fetch(
client: &CdpClient,
session_id: &str,
) -> Result<(), String> {
client
.send_command(
"Fetch.enable",
Some(json!({
"patterns": [{ "urlPattern": "*" }]
})),
Some(session_id),
)
.await?;
Ok(())
}
/// Install both layers of domain filtering on a session:
/// 1. JS patching (WebSocket, EventSource, sendBeacon)
/// 2. Fetch-based network interception
pub async fn install_domain_filter(
client: &CdpClient,
session_id: &str,
allowed_domains: &[String],
) -> Result<(), String> {
install_domain_filter_script(client, session_id, allowed_domains).await?;
install_domain_filter_fetch(client, session_id).await?;
Ok(())
}
// ---------------------------------------------------------------------------
// Console and error tracking
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct ConsoleEntry {
pub level: String,
pub text: String,
}
#[derive(Debug, Clone)]
pub struct ErrorEntry {
pub text: String,
pub url: Option<String>,
pub line: Option<i64>,
pub column: Option<i64>,
}
pub struct EventTracker {
pub console_entries: Vec<ConsoleEntry>,
pub error_entries: Vec<ErrorEntry>,
pub max_entries: usize,
}
impl EventTracker {
pub fn new() -> Self {
Self {
console_entries: Vec::new(),
error_entries: Vec::new(),
max_entries: 1000,
}
}
pub fn add_console(&mut self, level: &str, text: &str) {
if self.console_entries.len() >= self.max_entries {
self.console_entries.remove(0);
}
self.console_entries.push(ConsoleEntry {
level: level.to_string(),
text: text.to_string(),
});
}
pub fn add_error(
&mut self,
text: &str,
url: Option<&str>,
line: Option<i64>,
col: Option<i64>,
) {
if self.error_entries.len() >= self.max_entries {
self.error_entries.remove(0);
}
self.error_entries.push(ErrorEntry {
text: text.to_string(),
url: url.map(String::from),
line,
column: col,
});
}
pub fn get_console_json(&self) -> Value {
let entries: Vec<Value> = self
.console_entries
.iter()
.map(|e| json!({ "level": e.level, "text": e.text }))
.collect();
json!({ "entries": entries })
}
pub fn get_errors_json(&self) -> Value {
let entries: Vec<Value> = self
.error_entries
.iter()
.map(|e| {
json!({
"text": e.text,
"url": e.url,
"line": e.line,
"column": e.column,
})
})
.collect();
json!({ "errors": entries })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_domain_filter_exact() {
let filter = DomainFilter::new("example.com");
assert!(filter.is_allowed("example.com"));
assert!(!filter.is_allowed("other.com"));
}
#[test]
fn test_domain_filter_wildcard() {
let filter = DomainFilter::new("*.example.com");
assert!(filter.is_allowed("example.com"));
assert!(filter.is_allowed("api.example.com"));
assert!(filter.is_allowed("sub.api.example.com"));
assert!(!filter.is_allowed("other.com"));
}
#[test]
fn test_domain_filter_empty() {
let filter = DomainFilter::new("");
assert!(filter.is_allowed("anything.com"));
}
#[test]
fn test_domain_filter_multiple() {
let filter = DomainFilter::new("example.com, *.api.io");
assert!(filter.is_allowed("example.com"));
assert!(filter.is_allowed("api.io"));
assert!(filter.is_allowed("v1.api.io"));
assert!(!filter.is_allowed("other.com"));
}
#[test]
fn test_parse_domain_list() {
let domains = parse_domain_list("A.com, B.com , *.C.com");
assert_eq!(domains, vec!["a.com", "b.com", "*.c.com"]);
}
#[test]
fn test_event_tracker() {
let mut tracker = EventTracker::new();
tracker.add_console("log", "hello");
tracker.add_error("oops", Some("test.js"), Some(1), Some(5));
assert_eq!(tracker.console_entries.len(), 1);
assert_eq!(tracker.error_entries.len(), 1);
}
}
+625
View File
@@ -0,0 +1,625 @@
//! Parity tests for the native daemon's command interface.
//!
//! These unit tests verify:
//! - All documented actions are handled (not returning "Not yet implemented")
//! - Response format consistency (success/error structure)
//! - Credential and state actions work without a browser
use serde_json::{json, Value};
use super::actions::{execute_command, DaemonState};
/// All documented action names that should be implemented.
const DOCUMENTED_ACTIONS: &[&str] = &[
"launch",
"navigate",
"url",
"title",
"content",
"evaluate",
"close",
"snapshot",
"screenshot",
"click",
"dblclick",
"fill",
"type",
"press",
"hover",
"scroll",
"select",
"check",
"uncheck",
"wait",
"gettext",
"getattribute",
"isvisible",
"isenabled",
"ischecked",
"back",
"forward",
"reload",
"cookies_get",
"cookies_set",
"cookies_clear",
"storage_get",
"storage_set",
"storage_clear",
"setcontent",
"headers",
"offline",
"console",
"errors",
"state_save",
"state_load",
"state_list",
"state_show",
"state_clear",
"state_clean",
"state_rename",
"trace_start",
"trace_stop",
"profiler_start",
"profiler_stop",
"recording_start",
"recording_stop",
"recording_restart",
"pdf",
"tab_list",
"tab_new",
"tab_switch",
"tab_close",
"viewport",
"user_agent",
"set_media",
"download",
"diff_snapshot",
"diff_url",
"credentials_set",
"credentials_get",
"credentials_delete",
"credentials_list",
"mouse",
"keyboard",
"focus",
"clear",
"selectall",
"scrollintoview",
"dispatch",
"highlight",
"tap",
"boundingbox",
"innertext",
"innerhtml",
"inputvalue",
"setvalue",
"count",
"styles",
"bringtofront",
"timezone",
"locale",
"geolocation",
"permissions",
"dialog",
"upload",
"addscript",
"addinitscript",
"addstyle",
"clipboard",
"wheel",
"device",
"screencast_start",
"screencast_stop",
"waitforurl",
"waitforloadstate",
"waitforfunction",
"frame",
"mainframe",
"getbyrole",
"getbytext",
"getbylabel",
"getbyplaceholder",
"getbyalttext",
"getbytitle",
"getbytestid",
"nth",
"find",
"evalhandle",
"drag",
"expose",
"pause",
"multiselect",
"responsebody",
"waitfordownload",
"window_new",
"diff_screenshot",
"video_start",
"video_stop",
"har_start",
"har_stop",
"route",
"unroute",
"requests",
"credentials",
"auth_save",
"auth_login",
"auth_list",
"auth_delete",
"auth_show",
"confirm",
"deny",
"swipe",
"device_list",
"input_mouse",
"input_keyboard",
"input_touch",
"keydown",
"keyup",
"inserttext",
"mousemove",
"mousedown",
"mouseup",
];
fn minimal_command(action: &str, id: &str) -> Value {
let mut cmd = json!({ "action": action, "id": id });
let obj = cmd.as_object_mut().unwrap();
match action {
"navigate" | "diff_url" | "waitforurl" => {
obj.insert("url".to_string(), json!("https://example.com"));
}
"evaluate" | "expose" => {
obj.insert("script".to_string(), json!("1"));
}
"click" | "dblclick" | "fill" | "type" | "press" | "hover" | "scroll" | "select"
| "check" | "uncheck" | "gettext" | "getattribute" | "isvisible" | "isenabled"
| "ischecked" | "focus" | "clear" | "selectall" | "scrollintoview" | "dispatch"
| "highlight" | "tap" | "boundingbox" | "innertext" | "innerhtml" | "inputvalue"
| "setvalue" | "count" | "find" | "nth" | "getbytext" | "getbylabel"
| "getbyplaceholder" | "getbyalttext" | "getbytitle" | "getbytestid" => {
obj.insert("selector".to_string(), json!("body"));
}
"getbyrole" => {
obj.insert("role".to_string(), json!("button"));
obj.insert("selector".to_string(), json!("body"));
}
"setcontent" => {
obj.insert("html".to_string(), json!("<html></html>"));
}
"cookies_set" => {
obj.insert("name".to_string(), json!("test"));
obj.insert("value".to_string(), json!("val"));
}
"storage_get" | "storage_set" | "storage_clear" => {
obj.insert("origin".to_string(), json!("https://example.com"));
}
"state_save" | "state_load" | "state_show" | "state_clear" => {
obj.insert("path".to_string(), json!("test-parity-state.json"));
}
"state_rename" => {
obj.insert("path".to_string(), json!("test-parity-state.json"));
obj.insert("name".to_string(), json!("renamed"));
}
"state_clean" => {
obj.insert("days".to_string(), json!(7));
}
"credentials_set" => {
obj.insert("name".to_string(), json!("parity-test-cred"));
obj.insert("username".to_string(), json!("u"));
obj.insert("password".to_string(), json!("p"));
}
"auth_save" => {
obj.insert("name".to_string(), json!("parity-test-cred"));
obj.insert("url".to_string(), json!("https://example.com"));
obj.insert("username".to_string(), json!("u"));
obj.insert("password".to_string(), json!("p"));
}
"credentials_get" | "credentials_delete" | "auth_show" | "auth_delete" => {
obj.insert("name".to_string(), json!("parity-test-cred"));
}
"tab_switch" | "tab_close" => {
obj.insert("index".to_string(), json!(0));
}
"viewport" | "user_agent" | "set_media" | "timezone" | "locale" | "geolocation"
| "permissions" | "device" => {
obj.insert("value".to_string(), json!(null));
}
"headers" => {
obj.insert("headers".to_string(), json!({}));
}
"offline" => {
obj.insert("offline".to_string(), json!(false));
}
"wait" => {
obj.insert("timeout".to_string(), json!(100));
}
"waitforloadstate" => {
obj.insert("state".to_string(), json!("load"));
}
"waitforfunction" => {
obj.insert("script".to_string(), json!("() => true"));
}
"frame" => {
obj.insert("selector".to_string(), json!("iframe"));
}
"addscript" => {
obj.insert("content".to_string(), json!("console.log('test')"));
}
"addinitscript" => {
obj.insert("script".to_string(), json!("console.log('init')"));
}
"addstyle" => {
obj.insert("content".to_string(), json!("body { color: red }"));
}
"wheel" => {
obj.insert("deltaX".to_string(), json!(0));
obj.insert("deltaY".to_string(), json!(0));
}
"upload" => {
obj.insert("selector".to_string(), json!("input[type=file]"));
obj.insert("files".to_string(), json!([]));
}
"dialog" => {
obj.insert("accept".to_string(), json!(true));
}
"credentials" => {
obj.insert("username".to_string(), json!("u"));
obj.insert("password".to_string(), json!("p"));
}
"auth_login" => {
obj.insert("name".to_string(), json!("parity-test-cred"));
}
"route" => {
obj.insert("url".to_string(), json!("*"));
obj.insert("handler".to_string(), json!("continue"));
}
"diff_snapshot" | "diff_screenshot" => {
obj.insert("selector".to_string(), json!("body"));
}
"recording_start" | "recording_restart" => {
obj.insert("path".to_string(), json!("/tmp/parity-recording.webm"));
}
"video_start" => {
obj.insert("path".to_string(), json!("/tmp/parity-video.webm"));
}
"profiler_start" => {
obj.insert("path".to_string(), json!("/tmp/parity-profile"));
}
"trace_stop" | "har_stop" => {
obj.insert("path".to_string(), json!("/tmp/parity-trace"));
}
"download" => {
obj.insert("path".to_string(), json!("/tmp/parity-download"));
}
"multiselect" => {
obj.insert("selector".to_string(), json!("select"));
obj.insert("values".to_string(), json!([]));
}
"responsebody" => {
obj.insert("url".to_string(), json!("https://example.com"));
}
"waitfordownload" => {
obj.insert("path".to_string(), json!("/tmp/parity-download"));
}
"styles" => {
obj.insert("selector".to_string(), json!("body"));
obj.insert("names".to_string(), json!([]));
}
"evalhandle" => {
obj.insert("handle".to_string(), json!(""));
obj.insert("script".to_string(), json!("h => h"));
}
"drag" => {
obj.insert("selector".to_string(), json!("body"));
obj.insert("target".to_string(), json!("body"));
}
"swipe" => {
obj.insert("selector".to_string(), json!("body"));
obj.insert("direction".to_string(), json!("left"));
}
"input_mouse" | "mousemove" | "mousedown" | "mouseup" => {
obj.insert("x".to_string(), json!(100));
obj.insert("y".to_string(), json!(100));
}
"input_keyboard" | "keydown" | "keyup" => {
obj.insert("key".to_string(), json!("a"));
}
"input_touch" => {
obj.insert("type".to_string(), json!("touchStart"));
obj.insert("touchPoints".to_string(), json!([]));
}
"inserttext" => {
obj.insert("text".to_string(), json!("test"));
}
_ => {}
}
cmd
}
// ---------------------------------------------------------------------------
// 1. Action dispatch coverage
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_all_documented_actions_are_handled() {
let mut state = DaemonState::new();
for (i, action) in DOCUMENTED_ACTIONS.iter().enumerate() {
let id = format!("parity-{}", i);
let cmd = minimal_command(action, &id);
let result = execute_command(&cmd, &mut state).await;
assert!(
result.get("id").is_some(),
"Action '{}': response missing 'id'",
action
);
let error = result.get("error").and_then(|v| v.as_str()).unwrap_or("");
assert!(
!error.contains("Not yet implemented"),
"Action '{}' returned 'Not yet implemented')",
action
);
}
}
// ---------------------------------------------------------------------------
// 2. Response format consistency
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_success_response_format() {
let mut state = DaemonState::new();
let cmd = json!({ "action": "state_list", "id": "fmt-1" });
let result = execute_command(&cmd, &mut state).await;
assert_eq!(result["success"], true);
assert!(result.get("id").is_some());
assert!(result.get("data").is_some());
assert!(result.get("error").is_none());
}
#[tokio::test]
async fn test_error_response_format() {
let mut state = DaemonState::new();
let cmd = json!({ "action": "nonexistent_action_xyz", "id": "fmt-2" });
let result = execute_command(&cmd, &mut state).await;
assert_eq!(result["success"], false);
assert!(result.get("id").is_some());
assert!(result.get("error").is_some());
}
// ---------------------------------------------------------------------------
// 3. Credential/state actions work without a browser
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_state_list_without_browser() {
let mut state = DaemonState::new();
let cmd = json!({ "action": "state_list", "id": "nb-1" });
let result = execute_command(&cmd, &mut state).await;
assert_eq!(result["success"], true);
assert!(result["data"]["files"].is_array());
}
#[tokio::test]
async fn test_credentials_list_without_browser() {
let mut state = DaemonState::new();
let cmd = json!({ "action": "credentials_list", "id": "nb-2" });
let result = execute_command(&cmd, &mut state).await;
assert_eq!(result["success"], true);
assert!(result["data"]["credentials"].is_array() || result["data"]["profiles"].is_array());
}
// ---------------------------------------------------------------------------
// 4. New feature parity tests
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_auth_profile_name_validation() {
use super::auth;
let valid = auth::credentials_set("valid-name_123", "u", "p", None);
assert!(valid.is_ok());
let invalid = auth::credentials_set("invalid/name", "u", "p", None);
assert!(invalid.is_err());
let invalid2 = auth::credentials_set("", "u", "p", None);
assert!(invalid2.is_err());
let invalid3 = auth::credentials_set("has space", "u", "p", None);
assert!(invalid3.is_err());
// Cleanup
let _ = auth::credentials_delete("valid-name_123");
}
#[tokio::test]
async fn test_auth_save_and_show() {
use super::auth;
let result = auth::auth_save(
"parity-roundtrip",
"https://example.com",
"user",
"pass",
Some("input#user"),
None,
None,
);
assert!(result.is_ok());
let show = auth::auth_show("parity-roundtrip");
assert!(show.is_ok());
let data = show.unwrap();
assert_eq!(data["profile"]["username"], "user");
assert_eq!(data["profile"]["usernameSelector"], "input#user");
let full = auth::credentials_get_full("parity-roundtrip");
assert!(full.is_ok());
assert_eq!(full.unwrap().password, "pass");
// Cleanup
let _ = auth::credentials_delete("parity-roundtrip");
}
#[tokio::test]
async fn test_har_start_stop_without_browser() {
let mut state = DaemonState::new();
// har_start requires a browser. Because execute_command auto-launches when
// no browser is present, the result depends on Chrome availability: success
// if Chrome is found (CI), failure if not. Both outcomes are valid.
let cmd = json!({ "action": "har_start", "id": "har-1" });
let result = execute_command(&cmd, &mut state).await;
let success = result["success"].as_bool().unwrap_or(false);
if success {
assert!(state.har_recording);
} else {
assert!(result["error"].as_str().is_some());
}
}
#[tokio::test]
async fn test_state_clean_action() {
let mut state = DaemonState::new();
let cmd = json!({ "action": "state_clean", "id": "clean-1", "days": 30 });
let result = execute_command(&cmd, &mut state).await;
assert_eq!(result["success"], true);
}
#[tokio::test]
async fn test_daemon_state_new_defaults() {
let state = DaemonState::new();
assert!(state.browser.is_none());
assert!(!state.har_recording);
assert!(state.har_entries.is_empty());
assert!(state.pending_confirmation.is_none());
assert!(!state.request_tracking);
assert!(state.tracked_requests.is_empty());
assert!(state.active_frame_id.is_none());
assert!(state.webdriver_backend.is_none());
}
#[tokio::test]
async fn test_tracked_request_struct() {
use super::actions::TrackedRequest;
let tr = TrackedRequest {
url: "https://example.com/api".to_string(),
method: "GET".to_string(),
headers: json!({"Accept": "text/html"}),
timestamp: 12345,
resource_type: "Document".to_string(),
};
let serialized = serde_json::to_value(&tr).unwrap();
assert_eq!(serialized["url"], "https://example.com/api");
assert_eq!(serialized["method"], "GET");
assert_eq!(serialized["resourceType"], "Document");
assert_eq!(serialized["timestamp"], 12345);
}
#[tokio::test]
async fn test_request_tracking_state() {
let mut state = DaemonState::new();
assert!(!state.request_tracking);
assert!(state.tracked_requests.is_empty());
state.tracked_requests.push(super::actions::TrackedRequest {
url: "https://example.com".to_string(),
method: "GET".to_string(),
headers: json!({}),
timestamp: 1,
resource_type: "Document".to_string(),
});
state.tracked_requests.push(super::actions::TrackedRequest {
url: "https://other.com".to_string(),
method: "POST".to_string(),
headers: json!({}),
timestamp: 2,
resource_type: "XHR".to_string(),
});
assert_eq!(state.tracked_requests.len(), 2);
// Filter
let filtered: Vec<_> = state
.tracked_requests
.iter()
.filter(|r| r.url.contains("example"))
.collect();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].url, "https://example.com");
// Clear
state.tracked_requests.clear();
assert!(state.tracked_requests.is_empty());
}
#[tokio::test]
async fn test_addscript_and_addinitscript_separate_dispatch() {
let mut state = DaemonState::new();
// Both should be handled (not "Not yet implemented") even without a browser
let cmd1 = json!({ "action": "addscript", "id": "as-1", "content": "console.log(1)" });
let result1 = execute_command(&cmd1, &mut state).await;
let err1 = result1["error"].as_str().unwrap_or("");
assert!(
!err1.contains("Not yet implemented"),
"addscript should be handled"
);
let cmd2 = json!({ "action": "addinitscript", "id": "ais-1", "script": "console.log(2)" });
let result2 = execute_command(&cmd2, &mut state).await;
let err2 = result2["error"].as_str().unwrap_or("");
assert!(
!err2.contains("Not yet implemented"),
"addinitscript should be handled"
);
}
#[tokio::test]
async fn test_frame_context_management() {
let mut state = DaemonState::new();
assert!(state.active_frame_id.is_none());
// Set a frame ID and verify it persists
state.active_frame_id = Some("child-frame-123".to_string());
assert_eq!(state.active_frame_id.as_deref(), Some("child-frame-123"));
// Clearing the frame ID (what mainframe does)
state.active_frame_id = None;
assert!(state.active_frame_id.is_none());
}
#[tokio::test]
async fn test_addstyle_supports_content_and_url() {
let mut state = DaemonState::new();
// Both content-based and url-based addstyle should be recognized
let cmd1 = json!({ "action": "addstyle", "id": "style-1", "content": "body { color: red }" });
let result1 = execute_command(&cmd1, &mut state).await;
let err1 = result1["error"].as_str().unwrap_or("");
assert!(!err1.contains("Not yet implemented"));
let cmd2 =
json!({ "action": "addstyle", "id": "style-2", "url": "https://example.com/style.css" });
let result2 = execute_command(&cmd2, &mut state).await;
let err2 = result2["error"].as_str().unwrap_or("");
assert!(!err2.contains("Not yet implemented"));
}
#[tokio::test]
async fn test_domain_filter_sanitize() {
use super::network::DomainFilter;
let filter = DomainFilter::new("example.com");
assert!(filter.is_allowed("example.com"));
assert!(!filter.is_allowed("evil.com"));
filter.check_url("https://example.com/path").unwrap();
assert!(filter.check_url("https://evil.com").is_err());
}
#[tokio::test]
async fn test_state_find_auto_returns_none_for_nonexistent() {
use super::state;
let result = state::find_auto_state_file("nonexistent-session-xyz");
assert!(result.is_none());
}
+216
View File
@@ -0,0 +1,216 @@
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::env;
use std::fs;
use std::path::PathBuf;
/// Result of a policy check for an action.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PolicyResult {
/// Action is allowed.
Allow,
/// Action is blocked with the given reason.
Deny(String),
/// Action requires confirmation before proceeding.
RequiresConfirmation,
}
/// Policy configuration loaded from a JSON file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionPolicy {
#[serde(skip)]
path: PathBuf,
#[serde(default)]
default: Option<String>,
#[serde(default)]
allow: Option<Vec<String>>,
#[serde(default)]
deny: Option<Vec<String>>,
#[serde(default)]
confirm: Option<Vec<String>>,
}
/// Confirmation categories parsed from AGENT_BROWSER_CONFIRM_ACTIONS.
#[derive(Debug, Clone)]
pub struct ConfirmActions {
pub categories: HashSet<String>,
}
impl ConfirmActions {
pub fn from_env() -> Option<Self> {
let val = env::var("AGENT_BROWSER_CONFIRM_ACTIONS").ok()?;
if val.is_empty() {
return None;
}
let categories: HashSet<String> = val
.split(',')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect();
if categories.is_empty() {
None
} else {
Some(Self { categories })
}
}
pub fn requires_confirmation(&self, action: &str) -> bool {
self.categories.contains(action)
}
}
impl ActionPolicy {
/// Load policy from a JSON file at the given path.
pub fn load(path: &str) -> Result<Self, String> {
let path_buf = PathBuf::from(path);
let contents = fs::read_to_string(&path_buf)
.map_err(|e| format!("Failed to read policy file: {}", e))?;
let mut policy: ActionPolicy =
serde_json::from_str(&contents).map_err(|e| format!("Invalid policy JSON: {}", e))?;
policy.path = path_buf;
Ok(policy)
}
/// Load policy if AGENT_BROWSER_ACTION_POLICY env var is set.
/// Falls back to AGENT_BROWSER_POLICY for backwards compatibility.
pub fn load_if_exists() -> Option<Self> {
let path = env::var("AGENT_BROWSER_ACTION_POLICY")
.or_else(|_| env::var("AGENT_BROWSER_POLICY"))
.ok()?;
Self::load(&path).ok()
}
/// Check whether an action is allowed, denied, or requires confirmation.
pub fn check(&self, action: &str) -> PolicyResult {
if let Some(deny) = &self.deny {
if deny.iter().any(|a| a == action) {
return PolicyResult::Deny(format!("Action '{}' is denied by policy", action));
}
}
if let Some(confirm) = &self.confirm {
if confirm.iter().any(|a| a == action) {
return PolicyResult::RequiresConfirmation;
}
}
if let Some(allow) = &self.allow {
if !allow.is_empty() && !allow.iter().any(|a| a == action) {
let is_default_deny = self
.default
.as_deref()
.map(|d| d.eq_ignore_ascii_case("deny"))
.unwrap_or(true);
if is_default_deny {
return PolicyResult::Deny(format!(
"Action '{}' is not in the allow list",
action
));
}
}
} else if let Some(ref default) = self.default {
if default.eq_ignore_ascii_case("deny") {
return PolicyResult::Deny(format!(
"Action '{}' denied: default policy is deny",
action
));
}
}
PolicyResult::Allow
}
/// Reload policy from the file. Re-reads the JSON and updates the policy.
pub fn reload(&mut self) -> Result<(), String> {
let contents = fs::read_to_string(&self.path)
.map_err(|e| format!("Failed to read policy file: {}", e))?;
let mut policy: ActionPolicy =
serde_json::from_str(&contents).map_err(|e| format!("Invalid policy JSON: {}", e))?;
policy.path = self.path.clone();
*self = policy;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_policy_allow_whitelist() {
let json = r#"{"allow": ["click", "type"], "deny": [], "confirm": []}"#;
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
assert_eq!(policy.check("click"), PolicyResult::Allow);
assert_eq!(policy.check("type"), PolicyResult::Allow);
assert!(matches!(policy.check("navigate"), PolicyResult::Deny(_)));
}
#[test]
fn test_policy_deny() {
let json = r#"{"allow": [], "deny": ["delete"], "confirm": []}"#;
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
assert!(matches!(policy.check("delete"), PolicyResult::Deny(_)));
}
#[test]
fn test_policy_confirm() {
let json = r#"{"allow": [], "deny": [], "confirm": ["submit"]}"#;
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
assert_eq!(policy.check("submit"), PolicyResult::RequiresConfirmation);
}
#[test]
fn test_policy_deny_takes_precedence() {
let json = r#"{"allow": ["danger"], "deny": ["danger"], "confirm": []}"#;
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
assert!(matches!(policy.check("danger"), PolicyResult::Deny(_)));
}
#[test]
fn test_policy_confirm_takes_precedence_over_allow() {
let json = r#"{"allow": ["submit"], "deny": [], "confirm": ["submit"]}"#;
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
assert_eq!(policy.check("submit"), PolicyResult::RequiresConfirmation);
}
#[test]
fn test_policy_empty_allow_allows_all() {
let json = r#"{"allow": [], "deny": [], "confirm": []}"#;
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
assert_eq!(policy.check("anything"), PolicyResult::Allow);
}
#[test]
fn test_policy_missing_allow_allows_all() {
let json = r#"{"deny": []}"#;
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
assert_eq!(policy.check("anything"), PolicyResult::Allow);
}
#[test]
fn test_policy_default_allow() {
let json = r#"{"default": "allow", "deny": ["navigate"]}"#;
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
assert_eq!(policy.check("click"), PolicyResult::Allow);
assert!(matches!(policy.check("navigate"), PolicyResult::Deny(_)));
}
#[test]
fn test_policy_default_deny() {
let json = r#"{"default": "deny", "allow": ["click"]}"#;
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
assert_eq!(policy.check("click"), PolicyResult::Allow);
assert!(matches!(policy.check("navigate"), PolicyResult::Deny(_)));
}
#[test]
fn test_confirm_actions_from_env() {
env::set_var("AGENT_BROWSER_CONFIRM_ACTIONS", "navigate,click,fill");
let ca = ConfirmActions::from_env().unwrap();
assert!(ca.requires_confirmation("navigate"));
assert!(ca.requires_confirmation("click"));
assert!(ca.requires_confirmation("fill"));
assert!(!ca.requires_confirmation("screenshot"));
env::remove_var("AGENT_BROWSER_CONFIRM_ACTIONS");
}
}
+274
View File
@@ -0,0 +1,274 @@
//! Browser provider connections for remote CDP sessions.
//!
//! Supports Browserbase, Browser Use, and Kernel providers. Each provider
//! returns a CDP WebSocket URL for connecting via BrowserManager.
use serde_json::{json, Value};
use std::env;
/// Provider session info for cleanup on failure.
pub struct ProviderSession {
pub provider: String,
pub session_id: String,
}
/// Connects to the specified browser provider and returns a CDP WebSocket URL
/// along with session info for cleanup on failure.
pub async fn connect_provider(
provider_name: &str,
) -> Result<(String, Option<ProviderSession>), String> {
match provider_name.to_lowercase().as_str() {
"browserbase" => connect_browserbase().await,
"browser-use" | "browseruse" => connect_browser_use().await,
"kernel" => connect_kernel().await,
_ => Err(format!(
"Unknown provider '{}'. Supported: browserbase, browser-use, kernel",
provider_name
)),
}
}
/// Close a provider session (call on CDP connect failure).
pub async fn close_provider_session(session: &ProviderSession) {
let client = reqwest::Client::new();
match session.provider.as_str() {
"browserbase" => {
if let Ok(api_key) = env::var("BROWSERBASE_API_KEY") {
let _ = client
.delete(format!(
"https://api.browserbase.com/v1/sessions/{}",
session.session_id
))
.header("X-BB-API-Key", &api_key)
.send()
.await;
}
}
"browser-use" => {
if let Ok(api_key) = env::var("BROWSER_USE_API_KEY") {
let _ = client
.patch(format!(
"https://api.browser-use.com/api/v2/browsers/{}",
session.session_id
))
.header("X-Browser-Use-API-Key", &api_key)
.header("Content-Type", "application/json")
.json(&json!({ "action": "stop" }))
.send()
.await;
}
}
"kernel" => {
if let Ok(api_key) = env::var("KERNEL_API_KEY") {
let endpoint = env::var("KERNEL_ENDPOINT")
.unwrap_or_else(|_| "https://api.onkernel.com".to_string());
let _ = client
.delete(format!(
"{}/browsers/{}",
endpoint.trim_end_matches('/'),
session.session_id
))
.header("Authorization", format!("Bearer {}", api_key))
.send()
.await;
}
}
_ => {}
}
}
async fn connect_browserbase() -> Result<(String, Option<ProviderSession>), String> {
let api_key = env::var("BROWSERBASE_API_KEY")
.map_err(|_| "BROWSERBASE_API_KEY environment variable is not set")?;
let project_id = env::var("BROWSERBASE_PROJECT_ID")
.map_err(|_| "BROWSERBASE_PROJECT_ID environment variable is not set")?;
let client = reqwest::Client::new();
let response = client
.post("https://api.browserbase.com/v1/sessions")
.header("Content-Type", "application/json")
.header("X-BB-API-Key", &api_key)
.json(&json!({ "projectId": project_id }))
.send()
.await
.map_err(|e| format!("Browserbase request failed: {}", e))?;
let status = response.status();
let body = response
.text()
.await
.map_err(|e| format!("Failed to read Browserbase response: {}", e))?;
if !status.is_success() {
return Err(format!(
"Browserbase API error ({}): {}",
status.as_u16(),
body
));
}
let json: Value =
serde_json::from_str(&body).map_err(|e| format!("Invalid Browserbase response: {}", e))?;
let session_id = json
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let ws_url = json
.get("connectUrl")
.and_then(|v| v.as_str())
.map(String::from)
.ok_or_else(|| "Browserbase response missing connectUrl".to_string())?;
Ok((
ws_url,
Some(ProviderSession {
provider: "browserbase".to_string(),
session_id,
}),
))
}
async fn connect_browser_use() -> Result<(String, Option<ProviderSession>), String> {
let api_key = env::var("BROWSER_USE_API_KEY")
.map_err(|_| "BROWSER_USE_API_KEY environment variable is not set")?;
let client = reqwest::Client::new();
let response = client
.post("https://api.browser-use.com/api/v2/browsers")
.header("Content-Type", "application/json")
.header("X-Browser-Use-API-Key", &api_key)
.json(&json!({}))
.send()
.await
.map_err(|e| format!("Browser Use request failed: {}", e))?;
let status = response.status();
let body = response
.text()
.await
.map_err(|e| format!("Failed to read Browser Use response: {}", e))?;
if !status.is_success() {
return Err(format!(
"Browser Use API error ({}): {}",
status.as_u16(),
body
));
}
let json: Value =
serde_json::from_str(&body).map_err(|e| format!("Invalid Browser Use response: {}", e))?;
let session_id = json
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let ws_url = json
.get("cdp_url")
.or_else(|| json.get("cdpUrl"))
.and_then(|v| v.as_str())
.map(String::from)
.ok_or_else(|| "Browser Use response missing cdp_url or cdpUrl".to_string())?;
Ok((
ws_url,
Some(ProviderSession {
provider: "browser-use".to_string(),
session_id,
}),
))
}
async fn connect_kernel() -> Result<(String, Option<ProviderSession>), String> {
let api_key =
env::var("KERNEL_API_KEY").map_err(|_| "KERNEL_API_KEY environment variable is not set")?;
let endpoint =
env::var("KERNEL_ENDPOINT").unwrap_or_else(|_| "https://api.onkernel.com".to_string());
let url = format!("{}/browsers", endpoint.trim_end_matches('/'));
let headless = env::var("KERNEL_HEADLESS")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(true);
let stealth = env::var("KERNEL_STEALTH")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let timeout_seconds = env::var("KERNEL_TIMEOUT_SECONDS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(300);
let mut body = json!({
"headless": headless,
"stealth": stealth,
"timeout_seconds": timeout_seconds,
});
if let Ok(profile) = env::var("KERNEL_PROFILE_NAME") {
if !profile.is_empty() {
body.as_object_mut()
.unwrap()
.insert("profile".to_string(), json!(profile));
}
}
let client = reqwest::Client::new();
let response = client
.post(&url)
.header("Content-Type", "application/json")
.header("Authorization", format!("Bearer {}", api_key))
.json(&body)
.send()
.await
.map_err(|e| format!("Kernel request failed: {}", e))?;
let status = response.status();
let resp_body = response
.text()
.await
.map_err(|e| format!("Failed to read Kernel response: {}", e))?;
if !status.is_success() {
return Err(format!(
"Kernel API error ({}): {}",
status.as_u16(),
resp_body
));
}
let json: Value =
serde_json::from_str(&resp_body).map_err(|e| format!("Invalid Kernel response: {}", e))?;
let session_id = json
.get("session_id")
.or_else(|| json.get("id"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let ws_url = json
.get("cdp_ws_url")
.or_else(|| json.get("connectUrl"))
.or_else(|| json.get("connect_url"))
.or_else(|| json.get("cdpUrl"))
.or_else(|| json.get("cdp_url"))
.and_then(|v| v.as_str())
.map(String::from)
.ok_or_else(|| {
"Kernel response missing cdp_ws_url, connectUrl, connect_url, cdpUrl, or cdp_url"
.to_string()
})?;
Ok((
ws_url,
Some(ProviderSession {
provider: "kernel".to_string(),
session_id,
}),
))
}
+203
View File
@@ -0,0 +1,203 @@
use serde_json::{json, Value};
use std::path::PathBuf;
use std::process::Command;
pub struct RecordingState {
pub active: bool,
pub output_path: String,
pub temp_dir: PathBuf,
pub frame_count: u64,
}
impl RecordingState {
pub fn new() -> Self {
Self {
active: false,
output_path: String::new(),
temp_dir: PathBuf::new(),
frame_count: 0,
}
}
}
pub fn recording_start(state: &mut RecordingState, path: &str) -> Result<Value, String> {
if state.active {
return Err("Recording already active".to_string());
}
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let temp_dir = std::env::temp_dir().join(format!("agent-browser-recording-{}", timestamp));
let _ = std::fs::create_dir_all(&temp_dir);
state.active = true;
state.output_path = path.to_string();
state.temp_dir = temp_dir;
state.frame_count = 0;
Ok(json!({ "started": true, "path": path }))
}
pub fn recording_add_frame(state: &mut RecordingState, frame_data: &[u8]) {
if !state.active {
return;
}
let frame_path = state
.temp_dir
.join(format!("frame_{:06}.jpg", state.frame_count));
let _ = std::fs::write(&frame_path, frame_data);
state.frame_count += 1;
}
pub fn recording_stop(state: &mut RecordingState) -> Result<Value, String> {
if !state.active {
return Err("No recording in progress".to_string());
}
state.active = false;
if state.frame_count == 0 {
let _ = std::fs::remove_dir_all(&state.temp_dir);
return Err("No frames captured".to_string());
}
let frame_pattern = state
.temp_dir
.join("frame_%06d.jpg")
.to_string_lossy()
.to_string();
let output = &state.output_path;
// Encode with ffmpeg
let result = Command::new("ffmpeg")
.args([
"-y",
"-framerate",
"30",
"-i",
&frame_pattern,
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-preset",
"fast",
output,
])
.output();
let _ = std::fs::remove_dir_all(&state.temp_dir);
match result {
Ok(output_result) => {
if output_result.status.success() {
Ok(json!({ "path": output, "frames": state.frame_count }))
} else {
let stderr = String::from_utf8_lossy(&output_result.stderr);
Err(format!(
"ffmpeg failed: {}",
stderr.chars().take(200).collect::<String>()
))
}
}
Err(e) => Err(format!(
"ffmpeg not found or failed to execute: {}. Install ffmpeg to enable recording.",
e
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_recording_state_new() {
let state = RecordingState::new();
assert!(!state.active);
assert!(state.output_path.is_empty());
assert_eq!(state.frame_count, 0);
}
#[test]
fn test_recording_start_sets_active() {
let mut state = RecordingState::new();
let result = recording_start(&mut state, "/tmp/test.mp4");
assert!(result.is_ok());
assert!(state.active);
assert_eq!(state.output_path, "/tmp/test.mp4");
assert_eq!(state.frame_count, 0);
// Cleanup
let _ = std::fs::remove_dir_all(&state.temp_dir);
}
#[test]
fn test_recording_start_while_active() {
let mut state = RecordingState::new();
recording_start(&mut state, "/tmp/test1.mp4").unwrap();
let temp_dir = state.temp_dir.clone();
let result = recording_start(&mut state, "/tmp/test2.mp4");
assert!(result.is_err());
assert!(result.unwrap_err().contains("already active"));
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_recording_stop_not_active() {
let mut state = RecordingState::new();
let result = recording_stop(&mut state);
assert!(result.is_err());
assert!(result.unwrap_err().contains("No recording"));
}
#[test]
fn test_recording_stop_no_frames() {
let mut state = RecordingState::new();
recording_start(&mut state, "/tmp/test.mp4").unwrap();
let result = recording_stop(&mut state);
assert!(result.is_err());
assert!(result.unwrap_err().contains("No frames"));
assert!(!state.active);
}
#[test]
fn test_recording_add_frame_inactive() {
let mut state = RecordingState::new();
recording_add_frame(&mut state, b"fake-frame");
assert_eq!(state.frame_count, 0);
}
#[test]
fn test_recording_add_frame_active() {
let mut state = RecordingState::new();
recording_start(&mut state, "/tmp/test.mp4").unwrap();
recording_add_frame(&mut state, b"fake-frame-1");
recording_add_frame(&mut state, b"fake-frame-2");
assert_eq!(state.frame_count, 2);
let _ = std::fs::remove_dir_all(&state.temp_dir);
}
}
pub fn recording_restart(state: &mut RecordingState, path: &str) -> Result<Value, String> {
let previous = if state.active {
let stop_result = recording_stop(state);
stop_result
.ok()
.and_then(|v| v.get("path").and_then(|p| p.as_str()).map(String::from))
} else {
None
};
recording_start(state, path)?;
Ok(json!({
"restarted": true,
"previousPath": previous,
"path": path,
}))
}
+147
View File
@@ -0,0 +1,147 @@
use serde_json::Value;
use std::path::PathBuf;
use super::cdp::client::CdpClient;
use super::cdp::types::*;
use super::element::RefMap;
pub struct ScreenshotOptions {
pub selector: Option<String>,
pub path: Option<String>,
pub full_page: bool,
pub format: String,
pub quality: Option<i32>,
}
impl Default for ScreenshotOptions {
fn default() -> Self {
Self {
selector: None,
path: None,
full_page: false,
format: "png".to_string(),
quality: None,
}
}
}
pub async fn take_screenshot(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
options: &ScreenshotOptions,
) -> Result<(String, String), String> {
let mut params = CaptureScreenshotParams {
format: Some(options.format.clone()),
quality: if options.format == "jpeg" {
options.quality.or(Some(80))
} else {
None
},
clip: None,
from_surface: Some(true),
capture_beyond_viewport: if options.full_page { Some(true) } else { None },
};
if options.full_page {
let metrics: Value = client
.send_command_no_params("Page.getLayoutMetrics", Some(session_id))
.await?;
let content_size = metrics
.get("contentSize")
.or_else(|| metrics.get("cssContentSize"));
if let Some(size) = content_size {
let width = size.get("width").and_then(|v| v.as_f64()).unwrap_or(1280.0);
let height = size.get("height").and_then(|v| v.as_f64()).unwrap_or(720.0);
params.clip = Some(Viewport {
x: 0.0,
y: 0.0,
width,
height,
scale: 1.0,
});
}
} else if let Some(ref selector) = options.selector {
// Element screenshot via bounding box
let object_id =
super::element::resolve_element_object_id(client, session_id, ref_map, selector)
.await?;
let result: EvaluateResult = client
.send_command_typed(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: r#"function() {
const rect = this.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
}"#
.to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
if let Some(rect) = result.result.value {
let x = rect.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
let y = rect.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
let w = rect.get("width").and_then(|v| v.as_f64()).unwrap_or(100.0);
let h = rect.get("height").and_then(|v| v.as_f64()).unwrap_or(100.0);
params.clip = Some(Viewport {
x,
y,
width: w,
height: h,
scale: 1.0,
});
}
}
let result: CaptureScreenshotResult = client
.send_command_typed("Page.captureScreenshot", &params, Some(session_id))
.await?;
let ext = if options.format == "jpeg" {
"jpg"
} else {
"png"
};
let save_path = match &options.path {
Some(p) => p.clone(),
None => {
let dir = get_screenshot_dir();
let _ = std::fs::create_dir_all(&dir);
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let name = format!("screenshot-{}.{}", timestamp, ext);
dir.join(name).to_string_lossy().to_string()
}
};
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &result.data)
.map_err(|e| format!("Failed to decode screenshot: {}", e))?;
std::fs::write(&save_path, &bytes)
.map_err(|e| format!("Failed to save screenshot to {}: {}", save_path, e))?;
Ok((save_path, result.data))
}
fn get_screenshot_dir() -> PathBuf {
if let Some(home) = dirs::home_dir() {
home.join(".agent-browser").join("tmp").join("screenshots")
} else {
std::env::temp_dir()
.join("agent-browser")
.join("screenshots")
}
}
+736
View File
@@ -0,0 +1,736 @@
use std::collections::HashMap;
use serde_json::Value;
use super::cdp::client::CdpClient;
use super::cdp::types::{
AXNode, AXProperty, AXValue, CallFunctionOnParams, EvaluateParams, EvaluateResult,
GetFullAXTreeResult,
};
use super::element::RefMap;
const INTERACTIVE_ROLES: &[&str] = &[
"button",
"link",
"textbox",
"checkbox",
"radio",
"combobox",
"listbox",
"menuitem",
"menuitemcheckbox",
"menuitemradio",
"option",
"searchbox",
"slider",
"spinbutton",
"switch",
"tab",
"treeitem",
];
const CONTENT_ROLES: &[&str] = &[
"heading",
"cell",
"gridcell",
"columnheader",
"rowheader",
"listitem",
"article",
"region",
"main",
"navigation",
];
const STRUCTURAL_ROLES: &[&str] = &[
"generic",
"group",
"list",
"table",
"row",
"rowgroup",
"grid",
"treegrid",
"menu",
"menubar",
"toolbar",
"tablist",
"tree",
"directory",
"document",
"application",
"presentation",
"none",
"WebArea",
"RootWebArea",
];
pub struct SnapshotOptions {
pub selector: Option<String>,
pub interactive: bool,
pub compact: bool,
pub depth: Option<usize>,
pub cursor: bool,
}
impl Default for SnapshotOptions {
fn default() -> Self {
Self {
selector: None,
interactive: false,
compact: false,
depth: None,
cursor: false,
}
}
}
struct TreeNode {
role: String,
name: String,
level: Option<i64>,
checked: Option<String>,
expanded: Option<bool>,
selected: Option<bool>,
disabled: Option<bool>,
required: Option<bool>,
value_text: Option<String>,
backend_node_id: Option<i64>,
children: Vec<usize>,
has_ref: bool,
ref_id: Option<String>,
depth: usize,
}
struct RoleNameTracker {
counts: HashMap<String, usize>,
entries: Vec<(usize, String)>,
}
impl RoleNameTracker {
fn new() -> Self {
Self {
counts: HashMap::new(),
entries: Vec::new(),
}
}
fn track(&mut self, role: &str, name: &str, node_idx: usize) -> usize {
let key = format!("{}:{}", role, name);
let count = self.counts.entry(key.clone()).or_insert(0);
let nth = *count;
*count += 1;
self.entries.push((node_idx, key));
nth
}
fn get_duplicates(&self) -> HashMap<String, usize> {
self.counts
.iter()
.filter(|(_, &count)| count > 1)
.map(|(key, &count)| (key.clone(), count))
.collect()
}
}
pub async fn take_snapshot(
client: &CdpClient,
session_id: &str,
options: &SnapshotOptions,
ref_map: &mut RefMap,
) -> Result<String, String> {
client
.send_command_no_params("DOM.enable", Some(session_id))
.await?;
client
.send_command_no_params("Accessibility.enable", Some(session_id))
.await?;
let ax_tree: GetFullAXTreeResult = client
.send_command_typed(
"Accessibility.getFullAXTree",
&serde_json::json!({}),
Some(session_id),
)
.await?;
let (tree_nodes, root_indices) = build_tree(&ax_tree.nodes);
let mut tracker = RoleNameTracker::new();
let mut next_ref: usize = ref_map.next_ref_num();
let mut nodes_with_refs: Vec<(usize, usize)> = Vec::new();
for (idx, node) in tree_nodes.iter().enumerate() {
let role = node.role.as_str();
let should_ref = if INTERACTIVE_ROLES.contains(&role) {
true
} else if CONTENT_ROLES.contains(&role) {
!node.name.is_empty()
} else {
false
};
if should_ref {
let nth = tracker.track(role, &node.name, idx);
nodes_with_refs.push((idx, nth));
}
}
let duplicates = tracker.get_duplicates();
let mut tree_nodes = tree_nodes;
for (idx, nth) in &nodes_with_refs {
let node = &tree_nodes[*idx];
let key = format!("{}:{}", node.role, node.name);
let actual_nth = if duplicates.contains_key(&key) {
Some(*nth)
} else {
None
};
let ref_id = format!("e{}", next_ref);
next_ref += 1;
ref_map.add(
ref_id.clone(),
tree_nodes[*idx].backend_node_id,
&tree_nodes[*idx].role,
&tree_nodes[*idx].name,
actual_nth,
);
tree_nodes[*idx].has_ref = true;
tree_nodes[*idx].ref_id = Some(ref_id);
}
ref_map.set_next_ref_num(next_ref);
let mut output = String::new();
for &root_idx in &root_indices {
render_tree(&tree_nodes, root_idx, 0, &mut output, options);
}
if options.compact {
output = compact_tree(&output, options.interactive);
}
let mut trimmed = output.trim().to_string();
if trimmed.is_empty() {
if options.interactive {
return Ok("(no interactive elements)".to_string());
}
return Ok("(empty page)".to_string());
}
if options.cursor {
let cursor_section = find_cursor_interactive_elements(client, session_id, ref_map).await?;
if !cursor_section.is_empty() {
trimmed.push_str("\n# Cursor-interactive elements:\n");
trimmed.push_str(&cursor_section);
}
}
Ok(trimmed)
}
async fn find_cursor_interactive_elements(
client: &CdpClient,
session_id: &str,
ref_map: &mut RefMap,
) -> Result<String, String> {
let js = r#"
(function() {
const elements = [];
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
let node;
while (node = walker.nextNode()) {
if (node.closest && node.closest('[hidden], [aria-hidden="true"]')) continue;
const explicitRole = node.getAttribute ? node.getAttribute('role') : null;
if (explicitRole) continue;
const tag = node.tagName ? node.tagName.toLowerCase() : '';
const hasClick = node.onclick || (node.attributes && node.attributes.getNamedItem('onclick'));
const tabindex = node.getAttribute ? node.getAttribute('tabindex') : null;
const contentEditable = node.getAttribute ? node.getAttribute('contenteditable') : null;
const isInherentlyClickable =
(tag === 'a' && node.href) || tag === 'button' ||
(tag === 'input' && ['submit','button','image','reset'].indexOf((node.type||'').toLowerCase()) >= 0) ||
tag === 'summary';
const isFocusable = tabindex !== null && parseInt(tabindex, 10) >= 0;
const isEditable = contentEditable === '' || contentEditable === 'true';
if (hasClick || isInherentlyClickable || isFocusable || isEditable) {
elements.push(node);
}
}
return elements;
})()
"#;
let result: EvaluateResult = client
.send_command_typed(
"Runtime.evaluate",
&EvaluateParams {
expression: js.to_string(),
return_by_value: Some(false),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
let array_object_id = match result.result.object_id {
Some(id) => id,
None => return Ok(String::new()),
};
let props_result: Value = client
.send_command(
"Runtime.getProperties",
Some(serde_json::json!({ "objectId": array_object_id })),
Some(session_id),
)
.await?;
let empty: Vec<Value> = Vec::new();
let result_array = props_result
.get("result")
.and_then(|v| v.as_array())
.unwrap_or(&empty);
let mut indexed: Vec<(usize, String)> = Vec::new();
for prop in result_array {
let name = prop.get("name").and_then(|v| v.as_str()).unwrap_or("");
if let Ok(idx) = name.parse::<usize>() {
if let Some(obj_id) = prop
.get("value")
.and_then(|v| v.get("objectId"))
.and_then(|v| v.as_str())
{
indexed.push((idx, obj_id.to_string()));
}
}
}
indexed.sort_by_key(|(idx, _)| *idx);
let element_object_ids: Vec<String> = indexed.into_iter().map(|(_, id)| id).collect();
let mut next_ref = ref_map.next_ref_num();
let mut lines: Vec<String> = Vec::new();
let get_text_js =
r#"function(){ return (this.innerText || this.textContent || '').trim().slice(0, 100) }"#;
for object_id in &element_object_ids {
let describe: Value = client
.send_command(
"DOM.describeNode",
Some(serde_json::json!({ "objectId": object_id })),
Some(session_id),
)
.await?;
let backend_node_id = describe
.get("node")
.and_then(|n| n.get("backendNodeId"))
.and_then(|v| v.as_i64());
let text_result: EvaluateResult = client
.send_command_typed(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: get_text_js.to_string(),
object_id: Some(object_id.clone()),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
let text = text_result
.result
.value
.as_ref()
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
let kind = "clickable";
let ref_id = format!("e{}", next_ref);
next_ref += 1;
ref_map.add(ref_id.clone(), backend_node_id, kind, &text, None);
let escaped = text
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', " ")
.replace('\r', " ");
lines.push(format!("[ref={}] ({}) \"{}\"", ref_id, kind, escaped));
}
ref_map.set_next_ref_num(next_ref);
Ok(lines.join("\n"))
}
fn build_tree(nodes: &[AXNode]) -> (Vec<TreeNode>, Vec<usize>) {
let mut tree_nodes: Vec<TreeNode> = Vec::with_capacity(nodes.len());
let mut id_to_idx: HashMap<String, usize> = HashMap::new();
for (i, node) in nodes.iter().enumerate() {
let role = extract_ax_string(&node.role);
let name = extract_ax_string(&node.name);
let value_text = extract_ax_string_opt(&node.value);
let (level, checked, expanded, selected, disabled, required) =
extract_properties(&node.properties);
if node.ignored.unwrap_or(false) && role != "RootWebArea" {
tree_nodes.push(TreeNode {
role: String::new(),
name: String::new(),
level: None,
checked: None,
expanded: None,
selected: None,
disabled: None,
required: None,
value_text: None,
backend_node_id: None,
children: Vec::new(),
has_ref: false,
ref_id: None,
depth: 0,
});
id_to_idx.insert(node.node_id.clone(), i);
continue;
}
tree_nodes.push(TreeNode {
role,
name,
level,
checked,
expanded,
selected,
disabled,
required,
value_text,
backend_node_id: node.backend_d_o_m_node_id,
children: Vec::new(),
has_ref: false,
ref_id: None,
depth: 0,
});
id_to_idx.insert(node.node_id.clone(), i);
}
// Build parent-child relationships
for (i, node) in nodes.iter().enumerate() {
if let Some(ref child_ids) = node.child_ids {
for cid in child_ids {
if let Some(&child_idx) = id_to_idx.get(cid) {
tree_nodes[i].children.push(child_idx);
}
}
}
}
// Set depths
let mut root_indices = Vec::new();
let children_exist: Vec<bool> = nodes.iter().map(|_| false).collect();
let mut is_child = children_exist;
for node in &tree_nodes {
for &child in &node.children {
is_child[child] = true;
}
}
for (i, &is_c) in is_child.iter().enumerate() {
if !is_c {
root_indices.push(i);
}
}
fn set_depth(nodes: &mut [TreeNode], idx: usize, depth: usize) {
nodes[idx].depth = depth;
let children: Vec<usize> = nodes[idx].children.clone();
for child_idx in children {
set_depth(nodes, child_idx, depth + 1);
}
}
for &root in &root_indices {
set_depth(&mut tree_nodes, root, 0);
}
(tree_nodes, root_indices)
}
fn render_tree(
nodes: &[TreeNode],
idx: usize,
indent: usize,
output: &mut String,
options: &SnapshotOptions,
) {
let node = &nodes[idx];
if node.role.is_empty() {
// Ignored node -- still render children
for &child in &node.children {
render_tree(nodes, child, indent, output, options);
}
return;
}
if let Some(max_depth) = options.depth {
if indent > max_depth {
return;
}
}
let role = &node.role;
// Skip root WebArea wrapper
if role == "RootWebArea" || role == "WebArea" {
for &child in &node.children {
render_tree(nodes, child, indent, output, options);
}
return;
}
if options.interactive && !node.has_ref {
// In interactive mode, skip non-interactive but render children
for &child in &node.children {
render_tree(nodes, child, indent, output, options);
}
return;
}
let prefix = " ".repeat(indent);
let mut line = format!("{}- {}", prefix, role);
if !node.name.is_empty() {
line.push_str(&format!(" \"{}\"", node.name));
}
// Properties
let mut attrs = Vec::new();
if let Some(level) = node.level {
attrs.push(format!("level={}", level));
}
if let Some(ref checked) = node.checked {
attrs.push(format!("checked={}", checked));
}
if let Some(expanded) = node.expanded {
attrs.push(format!("expanded={}", expanded));
}
if let Some(selected) = node.selected {
if selected {
attrs.push("selected".to_string());
}
}
if let Some(disabled) = node.disabled {
if disabled {
attrs.push("disabled".to_string());
}
}
if let Some(required) = node.required {
if required {
attrs.push("required".to_string());
}
}
if let Some(ref ref_id) = node.ref_id {
attrs.push(format!("ref={}", ref_id));
}
if !attrs.is_empty() {
line.push_str(&format!(" [{}]", attrs.join(", ")));
}
// Value
if let Some(ref val) = node.value_text {
if !val.is_empty() && val != &node.name {
line.push_str(&format!(": {}", val));
}
}
output.push_str(&line);
output.push('\n');
for &child in &node.children {
render_tree(nodes, child, indent + 1, output, options);
}
}
fn compact_tree(tree: &str, interactive: bool) -> String {
let lines: Vec<&str> = tree.lines().collect();
if lines.is_empty() {
return String::new();
}
let mut keep = vec![false; lines.len()];
for (i, line) in lines.iter().enumerate() {
if line.contains("[ref=") || line.contains(": ") {
keep[i] = true;
// Mark ancestors
let my_indent = count_indent(line);
for j in (0..i).rev() {
let ancestor_indent = count_indent(lines[j]);
if ancestor_indent < my_indent {
keep[j] = true;
if ancestor_indent == 0 {
break;
}
}
}
}
}
let result: Vec<&str> = lines
.iter()
.enumerate()
.filter(|(i, _)| keep[*i])
.map(|(_, line)| *line)
.collect();
let output = result.join("\n");
if output.trim().is_empty() && interactive {
return "(no interactive elements)".to_string();
}
output
}
fn count_indent(line: &str) -> usize {
let trimmed = line.trim_start();
(line.len() - trimmed.len()) / 2
}
fn extract_ax_string(value: &Option<AXValue>) -> String {
match value {
Some(v) => match &v.value {
Some(Value::String(s)) => s.clone(),
Some(Value::Number(n)) => n.to_string(),
Some(Value::Bool(b)) => b.to_string(),
_ => String::new(),
},
None => String::new(),
}
}
fn extract_ax_string_opt(value: &Option<AXValue>) -> Option<String> {
match value {
Some(v) => match &v.value {
Some(Value::String(s)) if !s.is_empty() => Some(s.clone()),
Some(Value::Number(n)) => Some(n.to_string()),
_ => None,
},
None => None,
}
}
type NodeProperties = (
Option<i64>, // level
Option<String>, // checked
Option<bool>, // expanded
Option<bool>, // selected
Option<bool>, // disabled
Option<bool>, // required
);
fn extract_properties(props: &Option<Vec<AXProperty>>) -> NodeProperties {
let mut level = None;
let mut checked = None;
let mut expanded = None;
let mut selected = None;
let mut disabled = None;
let mut required = None;
if let Some(properties) = props {
for prop in properties {
match prop.name.as_str() {
"level" => {
level = prop.value.value.as_ref().and_then(|v| v.as_i64());
}
"checked" => {
checked = prop.value.value.as_ref().map(|v| match v {
Value::String(s) => s.clone(),
Value::Bool(b) => b.to_string(),
_ => "false".to_string(),
});
}
"expanded" => {
expanded = prop.value.value.as_ref().and_then(|v| v.as_bool());
}
"selected" => {
selected = prop.value.value.as_ref().and_then(|v| v.as_bool());
}
"disabled" => {
disabled = prop.value.value.as_ref().and_then(|v| v.as_bool());
}
"required" => {
required = prop.value.value.as_ref().and_then(|v| v.as_bool());
}
_ => {}
}
}
}
(level, checked, expanded, selected, disabled, required)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_interactive_roles() {
assert!(INTERACTIVE_ROLES.contains(&"button"));
assert!(INTERACTIVE_ROLES.contains(&"textbox"));
assert!(!INTERACTIVE_ROLES.contains(&"heading"));
}
#[test]
fn test_content_roles() {
assert!(CONTENT_ROLES.contains(&"heading"));
assert!(!CONTENT_ROLES.contains(&"button"));
}
#[test]
fn test_compact_tree_basic() {
let tree = "- navigation\n - link \"Home\" [ref=e1]\n - link \"About\" [ref=e2]\n- main\n - heading \"Title\"\n - paragraph\n - text: Hello\n";
let result = compact_tree(tree, false);
assert!(result.contains("[ref=e1]"));
assert!(result.contains("[ref=e2]"));
assert!(result.contains("Hello"));
}
#[test]
fn test_compact_tree_empty_interactive() {
let result = compact_tree("- generic\n", true);
assert_eq!(result, "(no interactive elements)");
}
#[test]
fn test_count_indent() {
assert_eq!(count_indent("- heading"), 0);
assert_eq!(count_indent(" - link"), 1);
assert_eq!(count_indent(" - text"), 2);
}
#[test]
fn test_role_name_tracker() {
let mut tracker = RoleNameTracker::new();
assert_eq!(tracker.track("button", "Submit", 0), 0);
assert_eq!(tracker.track("button", "Submit", 1), 1);
assert_eq!(tracker.track("button", "Cancel", 2), 0);
let dups = tracker.get_duplicates();
assert!(dups.contains_key("button:Submit"));
assert!(!dups.contains_key("button:Cancel"));
}
}
+607
View File
@@ -0,0 +1,607 @@
use aes_gcm::{aead::Aead, aead::KeyInit, Aes256Gcm};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::PathBuf;
use super::cdp::client::CdpClient;
use super::cdp::types::EvaluateParams;
use super::cookies::{self, Cookie};
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StorageState {
pub cookies: Vec<Cookie>,
pub origins: Vec<OriginStorage>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OriginStorage {
pub origin: String,
pub local_storage: Vec<StorageEntry>,
#[serde(default)]
pub session_storage: Vec<StorageEntry>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StorageEntry {
pub name: String,
pub value: String,
}
pub async fn save_state(
client: &CdpClient,
session_id: &str,
path: Option<&str>,
session_name: Option<&str>,
session_id_str: &str,
) -> Result<String, String> {
let cookies = cookies::get_cookies(client, session_id, None).await?;
// Get current origin's storage
let origin_js = r#"(() => {
const result = { origin: location.origin, localStorage: [], sessionStorage: [] };
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
result.localStorage.push({ name: key, value: localStorage.getItem(key) });
}
} catch(e) {}
try {
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i);
result.sessionStorage.push({ name: key, value: sessionStorage.getItem(key) });
}
} catch(e) {}
return result;
})()"#;
let origin_result: super::cdp::types::EvaluateResult = client
.send_command_typed(
"Runtime.evaluate",
&EvaluateParams {
expression: origin_js.to_string(),
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
let origin_data = origin_result.result.value.unwrap_or(Value::Null);
let origins = if origin_data.is_object() {
let origin = origin_data
.get("origin")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let local_storage: Vec<StorageEntry> = origin_data
.get("localStorage")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
let session_storage: Vec<StorageEntry> = origin_data
.get("sessionStorage")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
if !origin.is_empty() && origin != "null" {
vec![OriginStorage {
origin,
local_storage,
session_storage,
}]
} else {
vec![]
}
} else {
vec![]
};
let state = StorageState { cookies, origins };
let json_str = serde_json::to_string_pretty(&state)
.map_err(|e| format!("Failed to serialize state: {}", e))?;
let mut save_path = match path {
Some(p) => p.to_string(),
None => {
let dir = get_sessions_dir();
let _ = fs::create_dir_all(&dir);
let name = session_name.unwrap_or("default");
dir.join(format!("{}-{}.json", name, session_id_str))
.to_string_lossy()
.to_string()
}
};
if let Ok(key) = std::env::var("AGENT_BROWSER_ENCRYPTION_KEY") {
let encrypted = encrypt_data(json_str.as_bytes(), &key)?;
save_path.push_str(".enc");
fs::write(&save_path, &encrypted)
.map_err(|e| format!("Failed to write state to {}: {}", save_path, e))?;
} else {
fs::write(&save_path, &json_str)
.map_err(|e| format!("Failed to write state to {}: {}", save_path, e))?;
}
Ok(save_path)
}
pub async fn load_state(client: &CdpClient, session_id: &str, path: &str) -> Result<(), String> {
let json_str = if path.ends_with(".enc") {
let key = std::env::var("AGENT_BROWSER_ENCRYPTION_KEY").map_err(|_| {
"Encrypted state file requires AGENT_BROWSER_ENCRYPTION_KEY".to_string()
})?;
let data =
fs::read(path).map_err(|e| format!("Failed to read state from {}: {}", path, e))?;
let decrypted = decrypt_data(&data, &key)?;
String::from_utf8(decrypted)
.map_err(|e| format!("Decrypted state is not valid UTF-8: {}", e))?
} else {
match fs::read_to_string(path) {
Ok(s) => s,
Err(e) => {
if let Ok(key) = std::env::var("AGENT_BROWSER_ENCRYPTION_KEY") {
let enc_path = format!("{}.enc", path);
if let Ok(data) = fs::read(&enc_path) {
let decrypted = decrypt_data(&data, &key)?;
String::from_utf8(decrypted)
.map_err(|de| format!("Decrypted state is not valid UTF-8: {}", de))?
} else {
return Err(format!("Failed to read state from {}: {}", path, e));
}
} else {
return Err(format!("Failed to read state from {}: {}", path, e));
}
}
}
};
let state: StorageState =
serde_json::from_str(&json_str).map_err(|e| format!("Invalid state file: {}", e))?;
// Load cookies
if !state.cookies.is_empty() {
let cookie_values: Vec<Value> = state
.cookies
.iter()
.map(|c| serde_json::to_value(c).unwrap_or(Value::Null))
.collect();
cookies::set_cookies(client, session_id, cookie_values, None).await?;
}
// Load storage per origin
for origin in &state.origins {
if origin.local_storage.is_empty() && origin.session_storage.is_empty() {
continue;
}
// Navigate to origin to set storage
let navigate_url = format!("{}/", origin.origin.trim_end_matches('/'));
client
.send_command(
"Page.navigate",
Some(json!({ "url": navigate_url })),
Some(session_id),
)
.await?;
// Brief wait for navigation
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
for entry in &origin.local_storage {
let js = format!(
"localStorage.setItem({}, {})",
serde_json::to_string(&entry.name).unwrap_or_default(),
serde_json::to_string(&entry.value).unwrap_or_default(),
);
let _ = client
.send_command_typed::<_, super::cdp::types::EvaluateResult>(
"Runtime.evaluate",
&EvaluateParams {
expression: js,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await;
}
for entry in &origin.session_storage {
let js = format!(
"sessionStorage.setItem({}, {})",
serde_json::to_string(&entry.name).unwrap_or_default(),
serde_json::to_string(&entry.value).unwrap_or_default(),
);
let _ = client
.send_command_typed::<_, super::cdp::types::EvaluateResult>(
"Runtime.evaluate",
&EvaluateParams {
expression: js,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await;
}
}
Ok(())
}
fn is_state_file(path: &std::path::Path) -> bool {
let fname = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
fname.ends_with(".json") || fname.ends_with(".json.enc")
}
fn is_encrypted_state(path: &std::path::Path) -> bool {
path.to_string_lossy().ends_with(".json.enc")
}
pub fn state_list() -> Result<Value, String> {
let dir = get_sessions_dir();
if !dir.exists() {
return Ok(json!({ "files": [], "directory": dir.to_string_lossy() }));
}
let mut files = Vec::new();
let entries = fs::read_dir(&dir).map_err(|e| format!("Failed to read sessions dir: {}", e))?;
for entry in entries.flatten() {
let path = entry.path();
if is_state_file(&path) {
let metadata = fs::metadata(&path).ok();
let filename = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
let modified = metadata
.as_ref()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
let encrypted = is_encrypted_state(&path);
files.push(json!({
"filename": filename,
"path": path.to_string_lossy(),
"size": size,
"modified": modified,
"encrypted": encrypted,
}));
}
}
Ok(json!({ "files": files, "directory": dir.to_string_lossy() }))
}
pub fn state_show(path: &str) -> Result<Value, String> {
let encrypted = path.ends_with(".enc");
let json_str = if encrypted {
let key = std::env::var("AGENT_BROWSER_ENCRYPTION_KEY").map_err(|_| {
"Encrypted state file requires AGENT_BROWSER_ENCRYPTION_KEY".to_string()
})?;
let data = fs::read(path).map_err(|e| format!("Failed to read state file: {}", e))?;
let decrypted = decrypt_data(&data, &key)?;
String::from_utf8(decrypted)
.map_err(|e| format!("Decrypted state is not valid UTF-8: {}", e))?
} else {
fs::read_to_string(path).map_err(|e| format!("Failed to read state file: {}", e))?
};
let state: StorageState =
serde_json::from_str(&json_str).map_err(|e| format!("Invalid state file: {}", e))?;
let metadata = fs::metadata(path).ok();
let filename = std::path::Path::new(path)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
Ok(json!({
"filename": filename,
"path": path,
"size": metadata.as_ref().map(|m| m.len()).unwrap_or(0),
"modified": metadata.as_ref()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0),
"encrypted": encrypted,
"summary": format!("{} cookies, {} origins", state.cookies.len(), state.origins.len()),
"state": state,
}))
}
pub fn state_clear(path: Option<&str>) -> Result<Value, String> {
if let Some(p) = path {
fs::remove_file(p).map_err(|e| format!("Failed to delete state: {}", e))?;
return Ok(json!({ "deleted": p }));
}
let dir = get_sessions_dir();
if !dir.exists() {
return Ok(json!({ "deleted": 0 }));
}
let mut count = 0;
if let Ok(entries) = fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if is_state_file(&path) {
let _ = fs::remove_file(&path);
count += 1;
}
}
}
Ok(json!({ "deleted": count }))
}
pub fn state_clean(max_age_days: u64) -> Result<Value, String> {
let dir = get_sessions_dir();
if !dir.exists() {
return Ok(json!({ "cleaned": 0, "keptCount": 0, "days": max_age_days }));
}
let now = std::time::SystemTime::now();
let max_age = std::time::Duration::from_secs(max_age_days * 86400);
let mut deleted = 0;
let mut kept = 0;
if let Ok(entries) = fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if !is_state_file(&path) {
continue;
}
if let Ok(metadata) = fs::metadata(&path) {
if let Ok(modified) = metadata.modified() {
if let Ok(age) = now.duration_since(modified) {
if age > max_age {
let _ = fs::remove_file(&path);
deleted += 1;
continue;
}
}
}
}
kept += 1;
}
}
Ok(json!({ "cleaned": deleted, "keptCount": kept, "days": max_age_days }))
}
pub fn state_rename(old_path: &str, new_name: &str) -> Result<Value, String> {
let old = PathBuf::from(old_path);
if !old.exists() {
return Err(format!("State file not found: {}", old_path));
}
let fallback = PathBuf::from(".");
let dir = old.parent().unwrap_or(&fallback);
let new_path = dir.join(format!("{}.json", new_name));
fs::rename(&old, &new_path).map_err(|e| format!("Failed to rename state: {}", e))?;
Ok(json!({
"renamed": true,
"from": old_path,
"to": new_path.to_string_lossy(),
}))
}
fn encrypt_data(data: &[u8], key_str: &str) -> Result<Vec<u8>, String> {
let mut hasher = Sha256::new();
hasher.update(key_str.as_bytes());
let key_bytes = hasher.finalize();
let cipher =
Aes256Gcm::new_from_slice(&key_bytes).map_err(|e| format!("Invalid key: {}", e))?;
let mut nonce = [0u8; 12];
getrandom::getrandom(&mut nonce).map_err(|e| format!("Failed to generate nonce: {}", e))?;
let ciphertext = cipher
.encrypt(aes_gcm::Nonce::from_slice(&nonce), data)
.map_err(|e| format!("Encryption failed: {}", e))?;
let mut result = Vec::with_capacity(12 + ciphertext.len());
result.extend_from_slice(&nonce);
result.extend_from_slice(&ciphertext);
Ok(result)
}
fn decrypt_data(data: &[u8], key_str: &str) -> Result<Vec<u8>, String> {
if data.len() < 13 {
return Err("Ciphertext too short".to_string());
}
let (nonce_bytes, ciphertext) = data.split_at(12);
let mut hasher = Sha256::new();
hasher.update(key_str.as_bytes());
let key_bytes = hasher.finalize();
let cipher =
Aes256Gcm::new_from_slice(&key_bytes).map_err(|e| format!("Invalid key: {}", e))?;
let plaintext = cipher
.decrypt(aes_gcm::Nonce::from_slice(nonce_bytes), ciphertext)
.map_err(|e| format!("Decryption failed: {}", e))?;
Ok(plaintext)
}
pub fn find_auto_state_file(session_name: &str) -> Option<String> {
let dir = get_sessions_dir();
if !dir.exists() {
return None;
}
let prefix = format!("{}-", session_name);
let mut best_path: Option<(String, std::time::SystemTime)> = None;
if let Ok(entries) = fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
let fname = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let is_match = fname.starts_with(&prefix)
&& (fname.ends_with(".json") || fname.ends_with(".json.enc"));
if !is_match {
continue;
}
let modified = fs::metadata(&path)
.ok()
.and_then(|m| m.modified().ok())
.unwrap_or(std::time::UNIX_EPOCH);
if best_path.as_ref().map_or(true, |(_, t)| modified > *t) {
best_path = Some((path.to_string_lossy().to_string(), modified));
}
}
}
best_path.map(|(p, _)| p)
}
pub fn get_sessions_dir() -> PathBuf {
if let Some(home) = dirs::home_dir() {
home.join(".agent-browser").join("sessions")
} else {
std::env::temp_dir().join("agent-browser").join("sessions")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_storage_state_serialization() {
let state = StorageState {
cookies: vec![Cookie {
name: "session".to_string(),
value: "abc123".to_string(),
domain: ".example.com".to_string(),
path: "/".to_string(),
expires: 0.0,
size: 0,
http_only: true,
secure: false,
session: true,
same_site: Some("Lax".to_string()),
}],
origins: vec![OriginStorage {
origin: "https://example.com".to_string(),
local_storage: vec![StorageEntry {
name: "key".to_string(),
value: "val".to_string(),
}],
session_storage: vec![],
}],
};
let json = serde_json::to_string_pretty(&state).unwrap();
let parsed: StorageState = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.cookies.len(), 1);
assert_eq!(parsed.cookies[0].name, "session");
assert_eq!(parsed.origins.len(), 1);
assert_eq!(parsed.origins[0].local_storage.len(), 1);
}
#[test]
fn test_storage_state_empty() {
let state = StorageState {
cookies: vec![],
origins: vec![],
};
let json = serde_json::to_string(&state).unwrap();
let parsed: StorageState = serde_json::from_str(&json).unwrap();
assert!(parsed.cookies.is_empty());
assert!(parsed.origins.is_empty());
}
#[test]
fn test_state_show_nonexistent_file() {
let result = state_show("/tmp/nonexistent-agent-browser-state-file.json");
assert!(result.is_err());
}
#[test]
fn test_state_clear_nonexistent_file() {
let result = state_clear(Some("/tmp/nonexistent-agent-browser-state-file.json"));
assert!(result.is_err());
}
#[test]
fn test_state_rename_nonexistent() {
let result = state_rename("/tmp/nonexistent-agent-browser-state-file.json", "new-name");
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found"));
}
#[test]
fn test_state_list_returns_json() {
let result = state_list().unwrap();
assert!(result.get("files").is_some());
assert!(result.get("directory").is_some());
}
#[test]
fn test_sessions_dir_path() {
let dir = get_sessions_dir();
assert!(dir.to_string_lossy().contains("sessions"));
}
#[test]
fn test_encrypt_decrypt_roundtrip() {
let plain = b"hello world";
let key = "test-secret-key";
let encrypted = encrypt_data(plain, key).unwrap();
assert!(encrypted.len() > 12);
assert_ne!(&encrypted[12..], plain);
let decrypted = decrypt_data(&encrypted, key).unwrap();
assert_eq!(decrypted, plain);
}
#[test]
fn test_decrypt_wrong_key_fails() {
let plain = b"secret data";
let encrypted = encrypt_data(plain, "key1").unwrap();
let result = decrypt_data(&encrypted, "key2");
assert!(result.is_err());
}
#[test]
fn test_cookie_serde_roundtrip() {
let cookie = Cookie {
name: "test".to_string(),
value: "123".to_string(),
domain: ".test.com".to_string(),
path: "/api".to_string(),
expires: 1700000000.0,
size: 7,
http_only: false,
secure: true,
session: false,
same_site: Some("Strict".to_string()),
};
let json = serde_json::to_value(&cookie).unwrap();
assert_eq!(json["name"], "test");
assert_eq!(json["httpOnly"], false);
assert_eq!(json["secure"], true);
assert_eq!(json["sameSite"], "Strict");
}
}
+94
View File
@@ -0,0 +1,94 @@
use serde_json::{json, Value};
use super::cdp::client::CdpClient;
use super::cdp::types::EvaluateParams;
pub async fn storage_get(
client: &CdpClient,
session_id: &str,
storage_type: &str,
key: Option<&str>,
) -> Result<Value, String> {
let st = storage_js_name(storage_type);
if let Some(k) = key {
let js = format!(
"{}.getItem({})",
st,
serde_json::to_string(k).unwrap_or_default()
);
let result = eval_simple(client, session_id, &js).await?;
Ok(json!({ "key": k, "value": result }))
} else {
let js = format!(
r#"(() => {{
const s = {};
const data = {{}};
for (let i = 0; i < s.length; i++) {{
const key = s.key(i);
data[key] = s.getItem(key);
}}
return data;
}})()"#,
st
);
let result = eval_simple(client, session_id, &js).await?;
Ok(json!({ "data": result }))
}
}
pub async fn storage_set(
client: &CdpClient,
session_id: &str,
storage_type: &str,
key: &str,
value: &str,
) -> Result<(), String> {
let st = storage_js_name(storage_type);
let js = format!(
"{}.setItem({}, {})",
st,
serde_json::to_string(key).unwrap_or_default(),
serde_json::to_string(value).unwrap_or_default(),
);
eval_simple(client, session_id, &js).await?;
Ok(())
}
pub async fn storage_clear(
client: &CdpClient,
session_id: &str,
storage_type: &str,
) -> Result<(), String> {
let st = storage_js_name(storage_type);
let js = format!("{}.clear()", st);
eval_simple(client, session_id, &js).await?;
Ok(())
}
fn storage_js_name(storage_type: &str) -> &str {
match storage_type {
"session" => "sessionStorage",
_ => "localStorage",
}
}
async fn eval_simple(client: &CdpClient, session_id: &str, js: &str) -> Result<Value, String> {
let result: super::cdp::types::EvaluateResult = client
.send_command_typed(
"Runtime.evaluate",
&EvaluateParams {
expression: js.to_string(),
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
if let Some(ref details) = result.exception_details {
return Err(format!("Storage error: {}", details.text));
}
Ok(result.result.value.unwrap_or(Value::Null))
}
+385
View File
@@ -0,0 +1,385 @@
use serde_json::{json, Value};
use std::net::SocketAddr;
use std::sync::Arc;
use futures_util::{SinkExt, StreamExt};
use tokio::net::TcpListener;
use tokio::sync::{broadcast, Mutex};
use tokio_tungstenite::tungstenite::Message;
use super::cdp::client::CdpClient;
/// Frame metadata from CDP Page.screencastFrame events.
#[derive(Debug, Clone)]
pub struct FrameMetadata {
pub offset_top: f64,
pub page_scale_factor: f64,
pub device_width: u32,
pub device_height: u32,
pub scroll_offset_x: f64,
pub scroll_offset_y: f64,
pub timestamp: u64,
}
impl Default for FrameMetadata {
fn default() -> Self {
Self {
offset_top: 0.0,
page_scale_factor: 1.0,
device_width: 1280,
device_height: 720,
scroll_offset_x: 0.0,
scroll_offset_y: 0.0,
timestamp: 0,
}
}
}
pub struct StreamServer {
port: u16,
frame_tx: broadcast::Sender<String>,
client_count: Arc<Mutex<usize>>,
}
impl StreamServer {
pub async fn start(
preferred_port: u16,
client: Arc<CdpClient>,
session_id: String,
) -> Result<Self, String> {
let addr = format!("127.0.0.1:{}", preferred_port);
let listener = TcpListener::bind(&addr)
.await
.map_err(|e| format!("Failed to bind stream server: {}", e))?;
let actual_addr = listener
.local_addr()
.map_err(|e| format!("Failed to get stream address: {}", e))?;
let port = actual_addr.port();
let (frame_tx, _) = broadcast::channel::<String>(64);
let client_count = Arc::new(Mutex::new(0usize));
let frame_tx_clone = frame_tx.clone();
let client_count_clone = client_count.clone();
tokio::spawn(async move {
accept_loop(
listener,
frame_tx_clone,
client_count_clone,
client,
session_id,
)
.await;
});
Ok(Self {
port,
frame_tx,
client_count,
})
}
pub fn port(&self) -> u16 {
self.port
}
/// Broadcast a raw frame string (legacy).
pub fn broadcast_frame(&self, frame_json: &str) {
let _ = self.frame_tx.send(frame_json.to_string());
}
/// Broadcast a screencast frame with structured metadata.
pub fn broadcast_screencast_frame(&self, base64_data: &str, metadata: &FrameMetadata) {
let msg = json!({
"type": "frame",
"data": base64_data,
"metadata": {
"offsetTop": metadata.offset_top,
"pageScaleFactor": metadata.page_scale_factor,
"deviceWidth": metadata.device_width,
"deviceHeight": metadata.device_height,
"scrollOffsetX": metadata.scroll_offset_x,
"scrollOffsetY": metadata.scroll_offset_y,
"timestamp": metadata.timestamp,
}
});
let _ = self.frame_tx.send(msg.to_string());
}
/// Broadcast a status message to all connected clients.
pub fn broadcast_status(
&self,
connected: bool,
screencasting: bool,
viewport_width: u32,
viewport_height: u32,
) {
let msg = json!({
"type": "status",
"connected": connected,
"screencasting": screencasting,
"viewportWidth": viewport_width,
"viewportHeight": viewport_height,
});
let _ = self.frame_tx.send(msg.to_string());
}
/// Broadcast an error message to all connected clients.
pub fn broadcast_error(&self, message: &str) {
let msg = json!({
"type": "error",
"message": message,
});
let _ = self.frame_tx.send(msg.to_string());
}
}
async fn accept_loop(
listener: TcpListener,
frame_tx: broadcast::Sender<String>,
client_count: Arc<Mutex<usize>>,
cdp_client: Arc<CdpClient>,
session_id: String,
) {
while let Ok((stream, addr)) = listener.accept().await {
let frame_rx = frame_tx.subscribe();
let client_count = client_count.clone();
let cdp = cdp_client.clone();
let sid = session_id.clone();
tokio::spawn(async move {
handle_ws_client(stream, addr, frame_rx, client_count, cdp, sid).await;
});
}
}
async fn handle_ws_client(
stream: tokio::net::TcpStream,
_addr: SocketAddr,
mut frame_rx: broadcast::Receiver<String>,
client_count: Arc<Mutex<usize>>,
cdp_client: Arc<CdpClient>,
session_id: String,
) {
// Origin checking on WebSocket handshake
let callback =
|req: &tokio_tungstenite::tungstenite::handshake::server::Request,
resp: tokio_tungstenite::tungstenite::handshake::server::Response| {
let origin = req
.headers()
.get("origin")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
if !is_allowed_origin(origin.as_deref()) {
let mut reject =
tokio_tungstenite::tungstenite::handshake::server::ErrorResponse::new(Some(
"Origin not allowed".to_string(),
));
*reject.status_mut() = tokio_tungstenite::tungstenite::http::StatusCode::FORBIDDEN;
return Err(reject);
}
Ok(resp)
};
let ws_stream = match tokio_tungstenite::accept_hdr_async(stream, callback).await {
Ok(ws) => ws,
Err(_) => return,
};
{
let mut count = client_count.lock().await;
*count += 1;
}
let (mut ws_tx, mut ws_rx) = ws_stream.split();
loop {
tokio::select! {
frame = frame_rx.recv() => {
match frame {
Ok(data) => {
if ws_tx.send(Message::Text(data)).await.is_err() {
break;
}
}
Err(_) => break,
}
}
msg = ws_rx.next() => {
match msg {
Some(Ok(Message::Text(text))) => {
handle_client_message(&text, &cdp_client, &session_id).await;
}
Some(Ok(Message::Close(_))) | None => break,
_ => {}
}
}
}
}
{
let mut count = client_count.lock().await;
*count = count.saturating_sub(1);
}
}
async fn handle_client_message(msg: &str, client: &CdpClient, session_id: &str) {
let parsed: Value = match serde_json::from_str(msg) {
Ok(v) => v,
Err(_) => return,
};
let msg_type = parsed.get("type").and_then(|v| v.as_str()).unwrap_or("");
match msg_type {
"input_mouse" => {
let _ = client
.send_command(
"Input.dispatchMouseEvent",
Some(json!({
"type": parsed.get("eventType").and_then(|v| v.as_str()).unwrap_or("mouseMoved"),
"x": parsed.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0),
"y": parsed.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0),
"button": parsed.get("button").and_then(|v| v.as_str()).unwrap_or("none"),
"clickCount": parsed.get("clickCount").and_then(|v| v.as_i64()).unwrap_or(0),
"deltaX": parsed.get("deltaX").and_then(|v| v.as_f64()).unwrap_or(0.0),
"deltaY": parsed.get("deltaY").and_then(|v| v.as_f64()).unwrap_or(0.0),
"modifiers": parsed.get("modifiers").and_then(|v| v.as_i64()).unwrap_or(0),
})),
Some(session_id),
)
.await;
}
"input_keyboard" => {
let _ = client
.send_command(
"Input.dispatchKeyEvent",
Some(json!({
"type": parsed.get("eventType").and_then(|v| v.as_str()).unwrap_or("keyDown"),
"key": parsed.get("key"),
"code": parsed.get("code"),
"text": parsed.get("text"),
"modifiers": parsed.get("modifiers").and_then(|v| v.as_i64()).unwrap_or(0),
})),
Some(session_id),
)
.await;
}
"input_touch" => {
let _ = client
.send_command(
"Input.dispatchTouchEvent",
Some(json!({
"type": parsed.get("eventType").and_then(|v| v.as_str()).unwrap_or("touchStart"),
"touchPoints": parsed.get("touchPoints").unwrap_or(&json!([])),
"modifiers": parsed.get("modifiers").and_then(|v| v.as_i64()).unwrap_or(0),
})),
Some(session_id),
)
.await;
}
"status" => {
// Client requesting status -- handled via broadcast_status from the caller
}
_ => {}
}
}
pub fn is_allowed_origin(origin: Option<&str>) -> bool {
match origin {
None => true,
Some(o) => {
if o.starts_with("file://") {
return true;
}
if let Ok(url) = url::Url::parse(o) {
let host = url.host_str().unwrap_or("");
host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "[::1]"
} else {
false
}
}
}
}
pub async fn start_screencast(
client: &CdpClient,
session_id: &str,
format: &str,
quality: i32,
max_width: i32,
max_height: i32,
) -> Result<(), String> {
client
.send_command(
"Page.startScreencast",
Some(json!({
"format": format,
"quality": quality,
"maxWidth": max_width,
"maxHeight": max_height,
"everyNthFrame": 1,
})),
Some(session_id),
)
.await?;
Ok(())
}
pub async fn stop_screencast(client: &CdpClient, session_id: &str) -> Result<(), String> {
client
.send_command_no_params("Page.stopScreencast", Some(session_id))
.await?;
Ok(())
}
pub async fn ack_screencast_frame(
client: &CdpClient,
session_id: &str,
screencast_session_id: i64,
) -> Result<(), String> {
client
.send_command(
"Page.screencastFrameAck",
Some(json!({ "sessionId": screencast_session_id })),
Some(session_id),
)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_allowed_origin_none() {
assert!(is_allowed_origin(None));
}
#[test]
fn test_allowed_origin_file() {
assert!(is_allowed_origin(Some("file:///path/to/file")));
}
#[test]
fn test_allowed_origin_localhost() {
assert!(is_allowed_origin(Some("http://localhost:3000")));
assert!(is_allowed_origin(Some("http://127.0.0.1:8080")));
}
#[test]
fn test_disallowed_origin() {
assert!(!is_allowed_origin(Some("http://evil.com")));
}
#[test]
fn test_frame_metadata_default() {
let meta = FrameMetadata::default();
assert_eq!(meta.device_width, 1280);
assert_eq!(meta.device_height, 720);
assert_eq!(meta.page_scale_factor, 1.0);
}
}
+373
View File
@@ -0,0 +1,373 @@
use serde_json::{json, Value};
use std::path::PathBuf;
use super::cdp::client::CdpClient;
const MAX_PROFILE_EVENTS: usize = 5_000_000;
const DEFAULT_PROFILER_CATEGORIES: &[&str] = &[
"devtools.timeline",
"disabled-by-default-devtools.timeline",
"disabled-by-default-devtools.timeline.frame",
"disabled-by-default-devtools.timeline.stack",
"v8.execute",
"disabled-by-default-v8.cpu_profiler",
"disabled-by-default-v8.cpu_profiler.hires",
"v8",
"disabled-by-default-v8.runtime_stats",
"blink",
"blink.user_timing",
"latencyInfo",
"renderer.scheduler",
"sequence_manager",
"toplevel",
];
pub struct TracingState {
pub active: bool,
pub events: Vec<Value>,
pub events_dropped: bool,
}
impl TracingState {
pub fn new() -> Self {
Self {
active: false,
events: Vec::new(),
events_dropped: false,
}
}
}
pub async fn trace_start(
client: &CdpClient,
session_id: &str,
tracing_state: &mut TracingState,
) -> Result<Value, String> {
if tracing_state.active {
return Err("Tracing already active".to_string());
}
client
.send_command(
"Tracing.start",
Some(json!({
"traceConfig": {
"recordMode": "recordContinuously",
},
"transferMode": "ReturnAsStream",
})),
Some(session_id),
)
.await?;
tracing_state.active = true;
tracing_state.events.clear();
tracing_state.events_dropped = false;
Ok(json!({ "started": true }))
}
pub async fn trace_stop(
client: &CdpClient,
session_id: &str,
tracing_state: &mut TracingState,
path: Option<&str>,
) -> Result<Value, String> {
if !tracing_state.active {
return Err("No tracing in progress".to_string());
}
// Subscribe to events before stopping
let mut rx = client.subscribe();
client
.send_command_no_params("Tracing.end", Some(session_id))
.await?;
// Collect trace data with timeout
let mut trace_events: Vec<Value> = Vec::new();
let mut stream_handle: Option<String> = None;
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(30);
loop {
let result = tokio::time::timeout_at(deadline, rx.recv()).await;
match result {
Ok(Ok(event)) => {
if event.session_id.as_deref() != Some(session_id) {
continue;
}
match event.method.as_str() {
"Tracing.dataCollected" => {
if let Some(arr) = event.params.get("value").and_then(|v| v.as_array()) {
trace_events.extend(arr.iter().cloned());
}
}
"Tracing.tracingComplete" => {
stream_handle = event
.params
.get("stream")
.and_then(|v| v.as_str())
.map(String::from);
break;
}
_ => {}
}
}
Ok(Err(_)) => break,
Err(_) => {
return Err("Tracing stop timed out after 30s".to_string());
}
}
}
// If ReturnAsStream mode was used, read trace data from the IO stream
if let Some(handle) = stream_handle {
if trace_events.is_empty() {
let stream_data = read_io_stream(client, session_id, &handle).await?;
if let Ok(parsed) = serde_json::from_str::<Value>(&stream_data) {
if let Some(events) = parsed.get("traceEvents").and_then(|v| v.as_array()) {
trace_events.extend(events.iter().cloned());
}
} else {
// Try parsing as newline-delimited JSON
for line in stream_data.lines() {
if let Ok(val) = serde_json::from_str::<Value>(line) {
if let Some(events) = val.get("traceEvents").and_then(|v| v.as_array()) {
trace_events.extend(events.iter().cloned());
} else {
trace_events.push(val);
}
}
}
}
}
// Close the IO stream
let _ = client
.send_command(
"IO.close",
Some(json!({ "handle": handle })),
Some(session_id),
)
.await;
}
tracing_state.active = false;
let save_path = match path {
Some(p) => p.to_string(),
None => {
let dir = get_traces_dir();
let _ = std::fs::create_dir_all(&dir);
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
dir.join(format!("trace-{}.json", timestamp))
.to_string_lossy()
.to_string()
}
};
let trace_json = json!({ "traceEvents": trace_events });
let json_str = serde_json::to_string(&trace_json)
.map_err(|e| format!("Failed to serialize trace: {}", e))?;
std::fs::write(&save_path, json_str)
.map_err(|e| format!("Failed to write trace to {}: {}", save_path, e))?;
Ok(json!({ "path": save_path, "eventCount": trace_events.len() }))
}
pub async fn profiler_start(
client: &CdpClient,
session_id: &str,
tracing_state: &mut TracingState,
categories: Option<Vec<String>>,
) -> Result<Value, String> {
if tracing_state.active {
return Err("Profiling/tracing already active".to_string());
}
let cats: Vec<String> = categories.unwrap_or_else(|| {
DEFAULT_PROFILER_CATEGORIES
.iter()
.map(|s| s.to_string())
.collect()
});
client
.send_command(
"Tracing.start",
Some(json!({
"traceConfig": {
"includedCategories": cats,
"enableSampling": true,
},
"transferMode": "ReportEvents",
})),
Some(session_id),
)
.await?;
tracing_state.active = true;
tracing_state.events.clear();
tracing_state.events_dropped = false;
Ok(json!({ "started": true }))
}
pub async fn profiler_stop(
client: &CdpClient,
session_id: &str,
tracing_state: &mut TracingState,
path: Option<&str>,
) -> Result<Value, String> {
if !tracing_state.active {
return Err("No profiling in progress".to_string());
}
let mut rx = client.subscribe();
client
.send_command_no_params("Tracing.end", Some(session_id))
.await?;
let mut events: Vec<Value> = Vec::new();
let mut dropped = false;
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(30);
loop {
let result = tokio::time::timeout_at(deadline, rx.recv()).await;
match result {
Ok(Ok(event)) => {
if event.session_id.as_deref() != Some(session_id) {
continue;
}
match event.method.as_str() {
"Tracing.dataCollected" => {
if let Some(arr) = event.params.get("value").and_then(|v| v.as_array()) {
if events.len() + arr.len() > MAX_PROFILE_EVENTS {
dropped = true;
} else {
events.extend(arr.iter().cloned());
}
}
}
"Tracing.tracingComplete" => {
break;
}
_ => {}
}
}
Ok(Err(_)) => break,
Err(_) => {
return Err("Profiler stop timed out after 30s".to_string());
}
}
}
tracing_state.active = false;
let save_path = match path {
Some(p) => p.to_string(),
None => {
let dir = get_profiles_dir();
let _ = std::fs::create_dir_all(&dir);
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
dir.join(format!("profile-{}.json", timestamp))
.to_string_lossy()
.to_string()
}
};
let clock_domain = get_clock_domain();
let mut profile = json!({ "traceEvents": events });
if let Some(cd) = clock_domain {
profile
.as_object_mut()
.unwrap()
.insert("metadata".to_string(), json!({ "clock-domain": cd }));
}
let json_str = serde_json::to_string(&profile)
.map_err(|e| format!("Failed to serialize profile: {}", e))?;
std::fs::write(&save_path, json_str)
.map_err(|e| format!("Failed to write profile to {}: {}", save_path, e))?;
let event_count = events.len();
let mut result = json!({ "path": save_path, "eventCount": event_count });
if dropped {
result.as_object_mut().unwrap().insert(
"warning".to_string(),
Value::String(format!(
"Events exceeded {} limit; some dropped",
MAX_PROFILE_EVENTS
)),
);
}
Ok(result)
}
/// Read all data from a CDP IO stream handle.
async fn read_io_stream(
client: &CdpClient,
session_id: &str,
handle: &str,
) -> Result<String, String> {
let mut data = String::new();
loop {
let result = client
.send_command(
"IO.read",
Some(json!({
"handle": handle,
"size": 1024 * 1024,
})),
Some(session_id),
)
.await?;
if let Some(chunk) = result.get("data").and_then(|v| v.as_str()) {
data.push_str(chunk);
}
let eof = result.get("eof").and_then(|v| v.as_bool()).unwrap_or(true);
if eof {
break;
}
}
Ok(data)
}
fn get_clock_domain() -> Option<&'static str> {
if cfg!(target_os = "linux") {
Some("LINUX_CLOCK_MONOTONIC")
} else if cfg!(target_os = "macos") {
Some("MAC_MACH_ABSOLUTE_TIME")
} else {
None
}
}
fn get_traces_dir() -> PathBuf {
if let Some(home) = dirs::home_dir() {
home.join(".agent-browser").join("tmp").join("traces")
} else {
std::env::temp_dir().join("agent-browser").join("traces")
}
}
fn get_profiles_dir() -> PathBuf {
if let Some(home) = dirs::home_dir() {
home.join(".agent-browser").join("tmp").join("profiles")
} else {
std::env::temp_dir().join("agent-browser").join("profiles")
}
}
+201
View File
@@ -0,0 +1,201 @@
use serde_json::{json, Value};
use std::process::{Child, Command, Stdio};
use std::time::Duration;
use super::client::WebDriverClient;
const APPIUM_DEFAULT_PORT: u16 = 4723;
const APPIUM_STARTUP_TIMEOUT_SECS: u64 = 30;
pub struct AppiumManager {
pub client: WebDriverClient,
appium_process: Option<Child>,
pub device_udid: Option<String>,
}
impl AppiumManager {
pub async fn connect_or_launch(device_udid: Option<&str>) -> Result<Self, String> {
let port = APPIUM_DEFAULT_PORT;
let client = WebDriverClient::new(port);
// Check if Appium is already running
if is_appium_running(port).await {
return Ok(Self {
client,
appium_process: None,
device_udid: device_udid.map(String::from),
});
}
// Try to launch Appium
let appium_process = launch_appium(port)?;
// Wait for Appium to be ready
wait_for_appium(port, APPIUM_STARTUP_TIMEOUT_SECS).await?;
Ok(Self {
client,
appium_process: Some(appium_process),
device_udid: device_udid.map(String::from),
})
}
pub async fn create_ios_session(
&mut self,
device_name: Option<&str>,
platform_version: Option<&str>,
) -> Result<Value, String> {
let mut caps = json!({
"platformName": "iOS",
"automationName": "XCUITest",
"browserName": "Safari",
"noReset": true,
});
if let Some(name) = device_name {
caps["deviceName"] = json!(name);
} else {
caps["deviceName"] = json!("iPhone");
}
if let Some(ver) = platform_version {
caps["platformVersion"] = json!(ver);
}
if let Some(ref udid) = self.device_udid {
caps["udid"] = json!(udid);
}
self.client.create_session(caps).await
}
pub async fn tap(&self, x: f64, y: f64) -> Result<(), String> {
let sid = self
.client
.session_id_pub()
.ok_or("No active session")?
.to_string();
let actions = json!({
"actions": [{
"type": "pointer",
"id": "finger1",
"parameters": { "pointerType": "touch" },
"actions": [
{ "type": "pointerMove", "duration": 0, "x": x as i64, "y": y as i64 },
{ "type": "pointerDown", "button": 0 },
{ "type": "pause", "duration": 100 },
{ "type": "pointerUp", "button": 0 },
]
}]
});
self.client.execute_actions(&sid, &actions).await
}
pub async fn swipe(
&self,
start_x: f64,
start_y: f64,
end_x: f64,
end_y: f64,
duration_ms: u64,
) -> Result<(), String> {
let sid = self
.client
.session_id_pub()
.ok_or("No active session")?
.to_string();
let actions = json!({
"actions": [{
"type": "pointer",
"id": "finger1",
"parameters": { "pointerType": "touch" },
"actions": [
{ "type": "pointerMove", "duration": 0, "x": start_x as i64, "y": start_y as i64 },
{ "type": "pointerDown", "button": 0 },
{ "type": "pointerMove", "duration": duration_ms, "x": end_x as i64, "y": end_y as i64 },
{ "type": "pointerUp", "button": 0 },
]
}]
});
self.client.execute_actions(&sid, &actions).await
}
pub async fn close(&mut self) -> Result<(), String> {
let _ = self.client.delete_session().await;
if let Some(ref mut child) = self.appium_process {
let _ = child.kill();
let _ = child.wait();
}
Ok(())
}
}
impl Drop for AppiumManager {
fn drop(&mut self) {
if let Some(ref mut child) = self.appium_process {
let _ = child.kill();
let _ = child.wait();
}
}
}
async fn is_appium_running(port: u16) -> bool {
let addr = format!("127.0.0.1:{}", port);
tokio::time::timeout(
Duration::from_secs(2),
tokio::net::TcpStream::connect(&addr),
)
.await
.map(|r| r.is_ok())
.unwrap_or(false)
}
fn launch_appium(port: u16) -> Result<Child, String> {
// Try npx appium first, then direct appium
let result = Command::new("npx")
.args(["appium", "--relaxed-security", "--port", &port.to_string()])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
match result {
Ok(child) => Ok(child),
Err(_) => Command::new("appium")
.args(["--relaxed-security", "--port", &port.to_string()])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| {
format!(
"Failed to launch Appium. Install it with: npm install -g appium. Error: {}",
e
)
}),
}
}
async fn wait_for_appium(port: u16, timeout_secs: u64) -> Result<(), String> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
loop {
if tokio::time::Instant::now() > deadline {
return Err("Timeout waiting for Appium to start".to_string());
}
if is_appium_running(port).await {
return Ok(());
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_appium_constants() {
assert_eq!(APPIUM_DEFAULT_PORT, 4723);
assert_eq!(APPIUM_STARTUP_TIMEOUT_SECS, 30);
}
}
+142
View File
@@ -0,0 +1,142 @@
use async_trait::async_trait;
use serde_json::Value;
/// Abstract backend for browser automation. CDP (Chromium) and WebDriver
/// (Safari/iOS) share this interface so actions.rs can remain backend-agnostic
/// in the future.
#[async_trait]
pub trait BrowserBackend: Send + Sync {
async fn navigate(&self, url: &str) -> Result<(), String>;
async fn get_url(&self) -> Result<String, String>;
async fn get_title(&self) -> Result<String, String>;
async fn get_content(&self) -> Result<String, String>;
async fn evaluate(&self, script: &str) -> Result<Value, String>;
async fn screenshot(&self) -> Result<String, String>;
async fn click(&self, selector: &str) -> Result<(), String>;
async fn fill(&self, selector: &str, value: &str) -> Result<(), String>;
async fn close(&mut self) -> Result<(), String>;
async fn back(&self) -> Result<(), String>;
async fn forward(&self) -> Result<(), String>;
async fn reload(&self) -> Result<(), String>;
async fn get_cookies(&self) -> Result<Value, String>;
fn backend_type(&self) -> &str;
fn supports(&self, feature: &str) -> bool {
match feature {
"navigate" | "evaluate" | "screenshot" | "click" | "fill" => true,
"screencast" | "tracing" | "network_intercept" | "cdp" => self.backend_type() == "cdp",
_ => false,
}
}
fn unsupported_error(&self, action: &str) -> String {
format!(
"Action '{}' is not supported on the {} backend",
action,
self.backend_type()
)
}
}
/// WebDriver implementation of BrowserBackend
pub struct WebDriverBackend {
client: super::client::WebDriverClient,
}
impl WebDriverBackend {
pub fn new(client: super::client::WebDriverClient) -> Self {
Self { client }
}
}
#[async_trait]
impl BrowserBackend for WebDriverBackend {
async fn navigate(&self, url: &str) -> Result<(), String> {
self.client.navigate(url).await
}
async fn get_url(&self) -> Result<String, String> {
self.client.get_url().await
}
async fn get_title(&self) -> Result<String, String> {
self.client.get_title().await
}
async fn get_content(&self) -> Result<String, String> {
self.client.get_page_source().await
}
async fn evaluate(&self, script: &str) -> Result<Value, String> {
self.client.execute_script(script, vec![]).await
}
async fn screenshot(&self) -> Result<String, String> {
self.client.screenshot().await
}
async fn click(&self, selector: &str) -> Result<(), String> {
let element_id = self.client.find_element("css selector", selector).await?;
self.client.click_element(&element_id).await
}
async fn fill(&self, selector: &str, value: &str) -> Result<(), String> {
let element_id = self.client.find_element("css selector", selector).await?;
self.client.clear_element(&element_id).await?;
self.client.send_keys(&element_id, value).await
}
async fn close(&mut self) -> Result<(), String> {
self.client.delete_session().await
}
async fn back(&self) -> Result<(), String> {
self.client.back().await
}
async fn forward(&self) -> Result<(), String> {
self.client.forward().await
}
async fn reload(&self) -> Result<(), String> {
self.client.refresh().await
}
async fn get_cookies(&self) -> Result<Value, String> {
self.client.get_cookies().await
}
fn backend_type(&self) -> &str {
"webdriver"
}
}
/// CDP-backed backend constants for unsupported actions on WebDriver
pub const WEBDRIVER_UNSUPPORTED_ACTIONS: &[&str] = &[
"screencast_start",
"screencast_stop",
"trace_start",
"trace_stop",
"profiler_start",
"profiler_stop",
"route",
"unroute",
"expose",
"addscript",
"addinitscript",
"network",
"har_start",
"har_stop",
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_unsupported_actions() {
assert!(WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&"screencast_start"));
assert!(WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&"trace_start"));
assert!(!WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&"navigate"));
}
}
+318
View File
@@ -0,0 +1,318 @@
use serde_json::{json, Value};
use std::time::Duration;
pub struct WebDriverClient {
base_url: String,
session_id: Option<String>,
}
impl WebDriverClient {
pub fn new(port: u16) -> Self {
Self {
base_url: format!("http://127.0.0.1:{}", port),
session_id: None,
}
}
pub async fn create_session(&mut self, capabilities: Value) -> Result<Value, String> {
let body = json!({
"capabilities": {
"alwaysMatch": capabilities,
}
});
let response = self.post("/session", &body).await?;
let session_id = response
.get("value")
.and_then(|v| v.get("sessionId"))
.and_then(|v| v.as_str())
.ok_or("No sessionId in response")?
.to_string();
self.session_id = Some(session_id);
Ok(response)
}
pub async fn delete_session(&mut self) -> Result<(), String> {
if let Some(ref sid) = self.session_id.clone() {
let _ = self.delete(&format!("/session/{}", sid)).await;
self.session_id = None;
}
Ok(())
}
pub async fn navigate(&self, url: &str) -> Result<(), String> {
let sid = self.session_id()?.to_string();
self.post(&format!("/session/{}/url", sid), &json!({ "url": url }))
.await?;
Ok(())
}
pub async fn get_url(&self) -> Result<String, String> {
let sid = self.session_id()?.to_string();
let response = self.get(&format!("/session/{}/url", sid)).await?;
Ok(response
.get("value")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string())
}
pub async fn get_title(&self) -> Result<String, String> {
let sid = self.session_id()?.to_string();
let response = self.get(&format!("/session/{}/title", sid)).await?;
Ok(response
.get("value")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string())
}
pub async fn find_element(&self, using: &str, value: &str) -> Result<String, String> {
let sid = self.session_id()?.to_string();
let response = self
.post(
&format!("/session/{}/element", sid),
&json!({ "using": using, "value": value }),
)
.await?;
let element_value = response.get("value").ok_or("No element in response")?;
element_value
.get("element-6066-11e4-a52e-4f735466cecf")
.or_else(|| element_value.get("ELEMENT"))
.and_then(|v| v.as_str())
.map(String::from)
.ok_or("No element ID in response".to_string())
}
pub async fn click_element(&self, element_id: &str) -> Result<(), String> {
let sid = self.session_id()?.to_string();
self.post(
&format!("/session/{}/element/{}/click", sid, element_id),
&json!({}),
)
.await?;
Ok(())
}
pub async fn send_keys(&self, element_id: &str, text: &str) -> Result<(), String> {
let sid = self.session_id()?.to_string();
self.post(
&format!("/session/{}/element/{}/value", sid, element_id),
&json!({ "text": text }),
)
.await?;
Ok(())
}
pub async fn clear_element(&self, element_id: &str) -> Result<(), String> {
let sid = self.session_id()?.to_string();
self.post(
&format!("/session/{}/element/{}/clear", sid, element_id),
&json!({}),
)
.await?;
Ok(())
}
pub async fn execute_script(&self, script: &str, args: Vec<Value>) -> Result<Value, String> {
let sid = self.session_id()?.to_string();
let response = self
.post(
&format!("/session/{}/execute/sync", sid),
&json!({ "script": script, "args": args }),
)
.await?;
Ok(response.get("value").cloned().unwrap_or(Value::Null))
}
pub async fn screenshot(&self) -> Result<String, String> {
let sid = self.session_id()?.to_string();
let response = self.get(&format!("/session/{}/screenshot", sid)).await?;
Ok(response
.get("value")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string())
}
pub async fn get_cookies(&self) -> Result<Value, String> {
let sid = self.session_id()?.to_string();
let response = self.get(&format!("/session/{}/cookie", sid)).await?;
Ok(response.get("value").cloned().unwrap_or(Value::Null))
}
pub async fn get_page_source(&self) -> Result<String, String> {
let sid = self.session_id()?.to_string();
let response = self.get(&format!("/session/{}/source", sid)).await?;
Ok(response
.get("value")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string())
}
pub async fn back(&self) -> Result<(), String> {
let sid = self.session_id()?.to_string();
self.post(&format!("/session/{}/back", sid), &json!({}))
.await?;
Ok(())
}
pub async fn forward(&self) -> Result<(), String> {
let sid = self.session_id()?.to_string();
self.post(&format!("/session/{}/forward", sid), &json!({}))
.await?;
Ok(())
}
pub async fn refresh(&self) -> Result<(), String> {
let sid = self.session_id()?.to_string();
self.post(&format!("/session/{}/refresh", sid), &json!({}))
.await?;
Ok(())
}
pub fn session_id_pub(&self) -> Option<&str> {
self.session_id.as_deref()
}
pub fn new_with_session(port: u16, session_id: String) -> Self {
Self {
base_url: format!("http://127.0.0.1:{}", port),
session_id: Some(session_id),
}
}
pub async fn execute_actions(&self, session_id: &str, actions: &Value) -> Result<(), String> {
self.post(&format!("/session/{}/actions", session_id), actions)
.await?;
Ok(())
}
fn session_id(&self) -> Result<&str, String> {
self.session_id
.as_deref()
.ok_or("No active WebDriver session".to_string())
}
async fn get(&self, path: &str) -> Result<Value, String> {
http_request("GET", &format!("{}{}", self.base_url, path), None).await
}
async fn post(&self, path: &str, body: &Value) -> Result<Value, String> {
http_request("POST", &format!("{}{}", self.base_url, path), Some(body)).await
}
async fn delete(&self, path: &str) -> Result<Value, String> {
http_request("DELETE", &format!("{}{}", self.base_url, path), None).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_new() {
let client = WebDriverClient::new(4444);
assert_eq!(client.base_url, "http://127.0.0.1:4444");
assert!(client.session_id.is_none());
}
#[test]
fn test_session_id_none() {
let client = WebDriverClient::new(4444);
let result = client.session_id();
assert!(result.is_err());
assert!(result.unwrap_err().contains("No active WebDriver session"));
}
#[test]
fn test_client_custom_port() {
let client = WebDriverClient::new(9515);
assert_eq!(client.base_url, "http://127.0.0.1:9515");
}
}
async fn http_request(method: &str, url: &str, body: Option<&Value>) -> Result<Value, String> {
let parsed = url::Url::parse(url).map_err(|e| format!("Invalid URL: {}", e))?;
let host = parsed.host_str().unwrap_or("127.0.0.1");
let port = parsed.port().unwrap_or(80);
let path = parsed.path();
let addr = format!("{}:{}", host, port);
let stream = tokio::time::timeout(
Duration::from_secs(10),
tokio::net::TcpStream::connect(&addr),
)
.await
.map_err(|_| format!("Connection timeout: {}", addr))?
.map_err(|e| format!("Connection failed: {}", e))?;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let body_str = body
.map(|b| serde_json::to_string(b).unwrap_or_default())
.unwrap_or_default();
let request = if body.is_some() {
format!(
"{} {} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
method, path, addr, body_str.len(), body_str
)
} else {
format!(
"{} {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
method, path, addr
)
};
let mut stream = stream;
stream
.write_all(request.as_bytes())
.await
.map_err(|e| format!("Write failed: {}", e))?;
let mut response = Vec::new();
stream
.read_to_end(&mut response)
.await
.map_err(|e| format!("Read failed: {}", e))?;
let response_str = String::from_utf8_lossy(&response);
let body_part = response_str.split("\r\n\r\n").nth(1).unwrap_or("").trim();
// Handle chunked encoding
let json_body = if body_part.contains('\n')
&& body_part
.chars()
.next()
.map(|c| c.is_ascii_hexdigit())
.unwrap_or(false)
{
// Chunked: skip chunk size lines
body_part
.lines()
.filter(|l| !l.chars().all(|c| c.is_ascii_hexdigit() || c == '\r'))
.collect::<Vec<&str>>()
.join("")
} else {
body_part.to_string()
};
if json_body.is_empty() {
return Ok(json!({}));
}
serde_json::from_str(&json_body).map_err(|e| {
format!(
"Invalid JSON response: {} (body: {})",
e,
json_body.chars().take(100).collect::<String>()
)
})
}
+235
View File
@@ -0,0 +1,235 @@
use serde_json::{json, Value};
use std::process::Command;
#[derive(Debug, Clone)]
pub struct IosDevice {
pub name: String,
pub udid: String,
pub state: String,
pub runtime: String,
pub is_real: bool,
}
pub fn list_simulators() -> Result<Vec<IosDevice>, String> {
let output = Command::new("xcrun")
.args(["simctl", "list", "devices", "--json"])
.output()
.map_err(|e| format!("Failed to run xcrun simctl: {}", e))?;
if !output.status.success() {
return Err("xcrun simctl failed. Xcode may not be installed.".to_string());
}
let json_str = String::from_utf8_lossy(&output.stdout);
let parsed: Value =
serde_json::from_str(&json_str).map_err(|e| format!("Failed to parse simctl: {}", e))?;
let mut devices = Vec::new();
if let Some(device_map) = parsed.get("devices").and_then(|v| v.as_object()) {
for (runtime, device_list) in device_map {
if let Some(arr) = device_list.as_array() {
for device in arr {
let name = device
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let udid = device
.get("udid")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let state = device
.get("state")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
devices.push(IosDevice {
name,
udid,
state,
runtime: runtime.clone(),
is_real: false,
});
}
}
}
}
Ok(devices)
}
pub fn list_real_devices() -> Result<Vec<IosDevice>, String> {
let output = Command::new("xcrun")
.args(["xctrace", "list", "devices"])
.output()
.map_err(|e| format!("Failed to run xcrun xctrace: {}", e))?;
if !output.status.success() {
return Ok(Vec::new());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut devices = Vec::new();
let mut in_devices = false;
for line in stdout.lines() {
let trimmed = line.trim();
if trimmed.starts_with("== Devices ==") {
in_devices = true;
continue;
}
if trimmed.starts_with("== Simulators ==") {
break;
}
if !in_devices || trimmed.is_empty() {
continue;
}
// Format: "Device Name (OS Version) (UDID)"
if let Some(udid_start) = trimmed.rfind('(') {
let udid_end = trimmed.len() - 1;
let udid = &trimmed[udid_start + 1..udid_end];
// Validate it looks like a UDID (contains hyphens)
if udid.contains('-') && udid.len() > 20 {
let name_part = trimmed[..udid_start].trim();
let name = if let Some(paren_pos) = name_part.rfind('(') {
name_part[..paren_pos].trim().to_string()
} else {
name_part.to_string()
};
devices.push(IosDevice {
name,
udid: udid.to_string(),
state: "Connected".to_string(),
runtime: String::new(),
is_real: true,
});
}
}
}
Ok(devices)
}
pub fn list_all_devices() -> Result<Vec<IosDevice>, String> {
let mut all = list_simulators().unwrap_or_default();
all.extend(list_real_devices().unwrap_or_default());
Ok(all)
}
pub fn boot_simulator(udid: &str) -> Result<(), String> {
let output = Command::new("xcrun")
.args(["simctl", "boot", udid])
.output()
.map_err(|e| format!("Failed to boot simulator: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("current state: Booted") {
return Ok(());
}
return Err(format!("Failed to boot simulator {}: {}", udid, stderr));
}
Ok(())
}
pub fn shutdown_simulator(udid: &str) -> Result<(), String> {
let output = Command::new("xcrun")
.args(["simctl", "shutdown", udid])
.output()
.map_err(|e| format!("Failed to shutdown simulator: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("current state: Shutdown") {
return Ok(());
}
return Err(format!("Failed to shutdown simulator {}: {}", udid, stderr));
}
Ok(())
}
pub fn select_device(device_name: Option<&str>, udid: Option<&str>) -> Result<IosDevice, String> {
if let Some(u) = udid {
let devices = list_all_devices()?;
return devices
.into_iter()
.find(|d| d.udid == u)
.ok_or_else(|| format!("Device with UDID '{}' not found", u));
}
if let Some(name) = device_name {
let devices = list_all_devices()?;
return devices
.into_iter()
.find(|d| d.name.to_lowercase().contains(&name.to_lowercase()))
.ok_or_else(|| format!("Device '{}' not found", name));
}
// Default: prefer most recent iPhone, prefer Pro
let devices = list_simulators()?;
let iphone_devices: Vec<&IosDevice> = devices
.iter()
.filter(|d| d.name.starts_with("iPhone"))
.collect();
if iphone_devices.is_empty() {
return devices
.into_iter()
.next()
.ok_or("No iOS simulators found".to_string());
}
// Prefer Pro models
if let Some(pro) = iphone_devices.iter().find(|d| d.name.contains("Pro")) {
return Ok((*pro).clone());
}
Ok((*iphone_devices.last().unwrap()).clone())
}
pub fn to_device_json(devices: &[IosDevice]) -> Value {
let list: Vec<Value> = devices
.iter()
.map(|d| {
json!({
"name": d.name,
"udid": d.udid,
"state": d.state,
"runtime": d.runtime,
"isReal": d.is_real,
})
})
.collect();
json!({ "devices": list })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ios_device_struct() {
let device = IosDevice {
name: "iPhone 15 Pro".to_string(),
udid: "ABC-123".to_string(),
state: "Booted".to_string(),
runtime: "iOS-17-0".to_string(),
is_real: false,
};
assert_eq!(device.name, "iPhone 15 Pro");
assert!(!device.is_real);
}
#[test]
fn test_to_device_json() {
let devices = vec![IosDevice {
name: "Test".to_string(),
udid: "123".to_string(),
state: "Shutdown".to_string(),
runtime: "iOS-17".to_string(),
is_real: false,
}];
let json = to_device_json(&devices);
assert!(json.get("devices").unwrap().as_array().unwrap().len() == 1);
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod appium;
pub mod backend;
pub mod client;
pub mod ios;
pub mod safari;
pub mod types;
+80
View File
@@ -0,0 +1,80 @@
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::time::Duration;
pub struct SafariDriverProcess {
child: Child,
pub port: u16,
}
impl SafariDriverProcess {
pub fn kill(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Drop for SafariDriverProcess {
fn drop(&mut self) {
self.kill();
}
}
pub fn find_safaridriver() -> Option<PathBuf> {
let candidates = ["/usr/bin/safaridriver"];
for c in &candidates {
let p = PathBuf::from(c);
if p.exists() {
return Some(p);
}
}
// Try PATH
if let Ok(output) = Command::new("which").arg("safaridriver").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Some(PathBuf::from(path));
}
}
}
None
}
pub fn launch_safaridriver(port: u16) -> Result<SafariDriverProcess, String> {
let driver_path = find_safaridriver()
.ok_or("safaridriver not found. Safari WebDriver requires macOS with Safari.")?;
let child = Command::new(&driver_path)
.arg("--port")
.arg(port.to_string())
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to launch safaridriver: {}", e))?;
// Wait for driver to be ready
std::thread::sleep(Duration::from_millis(500));
Ok(SafariDriverProcess { child, port })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_find_safaridriver() {
// Only check on macOS
if cfg!(target_os = "macos") {
let result = find_safaridriver();
// Don't assert Some since it may not be enabled
if let Some(path) = result {
assert!(path.exists());
}
}
}
}
+97
View File
@@ -0,0 +1,97 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NewSessionRequest {
pub capabilities: Capabilities,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Capabilities {
pub always_match: Value,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionResponse {
pub value: SessionValue,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionValue {
pub session_id: String,
pub capabilities: Value,
}
#[derive(Debug, Deserialize)]
pub struct WebDriverResponse {
pub value: Value,
}
#[derive(Debug, Deserialize)]
pub struct WebDriverError {
pub error: String,
pub message: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ElementResponse {
pub value: ElementValue,
}
#[derive(Debug, Deserialize)]
pub struct ElementValue {
#[serde(rename = "element-6066-11e4-a52e-4f735466cecf")]
pub element_id: Option<String>,
#[serde(rename = "ELEMENT")]
pub element_legacy: Option<String>,
}
impl ElementValue {
pub fn id(&self) -> Option<&str> {
self.element_id
.as_deref()
.or(self.element_legacy.as_deref())
}
}
#[derive(Debug, Serialize)]
pub struct FindElementRequest {
pub using: String,
pub value: String,
}
#[derive(Debug, Serialize)]
pub struct ExecuteScriptRequest {
pub script: String,
pub args: Vec<Value>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CookieRequest {
pub cookie: CookieData,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CookieData {
pub name: String,
pub value: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub domain: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub secure: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub http_only: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expiry: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub same_site: Option<String>,
}
+138 -47
View File
@@ -38,7 +38,9 @@ fn truncate_if_needed(content: &str, max: Option<usize>) -> String {
let total_chars = content.chars().count();
format!(
"{}\n[truncated: showing {} of {} chars. Use --max-output to adjust]",
&content[..byte_offset], limit, total_chars
&content[..byte_offset],
limit,
total_chars
)
}
// Content has fewer than `limit` chars despite more bytes
@@ -51,7 +53,10 @@ fn print_with_boundaries(content: &str, origin: Option<&str>, opts: &OutputOptio
if opts.content_boundaries {
let origin_str = origin.unwrap_or("unknown");
let nonce = get_boundary_nonce();
println!("--- AGENT_BROWSER_PAGE_CONTENT nonce={} origin={} ---", nonce, origin_str);
println!(
"--- AGENT_BROWSER_PAGE_CONTENT nonce={} origin={} ---",
nonce, origin_str
);
println!("{}", content);
println!("--- END_AGENT_BROWSER_PAGE_CONTENT nonce={} ---", nonce);
} else {
@@ -65,14 +70,18 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
let mut json_val = serde_json::to_value(resp).unwrap_or_default();
if let Some(obj) = json_val.as_object_mut() {
let nonce = get_boundary_nonce();
let origin = obj.get("data")
let origin = obj
.get("data")
.and_then(|d| d.get("origin"))
.and_then(|v| v.as_str())
.unwrap_or("unknown");
obj.insert("_boundary".to_string(), serde_json::json!({
"nonce": nonce,
"origin": origin,
}));
obj.insert(
"_boundary".to_string(),
serde_json::json!({
"nonce": nonce,
"origin": origin,
}),
);
}
println!("{}", serde_json::to_string(&json_val).unwrap_or_default());
} else {
@@ -113,15 +122,11 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
return;
}
Some("diff_url") => {
if let Some(snap_data) =
obj.get("snapshot").and_then(|v| v.as_object())
{
if let Some(snap_data) = obj.get("snapshot").and_then(|v| v.as_object()) {
println!("{}", color::bold("Snapshot diff:"));
print_snapshot_diff(snap_data);
}
if let Some(ss_data) =
obj.get("screenshot").and_then(|v| v.as_object())
{
if let Some(ss_data) = obj.get("screenshot").and_then(|v| v.as_object()) {
println!("\n{}", color::bold("Screenshot diff:"));
print_screenshot_diff(ss_data);
}
@@ -269,7 +274,11 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
for log in logs {
let level = log.get("type").and_then(|v| v.as_str()).unwrap_or("log");
let text = log.get("text").and_then(|v| v.as_str()).unwrap_or("");
console_output.push_str(&format!("{} {}\n", color::console_level_prefix(level), text));
console_output.push_str(&format!(
"{} {}\n",
color::console_level_prefix(level),
text
));
}
if console_output.ends_with('\n') {
console_output.pop();
@@ -404,11 +413,7 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
}
_ => {
if let Some(path) = data.get("path").and_then(|v| v.as_str()) {
println!(
"{} Recording started: {}",
color::success_indicator(),
path
);
println!("{} Recording started: {}", color::success_indicator(), path);
} else {
println!("{} Recording started", color::success_indicator());
}
@@ -591,7 +596,10 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
let filename = file.get("filename").and_then(|v| v.as_str()).unwrap_or("");
let size = file.get("size").and_then(|v| v.as_i64()).unwrap_or(0);
let modified = file.get("modified").and_then(|v| v.as_str()).unwrap_or("");
let encrypted = file.get("encrypted").and_then(|v| v.as_bool()).unwrap_or(false);
let encrypted = file
.get("encrypted")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let size_str = if size > 1024 {
format!("{:.1}KB", size as f64 / 1024.0)
} else {
@@ -599,7 +607,11 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
};
let date_str = modified.split('T').next().unwrap_or(modified);
let enc_str = if encrypted { " [encrypted]" } else { "" };
println!(" {} {}", filename, color::dim(&format!("({}, {}){}", size_str, date_str, enc_str)));
println!(
" {} {}",
filename,
color::dim(&format!("({}, {}){}", size_str, date_str, enc_str))
);
}
}
return;
@@ -609,13 +621,22 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
if let Some(true) = data.get("renamed").and_then(|v| v.as_bool()) {
let old_name = data.get("oldName").and_then(|v| v.as_str()).unwrap_or("");
let new_name = data.get("newName").and_then(|v| v.as_str()).unwrap_or("");
println!("{} Renamed {} -> {}", color::success_indicator(), old_name, new_name);
println!(
"{} Renamed {} -> {}",
color::success_indicator(),
old_name,
new_name
);
return;
}
// State clear
if let Some(cleared) = data.get("cleared").and_then(|v| v.as_i64()) {
println!("{} Cleared {} state file(s)", color::success_indicator(), cleared);
println!(
"{} Cleared {} state file(s)",
color::success_indicator(),
cleared
);
return;
}
@@ -623,7 +644,10 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
if let Some(summary) = data.get("summary") {
let cookies = summary.get("cookies").and_then(|v| v.as_i64()).unwrap_or(0);
let origins = summary.get("origins").and_then(|v| v.as_i64()).unwrap_or(0);
let encrypted = data.get("encrypted").and_then(|v| v.as_bool()).unwrap_or(false);
let encrypted = data
.get("encrypted")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let enc_str = if encrypted { " (encrypted)" } else { "" };
println!("State file summary{}:", enc_str);
println!(" Cookies: {}", cookies);
@@ -633,7 +657,11 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
// State clean
if let Some(cleaned) = data.get("cleaned").and_then(|v| v.as_i64()) {
println!("{} Cleaned {} old state file(s)", color::success_indicator(), cleaned);
println!(
"{} Cleaned {} old state file(s)",
color::success_indicator(),
cleaned
);
return;
}
@@ -652,7 +680,12 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("");
let url = p.get("url").and_then(|v| v.as_str()).unwrap_or("");
let user = p.get("username").and_then(|v| v.as_str()).unwrap_or("");
println!(" {} {} {}", color::green(name), color::dim(user), color::dim(url));
println!(
" {} {} {}",
color::green(name),
color::dim(user),
color::dim(url)
);
}
}
return;
@@ -662,8 +695,14 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
if let Some(profile) = data.get("profile").and_then(|v| v.as_object()) {
let name = profile.get("name").and_then(|v| v.as_str()).unwrap_or("");
let url = profile.get("url").and_then(|v| v.as_str()).unwrap_or("");
let user = profile.get("username").and_then(|v| v.as_str()).unwrap_or("");
let created = profile.get("createdAt").and_then(|v| v.as_str()).unwrap_or("");
let user = profile
.get("username")
.and_then(|v| v.as_str())
.unwrap_or("");
let created = profile
.get("createdAt")
.and_then(|v| v.as_str())
.unwrap_or("");
let last_login = profile.get("lastLoginAt").and_then(|v| v.as_str());
println!("Name: {}", name);
println!("URL: {}", url);
@@ -678,47 +717,94 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
// Auth save/update/login/delete
if data.get("saved").and_then(|v| v.as_bool()).unwrap_or(false) {
let name = data.get("name").and_then(|v| v.as_str()).unwrap_or("");
println!("{} Auth profile '{}' saved", color::success_indicator(), name);
println!(
"{} Auth profile '{}' saved",
color::success_indicator(),
name
);
return;
}
if data.get("updated").and_then(|v| v.as_bool()).unwrap_or(false)
&& !data.get("saved").and_then(|v| v.as_bool()).unwrap_or(false) {
if data
.get("updated")
.and_then(|v| v.as_bool())
.unwrap_or(false)
&& !data.get("saved").and_then(|v| v.as_bool()).unwrap_or(false)
{
let name = data.get("name").and_then(|v| v.as_str()).unwrap_or("");
println!("{} Auth profile '{}' updated", color::success_indicator(), name);
println!(
"{} Auth profile '{}' updated",
color::success_indicator(),
name
);
return;
}
if data.get("loggedIn").and_then(|v| v.as_bool()).unwrap_or(false) {
if data
.get("loggedIn")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
let name = data.get("name").and_then(|v| v.as_str()).unwrap_or("");
if let Some(title) = data.get("title").and_then(|v| v.as_str()) {
println!("{} Logged in as '{}' - {}", color::success_indicator(), name, title);
println!(
"{} Logged in as '{}' - {}",
color::success_indicator(),
name,
title
);
} else {
println!("{} Logged in as '{}'", color::success_indicator(), name);
}
return;
}
if data.get("deleted").and_then(|v| v.as_bool()).unwrap_or(false) {
if data
.get("deleted")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
if let Some(name) = data.get("name").and_then(|v| v.as_str()) {
println!("{} Auth profile '{}' deleted", color::success_indicator(), name);
println!(
"{} Auth profile '{}' deleted",
color::success_indicator(),
name
);
return;
}
}
// Confirmation required (for orchestrator use)
if data.get("confirmation_required").and_then(|v| v.as_bool()).unwrap_or(false) {
if data
.get("confirmation_required")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
let category = data.get("category").and_then(|v| v.as_str()).unwrap_or("");
let description = data.get("description").and_then(|v| v.as_str()).unwrap_or("");
let cid = data.get("confirmation_id").and_then(|v| v.as_str()).unwrap_or("");
let description = data
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("");
let cid = data
.get("confirmation_id")
.and_then(|v| v.as_str())
.unwrap_or("");
println!("Confirmation required:");
println!(" {}: {}", category, description);
println!(" Run: agent-browser confirm {}", cid);
println!(" Or: agent-browser deny {}", cid);
return;
}
if data.get("confirmed").and_then(|v| v.as_bool()).unwrap_or(false) {
if data
.get("confirmed")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
println!("{} Action confirmed", color::success_indicator());
return;
}
if data.get("denied").and_then(|v| v.as_bool()).unwrap_or(false) {
if data
.get("denied")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
println!("{} Action denied", color::success_indicator());
return;
}
@@ -2362,6 +2448,7 @@ Options:
--action-policy <path> Action policy JSON file (or AGENT_BROWSER_ACTION_POLICY)
--confirm-actions <list> Categories requiring confirmation (or AGENT_BROWSER_CONFIRM_ACTIONS)
--confirm-interactive Interactive confirmation prompts; auto-denies if stdin is not a TTY (or AGENT_BROWSER_CONFIRM_INTERACTIVE)
--native [Experimental] Use native Rust daemon instead of Node.js (or AGENT_BROWSER_NATIVE)
--config <path> Use a custom config file (or AGENT_BROWSER_CONFIG env)
--debug Debug output
--version, -V Show version
@@ -2417,6 +2504,7 @@ Environment:
AGENT_BROWSER_ACTION_POLICY Path to action policy JSON file
AGENT_BROWSER_CONFIRM_ACTIONS Action categories requiring confirmation
AGENT_BROWSER_CONFIRM_INTERACTIVE Enable interactive confirmation prompts
AGENT_BROWSER_NATIVE Use native Rust daemon (experimental, no Node.js/Playwright)
Install (recommended, fastest - native Rust CLI):
npm install -g agent-browser
@@ -2494,10 +2582,7 @@ fn print_screenshot_diff(data: &serde_json::Map<String, serde_json::Value>) {
.get("mismatchPercentage")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let is_match = data
.get("match")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let is_match = data.get("match").and_then(|v| v.as_bool()).unwrap_or(false);
let dim_mismatch = data
.get("dimensionMismatch")
.and_then(|v| v.as_bool())
@@ -2508,7 +2593,10 @@ fn print_screenshot_diff(data: &serde_json::Map<String, serde_json::Value>) {
color::error_indicator()
);
} else if is_match {
println!("{} Images match (0% difference)", color::success_indicator());
println!(
"{} Images match (0% difference)",
color::success_indicator()
);
} else {
println!(
"{} {:.2}% pixels differ",
@@ -2519,7 +2607,10 @@ fn print_screenshot_diff(data: &serde_json::Map<String, serde_json::Value>) {
if let Some(diff_path) = data.get("diffPath").and_then(|v| v.as_str()) {
println!(" Diff image: {}", color::green(diff_path));
}
let total = data.get("totalPixels").and_then(|v| v.as_i64()).unwrap_or(0);
let total = data
.get("totalPixels")
.and_then(|v| v.as_i64())
.unwrap_or(0);
let different = data
.get("differentPixels")
.and_then(|v| v.as_i64())
+4 -1
View File
@@ -1,6 +1,9 @@
/// Check if a session name is valid (alphanumeric, hyphens, and underscores only)
pub fn is_valid_session_name(name: &str) -> bool {
!name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_')
!name.is_empty()
&& name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
}
/// Generate error message for invalid session name