diff --git a/cli/src/commands.rs b/cli/src/commands.rs index aaa09e7..cb31b08 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -18,7 +18,10 @@ pub enum ParseError { usage: &'static str, }, /// Argument exists but has an invalid value - InvalidValue { message: String, usage: &'static str }, + InvalidValue { + message: String, + usage: &'static str, + }, } impl ParseError { @@ -81,11 +84,12 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result", })?; let url_lower = url.to_lowercase(); - let url = if url_lower.starts_with("http://") + let url = if url_lower.starts_with("http://") || url_lower.starts_with("https://") - || url_lower.starts_with("about:") - || url_lower.starts_with("data:") - || url_lower.starts_with("file:") { + || url_lower.starts_with("about:") + || url_lower.starts_with("data:") + || url_lower.starts_with("file:") + { url.to_string() } else { format!("https://{}", url) @@ -299,7 +303,10 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result Result Result (None, None), }; - Ok(json!({ "id": id, "action": "screenshot", "path": path, "selector": selector, "fullPage": flags.full })) + Ok( + json!({ "id": id, "action": "screenshot", "path": path, "selector": selector, "fullPage": flags.full }), + ) } "pdf" => { let path = rest.get(0).ok_or_else(|| ParseError::MissingArguments { @@ -542,7 +553,10 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result { if let Some(same_site) = rest.get(i + 1) { // Validate sameSite value - if *same_site == "Strict" || *same_site == "Lax" || *same_site == "None" { + if *same_site == "Strict" + || *same_site == "Lax" + || *same_site == "None" + { cookie["sameSite"] = json!(same_site); i += 2; } else { @@ -583,9 +597,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result Ok(json!({ "id": id, "action": "cookies_clear" })), _ => Ok(json!({ "id": id, "action": "cookies_get" })), @@ -593,28 +605,26 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result { - match rest.get(0).map(|s| *s) { - Some("new") => { - let mut cmd = json!({ "id": id, "action": "tab_new" }); - if let Some(url) = rest.get(1) { - cmd["url"] = json!(url); - } - Ok(cmd) + "tab" => match rest.get(0).map(|s| *s) { + Some("new") => { + let mut cmd = json!({ "id": id, "action": "tab_new" }); + if let Some(url) = rest.get(1) { + cmd["url"] = json!(url); } - Some("list") => Ok(json!({ "id": id, "action": "tab_list" })), - Some("close") => { - let mut cmd = json!({ "id": id, "action": "tab_close" }); - if let Some(index) = rest.get(1).and_then(|s| s.parse::().ok()) { - cmd["index"] = json!(index); - } - Ok(cmd) - } - Some(n) if n.parse::().is_ok() => { - Ok(json!({ "id": id, "action": "tab_switch", "index": n.parse::().unwrap() })) - } - _ => Ok(json!({ "id": id, "action": "tab_list" })), + Ok(cmd) } + Some("list") => Ok(json!({ "id": id, "action": "tab_list" })), + Some("close") => { + let mut cmd = json!({ "id": id, "action": "tab_close" }); + if let Some(index) = rest.get(1).and_then(|s| s.parse::().ok()) { + cmd["index"] = json!(index); + } + Ok(cmd) + } + Some(n) if n.parse::().is_ok() => { + Ok(json!({ "id": id, "action": "tab_switch", "index": n.parse::().unwrap() })) + } + _ => Ok(json!({ "id": id, "action": "tab_list" })), }, // === Window === @@ -679,7 +689,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result", })?; Ok(json!({ "id": id, "action": "trace_stop", "path": path })) - }, + } Some(sub) => Err(ParseError::UnknownSubcommand { subcommand: sub.to_string(), valid_options: VALID, @@ -796,8 +806,10 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result Result { - const VALID: &[&str] = &["text", "html", "value", "attr", "url", "title", "count", "box", "styles"]; - + const VALID: &[&str] = &[ + "text", "html", "value", "attr", "url", "title", "count", "box", "styles", + ]; + match rest.get(0).map(|s| *s) { Some("text") => { let sel = rest.get(1).ok_or_else(|| ParseError::MissingArguments { @@ -952,35 +964,53 @@ fn parse_find(rest: &[&str], id: &str) -> Result { match *locator { "role" => { let mut cmd = json!({ "id": id, "action": "getbyrole", "role": value, "subaction": subaction, "name": name, "exact": exact }); - if let Some(v) = fill_value { cmd["value"] = json!(v); } + if let Some(v) = fill_value { + cmd["value"] = json!(v); + } Ok(cmd) } - "text" => Ok(json!({ "id": id, "action": "getbytext", "text": value, "subaction": subaction, "exact": exact })), + "text" => Ok( + json!({ "id": id, "action": "getbytext", "text": value, "subaction": subaction, "exact": exact }), + ), "label" => { let mut cmd = json!({ "id": id, "action": "getbylabel", "label": value, "subaction": subaction, "exact": exact }); - if let Some(v) = fill_value { cmd["value"] = json!(v); } + if let Some(v) = fill_value { + cmd["value"] = json!(v); + } Ok(cmd) } "placeholder" => { let mut cmd = json!({ "id": id, "action": "getbyplaceholder", "placeholder": value, "subaction": subaction, "exact": exact }); - if let Some(v) = fill_value { cmd["value"] = json!(v); } + if let Some(v) = fill_value { + cmd["value"] = json!(v); + } Ok(cmd) } - "alt" => Ok(json!({ "id": id, "action": "getbyalttext", "text": value, "subaction": subaction, "exact": exact })), - "title" => Ok(json!({ "id": id, "action": "getbytitle", "text": value, "subaction": subaction, "exact": exact })), + "alt" => Ok( + json!({ "id": id, "action": "getbyalttext", "text": value, "subaction": subaction, "exact": exact }), + ), + "title" => Ok( + json!({ "id": id, "action": "getbytitle", "text": value, "subaction": subaction, "exact": exact }), + ), "testid" => { let mut cmd = json!({ "id": id, "action": "getbytestid", "testId": value, "subaction": subaction }); - if let Some(v) = fill_value { cmd["value"] = json!(v); } + if let Some(v) = fill_value { + cmd["value"] = json!(v); + } Ok(cmd) } "first" => { let mut cmd = json!({ "id": id, "action": "nth", "selector": value, "index": 0, "subaction": subaction }); - if let Some(v) = fill_value { cmd["value"] = json!(v); } + if let Some(v) = fill_value { + cmd["value"] = json!(v); + } Ok(cmd) } "last" => { let mut cmd = json!({ "id": id, "action": "nth", "selector": value, "index": -1, "subaction": subaction }); - if let Some(v) = fill_value { cmd["value"] = json!(v); } + if let Some(v) = fill_value { + cmd["value"] = json!(v); + } Ok(cmd) } _ => unreachable!(), @@ -1008,7 +1038,9 @@ fn parse_find(rest: &[&str], id: &str) -> Result { None }; let mut cmd = json!({ "id": id, "action": "nth", "selector": sel, "index": idx, "subaction": sub }); - if let Some(v) = fv { cmd["value"] = json!(v); } + if let Some(v) = fv { + cmd["value"] = json!(v); + } Ok(cmd) } _ => Err(ParseError::UnknownSubcommand { @@ -1181,7 +1213,9 @@ fn parse_set(rest: &[&str], id: &str) -> Result { } else { "no-preference" }; - Ok(json!({ "id": id, "action": "emulatemedia", "colorScheme": color, "reducedMotion": reduced })) + Ok( + json!({ "id": id, "action": "emulatemedia", "colorScheme": color, "reducedMotion": reduced }), + ) } Some(sub) => Err(ParseError::UnknownSubcommand { subcommand: sub.to_string(), @@ -1214,7 +1248,7 @@ fn parse_network(rest: &[&str], id: &str) -> Result { cmd["url"] = json!(url); } Ok(cmd) - }, + } Some("requests") => { let clear = rest.iter().any(|&s| s == "--clear"); let filter_idx = rest.iter().position(|&s| s == "--filter"); @@ -1299,6 +1333,7 @@ mod tests { extensions: Vec::new(), cdp: None, profile: None, + state: None, proxy: None, proxy_bypass: None, args: None, @@ -1348,7 +1383,11 @@ mod tests { #[test] fn test_cookies_set_with_url() { - let cmd = parse_command(&args("cookies set mycookie myvalue --url https://example.com"), &default_flags()).unwrap(); + let cmd = parse_command( + &args("cookies set mycookie myvalue --url https://example.com"), + &default_flags(), + ) + .unwrap(); assert_eq!(cmd["action"], "cookies_set"); assert_eq!(cmd["cookies"][0]["name"], "mycookie"); assert_eq!(cmd["cookies"][0]["value"], "myvalue"); @@ -1357,7 +1396,11 @@ mod tests { #[test] fn test_cookies_set_with_domain() { - let cmd = parse_command(&args("cookies set mycookie myvalue --domain example.com"), &default_flags()).unwrap(); + let cmd = parse_command( + &args("cookies set mycookie myvalue --domain example.com"), + &default_flags(), + ) + .unwrap(); assert_eq!(cmd["action"], "cookies_set"); assert_eq!(cmd["cookies"][0]["name"], "mycookie"); assert_eq!(cmd["cookies"][0]["value"], "myvalue"); @@ -1366,7 +1409,11 @@ mod tests { #[test] fn test_cookies_set_with_path() { - let cmd = parse_command(&args("cookies set mycookie myvalue --path /api"), &default_flags()).unwrap(); + let cmd = parse_command( + &args("cookies set mycookie myvalue --path /api"), + &default_flags(), + ) + .unwrap(); assert_eq!(cmd["action"], "cookies_set"); assert_eq!(cmd["cookies"][0]["name"], "mycookie"); assert_eq!(cmd["cookies"][0]["value"], "myvalue"); @@ -1375,7 +1422,11 @@ mod tests { #[test] fn test_cookies_set_with_httponly() { - let cmd = parse_command(&args("cookies set mycookie myvalue --httpOnly"), &default_flags()).unwrap(); + let cmd = parse_command( + &args("cookies set mycookie myvalue --httpOnly"), + &default_flags(), + ) + .unwrap(); assert_eq!(cmd["action"], "cookies_set"); assert_eq!(cmd["cookies"][0]["name"], "mycookie"); assert_eq!(cmd["cookies"][0]["value"], "myvalue"); @@ -1384,7 +1435,11 @@ mod tests { #[test] fn test_cookies_set_with_secure() { - let cmd = parse_command(&args("cookies set mycookie myvalue --secure"), &default_flags()).unwrap(); + let cmd = parse_command( + &args("cookies set mycookie myvalue --secure"), + &default_flags(), + ) + .unwrap(); assert_eq!(cmd["action"], "cookies_set"); assert_eq!(cmd["cookies"][0]["name"], "mycookie"); assert_eq!(cmd["cookies"][0]["value"], "myvalue"); @@ -1393,7 +1448,11 @@ mod tests { #[test] fn test_cookies_set_with_samesite() { - let cmd = parse_command(&args("cookies set mycookie myvalue --sameSite Strict"), &default_flags()).unwrap(); + let cmd = parse_command( + &args("cookies set mycookie myvalue --sameSite Strict"), + &default_flags(), + ) + .unwrap(); assert_eq!(cmd["action"], "cookies_set"); assert_eq!(cmd["cookies"][0]["name"], "mycookie"); assert_eq!(cmd["cookies"][0]["value"], "myvalue"); @@ -1402,7 +1461,11 @@ mod tests { #[test] fn test_cookies_set_with_expires() { - let cmd = parse_command(&args("cookies set mycookie myvalue --expires 1234567890"), &default_flags()).unwrap(); + let cmd = parse_command( + &args("cookies set mycookie myvalue --expires 1234567890"), + &default_flags(), + ) + .unwrap(); assert_eq!(cmd["action"], "cookies_set"); assert_eq!(cmd["cookies"][0]["name"], "mycookie"); assert_eq!(cmd["cookies"][0]["value"], "myvalue"); @@ -1438,7 +1501,10 @@ mod tests { #[test] fn test_cookies_set_invalid_samesite() { - let result = parse_command(&args("cookies set mycookie myvalue --sameSite Invalid"), &default_flags()); + let result = parse_command( + &args("cookies set mycookie myvalue --sameSite Invalid"), + &default_flags(), + ); assert!(result.is_err()); } @@ -1658,11 +1724,7 @@ mod tests { #[test] fn test_select_multiple_values() { - let cmd = parse_command( - &args("select #menu opt1 opt2 opt3"), - &default_flags(), - ) - .unwrap(); + let cmd = parse_command(&args("select #menu opt1 opt2 opt3"), &default_flags()).unwrap(); assert_eq!(cmd["action"], "select"); assert_eq!(cmd["selector"], "#menu"); assert_eq!(cmd["values"], json!(["opt1", "opt2", "opt3"])); @@ -1680,7 +1742,10 @@ mod tests { fn test_tab_new() { let cmd = parse_command(&args("tab new"), &default_flags()).unwrap(); assert_eq!(cmd["action"], "tab_new"); - assert!(cmd.get("url").is_none(), "url should not be present when not provided"); + assert!( + cmd.get("url").is_none(), + "url should not be present when not provided" + ); } #[test] @@ -1872,7 +1937,11 @@ mod tests { #[test] fn test_record_start_with_url() { - let cmd = parse_command(&args("record start demo.webm https://example.com"), &default_flags()).unwrap(); + let cmd = parse_command( + &args("record start demo.webm https://example.com"), + &default_flags(), + ) + .unwrap(); assert_eq!(cmd["action"], "recording_start"); assert_eq!(cmd["path"], "demo.webm"); assert_eq!(cmd["url"], "https://example.com"); @@ -1880,7 +1949,11 @@ mod tests { #[test] fn test_record_start_with_url_no_protocol() { - let cmd = parse_command(&args("record start demo.webm example.com"), &default_flags()).unwrap(); + let cmd = parse_command( + &args("record start demo.webm example.com"), + &default_flags(), + ) + .unwrap(); assert_eq!(cmd["action"], "recording_start"); assert_eq!(cmd["path"], "demo.webm"); assert_eq!(cmd["url"], "https://example.com"); @@ -1890,7 +1963,10 @@ mod tests { fn test_record_start_missing_path() { let result = parse_command(&args("record start"), &default_flags()); assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), ParseError::MissingArguments { .. })); + assert!(matches!( + result.unwrap_err(), + ParseError::MissingArguments { .. } + )); } #[test] @@ -1909,7 +1985,11 @@ mod tests { #[test] fn test_record_restart_with_url() { - let cmd = parse_command(&args("record restart demo.webm https://example.com"), &default_flags()).unwrap(); + let cmd = parse_command( + &args("record restart demo.webm https://example.com"), + &default_flags(), + ) + .unwrap(); assert_eq!(cmd["action"], "recording_restart"); assert_eq!(cmd["path"], "demo.webm"); assert_eq!(cmd["url"], "https://example.com"); @@ -1919,21 +1999,30 @@ mod tests { fn test_record_restart_missing_path() { let result = parse_command(&args("record restart"), &default_flags()); assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), ParseError::MissingArguments { .. })); + assert!(matches!( + result.unwrap_err(), + ParseError::MissingArguments { .. } + )); } #[test] fn test_record_invalid_subcommand() { let result = parse_command(&args("record foo"), &default_flags()); assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), ParseError::UnknownSubcommand { .. })); + assert!(matches!( + result.unwrap_err(), + ParseError::UnknownSubcommand { .. } + )); } #[test] fn test_record_missing_subcommand() { let result = parse_command(&args("record"), &default_flags()); assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), ParseError::MissingArguments { .. })); + assert!(matches!( + result.unwrap_err(), + ParseError::MissingArguments { .. } + )); } #[test] @@ -2058,14 +2147,20 @@ mod tests { fn test_download_missing_path() { let result = parse_command(&args("download #btn"), &default_flags()); assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), ParseError::MissingArguments { .. })); + assert!(matches!( + result.unwrap_err(), + ParseError::MissingArguments { .. } + )); } #[test] fn test_download_missing_selector() { let result = parse_command(&args("download"), &default_flags()); assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), ParseError::MissingArguments { .. })); + assert!(matches!( + result.unwrap_err(), + ParseError::MissingArguments { .. } + )); } // === Wait for Download Tests === @@ -2086,14 +2181,19 @@ mod tests { #[test] fn test_wait_download_with_timeout() { - let cmd = parse_command(&args("wait --download --timeout 30000"), &default_flags()).unwrap(); + let cmd = + parse_command(&args("wait --download --timeout 30000"), &default_flags()).unwrap(); assert_eq!(cmd["action"], "waitfordownload"); assert_eq!(cmd["timeout"], 30000); } #[test] fn test_wait_download_with_path_and_timeout() { - let cmd = parse_command(&args("wait --download ./file.pdf --timeout 30000"), &default_flags()).unwrap(); + let cmd = parse_command( + &args("wait --download ./file.pdf --timeout 30000"), + &default_flags(), + ) + .unwrap(); assert_eq!(cmd["action"], "waitfordownload"); assert_eq!(cmd["path"], "./file.pdf"); assert_eq!(cmd["timeout"], 30000); @@ -2136,16 +2236,16 @@ mod tests { ]; let cmd = parse_command(&input, &default_flags()).unwrap(); assert_eq!(cmd["action"], "launch"); - assert_eq!(cmd["cdpUrl"], "wss://remote-browser.example.com/cdp?token=xyz"); + assert_eq!( + cmd["cdpUrl"], + "wss://remote-browser.example.com/cdp?token=xyz" + ); assert!(cmd.get("cdpPort").is_none()); } #[test] fn test_connect_with_http_url() { - let input: Vec = vec![ - "connect".to_string(), - "http://localhost:9222".to_string(), - ]; + let input: Vec = vec!["connect".to_string(), "http://localhost:9222".to_string()]; let cmd = parse_command(&input, &default_flags()).unwrap(); assert_eq!(cmd["action"], "launch"); assert_eq!(cmd["cdpUrl"], "http://localhost:9222"); @@ -2156,7 +2256,10 @@ mod tests { fn test_connect_missing_argument() { let result = parse_command(&args("connect"), &default_flags()); assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), ParseError::MissingArguments { .. })); + assert!(matches!( + result.unwrap_err(), + ParseError::MissingArguments { .. } + )); } #[test] diff --git a/cli/src/connection.rs b/cli/src/connection.rs index 2625a5a..ed8fa88 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -195,6 +195,8 @@ pub fn ensure_daemon( proxy: Option<&str>, proxy_bypass: Option<&str>, ignore_https_errors: bool, + profile: Option<&str>, + state: Option<&str>, ) -> Result { if is_daemon_running(session) && daemon_ready(session) { return Ok(DaemonResult { @@ -205,7 +207,8 @@ pub fn ensure_daemon( // Ensure socket directory exists let socket_dir = get_socket_dir(); if !socket_dir.exists() { - fs::create_dir_all(&socket_dir).map_err(|e| format!("Failed to create socket directory: {}", e))?; + fs::create_dir_all(&socket_dir) + .map_err(|e| format!("Failed to create socket directory: {}", e))?; } let exe_path = env::current_exe().map_err(|e| e.to_string())?; @@ -271,6 +274,14 @@ pub fn ensure_daemon( cmd.env("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "1"); } + if let Some(prof) = profile { + cmd.env("AGENT_BROWSER_PROFILE", prof); + } + + if let Some(st) = state { + cmd.env("AGENT_BROWSER_STATE", st); + } + // Create new process group and session to fully detach unsafe { cmd.pre_exec(|| { @@ -290,7 +301,7 @@ pub fn ensure_daemon( #[cfg(windows)] { use std::os::windows::process::CommandExt; - + // On Windows, call node directly. Command::new handles PATH resolution (node.exe or node.cmd) // and automatically quotes arguments containing spaces. let mut cmd = Command::new("node"); @@ -326,10 +337,18 @@ pub fn ensure_daemon( cmd.env("AGENT_BROWSER_PROXY_BYPASS", pb); } - if ignore_https_errors { + if ignore_https_errors { cmd.env("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "1"); } + if let Some(prof) = profile { + cmd.env("AGENT_BROWSER_PROFILE", prof); + } + + if let Some(st) = state { + cmd.env("AGENT_BROWSER_STATE", st); + } + // CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; const DETACHED_PROCESS: u32 = 0x00000008; @@ -446,7 +465,9 @@ mod tests { env::set_var("AGENT_BROWSER_SOCKET_DIR", ""); env::remove_var("XDG_RUNTIME_DIR"); - assert!(get_socket_dir().to_string_lossy().ends_with(".agent-browser")); + assert!(get_socket_dir() + .to_string_lossy() + .ends_with(".agent-browser")); } #[test] @@ -456,7 +477,10 @@ mod tests { env::remove_var("AGENT_BROWSER_SOCKET_DIR"); env::set_var("XDG_RUNTIME_DIR", "/run/user/1000"); - assert_eq!(get_socket_dir(), PathBuf::from("/run/user/1000/agent-browser")); + assert_eq!( + get_socket_dir(), + PathBuf::from("/run/user/1000/agent-browser") + ); } #[test] @@ -466,7 +490,9 @@ mod tests { env::set_var("AGENT_BROWSER_SOCKET_DIR", ""); env::set_var("XDG_RUNTIME_DIR", ""); - assert!(get_socket_dir().to_string_lossy().ends_with(".agent-browser")); + assert!(get_socket_dir() + .to_string_lossy() + .ends_with(".agent-browser")); } #[test] @@ -478,6 +504,8 @@ mod tests { let result = get_socket_dir(); assert!(result.to_string_lossy().ends_with(".agent-browser")); - assert!(result.to_string_lossy().contains("home") || result.to_string_lossy().contains("Users")); + assert!( + result.to_string_lossy().contains("home") || result.to_string_lossy().contains("Users") + ); } } diff --git a/cli/src/flags.rs b/cli/src/flags.rs index 63629f6..2abd1bc 100644 --- a/cli/src/flags.rs +++ b/cli/src/flags.rs @@ -11,6 +11,7 @@ pub struct Flags { pub cdp: Option, pub extensions: Vec, pub profile: Option, + pub state: Option, pub proxy: Option, pub proxy_bypass: Option, pub args: Option, @@ -22,7 +23,12 @@ pub struct Flags { pub fn parse_flags(args: &[String]) -> Flags { let extensions_env = env::var("AGENT_BROWSER_EXTENSIONS") .ok() - .map(|s| s.split(',').map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect::>()) + .map(|s| { + s.split(',') + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()) + .collect::>() + }) .unwrap_or_default(); let mut flags = Flags { @@ -36,6 +42,7 @@ pub fn parse_flags(args: &[String]) -> Flags { cdp: None, extensions: extensions_env, profile: env::var("AGENT_BROWSER_PROFILE").ok(), + state: env::var("AGENT_BROWSER_STATE").ok(), proxy: env::var("AGENT_BROWSER_PROXY").ok(), proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS").ok(), args: env::var("AGENT_BROWSER_ARGS").ok(), @@ -68,13 +75,13 @@ pub fn parse_flags(args: &[String]) -> Flags { flags.executable_path = Some(s.clone()); i += 1; } - }, + } "--extension" => { if let Some(s) = args.get(i + 1) { flags.extensions.push(s.clone()); i += 1; } - }, + } "--cdp" => { if let Some(s) = args.get(i + 1) { flags.cdp = Some(s.clone()); @@ -87,6 +94,12 @@ pub fn parse_flags(args: &[String]) -> Flags { i += 1; } } + "--state" => { + if let Some(s) = args.get(i + 1) { + flags.state = Some(s.clone()); + i += 1; + } + } "--proxy" => { if let Some(p) = args.get(i + 1) { flags.proxy = Some(p.clone()); @@ -130,7 +143,13 @@ pub fn clean_args(args: &[String]) -> Vec { let mut skip_next = false; // Global flags that should be stripped from command args - const GLOBAL_FLAGS: &[&str] = &["--json", "--full", "--headed", "--debug", "--ignore-https-errors"]; + const GLOBAL_FLAGS: &[&str] = &[ + "--json", + "--full", + "--headed", + "--debug", + "--ignore-https-errors", + ]; // Global flags that take a value (need to skip the next arg too) const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &[ "--session", @@ -139,6 +158,7 @@ pub fn clean_args(args: &[String]) -> Vec { "--cdp", "--extension", "--profile", + "--state", "--proxy", "--proxy-bypass", "--args", diff --git a/cli/src/install.rs b/cli/src/install.rs index 334cea3..a11ddbd 100644 --- a/cli/src/install.rs +++ b/cli/src/install.rs @@ -100,7 +100,10 @@ pub fn run_install(with_deps: bool) { ], ) } else { - eprintln!("{} No supported package manager found (apt-get, dnf, or yum)", color::error_indicator()); + eprintln!( + "{} No supported package manager found (apt-get, dnf, or yum)", + color::error_indicator() + ); exit(1); }; @@ -128,7 +131,10 @@ pub fn run_install(with_deps: bool) { Err(e) => eprintln!("{} Could not run install command: {}", color::warning_indicator(), e), } } else { - println!("{} Linux detected. If browser fails to launch, run:", color::warning_indicator()); + println!( + "{} Linux detected. If browser fails to launch, run:", + color::warning_indicator() + ); println!(" agent-browser install --with-deps"); println!(" or: npx playwright install-deps chromium"); println!(); @@ -136,7 +142,7 @@ pub fn run_install(with_deps: bool) { } println!("{}", color::cyan("Installing Chromium browser...")); - + // On Windows, we need to use cmd.exe to run npx because npx is actually npx.cmd // and Command::new() doesn't resolve .cmd files the way the shell does. // Pass the entire command as a single string to /c to handle paths with spaces. @@ -144,7 +150,7 @@ pub fn run_install(with_deps: bool) { let status = Command::new("cmd") .args(["/c", "npx playwright install chromium"]) .status(); - + #[cfg(not(windows))] let status = Command::new("npx") .args(["playwright", "install", "chromium"]) @@ -152,17 +158,26 @@ pub fn run_install(with_deps: bool) { match status { Ok(s) if s.success() => { - println!("{} Chromium installed successfully", color::success_indicator()); + println!( + "{} Chromium installed successfully", + color::success_indicator() + ); if is_linux && !with_deps { println!(); - println!("{} If you see \"shared library\" errors when running, use:", color::yellow("Note:")); + println!( + "{} If you see \"shared library\" errors when running, use:", + color::yellow("Note:") + ); println!(" agent-browser install --with-deps"); } } Ok(_) => { eprintln!("{} Failed to install browser", color::error_indicator()); if is_linux { - println!("{} Try installing system dependencies first:", color::yellow("Tip:")); + println!( + "{} Try installing system dependencies first:", + color::yellow("Tip:") + ); println!(" agent-browser install --with-deps"); } exit(1); diff --git a/cli/src/main.rs b/cli/src/main.rs index cd8b9f5..dcef175 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -106,7 +106,11 @@ fn run_session(args: &[String], session: &str, json_mode: bool) { } else { println!("Active sessions:"); for s in &sessions { - let marker = if s == session { color::cyan("→") } else { " ".to_string() }; + let marker = if s == session { + color::cyan("→") + } else { + " ".to_string() + }; println!("{} {}", marker, s); } } @@ -201,6 +205,8 @@ fn main() { flags.proxy.as_deref(), flags.proxy_bypass.as_deref(), flags.ignore_https_errors, + flags.profile.as_deref(), + flags.state.as_deref(), ) { Ok(result) => result, Err(e) => { @@ -218,8 +224,13 @@ fn main() { let has_extensions = !flags.extensions.is_empty(); let ignored_flags: Vec<&str> = [ flags.executable_path.as_ref().map(|_| "--executable-path"), - if has_extensions { Some("--extension") } else { None }, + if has_extensions { + Some("--extension") + } else { + None + }, flags.profile.as_ref().map(|_| "--profile"), + flags.state.as_ref().map(|_| "--state"), flags.args.as_ref().map(|_| "--args"), flags.user_agent.as_ref().map(|_| "--user-agent"), flags.proxy.as_ref().map(|_| "--proxy"), @@ -356,7 +367,10 @@ fn main() { let err = match send_command(launch_cmd, &flags.session) { Ok(resp) if resp.success => None, - Ok(resp) => Some(resp.error.unwrap_or_else(|| "Provider connection failed".to_string())), + Ok(resp) => Some( + resp.error + .unwrap_or_else(|| "Provider connection failed".to_string()), + ), Err(e) => Some(e.to_string()), }; @@ -371,14 +385,23 @@ fn main() { } // Launch headed browser or configure browser options (without CDP or provider) - if (flags.headed || flags.profile.is_some() || flags.proxy.is_some() || flags.args.is_some() || flags.user_agent.is_some()) && flags.cdp.is_none() && flags.provider.is_none() { + if (flags.headed + || flags.profile.is_some() + || flags.state.is_some() + || flags.proxy.is_some() + || flags.args.is_some() + || flags.user_agent.is_some()) + && flags.cdp.is_none() + && flags.provider.is_none() + { let mut launch_cmd = json!({ "id": gen_id(), "action": "launch", "headless": !flags.headed }); - let cmd_obj = launch_cmd.as_object_mut() + let cmd_obj = launch_cmd + .as_object_mut() .expect("json! macro guarantees object type"); // Add profile path if specified @@ -386,6 +409,11 @@ fn main() { cmd_obj.insert("profile".to_string(), json!(profile_path)); } + // Add state path if specified + if let Some(ref state_path) = flags.state { + cmd_obj.insert("storageState".to_string(), json!(state_path)); + } + if let Some(ref proxy_str) = flags.proxy { let mut proxy_obj = parse_proxy(proxy_str); // Add bypass if specified @@ -417,7 +445,11 @@ fn main() { if let Err(e) = send_command(launch_cmd, &flags.session) { if !flags.json { - eprintln!("{} Could not configure browser: {}", color::warning_indicator(), e); + eprintln!( + "{} Could not configure browser: {}", + color::warning_indicator(), + e + ); } } } @@ -426,9 +458,7 @@ fn main() { Ok(resp) => { let success = resp.success; // Extract action for context-specific output handling - let action = cmd - .get("action") - .and_then(|v| v.as_str()); + let action = cmd.get("action").and_then(|v| v.as_str()); print_response(&resp, flags.json, action); if !success { exit(1); diff --git a/cli/src/output.rs b/cli/src/output.rs index f8fab1e..dc659f8 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -87,7 +87,11 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) { .unwrap_or("Untitled"); let url = tab.get("url").and_then(|v| v.as_str()).unwrap_or(""); let active = tab.get("active").and_then(|v| v.as_bool()).unwrap_or(false); - let marker = if active { color::cyan("→") } else { " ".to_string() }; + let marker = if active { + color::cyan("→") + } else { + " ".to_string() + }; println!("{} [{}] {} - {}", marker, i, title, url); } return; @@ -126,7 +130,10 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) { for req in requests { let method = req.get("method").and_then(|v| v.as_str()).unwrap_or("GET"); let url = req.get("url").and_then(|v| v.as_str()).unwrap_or(""); - let resource_type = req.get("resourceType").and_then(|v| v.as_str()).unwrap_or(""); + let resource_type = req + .get("resourceType") + .and_then(|v| v.as_str()) + .unwrap_or(""); println!("{} {} ({})", method, url, resource_type); } } @@ -153,7 +160,7 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) { let tag = el.get("tag").and_then(|v| v.as_str()).unwrap_or("?"); let text = el.get("text").and_then(|v| v.as_str()).unwrap_or(""); println!("[{}] {} \"{}\"", i, tag, text); - + if let Some(box_data) = el.get("box") { let w = box_data.get("width").and_then(|v| v.as_i64()).unwrap_or(0); let h = box_data.get("height").and_then(|v| v.as_i64()).unwrap_or(0); @@ -161,15 +168,30 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) { let y = box_data.get("y").and_then(|v| v.as_i64()).unwrap_or(0); println!(" box: {}x{} at ({}, {})", w, h, x, y); } - + if let Some(styles) = el.get("styles") { - let font_size = styles.get("fontSize").and_then(|v| v.as_str()).unwrap_or(""); - let font_weight = styles.get("fontWeight").and_then(|v| v.as_str()).unwrap_or(""); - let font_family = styles.get("fontFamily").and_then(|v| v.as_str()).unwrap_or(""); + let font_size = styles + .get("fontSize") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let font_weight = styles + .get("fontWeight") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let font_family = styles + .get("fontFamily") + .and_then(|v| v.as_str()) + .unwrap_or(""); let color = styles.get("color").and_then(|v| v.as_str()).unwrap_or(""); - let bg = styles.get("backgroundColor").and_then(|v| v.as_str()).unwrap_or(""); - let radius = styles.get("borderRadius").and_then(|v| v.as_str()).unwrap_or(""); - + let bg = styles + .get("backgroundColor") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let radius = styles + .get("borderRadius") + .and_then(|v| v.as_str()) + .unwrap_or(""); + println!(" font: {} {} {}", font_size, font_weight, font_family); println!(" color: {}", color); println!(" background: {}", bg); @@ -199,9 +221,17 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) { } // Recording restart (has "stopped" field - from recording_restart action) if data.get("stopped").is_some() { - let path = data.get("path").and_then(|v| v.as_str()).unwrap_or("unknown"); + let path = data + .get("path") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); if let Some(prev_path) = data.get("previousPath").and_then(|v| v.as_str()) { - println!("{} Recording restarted: {} (previous saved to {})", color::success_indicator(), path, prev_path); + println!( + "{} Recording restarted: {} (previous saved to {})", + color::success_indicator(), + path, + prev_path + ); } else { println!("{} Recording started: {}", color::success_indicator(), path); } @@ -211,7 +241,12 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) { if data.get("frames").is_some() { if let Some(path) = data.get("path").and_then(|v| v.as_str()) { if let Some(error) = data.get("error").and_then(|v| v.as_str()) { - println!("{} Recording saved to {} - {}", color::warning_indicator(), path, error); + println!( + "{} Recording saved to {} - {}", + color::warning_indicator(), + path, + error + ); } else { println!("{} Recording saved to {}", color::success_indicator(), path); } @@ -223,14 +258,24 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) { // Download response (has "suggestedFilename" or "filename" field) if data.get("suggestedFilename").is_some() || data.get("filename").is_some() { if let Some(path) = data.get("path").and_then(|v| v.as_str()) { - let filename = data.get("suggestedFilename") + let filename = data + .get("suggestedFilename") .or_else(|| data.get("filename")) .and_then(|v| v.as_str()) .unwrap_or(""); if filename.is_empty() { - println!("{} Downloaded to {}", color::success_indicator(), color::green(path)); + println!( + "{} Downloaded to {}", + color::success_indicator(), + color::green(path) + ); } else { - println!("{} Downloaded to {} ({})", color::success_indicator(), color::green(path), filename); + println!( + "{} Downloaded to {} ({})", + color::success_indicator(), + color::green(path), + filename + ); } return; } @@ -243,18 +288,50 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) { // Path-based operations (screenshot/pdf/trace/har/download/state/video) if let Some(path) = data.get("path").and_then(|v| v.as_str()) { match action.unwrap_or("") { - "screenshot" => println!("{} Screenshot saved to {}", color::success_indicator(), color::green(path)), - "pdf" => println!("{} PDF saved to {}", color::success_indicator(), color::green(path)), - "trace_stop" => println!("{} Trace saved to {}", color::success_indicator(), color::green(path)), - "har_stop" => println!("{} HAR saved to {}", color::success_indicator(), color::green(path)), - "download" | "waitfordownload" => println!("{} Download saved to {}", color::success_indicator(), color::green(path)), - "video_stop" => println!("{} Video saved to {}", color::success_indicator(), color::green(path)), - "state_save" => println!("{} State saved to {}", color::success_indicator(), color::green(path)), + "screenshot" => println!( + "{} Screenshot saved to {}", + color::success_indicator(), + color::green(path) + ), + "pdf" => println!( + "{} PDF saved to {}", + color::success_indicator(), + color::green(path) + ), + "trace_stop" => println!( + "{} Trace saved to {}", + color::success_indicator(), + color::green(path) + ), + "har_stop" => println!( + "{} HAR saved to {}", + color::success_indicator(), + color::green(path) + ), + "download" | "waitfordownload" => println!( + "{} Download saved to {}", + color::success_indicator(), + color::green(path) + ), + "video_stop" => println!( + "{} Video saved to {}", + color::success_indicator(), + color::green(path) + ), + "state_save" => println!( + "{} State saved to {}", + color::success_indicator(), + color::green(path) + ), "state_load" => { if let Some(note) = data.get("note").and_then(|v| v.as_str()) { println!("{}", note); } - println!("{} State path set to {}", color::success_indicator(), color::green(path)); + println!( + "{} State path set to {}", + color::success_indicator(), + color::green(path) + ); } // video_start and other commands that provide a path with a note "video_start" => { @@ -263,7 +340,11 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) { } println!("Path: {}", path); } - _ => println!("{} Saved to {}", color::success_indicator(), color::green(path)), + _ => println!( + "{} Saved to {}", + color::success_indicator(), + color::green(path) + ), } return; } @@ -1251,7 +1332,8 @@ Examples: } // === Record (video) === - "record" => r##" + "record" => { + r##" agent-browser record - Record browser session to video Usage: agent-browser record start [url] @@ -1284,7 +1366,8 @@ Examples: # Restart recording with a new file (stops previous, starts new) agent-browser record restart ./take2.webm -"##, +"## + } // === Console/Errors === "console" => { @@ -1358,7 +1441,13 @@ Save or restore browser state (cookies, localStorage, sessionStorage). Operations: save Save current state to file - load Load state from file + load Note: State must be loaded at browser launch via --state flag + +Applying State: + Use --state flag when launching browser to load saved state: + agent-browser --state ./auth-state.json open https://example.com + + Or set AGENT_BROWSER_STATE environment variable. Global Options: --json Output as JSON @@ -1366,7 +1455,7 @@ Global Options: Examples: agent-browser state save ./auth-state.json - agent-browser state load ./auth-state.json + agent-browser --state ./auth-state.json open https://example.com "## } @@ -1553,6 +1642,7 @@ Snapshot Options: Options: --session Isolated session (or AGENT_BROWSER_SESSION env) --profile Persistent browser profile (or AGENT_BROWSER_PROFILE env) + --state Load storage state from JSON file (or AGENT_BROWSER_STATE env) --headers HTTP headers scoped to URL's origin (for auth) --executable-path Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH) --extension Load browser extensions (repeatable) diff --git a/src/browser.ts b/src/browser.ts index 415da78..04089b2 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -819,6 +819,7 @@ export class BrowserManager { const cdpEndpoint = options.cdpUrl ?? (options.cdpPort ? String(options.cdpPort) : undefined); const hasExtensions = !!options.extensions?.length; const hasProfile = !!options.profile; + const hasStorageState = !!options.storageState; if (hasExtensions && cdpEndpoint) { throw new Error('Extensions cannot be used with CDP connection'); @@ -828,6 +829,18 @@ export class BrowserManager { throw new Error('Profile cannot be used with CDP connection'); } + if (hasStorageState && hasProfile) { + throw new Error( + 'Storage state cannot be used with profile (profile is already persistent storage)' + ); + } + + if (hasStorageState && hasExtensions) { + throw new Error( + 'Storage state cannot be used with extensions (extensions require persistent context)' + ); + } + if (this.isLaunched()) { const needsRelaunch = (!cdpEndpoint && this.cdpEndpoint !== null) || @@ -912,6 +925,7 @@ export class BrowserManager { userAgent: options.userAgent, ...(options.proxy && { proxy: options.proxy }), ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false, + ...(options.storageState && { storageState: options.storageState }), }); } diff --git a/src/daemon.ts b/src/daemon.ts index fc9dc1c..7447261 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -255,6 +255,8 @@ export async function startDaemon(options?: { streamPort?: number }): Promise