Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70ab38d35f | ||
|
|
6830df50ea | ||
|
|
cd47ec43d0 | ||
|
|
2cd361817d | ||
|
|
42f47c49aa | ||
|
|
e29800df72 |
Generated
+1
-1
@@ -290,7 +290,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrome-use"
|
name = "chrome-use"
|
||||||
version = "1.5.8"
|
version = "1.5.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "chrome-use"
|
name = "chrome-use"
|
||||||
version = "1.5.8"
|
version = "1.5.11"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Fast browser automation CLI for AI agents"
|
description = "Fast browser automation CLI for AI agents"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|||||||
+124
-11
@@ -729,10 +729,56 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
|||||||
} else {
|
} else {
|
||||||
return Err(ParseError::MissingArguments {
|
return Err(ParseError::MissingArguments {
|
||||||
context: "scroll --selector".to_string(),
|
context: "scroll --selector".to_string(),
|
||||||
usage: "scroll [direction] [amount] [--selector <sel>]",
|
usage: "scroll [direction] [amount] [--selector <sel>] [--at <x,y>] [--frame <n>]",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"--at" => {
|
||||||
|
// `--at x,y`: dispatch the wheel at this viewport pixel, so it
|
||||||
|
// scrolls whatever element/iframe is under the pointer — including
|
||||||
|
// cross-origin iframes that `window.scrollBy` can't reach (#36).
|
||||||
|
let val = rest.get(i + 1).ok_or(ParseError::MissingArguments {
|
||||||
|
context: "scroll --at".to_string(),
|
||||||
|
usage: "scroll [direction] [amount] --at <x,y>",
|
||||||
|
})?;
|
||||||
|
let mut parts = val.split(',');
|
||||||
|
match (
|
||||||
|
parts.next().and_then(|s| s.trim().parse::<f64>().ok()),
|
||||||
|
parts.next().and_then(|s| s.trim().parse::<f64>().ok()),
|
||||||
|
) {
|
||||||
|
(Some(x), Some(y)) => {
|
||||||
|
obj.insert("at".to_string(), json!([x, y]));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(ParseError::InvalidValue {
|
||||||
|
message: format!("scroll --at: invalid coordinate `{}`", val),
|
||||||
|
usage: "scroll [direction] [amount] --at <x,y> (e.g. --at 640,400)",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
"--frame" => {
|
||||||
|
// `--frame n`: scroll the n-th frame from `chrome-use frames` by
|
||||||
|
// dispatching the wheel at that frame's center — reaches content in
|
||||||
|
// a cross-origin iframe without needing a selector into it (#36).
|
||||||
|
let val = rest.get(i + 1).ok_or(ParseError::MissingArguments {
|
||||||
|
context: "scroll --frame".to_string(),
|
||||||
|
usage: "scroll [direction] [amount] --frame <n>",
|
||||||
|
})?;
|
||||||
|
match val.trim().parse::<usize>() {
|
||||||
|
Ok(n) => {
|
||||||
|
obj.insert("frame".to_string(), json!(n));
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
return Err(ParseError::InvalidValue {
|
||||||
|
message: format!("scroll --frame: invalid index `{}`", val),
|
||||||
|
usage: "scroll [direction] [amount] --frame <n> (index from `chrome-use frames`)",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
arg if arg.starts_with('-') => {}
|
arg if arg.starts_with('-') => {}
|
||||||
_ => {
|
_ => {
|
||||||
match positional_index {
|
match positional_index {
|
||||||
@@ -907,17 +953,37 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
|||||||
// selector: @ref or CSS selector
|
// selector: @ref or CSS selector
|
||||||
// path: file path (contains / or . or ends with known extension)
|
// path: file path (contains / or . or ends with known extension)
|
||||||
let mut full_page = false;
|
let mut full_page = false;
|
||||||
let positional: Vec<&str> = rest
|
let mut clip: Option<Value> = None;
|
||||||
.iter()
|
let mut positional: Vec<&str> = Vec::new();
|
||||||
.filter(|arg| match **arg {
|
let mut i = 0;
|
||||||
"--full" | "-f" => {
|
while i < rest.len() {
|
||||||
full_page = true;
|
match rest[i] {
|
||||||
false
|
"--full" | "-f" => full_page = true,
|
||||||
|
// `--clip x,y,w,h` captures a pixel region (issue #34).
|
||||||
|
"--clip" => {
|
||||||
|
let raw = rest.get(i + 1).ok_or_else(|| ParseError::MissingArguments {
|
||||||
|
context: "screenshot --clip".to_string(),
|
||||||
|
usage: "screenshot --clip <x,y,w,h> [path]",
|
||||||
|
})?;
|
||||||
|
let nums: Vec<f64> = raw
|
||||||
|
.split(',')
|
||||||
|
.filter_map(|n| n.trim().parse::<f64>().ok())
|
||||||
|
.collect();
|
||||||
|
if nums.len() != 4 {
|
||||||
|
return Err(ParseError::InvalidValue {
|
||||||
|
message: format!("--clip expects 'x,y,w,h' (4 numbers), got '{raw}'"),
|
||||||
|
usage: "screenshot --clip <x,y,w,h> [path]",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
clip = Some(json!({
|
||||||
|
"x": nums[0], "y": nums[1], "width": nums[2], "height": nums[3]
|
||||||
|
}));
|
||||||
|
i += 1;
|
||||||
}
|
}
|
||||||
_ => true,
|
other => positional.push(other),
|
||||||
})
|
}
|
||||||
.copied()
|
i += 1;
|
||||||
.collect();
|
}
|
||||||
let (selector, path) = match (positional.first(), positional.get(1)) {
|
let (selector, path) = match (positional.first(), positional.get(1)) {
|
||||||
(Some(first), Some(second)) => {
|
(Some(first), Some(second)) => {
|
||||||
// Two args: first is selector, second is path
|
// Two args: first is selector, second is path
|
||||||
@@ -948,6 +1014,9 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
|||||||
"path": path, "selector": selector,
|
"path": path, "selector": selector,
|
||||||
"fullPage": full_page, "annotate": flags.annotate
|
"fullPage": full_page, "annotate": flags.annotate
|
||||||
});
|
});
|
||||||
|
if let Some(c) = clip {
|
||||||
|
cmd["clip"] = c;
|
||||||
|
}
|
||||||
if let Some(ref fmt) = flags.screenshot_format {
|
if let Some(ref fmt) = flags.screenshot_format {
|
||||||
cmd["format"] = json!(fmt);
|
cmd["format"] = json!(fmt);
|
||||||
}
|
}
|
||||||
@@ -4327,6 +4396,21 @@ mod tests {
|
|||||||
assert_eq!(cmd["fullPage"], true);
|
assert_eq!(cmd["fullPage"], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_screenshot_clip() {
|
||||||
|
// `--clip x,y,w,h` captures a pixel region (issue #34); the path still parses.
|
||||||
|
let cmd = parse_command(&args("screenshot --clip 10,20,200,40 out.png"), &default_flags())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cmd["action"], "screenshot");
|
||||||
|
assert_eq!(cmd["clip"]["x"], 10.0);
|
||||||
|
assert_eq!(cmd["clip"]["y"], 20.0);
|
||||||
|
assert_eq!(cmd["clip"]["width"], 200.0);
|
||||||
|
assert_eq!(cmd["clip"]["height"], 40.0);
|
||||||
|
assert_eq!(cmd["path"], "out.png");
|
||||||
|
// Bad clip is a clear error, not silent.
|
||||||
|
assert!(parse_command(&args("screenshot --clip 1,2,3"), &default_flags()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_screenshot_with_ref() {
|
fn test_screenshot_with_ref() {
|
||||||
let cmd = parse_command(&args("screenshot @e1"), &default_flags()).unwrap();
|
let cmd = parse_command(&args("screenshot @e1"), &default_flags()).unwrap();
|
||||||
@@ -5830,6 +5914,35 @@ mod tests {
|
|||||||
assert_eq!(cmd["selector"], ".sidebar");
|
assert_eq!(cmd["selector"], ".sidebar");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scroll_at_coordinate() {
|
||||||
|
// `--at x,y` carries a [x, y] array for a wheel dispatched at that pixel
|
||||||
|
// (issue #36: cross-origin iframe scroll).
|
||||||
|
let cmd = parse_command(&args("scroll down 700 --at 640,400"), &default_flags()).unwrap();
|
||||||
|
assert_eq!(cmd["action"], "scroll");
|
||||||
|
assert_eq!(cmd["direction"], "down");
|
||||||
|
assert_eq!(cmd["amount"], 700);
|
||||||
|
assert_eq!(cmd["at"], json!([640.0, 400.0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scroll_at_rejects_garbage() {
|
||||||
|
assert!(parse_command(&args("scroll --at nope"), &default_flags()).is_err());
|
||||||
|
assert!(parse_command(&args("scroll --at 1"), &default_flags()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scroll_frame_index() {
|
||||||
|
let cmd = parse_command(&args("scroll down 700 --frame 2"), &default_flags()).unwrap();
|
||||||
|
assert_eq!(cmd["action"], "scroll");
|
||||||
|
assert_eq!(cmd["frame"], 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scroll_frame_rejects_non_integer() {
|
||||||
|
assert!(parse_command(&args("scroll --frame two"), &default_flags()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_scroll_selector_before_positional() {
|
fn test_scroll_selector_before_positional() {
|
||||||
let cmd =
|
let cmd =
|
||||||
|
|||||||
@@ -1369,6 +1369,20 @@ fn main() {
|
|||||||
&& flags.provider.is_none()
|
&& flags.provider.is_none()
|
||||||
&& (flags.force_launch || !flags.auto_connect)
|
&& (flags.force_launch || !flags.auto_connect)
|
||||||
{
|
{
|
||||||
|
// Launching a debug-port Chrome pops Chrome's "Allow remote debugging?"
|
||||||
|
// consent modal (Chrome 136+). When the ab-connect relay is already up,
|
||||||
|
// this is almost always unintended — the relay drives the user's real
|
||||||
|
// Chrome with NO modal. Warn so the modal is self-explained and the
|
||||||
|
// caller (often a stray --launch / --no-auto-connect) is fixable (#32).
|
||||||
|
if !flags.json && connect::relay_url().is_some() {
|
||||||
|
eprintln!(
|
||||||
|
"{} launching a new Chrome with a debug port — this pops Chrome's \
|
||||||
|
\"Allow remote debugging?\" modal.\n The ab-connect relay is up; \
|
||||||
|
drop --launch/--new (and don't pass --no-auto-connect) to drive your \
|
||||||
|
real Chrome with no modal.",
|
||||||
|
color::warning_indicator()
|
||||||
|
);
|
||||||
|
}
|
||||||
let mut launch_cmd = json!({
|
let mut launch_cmd = json!({
|
||||||
"id": gen_id(),
|
"id": gen_id(),
|
||||||
"action": "launch",
|
"action": "launch",
|
||||||
|
|||||||
+172
-11
@@ -3000,6 +3000,14 @@ async fn handle_screenshot(cmd: &Value, state: &mut DaemonState) -> Result<Value
|
|||||||
.get("screenshotDir")
|
.get("screenshotDir")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(String::from),
|
.map(String::from),
|
||||||
|
clip: cmd.get("clip").and_then(|c| {
|
||||||
|
Some((
|
||||||
|
c.get("x")?.as_f64()?,
|
||||||
|
c.get("y")?.as_f64()?,
|
||||||
|
c.get("width")?.as_f64()?,
|
||||||
|
c.get("height")?.as_f64()?,
|
||||||
|
))
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if annotate {
|
if annotate {
|
||||||
@@ -3459,17 +3467,169 @@ async fn handle_scroll(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interaction::scroll(
|
// An explicit `--selector` keeps the precise element-scroll path (scrollBy on
|
||||||
&mgr.client,
|
// the resolved node, same-origin only).
|
||||||
&session_id,
|
if let Some(sel) = selector {
|
||||||
&state.ref_map,
|
interaction::scroll(
|
||||||
selector,
|
&mgr.client,
|
||||||
dx,
|
&session_id,
|
||||||
dy,
|
&state.ref_map,
|
||||||
&state.iframe_sessions,
|
Some(sel),
|
||||||
)
|
dx,
|
||||||
.await?;
|
dy,
|
||||||
Ok(json!({ "scrolled": true }))
|
&state.iframe_sessions,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(json!({ "scrolled": true, "via": "selector" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// No selector: dispatch a real (isTrusted) wheel at a viewport coordinate.
|
||||||
|
// This hits the compositor and scrolls whatever scroll container is under the
|
||||||
|
// pointer — including cross-origin iframes that `window.scrollBy` on the top
|
||||||
|
// document silently no-ops on (issue #36). The coordinate is, in priority:
|
||||||
|
// --at x,y → that exact pixel
|
||||||
|
// --frame n → the center of frame n from `chrome-use frames`
|
||||||
|
// default → the viewport center
|
||||||
|
let (x, y, via) = if let Some(at) = cmd.get("at").and_then(|v| v.as_array()) {
|
||||||
|
let x = at.first().and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||||
|
let y = at.get(1).and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||||
|
(x, y, "at")
|
||||||
|
} else if let Some(n) = cmd.get("frame").and_then(|v| v.as_u64()) {
|
||||||
|
let (x, y) = frame_center(mgr, &session_id, &state.iframe_sessions, n as usize).await?;
|
||||||
|
(x, y, "frame")
|
||||||
|
} else {
|
||||||
|
let (x, y) = viewport_center(mgr, &session_id).await?;
|
||||||
|
(x, y, "center")
|
||||||
|
};
|
||||||
|
|
||||||
|
dispatch_wheel(&mgr.client, &session_id, x, y, dx, dy).await?;
|
||||||
|
Ok(json!({ "scrolled": true, "via": via, "at": [x, y] }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Viewport center in CSS pixels, used as the default wheel landing point for
|
||||||
|
/// `scroll` (issue #36). Falls back to a sane 640×400 center if the page can't
|
||||||
|
/// be evaluated (e.g. a restricted document).
|
||||||
|
async fn viewport_center(mgr: &BrowserManager, session_id: &str) -> Result<(f64, f64), String> {
|
||||||
|
let dims = mgr
|
||||||
|
.client
|
||||||
|
.send_command_typed::<_, Value>(
|
||||||
|
"Runtime.evaluate",
|
||||||
|
&super::cdp::types::EvaluateParams {
|
||||||
|
expression: "[window.innerWidth, window.innerHeight]".to_string(),
|
||||||
|
return_by_value: Some(true),
|
||||||
|
await_promise: Some(false),
|
||||||
|
},
|
||||||
|
Some(session_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
let arr = dims
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|v| v.get("result"))
|
||||||
|
.and_then(|v| v.get("value"))
|
||||||
|
.and_then(|v| v.as_array());
|
||||||
|
let w = arr
|
||||||
|
.and_then(|a| a.first())
|
||||||
|
.and_then(|v| v.as_f64())
|
||||||
|
.filter(|w| *w > 0.0)
|
||||||
|
.unwrap_or(1280.0);
|
||||||
|
let h = arr
|
||||||
|
.and_then(|a| a.get(1))
|
||||||
|
.and_then(|v| v.as_f64())
|
||||||
|
.filter(|h| *h > 0.0)
|
||||||
|
.unwrap_or(800.0);
|
||||||
|
Ok((w / 2.0, h / 2.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Center of the `n`-th frame (as listed by `chrome-use frames`) in top-viewport
|
||||||
|
/// CSS pixels, so `scroll --frame n` lands its wheel inside a cross-origin iframe
|
||||||
|
/// without needing a selector into it (issue #36). Resolves the frame's owning
|
||||||
|
/// `<iframe>` element box via `DOM.getFrameOwner` + `DOM.getBoxModel` — exact for
|
||||||
|
/// a frame nested directly under the top document; for a deeper nesting the box is
|
||||||
|
/// relative to the intermediate frame, so prefer `--at x,y` from a screenshot.
|
||||||
|
async fn frame_center(
|
||||||
|
mgr: &BrowserManager,
|
||||||
|
session_id: &str,
|
||||||
|
iframe_sessions: &HashMap<String, String>,
|
||||||
|
n: usize,
|
||||||
|
) -> Result<(f64, f64), String> {
|
||||||
|
let frames =
|
||||||
|
super::element::collect_all_frames_text(&mgr.client, session_id, iframe_sessions).await?;
|
||||||
|
let frame = frames.get(n).ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"frame index {} out of range (run `chrome-use frames`: {} frame(s))",
|
||||||
|
n,
|
||||||
|
frames.len()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if n == 0 {
|
||||||
|
// Frame 0 is the top document — there's no owner element; scroll its center.
|
||||||
|
return viewport_center(mgr, session_id).await;
|
||||||
|
}
|
||||||
|
let owner = mgr
|
||||||
|
.client
|
||||||
|
.send_command_typed::<_, Value>(
|
||||||
|
"DOM.getFrameOwner",
|
||||||
|
&json!({ "frameId": frame.frame_id }),
|
||||||
|
Some(session_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("can't locate frame {}'s owner element: {}", n, e))?;
|
||||||
|
let backend_node_id = owner
|
||||||
|
.get("backendNodeId")
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.ok_or_else(|| format!("frame {} has no owner <iframe> element", n))?;
|
||||||
|
let box_model = mgr
|
||||||
|
.client
|
||||||
|
.send_command_typed::<_, Value>(
|
||||||
|
"DOM.getBoxModel",
|
||||||
|
&json!({ "backendNodeId": backend_node_id }),
|
||||||
|
Some(session_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("can't measure frame {}'s box: {}", n, e))?;
|
||||||
|
let content = box_model
|
||||||
|
.get("model")
|
||||||
|
.and_then(|m| m.get("content"))
|
||||||
|
.and_then(|c| c.as_array())
|
||||||
|
.ok_or_else(|| format!("frame {} box model has no content quad", n))?;
|
||||||
|
let coord = |i: usize| content.get(i).and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||||
|
// content quad is [x1,y1, x2,y2, x3,y3, x4,y4]; opposite corners are 0 and 2.
|
||||||
|
let cx = (coord(0) + coord(4)) / 2.0;
|
||||||
|
let cy = (coord(1) + coord(5)) / 2.0;
|
||||||
|
Ok((cx, cy))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatch a trusted mouse wheel at `(x, y)`, humanized like `handle_wheel`.
|
||||||
|
async fn dispatch_wheel(
|
||||||
|
client: &super::cdp::client::CdpClient,
|
||||||
|
session_id: &str,
|
||||||
|
x: f64,
|
||||||
|
y: f64,
|
||||||
|
delta_x: f64,
|
||||||
|
delta_y: f64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let level = humanize::active_level();
|
||||||
|
let seed = humanize::next_seed();
|
||||||
|
for (dx, dy, delay) in humanize::scroll_segments(delta_x, delta_y, level, seed) {
|
||||||
|
client
|
||||||
|
.send_command(
|
||||||
|
"Input.dispatchMouseEvent",
|
||||||
|
Some(json!({
|
||||||
|
"type": "mouseWheel",
|
||||||
|
"x": x,
|
||||||
|
"y": y,
|
||||||
|
"deltaX": dx,
|
||||||
|
"deltaY": dy,
|
||||||
|
})),
|
||||||
|
Some(session_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if !delay.is_zero() {
|
||||||
|
tokio::time::sleep(delay).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_select(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
async fn handle_select(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||||
@@ -7446,6 +7606,7 @@ async fn handle_diff_screenshot(cmd: &Value, state: &DaemonState) -> Result<Valu
|
|||||||
quality: None,
|
quality: None,
|
||||||
annotate: false,
|
annotate: false,
|
||||||
output_dir: None,
|
output_dir: None,
|
||||||
|
clip: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = screenshot::take_screenshot(
|
let result = screenshot::take_screenshot(
|
||||||
|
|||||||
+132
-11
@@ -121,7 +121,7 @@ fn normalize_url_for_match(url: &str) -> String {
|
|||||||
fn update_page_target_info_in_pages(pages: &mut [PageInfo], target: &TargetInfo) -> bool {
|
fn update_page_target_info_in_pages(pages: &mut [PageInfo], target: &TargetInfo) -> bool {
|
||||||
if let Some(page) = pages.iter_mut().find(|p| p.target_id == target.target_id) {
|
if let Some(page) = pages.iter_mut().find(|p| p.target_id == target.target_id) {
|
||||||
page.url = target.url.clone();
|
page.url = target.url.clone();
|
||||||
page.title = target.title.clone();
|
page.title = sanitize_title(&target.title);
|
||||||
page.target_type = target.target_type.clone();
|
page.target_type = target.target_type.clone();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -166,6 +166,29 @@ fn resolve_active_index(
|
|||||||
active_page_index
|
active_page_index
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Strip zero-width / invisible / bidi-format Unicode from a page title before
|
||||||
|
/// we store it. Some sites prepend runs of ZWJ / word-joiner / invisible-times /
|
||||||
|
/// BOM to `document.title` (badging, watermarking, anti-scrape); left in, they
|
||||||
|
/// pollute `tab list`, break text matching, and wreck column alignment (#33).
|
||||||
|
fn sanitize_title(s: &str) -> String {
|
||||||
|
s.chars()
|
||||||
|
.filter(|&c| {
|
||||||
|
!matches!(c as u32,
|
||||||
|
0x00AD // soft hyphen
|
||||||
|
| 0x200B..=0x200F // ZWSP, ZWNJ, ZWJ, LRM, RLM
|
||||||
|
| 0x2028 | 0x2029 // line / paragraph separators
|
||||||
|
| 0x202A..=0x202E // bidi embedding/override
|
||||||
|
| 0x2060..=0x2064 // word joiner, invisible operators
|
||||||
|
| 0x2066..=0x2069 // bidi isolates
|
||||||
|
| 0x180E // Mongolian vowel separator
|
||||||
|
| 0xFEFF // BOM / ZW no-break space
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<String>()
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// Best-effort MIME type from a filename extension, for the relay file-upload
|
/// Best-effort MIME type from a filename extension, for the relay file-upload
|
||||||
/// fallback (the page-constructed `File` needs a sensible `type`). Covers the
|
/// fallback (the page-constructed `File` needs a sensible `type`). Covers the
|
||||||
/// common upload kinds; anything unknown falls back to a generic binary type.
|
/// common upload kinds; anything unknown falls back to a generic binary type.
|
||||||
@@ -231,6 +254,21 @@ fn active_index_is_owned(
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a CDP error means the bound relay target is gone — the tab was
|
||||||
|
/// closed, navigated across processes (renderer swap), or lost after an
|
||||||
|
/// extension/service-worker restart, and the relay could not re-attach. The
|
||||||
|
/// ab-connect relay surfaces these as `stale sessionId … its tab is gone`,
|
||||||
|
/// `unknown sessionId …`, or `no attached tab …`. `navigate` keys its
|
||||||
|
/// auto-reattach recovery off this (issue #35) so a dead session rebinds to a
|
||||||
|
/// fresh tab instead of erroring on every command until the user runs `tab new`.
|
||||||
|
fn is_stale_target_error(error: &str) -> bool {
|
||||||
|
let lower = error.to_lowercase();
|
||||||
|
lower.contains("its tab is gone")
|
||||||
|
|| lower.contains("stale sessionid")
|
||||||
|
|| lower.contains("unknown sessionid")
|
||||||
|
|| lower.contains("no attached tab")
|
||||||
|
}
|
||||||
|
|
||||||
/// Converts common error messages into AI-friendly, actionable descriptions.
|
/// Converts common error messages into AI-friendly, actionable descriptions.
|
||||||
pub fn to_ai_friendly_error(error: &str) -> String {
|
pub fn to_ai_friendly_error(error: &str) -> String {
|
||||||
let lower = error.to_lowercase();
|
let lower = error.to_lowercase();
|
||||||
@@ -773,7 +811,7 @@ impl BrowserManager {
|
|||||||
target_id: target.target_id.clone(),
|
target_id: target.target_id.clone(),
|
||||||
session_id: attach_result.session_id.clone(),
|
session_id: attach_result.session_id.clone(),
|
||||||
url: target.url.clone(),
|
url: target.url.clone(),
|
||||||
title: target.title.clone(),
|
title: sanitize_title(&target.title),
|
||||||
target_type: target.target_type.clone(),
|
target_type: target.target_type.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -892,6 +930,24 @@ impl BrowserManager {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop the page bound to `session_id` from the tracked list — used when the
|
||||||
|
/// relay reports its tab is gone (issue #35) so the stale entry can't keep
|
||||||
|
/// resolving as active. Forgets ownership, unpins it if it was pinned, and
|
||||||
|
/// keeps `active_page_index` in range.
|
||||||
|
fn drop_page_by_session(&mut self, session_id: &str) {
|
||||||
|
let Some(pos) = self.pages.iter().position(|p| p.session_id == session_id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let target_id = self.pages[pos].target_id.clone();
|
||||||
|
self.pages.remove(pos);
|
||||||
|
self.created_targets.remove(&target_id);
|
||||||
|
if self.active_target_id.as_deref() == Some(target_id.as_str()) {
|
||||||
|
self.active_target_id = None;
|
||||||
|
}
|
||||||
|
self.active_page_index =
|
||||||
|
active_page_index_after_removal(self.active_page_index, pos, self.pages.len());
|
||||||
|
}
|
||||||
|
|
||||||
/// Pin the current active page by target_id so later commands stick to it.
|
/// Pin the current active page by target_id so later commands stick to it.
|
||||||
/// Call after any explicit open / tab new / tab switch.
|
/// Call after any explicit open / tab new / tab switch.
|
||||||
fn pin_active_target(&mut self) {
|
fn pin_active_target(&mut self) {
|
||||||
@@ -921,10 +977,10 @@ impl BrowserManager {
|
|||||||
if self.agent_group().is_some() && !self.active_is_session_owned() {
|
if self.agent_group().is_some() && !self.active_is_session_owned() {
|
||||||
self.tab_new(None, None).await?;
|
self.tab_new(None, None).await?;
|
||||||
}
|
}
|
||||||
let session_id = self.active_session_id()?.to_string();
|
let mut session_id = self.active_session_id()?.to_string();
|
||||||
let mut lifecycle_rx = self.client.subscribe();
|
let mut lifecycle_rx = self.client.subscribe();
|
||||||
|
|
||||||
let nav_result: PageNavigateResult = self
|
let nav_result: PageNavigateResult = match self
|
||||||
.client
|
.client
|
||||||
.send_command_typed(
|
.send_command_typed(
|
||||||
"Page.navigate",
|
"Page.navigate",
|
||||||
@@ -934,7 +990,38 @@ impl BrowserManager {
|
|||||||
},
|
},
|
||||||
Some(&session_id),
|
Some(&session_id),
|
||||||
)
|
)
|
||||||
.await?;
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) => r,
|
||||||
|
// Auto-reattach when the bound tab is gone (issue #35). On the shared
|
||||||
|
// real browser the human can close/swap the agent's tab, and a
|
||||||
|
// cross-process nav can destroy the target without a re-attachable
|
||||||
|
// tabId — both leave the cached `cb-tab-<id>` session stale, so every
|
||||||
|
// command (including `open`) failed on it and only `tab new`
|
||||||
|
// recovered. The relay error literally says "re-open your target URL
|
||||||
|
// to re-attach"; fulfil that here: drop the dead page, open a fresh
|
||||||
|
// owned tab in this session's group, and navigate THAT. Gated on the
|
||||||
|
// relay (`agent_group`) and on the explicit navigation intent — read
|
||||||
|
// commands deliberately still fail loudly rather than silently
|
||||||
|
// recover onto a blank tab and return wrong data (issue #8.1).
|
||||||
|
Err(e) if self.agent_group().is_some() && is_stale_target_error(&e) => {
|
||||||
|
self.drop_page_by_session(&session_id);
|
||||||
|
self.tab_new(None, None).await?;
|
||||||
|
session_id = self.active_session_id()?.to_string();
|
||||||
|
lifecycle_rx = self.client.subscribe();
|
||||||
|
self.client
|
||||||
|
.send_command_typed(
|
||||||
|
"Page.navigate",
|
||||||
|
&PageNavigateParams {
|
||||||
|
url: url.to_string(),
|
||||||
|
referrer: None,
|
||||||
|
},
|
||||||
|
Some(&session_id),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(ref error_text) = nav_result.error_text {
|
if let Some(ref error_text) = nav_result.error_text {
|
||||||
return Err(format!("Navigation failed: {}", error_text));
|
return Err(format!("Navigation failed: {}", error_text));
|
||||||
@@ -999,7 +1086,7 @@ impl BrowserManager {
|
|||||||
self.active_page_index = self.resolved_active_index();
|
self.active_page_index = self.resolved_active_index();
|
||||||
if let Some(page) = self.pages.get_mut(self.active_page_index) {
|
if let Some(page) = self.pages.get_mut(self.active_page_index) {
|
||||||
page.url = page_url.clone();
|
page.url = page_url.clone();
|
||||||
page.title = title.clone();
|
page.title = sanitize_title(&title);
|
||||||
}
|
}
|
||||||
self.pin_active_target();
|
self.pin_active_target();
|
||||||
|
|
||||||
@@ -1061,7 +1148,7 @@ impl BrowserManager {
|
|||||||
|
|
||||||
pub async fn get_title(&self) -> Result<String, String> {
|
pub async fn get_title(&self) -> Result<String, String> {
|
||||||
let result = self.evaluate_simple("document.title").await?;
|
let result = self.evaluate_simple("document.title").await?;
|
||||||
Ok(result.as_str().unwrap_or("").to_string())
|
Ok(sanitize_title(result.as_str().unwrap_or("")))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_content(&self) -> Result<String, String> {
|
pub async fn get_content(&self) -> Result<String, String> {
|
||||||
@@ -1404,7 +1491,7 @@ impl BrowserManager {
|
|||||||
target_id: target.target_id.clone(),
|
target_id: target.target_id.clone(),
|
||||||
session_id: attach.session_id.clone(),
|
session_id: attach.session_id.clone(),
|
||||||
url: target.url.clone(),
|
url: target.url.clone(),
|
||||||
title: target.title.clone(),
|
title: sanitize_title(&target.title),
|
||||||
target_type: target.target_type.clone(),
|
target_type: target.target_type.clone(),
|
||||||
};
|
};
|
||||||
self.add_background_page(page.clone());
|
self.add_background_page(page.clone());
|
||||||
@@ -1465,7 +1552,7 @@ impl BrowserManager {
|
|||||||
target_id: target.target_id.clone(),
|
target_id: target.target_id.clone(),
|
||||||
session_id: attach_result.session_id.clone(),
|
session_id: attach_result.session_id.clone(),
|
||||||
url: target.url.clone(),
|
url: target.url.clone(),
|
||||||
title: target.title.clone(),
|
title: sanitize_title(&target.title),
|
||||||
target_type: target.target_type.clone(),
|
target_type: target.target_type.clone(),
|
||||||
});
|
});
|
||||||
let _ = self.enable_domains(&attach_result.session_id).await;
|
let _ = self.enable_domains(&attach_result.session_id).await;
|
||||||
@@ -1508,7 +1595,7 @@ impl BrowserManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(t) = ti.get("title").and_then(|v| v.as_str()) {
|
if let Some(t) = ti.get("title").and_then(|v| v.as_str()) {
|
||||||
page.title = t.to_string();
|
page.title = sanitize_title(t);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1696,7 +1783,7 @@ impl BrowserManager {
|
|||||||
|
|
||||||
if let Some(page) = self.pages.get_mut(index) {
|
if let Some(page) = self.pages.get_mut(index) {
|
||||||
page.url = url.clone();
|
page.url = url.clone();
|
||||||
page.title = title.clone();
|
page.title = sanitize_title(&title);
|
||||||
}
|
}
|
||||||
|
|
||||||
let page = &self.pages[index];
|
let page = &self.pages[index];
|
||||||
@@ -2670,6 +2757,27 @@ mod tests {
|
|||||||
assert_eq!(active_page_index_after_removal(0, 0, 0), 0);
|
assert_eq!(active_page_index_after_removal(0, 0, 0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_target_error_matches_relay_signatures() {
|
||||||
|
// The exact relay error `open` must recover from (issue #35), as wrapped
|
||||||
|
// by send_command's `CDP error (Page.navigate): …` prefix.
|
||||||
|
assert!(is_stale_target_error(
|
||||||
|
"CDP error (Page.navigate): stale sessionId cb-tab-1655244623 for Page.navigate: \
|
||||||
|
its tab is gone (closed, navigated across processes, or lost after an extension \
|
||||||
|
restart). Re-attach by re-opening your target URL before retrying."
|
||||||
|
));
|
||||||
|
assert!(is_stale_target_error("unknown sessionId cb-tab-7 for Page.navigate"));
|
||||||
|
assert!(is_stale_target_error("no attached tab for Page.navigate"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_target_error_ignores_unrelated_failures() {
|
||||||
|
// A genuine navigation failure (bad URL, DNS, blocked) must NOT trigger
|
||||||
|
// the open-a-fresh-tab recovery — that would mask the real error.
|
||||||
|
assert!(!is_stale_target_error("Navigation failed: net::ERR_NAME_NOT_RESOLVED"));
|
||||||
|
assert!(!is_stale_target_error("CDP command timed out: Page.navigate"));
|
||||||
|
}
|
||||||
|
|
||||||
fn page(target_id: &str) -> PageInfo {
|
fn page(target_id: &str) -> PageInfo {
|
||||||
PageInfo {
|
PageInfo {
|
||||||
tab_id: 1,
|
tab_id: 1,
|
||||||
@@ -2767,6 +2875,19 @@ mod tests {
|
|||||||
assert!(!active_index_is_owned(&[], None, 0, &created));
|
assert!(!active_index_is_owned(&[], None, 0, &created));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_title() {
|
||||||
|
// The exact pollution from #33: ZWJ / word-joiner / invisible-times / BOM
|
||||||
|
// prepended to "GitHub".
|
||||||
|
let dirty = "\u{200d}\u{2061}\u{200d}\u{2063}\u{200b}\u{2062}\u{feff}GitHub";
|
||||||
|
assert_eq!(sanitize_title(dirty), "GitHub");
|
||||||
|
// Clean titles (incl. CJK + normal punctuation) pass through untouched.
|
||||||
|
assert_eq!(sanitize_title("購入手続きへ - メルカリ"), "購入手続きへ - メルカリ");
|
||||||
|
assert_eq!(sanitize_title(" Hello World "), "Hello World");
|
||||||
|
// Emoji and real content survive; only the invisibles are dropped.
|
||||||
|
assert_eq!(sanitize_title("✓ Done\u{200b}"), "✓ Done");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_mime_for_path() {
|
fn test_mime_for_path() {
|
||||||
assert_eq!(mime_for_path("a.png"), "image/png");
|
assert_eq!(mime_for_path("a.png"), "image/png");
|
||||||
|
|||||||
@@ -45,6 +45,31 @@ pub async fn click(
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Over the extension relay we drive the user's real, in-use Chrome, where a
|
||||||
|
// coordinate `Input.dispatchMouseEvent` is NOT reliably confined to our target
|
||||||
|
// tab — it can be delivered to whatever tab is in the foreground, and an OOPIF
|
||||||
|
// element's box can't be mapped to a top-viewport point at all. This twice
|
||||||
|
// opened an unrelated tab on the user's busy Chrome (issues #31/#36). So on the
|
||||||
|
// relay, never use coordinates for a normal left click: DOM-dispatch invokes
|
||||||
|
// the element's click in its own (frame) session, always hitting the right
|
||||||
|
// element in the right tab. Double/right clicks still need true pointer
|
||||||
|
// semantics, and `coord` mode is an explicit opt-out.
|
||||||
|
if mode != "coord" && button == "left" && click_count == 1 {
|
||||||
|
let in_iframe = parse_ref(selector_or_ref)
|
||||||
|
.and_then(|r| ref_map.get(&r).map(|e| e.frame_id.is_some()))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if in_iframe || crate::connect::relay_url().is_some() {
|
||||||
|
return dom_click(
|
||||||
|
client,
|
||||||
|
session_id,
|
||||||
|
ref_map,
|
||||||
|
selector_or_ref,
|
||||||
|
iframe_sessions,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let resolved = resolve_element_center(
|
let resolved = resolve_element_center(
|
||||||
client,
|
client,
|
||||||
session_id,
|
session_id,
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ pub struct ScreenshotOptions {
|
|||||||
pub quality: Option<i32>,
|
pub quality: Option<i32>,
|
||||||
pub annotate: bool,
|
pub annotate: bool,
|
||||||
pub output_dir: Option<String>,
|
pub output_dir: Option<String>,
|
||||||
|
/// Explicit pixel region (x, y, width, height) — `--clip` (issue #34). Takes
|
||||||
|
/// precedence over selector/full_page.
|
||||||
|
pub clip: Option<(f64, f64, f64, f64)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ScreenshotOptions {
|
impl Default for ScreenshotOptions {
|
||||||
@@ -72,6 +75,7 @@ impl Default for ScreenshotOptions {
|
|||||||
quality: None,
|
quality: None,
|
||||||
annotate: false,
|
annotate: false,
|
||||||
output_dir: None,
|
output_dir: None,
|
||||||
|
clip: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,7 +191,16 @@ async fn capture_screenshot_base64(
|
|||||||
capture_beyond_viewport: if options.full_page { Some(true) } else { None },
|
capture_beyond_viewport: if options.full_page { Some(true) } else { None },
|
||||||
};
|
};
|
||||||
|
|
||||||
if options.full_page {
|
if let Some((x, y, width, height)) = options.clip {
|
||||||
|
// Explicit pixel region wins over selector/full_page (issue #34).
|
||||||
|
params.clip = Some(Viewport {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
scale: 1.0,
|
||||||
|
});
|
||||||
|
} else if options.full_page {
|
||||||
let metrics: Value = client
|
let metrics: Value = client
|
||||||
.send_command_no_params("Page.getLayoutMetrics", Some(session_id))
|
.send_command_no_params("Page.getLayoutMetrics", Some(session_id))
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
+18
-1
@@ -1735,12 +1735,23 @@ Usage: chrome-use scroll [direction] [amount] [options]
|
|||||||
|
|
||||||
Scrolls the page or a specific element in the specified direction.
|
Scrolls the page or a specific element in the specified direction.
|
||||||
|
|
||||||
|
Without --selector, scroll dispatches a real (isTrusted) mouse wheel at a
|
||||||
|
viewport coordinate, so it scrolls whatever container is under the pointer —
|
||||||
|
including cross-origin iframes (Google Payments, Stripe, embedded checkout/KYC)
|
||||||
|
that plain page scroll can't reach.
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
direction up, down, left, right (default: down)
|
direction up, down, left, right (default: down)
|
||||||
amount Pixels to scroll (default: 300)
|
amount Pixels to scroll (default: 300)
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-s, --selector <sel> CSS selector for a scrollable container
|
-s, --selector <sel> CSS selector for a scrollable container (same-origin)
|
||||||
|
--at <x,y> Dispatch the wheel at this viewport pixel (read it from a
|
||||||
|
screenshot) — precise way into a cross-origin iframe
|
||||||
|
--frame <n> Scroll the n-th frame from `chrome-use frames` (wheel at
|
||||||
|
that frame's center)
|
||||||
|
|
||||||
|
Without --selector/--at/--frame the wheel lands at the viewport center.
|
||||||
|
|
||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
@@ -1752,6 +1763,8 @@ Examples:
|
|||||||
chrome-use scroll up 200
|
chrome-use scroll up 200
|
||||||
chrome-use scroll left 100
|
chrome-use scroll left 100
|
||||||
chrome-use scroll down 500 --selector "div.scroll-container"
|
chrome-use scroll down 500 --selector "div.scroll-container"
|
||||||
|
chrome-use scroll down 700 --at 640,400 # wheel at a pixel over an iframe
|
||||||
|
chrome-use scroll down 700 --frame 2 # scroll frame 2 from `frames`
|
||||||
"##
|
"##
|
||||||
}
|
}
|
||||||
"scrollintoview" | "scrollinto" => {
|
"scrollintoview" | "scrollinto" => {
|
||||||
@@ -1832,6 +1845,8 @@ Pass --hide-scrollbars false when launching to keep native scrollbars visible.
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
--full, -f Capture full page (not just viewport)
|
--full, -f Capture full page (not just viewport)
|
||||||
|
[selector] Capture just an element (CSS or @ref), e.g. `screenshot ".header" h.png`
|
||||||
|
--clip <x,y,w,h> Capture a pixel region, e.g. `screenshot --clip 0,0,200,40 corner.png`
|
||||||
--annotate Overlay numbered labels on interactive elements.
|
--annotate Overlay numbered labels on interactive elements.
|
||||||
Each label [N] corresponds to ref @eN from snapshot.
|
Each label [N] corresponds to ref @eN from snapshot.
|
||||||
Prints a legend mapping labels to element roles/names.
|
Prints a legend mapping labels to element roles/names.
|
||||||
@@ -1852,6 +1867,8 @@ Examples:
|
|||||||
chrome-use screenshot
|
chrome-use screenshot
|
||||||
chrome-use screenshot ./screenshot.png
|
chrome-use screenshot ./screenshot.png
|
||||||
chrome-use screenshot --full ./full-page.png
|
chrome-use screenshot --full ./full-page.png
|
||||||
|
chrome-use screenshot ".header .indicator" corner.png # just one element
|
||||||
|
chrome-use screenshot --clip 1600,0,200,40 corner.png # a pixel region
|
||||||
chrome-use screenshot --annotate # Labeled screenshot + legend
|
chrome-use screenshot --annotate # Labeled screenshot + legend
|
||||||
chrome-use screenshot --annotate ./page.png # Save annotated screenshot
|
chrome-use screenshot --annotate ./page.png # Save annotated screenshot
|
||||||
chrome-use screenshot --annotate --json # JSON output with annotations
|
chrome-use screenshot --annotate --json # JSON output with annotations
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "chrome-use",
|
"name": "chrome-use",
|
||||||
"version": "1.5.8",
|
"version": "1.5.11",
|
||||||
"description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default",
|
"description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@11.1.3",
|
"packageManager": "pnpm@11.1.3",
|
||||||
|
|||||||
@@ -287,6 +287,10 @@ chrome-use upload @e5 file1.pdf # upload file(s) — works over the exten
|
|||||||
# a File there (chunked under native-messaging's 1 MiB cap).
|
# a File there (chunked under native-messaging's 1 MiB cap).
|
||||||
# Works on file <input>s and drop/paste composers (e.g. X).
|
# Works on file <input>s and drop/paste composers (e.g. X).
|
||||||
chrome-use scroll down 500 # scroll page (up/down/left/right)
|
chrome-use scroll down 500 # scroll page (up/down/left/right)
|
||||||
|
chrome-use scroll down 700 --at 640,400 # wheel at a pixel — scrolls a cross-origin
|
||||||
|
# iframe (Payments/Stripe/checkout/KYC) that
|
||||||
|
# plain page scroll can't reach
|
||||||
|
chrome-use scroll down 700 --frame 2 # scroll frame 2 from `chrome-use frames`
|
||||||
chrome-use scrollintoview @e1 # scroll element into view
|
chrome-use scrollintoview @e1 # scroll element into view
|
||||||
chrome-use drag @e1 @e2 # drag and drop
|
chrome-use drag @e1 @e2 # drag and drop
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user