Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42f47c49aa | ||
|
|
e29800df72 | ||
|
|
e7e849ea39 | ||
|
|
ebb02c65c8 |
Generated
+1
-1
@@ -290,7 +290,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrome-use"
|
name = "chrome-use"
|
||||||
version = "1.5.7"
|
version = "1.5.9"
|
||||||
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.7"
|
version = "1.5.9"
|
||||||
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"
|
||||||
|
|||||||
+48
-10
@@ -907,17 +907,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 +968,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 +4350,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();
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -7446,6 +7454,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(
|
||||||
|
|||||||
+227
-28
@@ -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,54 @@ 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
|
||||||
|
/// fallback (the page-constructed `File` needs a sensible `type`). Covers the
|
||||||
|
/// common upload kinds; anything unknown falls back to a generic binary type.
|
||||||
|
fn mime_for_path(name: &str) -> &'static str {
|
||||||
|
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
|
||||||
|
match ext.as_str() {
|
||||||
|
"png" => "image/png",
|
||||||
|
"jpg" | "jpeg" => "image/jpeg",
|
||||||
|
"gif" => "image/gif",
|
||||||
|
"webp" => "image/webp",
|
||||||
|
"svg" => "image/svg+xml",
|
||||||
|
"bmp" => "image/bmp",
|
||||||
|
"pdf" => "application/pdf",
|
||||||
|
"txt" => "text/plain",
|
||||||
|
"csv" => "text/csv",
|
||||||
|
"json" => "application/json",
|
||||||
|
"mp4" => "video/mp4",
|
||||||
|
"webm" => "video/webm",
|
||||||
|
"mov" => "video/quicktime",
|
||||||
|
"mp3" => "audio/mpeg",
|
||||||
|
"zip" => "application/zip",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Target ids to prune after a `Target.getTargets` resync: tracked pages whose
|
/// Target ids to prune after a `Target.getTargets` resync: tracked pages whose
|
||||||
/// target is no longer in the live set — EXCEPT the explicitly-pinned active
|
/// target is no longer in the live set — EXCEPT the explicitly-pinned active
|
||||||
/// target, which is protected. The relay against a busy real Chrome occasionally
|
/// target, which is protected. The relay against a busy real Chrome occasionally
|
||||||
@@ -748,7 +796,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(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -974,7 +1022,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();
|
||||||
|
|
||||||
@@ -1036,7 +1084,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> {
|
||||||
@@ -1379,7 +1427,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());
|
||||||
@@ -1440,7 +1488,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;
|
||||||
@@ -1483,7 +1531,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1671,7 +1719,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];
|
||||||
@@ -1926,7 +1974,8 @@ impl BrowserManager {
|
|||||||
.and_then(|v| v.as_i64())
|
.and_then(|v| v.as_i64())
|
||||||
.ok_or("Could not get backendNodeId for file input")?;
|
.ok_or("Could not get backendNodeId for file input")?;
|
||||||
|
|
||||||
self.client
|
let set_files = self
|
||||||
|
.client
|
||||||
.send_command(
|
.send_command(
|
||||||
"DOM.setFileInputFiles",
|
"DOM.setFileInputFiles",
|
||||||
Some(json!({
|
Some(json!({
|
||||||
@@ -1935,26 +1984,153 @@ impl BrowserManager {
|
|||||||
})),
|
})),
|
||||||
Some(&effective_session_id),
|
Some(&effective_session_id),
|
||||||
)
|
)
|
||||||
.await
|
.await;
|
||||||
.map_err(|e| {
|
|
||||||
// Chrome's chrome.debugger API (the extension-relay transport)
|
|
||||||
// forbids DOM.setFileInputFiles for security, surfacing as an
|
|
||||||
// opaque `-32000 "Not allowed"`. Translate it into an actionable
|
|
||||||
// message rather than leaking the raw CDP error (issue #13).
|
|
||||||
if e.contains("Not allowed") || e.contains("-32000") {
|
|
||||||
"file upload isn't supported over the extension relay — \
|
|
||||||
Chrome's chrome.debugger API forbids DOM.setFileInputFiles. \
|
|
||||||
Use a direct-CDP session instead: \
|
|
||||||
`chrome-use --session up --launch open <url>` (carry your \
|
|
||||||
login over with `cookies export` | `cookies set --curl`), \
|
|
||||||
then run `upload` in that session. \
|
|
||||||
See https://github.com/leeguooooo/chrome-use/issues/13"
|
|
||||||
.to_string()
|
|
||||||
} else {
|
|
||||||
e
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
|
if let Err(e) = set_files {
|
||||||
|
// Chrome's chrome.debugger API (the extension-relay transport) forbids
|
||||||
|
// DOM.setFileInputFiles for security, surfacing as an opaque
|
||||||
|
// `-32000 "Not allowed"`. Fall back to constructing the File entirely
|
||||||
|
// IN THE PAGE and assigning it to the input — the standard
|
||||||
|
// Playwright/Cypress trick, which needs no privileged CDP and so works
|
||||||
|
// over the relay (issue #13).
|
||||||
|
if e.contains("Not allowed") || e.contains("-32000") {
|
||||||
|
return self
|
||||||
|
.upload_files_via_page(object_id, files, &effective_session_id)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Relay-safe file upload: read each file locally, hand its bytes to the page
|
||||||
|
/// as base64, and rebuild a `File` there — then either assign it to a file
|
||||||
|
/// `<input>` (Chrome allows `input.files = dataTransfer.files`) or, for a
|
||||||
|
/// dropzone/composer, dispatch synthetic `paste`/`drop` events carrying the
|
||||||
|
/// `DataTransfer`. No `DOM.setFileInputFiles`, so chrome.debugger permits it.
|
||||||
|
async fn upload_files_via_page(
|
||||||
|
&self,
|
||||||
|
object_id: String,
|
||||||
|
files: &[String],
|
||||||
|
session_id: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use base64::Engine;
|
||||||
|
// The relay tunnels every CDP message through Chrome native messaging,
|
||||||
|
// which caps a single message at ~1 MiB. A whole image's base64 blows
|
||||||
|
// past that ("CDP response channel closed"), so we STREAM the bytes into
|
||||||
|
// a page-side buffer in sub-limit chunks, then assemble the File from it.
|
||||||
|
const CHUNK: usize = 96 * 1024; // base64 chars per message; safe under 1 MiB
|
||||||
|
|
||||||
|
// Reset the staging buffer.
|
||||||
|
self.client
|
||||||
|
.send_command(
|
||||||
|
"Runtime.evaluate",
|
||||||
|
Some(json!({ "expression": "window.__cuUpload = [];", "returnByValue": true })),
|
||||||
|
Some(session_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("relay upload (reset) failed: {}", e))?;
|
||||||
|
|
||||||
|
for path in files {
|
||||||
|
let bytes = std::fs::read(path).map_err(|e| format!("cannot read {}: {}", path, e))?;
|
||||||
|
let name = std::path::Path::new(path)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("upload.bin")
|
||||||
|
.to_string();
|
||||||
|
let mime = mime_for_path(&name);
|
||||||
|
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||||
|
|
||||||
|
// Push the file's metadata with an empty buffer.
|
||||||
|
let init = format!(
|
||||||
|
"window.__cuUpload.push({{ name: {}, type: {}, b64: '' }});",
|
||||||
|
serde_json::to_string(&name).unwrap_or_default(),
|
||||||
|
serde_json::to_string(mime).unwrap_or_default(),
|
||||||
|
);
|
||||||
|
self.client
|
||||||
|
.send_command(
|
||||||
|
"Runtime.evaluate",
|
||||||
|
Some(json!({ "expression": init, "returnByValue": true })),
|
||||||
|
Some(session_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("relay upload (init) failed: {}", e))?;
|
||||||
|
|
||||||
|
// Stream the base64 in chunks. base64's alphabet (A–Za–z0–9+/=) needs
|
||||||
|
// no escaping inside a single-quoted JS string, so concatenation is safe.
|
||||||
|
let idx = "window.__cuUpload[window.__cuUpload.length-1].b64";
|
||||||
|
let mut start = 0;
|
||||||
|
while start < b64.len() {
|
||||||
|
let end = (start + CHUNK).min(b64.len());
|
||||||
|
let chunk = &b64[start..end];
|
||||||
|
let expr = format!("{idx} += '{chunk}';");
|
||||||
|
self.client
|
||||||
|
.send_command(
|
||||||
|
"Runtime.evaluate",
|
||||||
|
Some(json!({ "expression": expr, "returnByValue": true })),
|
||||||
|
Some(session_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("relay upload (chunk) failed: {}", e))?;
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assemble the Files from the buffer and attach to the element, then clean up.
|
||||||
|
let func = r#"function() {
|
||||||
|
const filesData = window.__cuUpload || [];
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
for (const f of filesData) {
|
||||||
|
const bin = atob(f.b64);
|
||||||
|
const arr = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
||||||
|
dt.items.add(new File([arr], f.name, { type: f.type }));
|
||||||
|
}
|
||||||
|
try { delete window.__cuUpload; } catch (e) { window.__cuUpload = undefined; }
|
||||||
|
const el = this;
|
||||||
|
if (el.tagName === 'INPUT' && el.type === 'file') {
|
||||||
|
el.files = dt.files;
|
||||||
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
return 'input:' + dt.files.length;
|
||||||
|
}
|
||||||
|
// Dropzone / rich composer: replay paste then drop with the files.
|
||||||
|
try { el.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, clipboardData: dt })); } catch (e) {}
|
||||||
|
try {
|
||||||
|
const ev = new DragEvent('drop', { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(ev, 'dataTransfer', { value: dt });
|
||||||
|
el.dispatchEvent(ev);
|
||||||
|
} catch (e) {}
|
||||||
|
return 'event:' + dt.files.length;
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let result: EvaluateResult = self
|
||||||
|
.client
|
||||||
|
.send_command_typed(
|
||||||
|
"Runtime.callFunctionOn",
|
||||||
|
&CallFunctionOnParams {
|
||||||
|
function_declaration: func.to_string(),
|
||||||
|
object_id: Some(object_id),
|
||||||
|
arguments: None,
|
||||||
|
return_by_value: Some(true),
|
||||||
|
await_promise: Some(false),
|
||||||
|
},
|
||||||
|
Some(session_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("relay file-injection failed: {}", e))?;
|
||||||
|
|
||||||
|
if let Some(ref details) = result.exception_details {
|
||||||
|
return Err(format!(
|
||||||
|
"relay file-injection threw: {}",
|
||||||
|
details
|
||||||
|
.exception
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|ex| ex.description.as_deref())
|
||||||
|
.unwrap_or(&details.text)
|
||||||
|
));
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2614,6 +2790,29 @@ 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]
|
||||||
|
fn test_mime_for_path() {
|
||||||
|
assert_eq!(mime_for_path("a.png"), "image/png");
|
||||||
|
assert_eq!(mime_for_path("PHOTO.JPG"), "image/jpeg");
|
||||||
|
assert_eq!(mime_for_path("clip.webp"), "image/webp");
|
||||||
|
assert_eq!(mime_for_path("doc.pdf"), "application/pdf");
|
||||||
|
assert_eq!(mime_for_path("noext"), "application/octet-stream");
|
||||||
|
assert_eq!(mime_for_path("weird.xyz"), "application/octet-stream");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prune_protects_pinned_target_on_transient_snapshot() {
|
fn prune_protects_pinned_target_on_transient_snapshot() {
|
||||||
// The relay returned a getTargets snapshot missing the pinned tab "A"
|
// The relay returned a getTargets snapshot missing the pinned tab "A"
|
||||||
|
|||||||
@@ -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?;
|
||||||
|
|||||||
@@ -1832,6 +1832,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 +1854,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.7",
|
"version": "1.5.9",
|
||||||
"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",
|
||||||
|
|||||||
@@ -281,11 +281,11 @@ chrome-use pick @e4 --option "Europe" # ANY combobox (react-select / ARIA /
|
|||||||
# (no silent no-op). Use this for custom
|
# (no silent no-op). Use this for custom
|
||||||
# dropdowns where `select` returns ✓ but
|
# dropdowns where `select` returns ✓ but
|
||||||
# changes nothing.
|
# changes nothing.
|
||||||
chrome-use upload @e5 file1.pdf # upload file(s) — NOTE: needs a --launch/direct-CDP
|
chrome-use upload @e5 file1.pdf # upload file(s) — works over the extension relay too:
|
||||||
# session. Over the extension relay it CANNOT work
|
# chrome.debugger forbids setFileInputFiles, so the
|
||||||
# (Chrome's chrome.debugger forbids it); chrome-use
|
# file's bytes are streamed into the page and rebuilt as
|
||||||
# errors with a hint. Carry your login into a launched
|
# a File there (chunked under native-messaging's 1 MiB cap).
|
||||||
# session via `cookies export` | `cookies set --curl`.
|
# 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 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