From 12dcc696d7328a703ed7f142dbab85e493f7a18e Mon Sep 17 00:00:00 2001 From: Aero Date: Mon, 25 May 2026 01:50:20 +0800 Subject: [PATCH 01/23] feat: add extra-headers, geolocation emulation, and unknown arg validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `--extra-headers` flag to `navigate` command for setting custom HTTP headers (e.g. Authorization) via Network.setExtraHTTPHeaders - Add `set-geolocation` command for geolocation emulation with --latitude, --longitude, --accuracy, and --clear flags using Emulation.setGeolocationOverride - Add unknown argument validation in daemon executor — returns structured error with expected argument list when unexpected fields are passed in a request - Update SKILL.md with examples for both new features --- skill/chrome-devtools/SKILL.md | 4 ++ src/commands/emulation.rs | 45 +++++++++++++++++++++++ src/commands/executor.rs | 67 ++++++++++++++++++++++++++++++++++ src/commands/mod.rs | 1 + src/commands/navigate.rs | 17 +++++++++ src/main.rs | 50 ++++++++++++++++++++++++- 6 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 src/commands/emulation.rs diff --git a/skill/chrome-devtools/SKILL.md b/skill/chrome-devtools/SKILL.md index f36c0a2..bead71f 100644 --- a/skill/chrome-devtools/SKILL.md +++ b/skill/chrome-devtools/SKILL.md @@ -44,6 +44,7 @@ chrome-devtools navigate -o /tmp/res.txt # Save success/result to file chrome-devtools navigate --back chrome-devtools navigate --forward chrome-devtools navigate --reload +chrome-devtools navigate --extra-headers '{"Authorization":"Bearer token"}' # Set custom HTTP headers chrome-devtools new-page # Open new tab chrome-devtools close-page chrome-devtools select-page @@ -83,6 +84,9 @@ chrome-devtools execute-3p-tool '' # Execute a custom tool ```bash chrome-devtools --target wait-for "Success" --timeout 10000 chrome-devtools --target resize 1280 720 +chrome-devtools --target set-geolocation --latitude 37.7749 --longitude -122.4194 +chrome-devtools --target set-geolocation --latitude 37.7749 --longitude -122.4194 --accuracy 10 +chrome-devtools --target set-geolocation --clear ``` ## Global flags & Environment Variables diff --git a/src/commands/emulation.rs b/src/commands/emulation.rs new file mode 100644 index 0000000..404d273 --- /dev/null +++ b/src/commands/emulation.rs @@ -0,0 +1,45 @@ +use anyhow::Result; +use serde_json::json; + +use crate::cdp::CdpClient; +use crate::result::CommandResult; + +/// Set geolocation override for the current page. +/// +/// Uses `Emulation.setGeolocationOverride` CDP method. +/// Pass `--clear` to remove the override. +pub async fn set_geolocation( + client: &mut CdpClient, + session_id: &str, + latitude: Option, + longitude: Option, + accuracy: Option, + clear: bool, +) -> Result { + if clear { + client + .send_to_target(session_id, "Emulation.clearGeolocationOverride", json!({})) + .await?; + return Ok(CommandResult::output("Geolocation override cleared".to_string())); + } + + let lat = latitude.ok_or_else(|| anyhow::anyhow!("latitude required (or use --clear)"))?; + let lon = longitude.ok_or_else(|| anyhow::anyhow!("longitude required (or use --clear)"))?; + let acc = accuracy.unwrap_or(100.0); + + client + .send_to_target( + session_id, + "Emulation.setGeolocationOverride", + json!({ + "latitude": lat, + "longitude": lon, + "accuracy": acc, + }), + ) + .await?; + + Ok(CommandResult::output(format!( + "Geolocation set to {lat}, {lon} (accuracy: {acc}m)" + ))) +} diff --git a/src/commands/executor.rs b/src/commands/executor.rs index 4293202..f503b38 100644 --- a/src/commands/executor.rs +++ b/src/commands/executor.rs @@ -7,6 +7,46 @@ use crate::friendly; use crate::protocol::DaemonRequest; use crate::result::CommandResult; +/// Known arguments for each command. Used to detect and report unknown arguments. +fn known_args(cmd: &str) -> &'static [&'static str] { + match cmd { + "navigate" => &["url", "back", "forward", "reload", "extra_headers", "output"], + "screenshot" => &["output", "format", "full_page"], + "evaluate" => &["expression", "dialog_action", "output", "track_navigation"], + "click" => &["selector"], + "click-at" => &["x", "y"], + "fill" => &["selector", "value"], + "type-text" => &["text", "submit_key"], + "press-key" => &["key"], + "hover" => &["selector"], + "snapshot" => &["output"], + "resize" => &["width", "height"], + "set-geolocation" => &["latitude", "longitude", "accuracy", "clear"], + "wait-for" => &["text", "timeout"], + "list-3p-tools" => &[], + "execute-3p-tool" => &["name", "params"], + _ => &[], + } +} + +/// Check for unknown arguments and return an error message if any are found. +fn validate_args(cmd: &str, args: &serde_json::Value) -> Result<()> { + let known = known_args(cmd); + if let Some(obj) = args.as_object() { + let unknown: Vec<&String> = obj.keys().filter(|k| !known.contains(&k.as_str())).collect(); + if !unknown.is_empty() { + let unknown_names: Vec<&str> = unknown.iter().map(|s| s.as_str()).collect(); + bail!( + "Unknown argument(s) for '{}': {}. Expected: {}", + cmd, + unknown_names.join(", "), + known.join(", ") + ); + } + } + Ok(()) +} + /// Whether a command operates at the browser level (no page session needed). fn is_browser_level(cmd: &str) -> bool { matches!( @@ -87,6 +127,8 @@ async fn inner_execute( let args = &req.args; let cmd = req.command.as_str(); + validate_args(cmd, args)?; + // Enable Page domain to receive dialog events for proactive rejection client .send_to_target(session_id, "Page.enable", json!({})) @@ -110,6 +152,7 @@ async fn inner_execute( args.get("reload") .and_then(|v| v.as_bool()) .unwrap_or(false), + args.get("extra_headers").and_then(|v| v.as_str()), args.get("output").and_then(|v| v.as_str()), ) .await @@ -202,6 +245,30 @@ async fn inner_execute( }, _ => bail!("width and height required"), }, + "set-geolocation" => { + let clear = args + .get("clear") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if clear { + commands::emulation::set_geolocation(client, session_id, None, None, None, true) + .await + } else { + let lat = args + .get("latitude") + .and_then(|v| v.as_f64()) + .ok_or(anyhow!("latitude required (or use --clear)"))?; + let lon = args + .get("longitude") + .and_then(|v| v.as_f64()) + .ok_or(anyhow!("longitude required (or use --clear)"))?; + let acc = args.get("accuracy").and_then(|v| v.as_f64()); + commands::emulation::set_geolocation( + client, session_id, Some(lat), Some(lon), acc, false, + ) + .await + } + } "wait-for" => match args.get("text").and_then(|v| v.as_str()) { Some(text) => { let timeout = args diff --git a/src/commands/mod.rs b/src/commands/mod.rs index dc06b80..20fcdc0 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod emulation; pub mod evaluate; pub mod executor; pub mod input; diff --git a/src/commands/navigate.rs b/src/commands/navigate.rs index b4ff6cd..1c3533f 100644 --- a/src/commands/navigate.rs +++ b/src/commands/navigate.rs @@ -11,8 +11,25 @@ pub async fn navigate( back: bool, forward: bool, reload: bool, + extra_headers: Option<&str>, output: Option<&str>, ) -> Result { + // Apply extra HTTP headers if provided (before any navigation) + if let Some(headers_json) = extra_headers { + let headers: serde_json::Value = serde_json::from_str(headers_json) + .map_err(|e| anyhow::anyhow!("Invalid --extra-headers JSON: {e}"))?; + if !headers.is_object() { + anyhow::bail!("--extra-headers must be a JSON object"); + } + client + .send_to_target( + session_id, + "Network.setExtraHTTPHeaders", + json!({"headers": headers}), + ) + .await?; + } + if back { return go_back(client, session_id, output).await; } diff --git a/src/main.rs b/src/main.rs index ba4e759..9edfda4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -66,6 +66,9 @@ enum Commands { forward: bool, #[arg(long)] reload: bool, + /// Extra HTTP headers as a JSON object (e.g. '{"Authorization":"Bearer token"}') + #[arg(long)] + extra_headers: Option, /// Write output to a file instead of stdout #[arg(long, short)] output: Option, @@ -150,6 +153,22 @@ enum Commands { /// Resize the page viewport Resize { width: u32, height: u32 }, + /// Set geolocation override (latitude, longitude in degrees; accuracy in meters) + SetGeolocation { + /// Latitude in degrees + #[arg(long)] + latitude: Option, + /// Longitude in degrees + #[arg(long)] + longitude: Option, + /// Accuracy in meters (default: 100) + #[arg(long)] + accuracy: Option, + /// Clear geolocation override + #[arg(long)] + clear: bool, + }, + /// Wait for text to appear on the page WaitFor { text: String, @@ -199,6 +218,7 @@ impl Cli { Commands::Hover { .. } => "hover", Commands::Snapshot { .. } => "snapshot", Commands::Resize { .. } => "resize", + Commands::SetGeolocation { .. } => "set-geolocation", Commands::WaitFor { .. } => "wait-for", Commands::List3pTools => "list-3p-tools", Commands::Execute3pTool { .. } => "execute-3p-tool", @@ -249,10 +269,11 @@ fn build_request(cli: &Cli) -> DaemonRequest { back, forward, reload, + extra_headers, output, } => ( "navigate", - json!({"url": url, "back": back, "forward": forward, "reload": reload, "output": output}), + json!({"url": url, "back": back, "forward": forward, "reload": reload, "extra_headers": extra_headers, "output": output}), ), Commands::NewPage { url } => ("new-page", json!({"url": url})), Commands::ClosePage { index } => ("close-page", json!({"index": index})), @@ -286,6 +307,15 @@ fn build_request(cli: &Cli) -> DaemonRequest { Commands::Hover { selector } => ("hover", json!({"selector": selector})), Commands::Snapshot { output } => ("snapshot", json!({"output": output})), Commands::Resize { width, height } => ("resize", json!({"width": width, "height": height})), + Commands::SetGeolocation { + latitude, + longitude, + accuracy, + clear, + } => ( + "set-geolocation", + json!({"latitude": latitude, "longitude": longitude, "accuracy": accuracy, "clear": clear}), + ), Commands::WaitFor { text, timeout } => { ("wait-for", json!({"text": text, "timeout": timeout})) } @@ -451,6 +481,7 @@ async fn run_direct(cli: &Cli, ws_url: &str) -> Result { back, forward, reload, + extra_headers, output, } => { commands::navigate::navigate( @@ -460,6 +491,7 @@ async fn run_direct(cli: &Cli, ws_url: &str) -> Result { *back, *forward, *reload, + extra_headers.as_deref(), output.as_deref(), ) .await @@ -519,6 +551,22 @@ async fn run_direct(cli: &Cli, ws_url: &str) -> Result { Commands::Resize { width, height } => { commands::pages::resize(&mut client, &session_id, *width, *height).await } + Commands::SetGeolocation { + latitude, + longitude, + accuracy, + clear, + } => { + commands::emulation::set_geolocation( + &mut client, + &session_id, + *latitude, + *longitude, + *accuracy, + *clear, + ) + .await + } Commands::WaitFor { text, timeout } => { commands::pages::wait_for(&mut client, &session_id, text, *timeout).await } From 45da69f1db7e146a59fa0d93d55fc6c83fff74ad Mon Sep 17 00:00:00 2001 From: Aero Date: Mon, 25 May 2026 10:59:11 +0800 Subject: [PATCH 02/23] fix: harden argument validation and geolocation input checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add lat/lon range validation (-90..90, -180..180) and non-negative finite accuracy check in set_geolocation before CDP call - Move validate_args to execute_command so browser-level commands (list-pages, new-page, close-page, select-page) are also validated - Add browser-level commands to known_args whitelist - Allow dialog_action globally for all page-level commands in validator - Simplify set-geolocation executor dispatch — pass options directly and let emulation.rs handle requirement logic (removes duplication) - Move extra-headers application after navigation intent validation in navigate.rs to avoid mutating session state on invalid input --- src/commands/emulation.rs | 10 ++++++++ src/commands/executor.rs | 48 +++++++++++++++++++-------------------- src/commands/navigate.rs | 10 +++++--- 3 files changed, 40 insertions(+), 28 deletions(-) diff --git a/src/commands/emulation.rs b/src/commands/emulation.rs index 404d273..e0ad38a 100644 --- a/src/commands/emulation.rs +++ b/src/commands/emulation.rs @@ -27,6 +27,16 @@ pub async fn set_geolocation( let lon = longitude.ok_or_else(|| anyhow::anyhow!("longitude required (or use --clear)"))?; let acc = accuracy.unwrap_or(100.0); + if !(-90.0..=90.0).contains(&lat) { + anyhow::bail!("latitude must be between -90 and 90"); + } + if !(-180.0..=180.0).contains(&lon) { + anyhow::bail!("longitude must be between -180 and 180"); + } + if !acc.is_finite() || acc < 0.0 { + anyhow::bail!("accuracy must be a non-negative finite number"); + } + client .send_to_target( session_id, diff --git a/src/commands/executor.rs b/src/commands/executor.rs index f503b38..d49b3c2 100644 --- a/src/commands/executor.rs +++ b/src/commands/executor.rs @@ -10,6 +10,10 @@ use crate::result::CommandResult; /// Known arguments for each command. Used to detect and report unknown arguments. fn known_args(cmd: &str) -> &'static [&'static str] { match cmd { + "list-pages" => &[], + "new-page" => &["url"], + "close-page" => &["index"], + "select-page" => &["index"], "navigate" => &["url", "back", "forward", "reload", "extra_headers", "output"], "screenshot" => &["output", "format", "full_page"], "evaluate" => &["expression", "dialog_action", "output", "track_navigation"], @@ -33,7 +37,14 @@ fn known_args(cmd: &str) -> &'static [&'static str] { fn validate_args(cmd: &str, args: &serde_json::Value) -> Result<()> { let known = known_args(cmd); if let Some(obj) = args.as_object() { - let unknown: Vec<&String> = obj.keys().filter(|k| !known.contains(&k.as_str())).collect(); + let unknown: Vec<&String> = obj.keys().filter(|k| { + let key = k.as_str(); + // dialog_action is handled globally for all page-level commands + if key == "dialog_action" && !is_browser_level(cmd) { + return false; + } + !known.contains(&key) + }).collect(); if !unknown.is_empty() { let unknown_names: Vec<&str> = unknown.iter().map(|s| s.as_str()).collect(); bail!( @@ -77,6 +88,8 @@ pub async fn execute_command(client: &mut CdpClient, req: &DaemonRequest) -> Res let args = &req.args; let cmd = req.command.as_str(); + validate_args(cmd, args)?; + if is_browser_level(cmd) { return match cmd { "list-pages" => commands::pages::list_pages(client, req.json_output).await, @@ -127,8 +140,6 @@ async fn inner_execute( let args = &req.args; let cmd = req.command.as_str(); - validate_args(cmd, args)?; - // Enable Page domain to receive dialog events for proactive rejection client .send_to_target(session_id, "Page.enable", json!({})) @@ -246,28 +257,15 @@ async fn inner_execute( _ => bail!("width and height required"), }, "set-geolocation" => { - let clear = args - .get("clear") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - if clear { - commands::emulation::set_geolocation(client, session_id, None, None, None, true) - .await - } else { - let lat = args - .get("latitude") - .and_then(|v| v.as_f64()) - .ok_or(anyhow!("latitude required (or use --clear)"))?; - let lon = args - .get("longitude") - .and_then(|v| v.as_f64()) - .ok_or(anyhow!("longitude required (or use --clear)"))?; - let acc = args.get("accuracy").and_then(|v| v.as_f64()); - commands::emulation::set_geolocation( - client, session_id, Some(lat), Some(lon), acc, false, - ) - .await - } + commands::emulation::set_geolocation( + client, + session_id, + args.get("latitude").and_then(|v| v.as_f64()), + args.get("longitude").and_then(|v| v.as_f64()), + args.get("accuracy").and_then(|v| v.as_f64()), + args.get("clear").and_then(|v| v.as_bool()).unwrap_or(false), + ) + .await } "wait-for" => match args.get("text").and_then(|v| v.as_str()) { Some(text) => { diff --git a/src/commands/navigate.rs b/src/commands/navigate.rs index 1c3533f..bbdd6f5 100644 --- a/src/commands/navigate.rs +++ b/src/commands/navigate.rs @@ -14,7 +14,12 @@ pub async fn navigate( extra_headers: Option<&str>, output: Option<&str>, ) -> Result { - // Apply extra HTTP headers if provided (before any navigation) + // Validate navigation intent before mutating session state + if !back && !forward && !reload && url.is_none() { + bail!("URL required (or use --back, --forward, --reload)"); + } + + // Apply extra HTTP headers if provided if let Some(headers_json) = extra_headers { let headers: serde_json::Value = serde_json::from_str(headers_json) .map_err(|e| anyhow::anyhow!("Invalid --extra-headers JSON: {e}"))?; @@ -40,8 +45,7 @@ pub async fn navigate( return do_reload(client, session_id, output).await; } - let url = - url.ok_or_else(|| anyhow::anyhow!("URL required (or use --back, --forward, --reload)"))?; + let url = url.unwrap(); // safe: validated above let result = client .send_to_target(session_id, "Page.navigate", json!({"url": url})) From d91b73fb2de4864edded0479fa26d14b36f90e48 Mon Sep 17 00:00:00 2001 From: Aero Date: Mon, 25 May 2026 21:42:39 +0800 Subject: [PATCH 03/23] feat: unify emulation into emulate command, add getters, fix help - Replace resize and set-geolocation with single `emulate` command: --viewport 1280x720, --geolocation lat,lon, --accuracy N, --clear-viewport, --clear-geolocation, --clear-all - `emulate` with no flags shows all active overrides (viewport via Emulation.getDeviceMetricsOverride, geolocation via JS) - Add --latitude/--longitude/--accuracy/--clear-geolocation flags to navigate so geolocation is set before page loads and persists after - Extra headers: validate all values are strings, call Network.enable before Network.setExtraHTTPHeaders - Move extra-headers application after navigation intent validation - Move validate_args to execute_command so browser-level commands are also validated; allow dialog_action globally for page commands - Add browser-level commands to known_args whitelist - Show subcommand-specific help on bad args instead of global help - Document viewport/geolocation as page-based in README and SKILL --- README.md | 10 +- skill/chrome-devtools/SKILL.md | 17 ++- src/commands/emulation.rs | 206 ++++++++++++++++++++++++++++----- src/commands/executor.rs | 43 +++---- src/commands/navigate.rs | 46 +++++++- src/commands/pages.rs | 23 ---- src/main.rs | 104 +++++++++++------ 7 files changed, 334 insertions(+), 115 deletions(-) diff --git a/README.md b/README.md index d250f59..9d6c9ce 100644 --- a/README.md +++ b/README.md @@ -155,9 +155,14 @@ These commands interact with tools injected into the page via `window.__dtmcp.to | Command | Description | |---------|-------------| -| `resize ` | Set viewport size | +| `emulate` | Get/set page-based emulation overrides (viewport, geolocation) | +| `emulate --viewport 1280x720` | Set viewport size (page-based, persists) | +| `emulate --geolocation 37.77,-122.41` | Set geolocation (page-based, persists) | +| `emulate --clear-all` | Clear all emulation overrides | | `wait-for [--timeout ms]` | Wait for text to appear (default 30s) | +`emulate` with no flags shows all active overrides. Viewport and geolocation overrides are **page-based** — they persist until cleared or the page is closed. + ## Global options | Flag | Description | @@ -191,11 +196,12 @@ src/ ├── friendly.rs # Target ID → word-pair names └── commands/ ├── navigate.rs - ├── pages.rs # list/new/close/select/resize/wait-for + ├── pages.rs # list/new/close/select/wait-for ├── screenshot.rs ├── evaluate.rs ├── input.rs # click/fill/type/press/hover ├── snapshot.rs + ├── emulation.rs # emulate (viewport/geolocation get/set/clear) └── third_party.rs # list-3p-tools/execute-3p-tool ``` diff --git a/skill/chrome-devtools/SKILL.md b/skill/chrome-devtools/SKILL.md index bead71f..82fe4b1 100644 --- a/skill/chrome-devtools/SKILL.md +++ b/skill/chrome-devtools/SKILL.md @@ -45,6 +45,9 @@ chrome-devtools navigate --back chrome-devtools navigate --forward chrome-devtools navigate --reload chrome-devtools navigate --extra-headers '{"Authorization":"Bearer token"}' # Set custom HTTP headers +chrome-devtools navigate --latitude 37.7749 --longitude -122.4194 # Set geolocation then navigate +chrome-devtools navigate --latitude 37.7749 --longitude -122.4194 --accuracy 10 +chrome-devtools navigate --clear-geolocation # Clear geolocation override chrome-devtools new-page # Open new tab chrome-devtools close-page chrome-devtools select-page @@ -83,10 +86,16 @@ chrome-devtools execute-3p-tool '' # Execute a custom tool ### Utilities ```bash chrome-devtools --target wait-for "Success" --timeout 10000 -chrome-devtools --target resize 1280 720 -chrome-devtools --target set-geolocation --latitude 37.7749 --longitude -122.4194 -chrome-devtools --target set-geolocation --latitude 37.7749 --longitude -122.4194 --accuracy 10 -chrome-devtools --target set-geolocation --clear + +# Emulation — page-based overrides (persist until cleared or page closed) +chrome-devtools --target emulate # Show active overrides +chrome-devtools --target emulate --viewport 1280x720 # Set viewport +chrome-devtools --target emulate --geolocation 37.77,-122.41 # Set geolocation +chrome-devtools --target emulate --geolocation 37.77,-122.41 --accuracy 10 +chrome-devtools --target emulate --viewport 1280x720 --geolocation 37.77,-122.41 +chrome-devtools --target emulate --clear-viewport # Clear viewport +chrome-devtools --target emulate --clear-geolocation # Clear geolocation +chrome-devtools --target emulate --clear-all # Clear everything ``` ## Global flags & Environment Variables diff --git a/src/commands/emulation.rs b/src/commands/emulation.rs index e0ad38a..71de6ab 100644 --- a/src/commands/emulation.rs +++ b/src/commands/emulation.rs @@ -1,55 +1,203 @@ -use anyhow::Result; +use anyhow::{bail, Result}; use serde_json::json; use crate::cdp::CdpClient; use crate::result::CommandResult; -/// Set geolocation override for the current page. +/// Unified emulation command — get or set viewport and geolocation overrides. /// -/// Uses `Emulation.setGeolocationOverride` CDP method. -/// Pass `--clear` to remove the override. -pub async fn set_geolocation( +/// With no flags, returns all active overrides. +/// Page-based: overrides persist until cleared or page is closed. +pub async fn emulate( client: &mut CdpClient, session_id: &str, - latitude: Option, - longitude: Option, + as_json: bool, + viewport: Option<&str>, + geolocation: Option<&str>, accuracy: Option, - clear: bool, + clear_viewport: bool, + clear_geolocation: bool, + clear_all: bool, ) -> Result { - if clear { + // No flags → show current state + if viewport.is_none() + && geolocation.is_none() + && !clear_viewport + && !clear_geolocation + && !clear_all + { + return get_all(client, session_id, as_json).await; + } + + if clear_all { + clear_viewport_override(client, session_id).await?; + clear_geolocation_override(client, session_id).await?; + return Ok(CommandResult::output("All emulation overrides cleared".to_string())); + } + + if clear_viewport { + clear_viewport_override(client, session_id).await?; + } + if clear_geolocation { + clear_geolocation_override(client, session_id).await?; + } + + if let Some(viewport_str) = viewport { + let (w, h) = parse_viewport(viewport_str)?; client - .send_to_target(session_id, "Emulation.clearGeolocationOverride", json!({})) + .send_to_target( + session_id, + "Emulation.setDeviceMetricsOverride", + json!({"width": w, "height": h, "deviceScaleFactor": 1, "mobile": false}), + ) .await?; - return Ok(CommandResult::output("Geolocation override cleared".to_string())); } - let lat = latitude.ok_or_else(|| anyhow::anyhow!("latitude required (or use --clear)"))?; - let lon = longitude.ok_or_else(|| anyhow::anyhow!("longitude required (or use --clear)"))?; - let acc = accuracy.unwrap_or(100.0); - - if !(-90.0..=90.0).contains(&lat) { - anyhow::bail!("latitude must be between -90 and 90"); + if let Some(geo_str) = geolocation { + let (lat, lon) = parse_geolocation(geo_str)?; + let acc = accuracy.unwrap_or(100.0); + if !(-90.0..=90.0).contains(&lat) { + bail!("latitude must be between -90 and 90"); + } + if !(-180.0..=180.0).contains(&lon) { + bail!("longitude must be between -180 and 180"); + } + if !acc.is_finite() || acc < 0.0 { + bail!("accuracy must be a non-negative finite number"); + } + client + .send_to_target( + session_id, + "Emulation.setGeolocationOverride", + json!({"latitude": lat, "longitude": lon, "accuracy": acc}), + ) + .await?; } - if !(-180.0..=180.0).contains(&lon) { - anyhow::bail!("longitude must be between -180 and 180"); + + Ok(CommandResult::output("Emulation overrides applied".to_string())) +} + +/// Parse "WxH" viewport string. +fn parse_viewport(s: &str) -> Result<(u32, u32)> { + let parts: Vec<&str> = s.split('x').collect(); + if parts.len() != 2 { + bail!("--viewport must be WxH format (e.g. 1280x720)"); } - if !acc.is_finite() || acc < 0.0 { - anyhow::bail!("accuracy must be a non-negative finite number"); + let w: u32 = parts[0] + .parse() + .map_err(|_| anyhow::anyhow!("invalid viewport width: '{}'", parts[0]))?; + let h: u32 = parts[1] + .parse() + .map_err(|_| anyhow::anyhow!("invalid viewport height: '{}'", parts[1]))?; + Ok((w, h)) +} + +/// Parse "lat,lon" geolocation string. +fn parse_geolocation(s: &str) -> Result<(f64, f64)> { + let parts: Vec<&str> = s.split(',').collect(); + if parts.len() != 2 { + bail!("--geolocation must be lat,lon format (e.g. 37.7749,-122.4194)"); } + let lat: f64 = parts[0] + .trim() + .parse() + .map_err(|_| anyhow::anyhow!("invalid latitude: '{}'", parts[0]))?; + let lon: f64 = parts[1] + .trim() + .parse() + .map_err(|_| anyhow::anyhow!("invalid longitude: '{}'", parts[1]))?; + Ok((lat, lon)) +} +async fn clear_viewport_override(client: &mut CdpClient, session_id: &str) -> Result<()> { client + .send_to_target(session_id, "Emulation.clearDeviceMetricsOverride", json!({})) + .await?; + Ok(()) +} + +async fn clear_geolocation_override(client: &mut CdpClient, session_id: &str) -> Result<()> { + client + .send_to_target(session_id, "Emulation.clearGeolocationOverride", json!({})) + .await?; + Ok(()) +} + +/// Get all active emulation overrides. +async fn get_all( + client: &mut CdpClient, + session_id: &str, + as_json: bool, +) -> Result { + let mut out = Vec::new(); + + // Viewport + let vp = client + .send_to_target(session_id, "Emulation.getDeviceMetricsOverride", json!({})) + .await?; + let vp_override = vp["width"].as_u64().and_then(|w| { + vp["height"].as_u64().map(|h| { + ( + w, + h, + vp["deviceScaleFactor"].as_f64().unwrap_or(1.0), + vp["mobile"].as_bool().unwrap_or(false), + ) + }) + }); + + // Geolocation (try to read via JS) + let geo_expr = r#" + new Promise((resolve, reject) => { + navigator.geolocation.getCurrentPosition( + pos => resolve({latitude: pos.coords.latitude, longitude: pos.coords.longitude, accuracy: pos.coords.accuracy}), + err => reject(err.message), + {maximumAge: 0, timeout: 3000} + ); + }) + "#; + let geo = client .send_to_target( session_id, - "Emulation.setGeolocationOverride", + "Runtime.evaluate", json!({ - "latitude": lat, - "longitude": lon, - "accuracy": acc, + "expression": geo_expr, + "returnByValue": true, + "awaitPromise": true, }), ) - .await?; + .await; + let geo_override = geo.ok().and_then(|r| { + if r.get("exceptionDetails").is_some() { + None + } else { + let v = &r["result"]["value"]; + Some(( + v["latitude"].as_f64().unwrap_or(0.0), + v["longitude"].as_f64().unwrap_or(0.0), + v["accuracy"].as_f64().unwrap_or(0.0), + )) + } + }); + + if as_json { + let obj = json!({ + "viewport": vp_override.map(|(w, h, s, m)| json!({"width": w, "height": h, "scale": s, "mobile": m})), + "geolocation": geo_override.map(|(lat, lon, acc)| json!({"latitude": lat, "longitude": lon, "accuracy": acc})), + }); + return Ok(CommandResult::output(serde_json::to_string_pretty(&obj)?)); + } + + match vp_override { + Some((w, h, s, m)) => out.push(format!("Viewport: {w}x{h} (scale: {s}, mobile: {m})")), + None => out.push("Viewport: none".to_string()), + } + match geo_override { + Some((lat, lon, acc)) => { + out.push(format!("Geolocation: {lat}, {lon} (accuracy: {acc}m)")) + } + None => out.push("Geolocation: none".to_string()), + } - Ok(CommandResult::output(format!( - "Geolocation set to {lat}, {lon} (accuracy: {acc}m)" - ))) + Ok(CommandResult::output(out.join("\n"))) } diff --git a/src/commands/executor.rs b/src/commands/executor.rs index d49b3c2..596c040 100644 --- a/src/commands/executor.rs +++ b/src/commands/executor.rs @@ -14,7 +14,7 @@ fn known_args(cmd: &str) -> &'static [&'static str] { "new-page" => &["url"], "close-page" => &["index"], "select-page" => &["index"], - "navigate" => &["url", "back", "forward", "reload", "extra_headers", "output"], + "navigate" => &["url", "back", "forward", "reload", "extra_headers", "latitude", "longitude", "accuracy", "clear_geolocation", "output"], "screenshot" => &["output", "format", "full_page"], "evaluate" => &["expression", "dialog_action", "output", "track_navigation"], "click" => &["selector"], @@ -24,8 +24,7 @@ fn known_args(cmd: &str) -> &'static [&'static str] { "press-key" => &["key"], "hover" => &["selector"], "snapshot" => &["output"], - "resize" => &["width", "height"], - "set-geolocation" => &["latitude", "longitude", "accuracy", "clear"], + "emulate" => &["viewport", "geolocation", "accuracy", "clear_viewport", "clear_geolocation", "clear_all"], "wait-for" => &["text", "timeout"], "list-3p-tools" => &[], "execute-3p-tool" => &["name", "params"], @@ -164,6 +163,12 @@ async fn inner_execute( .and_then(|v| v.as_bool()) .unwrap_or(false), args.get("extra_headers").and_then(|v| v.as_str()), + args.get("latitude").and_then(|v| v.as_f64()), + args.get("longitude").and_then(|v| v.as_f64()), + args.get("accuracy").and_then(|v| v.as_f64()), + args.get("clear_geolocation") + .and_then(|v| v.as_bool()) + .unwrap_or(false), args.get("output").and_then(|v| v.as_str()), ) .await @@ -243,27 +248,23 @@ async fn inner_execute( ) .await } - "resize" => match ( - args.get("width").and_then(|v| v.as_u64()), - args.get("height").and_then(|v| v.as_u64()), - ) { - (Some(w), Some(h)) => match (w.try_into(), h.try_into()) { - (Ok(w_val), Ok(h_val)) => { - commands::pages::resize(client, session_id, w_val, h_val).await - } - (Err(_), _) => bail!("width too large"), - (_, Err(_)) => bail!("height too large"), - }, - _ => bail!("width and height required"), - }, - "set-geolocation" => { - commands::emulation::set_geolocation( + "emulate" => { + commands::emulation::emulate( client, session_id, - args.get("latitude").and_then(|v| v.as_f64()), - args.get("longitude").and_then(|v| v.as_f64()), + req.json_output, + args.get("viewport").and_then(|v| v.as_str()), + args.get("geolocation").and_then(|v| v.as_str()), args.get("accuracy").and_then(|v| v.as_f64()), - args.get("clear").and_then(|v| v.as_bool()).unwrap_or(false), + args.get("clear_viewport") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + args.get("clear_geolocation") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + args.get("clear_all") + .and_then(|v| v.as_bool()) + .unwrap_or(false), ) .await } diff --git a/src/commands/navigate.rs b/src/commands/navigate.rs index bbdd6f5..c2304e7 100644 --- a/src/commands/navigate.rs +++ b/src/commands/navigate.rs @@ -12,6 +12,10 @@ pub async fn navigate( forward: bool, reload: bool, extra_headers: Option<&str>, + latitude: Option, + longitude: Option, + accuracy: Option, + clear_geolocation: bool, output: Option<&str>, ) -> Result { // Validate navigation intent before mutating session state @@ -19,18 +23,54 @@ pub async fn navigate( bail!("URL required (or use --back, --forward, --reload)"); } + // Apply geolocation override if requested (same session as navigation) + if clear_geolocation { + client + .send_to_target(session_id, "Emulation.clearGeolocationOverride", json!({})) + .await?; + } else if latitude.is_some() || longitude.is_some() { + let lat = latitude.ok_or_else(|| anyhow::anyhow!("--latitude required with --longitude"))?; + let lon = + longitude.ok_or_else(|| anyhow::anyhow!("--longitude required with --latitude"))?; + let acc = accuracy.unwrap_or(100.0); + if !(-90.0..=90.0).contains(&lat) { + bail!("latitude must be between -90 and 90"); + } + if !(-180.0..=180.0).contains(&lon) { + bail!("longitude must be between -180 and 180"); + } + if !acc.is_finite() || acc < 0.0 { + bail!("accuracy must be a non-negative finite number"); + } + client + .send_to_target( + session_id, + "Emulation.setGeolocationOverride", + json!({"latitude": lat, "longitude": lon, "accuracy": acc}), + ) + .await?; + } + // Apply extra HTTP headers if provided if let Some(headers_json) = extra_headers { let headers: serde_json::Value = serde_json::from_str(headers_json) .map_err(|e| anyhow::anyhow!("Invalid --extra-headers JSON: {e}"))?; - if !headers.is_object() { - anyhow::bail!("--extra-headers must be a JSON object"); + let headers_obj = headers + .as_object() + .ok_or_else(|| anyhow::anyhow!("--extra-headers must be a JSON object"))?; + for (k, v) in headers_obj { + if !v.is_string() { + anyhow::bail!("Header value for '{}' must be a string", k); + } } + client + .send_to_target(session_id, "Network.enable", json!({})) + .await?; client .send_to_target( session_id, "Network.setExtraHTTPHeaders", - json!({"headers": headers}), + json!({"headers": headers_obj}), ) .await?; } diff --git a/src/commands/pages.rs b/src/commands/pages.rs index 5202058..793d9f4 100644 --- a/src/commands/pages.rs +++ b/src/commands/pages.rs @@ -67,29 +67,6 @@ pub async fn select_page(client: &mut CdpClient, index: usize) -> Result Result { - client - .send_to_target( - session_id, - "Emulation.setDeviceMetricsOverride", - json!({ - "width": width, - "height": height, - "deviceScaleFactor": 1, - "mobile": false, - }), - ) - .await?; - Ok(CommandResult::output(format!( - "Resized viewport to {width}x{height}" - ))) -} - pub async fn wait_for( client: &mut CdpClient, session_id: &str, diff --git a/src/main.rs b/src/main.rs index 9edfda4..74b47ca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -69,6 +69,18 @@ enum Commands { /// Extra HTTP headers as a JSON object (e.g. '{"Authorization":"Bearer token"}') #[arg(long)] extra_headers: Option, + /// Geolocation latitude in degrees (requires --longitude) + #[arg(long)] + latitude: Option, + /// Geolocation longitude in degrees (requires --latitude) + #[arg(long)] + longitude: Option, + /// Geolocation accuracy in meters (default: 100) + #[arg(long)] + accuracy: Option, + /// Clear geolocation override + #[arg(long)] + clear_geolocation: bool, /// Write output to a file instead of stdout #[arg(long, short)] output: Option, @@ -150,23 +162,26 @@ enum Commands { output: Option, }, - /// Resize the page viewport - Resize { width: u32, height: u32 }, - - /// Set geolocation override (latitude, longitude in degrees; accuracy in meters) - SetGeolocation { - /// Latitude in degrees + /// Get or set emulated page parameters (viewport, geolocation) + Emulate { + /// Set viewport size as WxH (e.g. 1280x720) #[arg(long)] - latitude: Option, - /// Longitude in degrees + viewport: Option, + /// Set geolocation as lat,lon (e.g. 37.7749,-122.4194) #[arg(long)] - longitude: Option, - /// Accuracy in meters (default: 100) + geolocation: Option, + /// Geolocation accuracy in meters (default: 100, used with --geolocation) #[arg(long)] accuracy: Option, + /// Clear viewport override + #[arg(long)] + clear_viewport: bool, /// Clear geolocation override #[arg(long)] - clear: bool, + clear_geolocation: bool, + /// Clear all emulation overrides + #[arg(long)] + clear_all: bool, }, /// Wait for text to appear on the page @@ -217,8 +232,7 @@ impl Cli { Commands::PressKey { .. } => "press-key", Commands::Hover { .. } => "hover", Commands::Snapshot { .. } => "snapshot", - Commands::Resize { .. } => "resize", - Commands::SetGeolocation { .. } => "set-geolocation", + Commands::Emulate { .. } => "emulate", Commands::WaitFor { .. } => "wait-for", Commands::List3pTools => "list-3p-tools", Commands::Execute3pTool { .. } => "execute-3p-tool", @@ -270,10 +284,14 @@ fn build_request(cli: &Cli) -> DaemonRequest { forward, reload, extra_headers, + latitude, + longitude, + accuracy, + clear_geolocation, output, } => ( "navigate", - json!({"url": url, "back": back, "forward": forward, "reload": reload, "extra_headers": extra_headers, "output": output}), + json!({"url": url, "back": back, "forward": forward, "reload": reload, "extra_headers": extra_headers, "latitude": latitude, "longitude": longitude, "accuracy": accuracy, "clear_geolocation": clear_geolocation, "output": output}), ), Commands::NewPage { url } => ("new-page", json!({"url": url})), Commands::ClosePage { index } => ("close-page", json!({"index": index})), @@ -306,15 +324,16 @@ fn build_request(cli: &Cli) -> DaemonRequest { Commands::PressKey { key } => ("press-key", json!({"key": key})), Commands::Hover { selector } => ("hover", json!({"selector": selector})), Commands::Snapshot { output } => ("snapshot", json!({"output": output})), - Commands::Resize { width, height } => ("resize", json!({"width": width, "height": height})), - Commands::SetGeolocation { - latitude, - longitude, + Commands::Emulate { + viewport, + geolocation, accuracy, - clear, + clear_viewport, + clear_geolocation, + clear_all, } => ( - "set-geolocation", - json!({"latitude": latitude, "longitude": longitude, "accuracy": accuracy, "clear": clear}), + "emulate", + json!({"viewport": viewport, "geolocation": geolocation, "accuracy": accuracy, "clear_viewport": clear_viewport, "clear_geolocation": clear_geolocation, "clear_all": clear_all}), ), Commands::WaitFor { text, timeout } => { ("wait-for", json!({"text": text, "timeout": timeout})) @@ -379,8 +398,17 @@ async fn run() -> Result<()> { let clean_err = err_str.replace("For more information, try '--help'.", ""); eprintln!("{}", clean_err.trim_end()); println!(); + + // Show subcommand-specific help when the error is about a subcommand's args let mut cmd = Cli::command(); - let _ = cmd.print_help(); + let sub = std::env::args() + .skip(1) + .find(|a| !a.starts_with('-')) + .and_then(|name| cmd.find_subcommand_mut(&name)); + match sub { + Some(sub_cmd) => { let _ = sub_cmd.print_help(); } + None => { let _ = cmd.print_help(); } + } std::process::exit(1); } }; @@ -482,6 +510,10 @@ async fn run_direct(cli: &Cli, ws_url: &str) -> Result { forward, reload, extra_headers, + latitude, + longitude, + accuracy, + clear_geolocation, output, } => { commands::navigate::navigate( @@ -492,6 +524,10 @@ async fn run_direct(cli: &Cli, ws_url: &str) -> Result { *forward, *reload, extra_headers.as_deref(), + *latitude, + *longitude, + *accuracy, + *clear_geolocation, output.as_deref(), ) .await @@ -548,22 +584,24 @@ async fn run_direct(cli: &Cli, ws_url: &str) -> Result { commands::snapshot::take_snapshot(&mut client, &session_id, cli.json, output.as_deref()) .await } - Commands::Resize { width, height } => { - commands::pages::resize(&mut client, &session_id, *width, *height).await - } - Commands::SetGeolocation { - latitude, - longitude, + Commands::Emulate { + viewport, + geolocation, accuracy, - clear, + clear_viewport, + clear_geolocation, + clear_all, } => { - commands::emulation::set_geolocation( + commands::emulation::emulate( &mut client, &session_id, - *latitude, - *longitude, + cli.json, + viewport.as_deref(), + geolocation.as_deref(), *accuracy, - *clear, + *clear_viewport, + *clear_geolocation, + *clear_all, ) .await } From 3efdec4ff21b8709ab0a8352be86c08a6bb8009d Mon Sep 17 00:00:00 2001 From: Aero Date: Mon, 25 May 2026 22:37:48 +0800 Subject: [PATCH 04/23] feat: unified emulation and atomic navigation support - Consolidate viewport and geolocation overrides into a single `emulate` command. - Restore atomic emulation support in `navigate` and `new-page` commands. - Refactor `close-page` and `select-page` to support global `--target` and `--page` flags. - Update documentation in `README.md` and `SKILL.md` to reflect the new command structure. --- README.md | 245 +++++---------------------------- skill/chrome-devtools/SKILL.md | 150 +++++++------------- src/commands/emulation.rs | 241 ++++++++++++++------------------ src/commands/executor.rs | 100 ++++++++------ src/commands/navigate.rs | 32 ----- src/commands/pages.rs | 66 +++++---- src/main.rs | 179 +++++++++++++++++------- 7 files changed, 419 insertions(+), 594 deletions(-) diff --git a/README.md b/README.md index 9d6c9ce..d6b87f9 100644 --- a/README.md +++ b/README.md @@ -1,235 +1,56 @@ -# Chrome DevTools CLI +# chrome-devtools-cli -Rust CLI that connects to an existing Chrome browser via the DevTools Protocol. Auto-connects by default — no manual WebSocket URL needed. +A high-performance, developer-friendly CLI for interacting with Chrome via the DevTools Protocol (CDP). -[![crates.io](https://img.shields.io/crates/v/chrome-devtools-cli.svg)](https://crates.io/crates/chrome-devtools-cli) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) -[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/aeroxy/chrome-devtools-cli) +## Key Features -## Installation +- **Page Emulation**: Manage viewport size and geolocation overrides in one place. +- **Smart Navigation**: URL navigation, back/forward, and reload with automatic page-load waiting. +- **Visual Tools**: High-quality screenshots (including full-page) and accessibility tree snapshots. +- **Interaction**: Click, fill, type, and hover using CSS selectors or coordinates. +- **JS Evaluation**: Run JavaScript on the page with support for handling dialogs. +- **3rd Party Integration**: Access tools exposed by pages via custom protocol extensions. -### Homebrew (macOS, recommended) +## Installation ```bash -brew install aeroxy/tap/chrome-devtools +cargo install --path . ``` -### Cargo +## Quick Start +### General Usage ```bash -cargo install chrome-devtools-cli +chrome-devtools list-pages +chrome-devtools --page 0 navigate https://google.com +chrome-devtools --target main screenshot --output screenshot.png ``` -The installed binary is named `chrome-devtools`. - -### Build from source +### Emulation (Page-level Overrides) +Overrides like viewport size and geolocation are persistent per page. ```bash -cargo build --release -# Binary: ./target/release/chrome-devtools -``` - -## Why this exists - -Inspired by [chrome-devtools-mcp](https://github.com/ChromeDevTools/chrome-devtools-mcp) — the official MCP server for Chrome DevTools. It works well, but MCP-based browser tools consume a lot of token context: every interaction sends and receives large protocol payloads through the MCP layer. - -99% of the time the browser being controlled is the user's own Chrome with their own credentials, so there is no need for a full headless browser stack like Puppeteer or Playwright, and no need for the MCP overhead. - -This is a lightweight Rust binary that talks directly to Chrome's DevTools Protocol. One command in, one result out. No separate browser process, no credential handoff, no heavyweight runtime. The agent skill for this tool is a single `SKILL.md` file — the entire context overhead is this documentation. - -## Architecture - -``` -chrome-devtools navigate https://example.com - │ - ├─ Try daemon (Unix socket /tmp/chrome-devtools-daemon.sock) - │ └─ If running → send command → get result - │ - ├─ If no daemon → spawn one (background process) - │ └─ Daemon connects to Chrome WebSocket (one-time approval) - │ └─ Listens on Unix socket, 5-min idle timeout - │ - └─ Fallback → direct WebSocket connection (no daemon) -``` - -The daemon keeps a persistent WebSocket connection to Chrome, so the browser only prompts for DevTools access once. Subsequent commands reuse the connection. - -## Prerequisites - -Chrome must have remote debugging enabled: +# View current active overrides +chrome-devtools emulate -1. Open Chrome -2. Go to `chrome://inspect/#remote-debugging` -3. Enable the remote debugging server +# Set viewport and geolocation +chrome-devtools emulate --viewport 1280x720 --geolocation 37.77,-122.41 -## Auto-connect - -By default, the CLI reads `DevToolsActivePort` from Chrome's user data directory: - -| OS | Default path | -|----|-------------| -| macOS | `~/Library/Application Support/Google/Chrome/` | -| Linux | `~/.config/google-chrome/` | -| Windows | `%LOCALAPPDATA%\Google\Chrome\User Data\` | - -Override with `--user-data-dir`, `--channel` (beta/canary/dev), or `--ws-endpoint`. All three also read from environment variables: - -| Environment Variable | Corresponding Flag | -|----------------------|--------------------| -| `CHROME_WS_ENDPOINT` | `--ws-endpoint` | -| `CHROME_USER_DATA_DIR` | `--user-data-dir` | -| `CHROME_CHANNEL` | `--channel` | - -## Page targeting - -Every page-level command outputs a friendly target name like `[target:red-snake]`. This is a deterministic word-pair derived from Chrome's internal target ID — same page always gets the same name. - -```bash -# Navigate — note the target name -chrome-devtools navigate https://example.com -# Navigated to https://example.com -# [target:red-snake] - -# Pin subsequent commands to the same page -chrome-devtools --target red-snake screenshot --output /tmp/page.png -chrome-devtools --target red-snake evaluate "document.title" -``` - -Without `--target`, commands default to page index 0, which may vary as Chrome reorders tabs. Always capture and reuse the target name. - -`list-pages` shows all pages with their friendly names: - -``` -[0] (green-dog) My App — https://localhost:3000 -[1] (red-snake) Example Domain — https://example.com -[2] (bold-stag) GitHub — https://github.com +# Clear overrides +chrome-devtools emulate --clear-all ``` -You can also use `--page ` for quick one-offs, or pass the raw hex target ID. - -## Commands - -### Navigation - -| Command | Description | -|---------|-------------| -| `navigate ` | Go to URL (waits for load) | -| `navigate --back` | Go back in history | -| `navigate --forward` | Go forward | -| `navigate --reload` | Reload page | -| `new-page ` | Open new tab | -| `close-page ` | Close tab by index | -| `select-page ` | Bring tab to front | -| `list-pages` | List all open tabs | - -### Inspection - -| Command | Description | -|---------|-------------| -| `screenshot --output ` | Save screenshot to file | -| `screenshot --full-page` | Capture full scrollable page | -| `evaluate [--dialog-action ]` | Run JavaScript (optionally handle dialogs: accept, dismiss, or prompt text) | -| `snapshot` | Accessibility tree dump | - ### Interaction - -| Command | Description | -|---------|-------------| -| `click ` | Click element by CSS selector | -| `click-at ` | Click at specific coordinates | -| `fill ` | Fill input field, dropdown (`