use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use serde_json::{Value, json};
use tokio::sync::broadcast::error::RecvError;
use tokio::time::{Instant, timeout};
use super::core::CdpCore;
use crate::Result;
#[derive(Debug, Clone)]
pub struct ScriptInfo {
pub script_id: String,
pub url: String,
pub length: u32,
pub is_wasm: bool,
pub source_map_url: String,
}
#[derive(Debug, Clone)]
pub struct ScriptMatch {
pub url: String,
pub script_id: String,
pub line_number: u32,
pub line_content: String,
pub snippet: String,
}
pub struct ChromiumScripts {
core: Arc<CdpCore>,
}
impl ChromiumScripts {
pub(crate) fn new(core: Arc<CdpCore>) -> Self {
Self { core }
}
pub async fn list(&self) -> Result<Vec<ScriptInfo>> {
let first = self.collect_scripts().await?;
if !first.is_empty() {
return Ok(first);
}
let _ = self.core.send("Debugger.disable", json!({})).await;
self.collect_scripts().await
}
async fn collect_scripts(&self) -> Result<Vec<ScriptInfo>> {
let mut events = self.core.conn.subscribe(); self.core.send("Debugger.enable", json!({})).await?;
let _ = self
.core
.send("Debugger.setSkipAllPauses", json!({ "skip": true }))
.await;
let sid = self.core.session_id.clone();
let quiet = Duration::from_millis(400);
let hard_deadline = Instant::now() + self.core.timeout();
let mut out: Vec<ScriptInfo> = Vec::new();
let mut seen = std::collections::HashSet::new();
loop {
let remain = hard_deadline.saturating_duration_since(Instant::now());
if remain.is_zero() {
break;
}
let wait_for = remain.min(quiet);
match timeout(wait_for, events.recv()).await {
Ok(Ok(ev)) => {
if ev.method != "Debugger.scriptParsed"
|| ev.session_id.as_deref() != Some(sid.as_str())
{
continue;
}
let info = parse_script_parsed(&ev.params);
if !info.script_id.is_empty() && seen.insert(info.script_id.clone()) {
out.push(info);
}
}
Ok(Err(RecvError::Lagged(_))) => continue,
Ok(Err(RecvError::Closed)) => break,
Err(_) => break, }
}
Ok(out)
}
pub async fn source(&self, script_id: &str) -> Result<String> {
let r = self
.core
.send("Debugger.getScriptSource", json!({ "scriptId": script_id }))
.await?;
Ok(r["scriptSource"].as_str().unwrap_or_default().to_string())
}
pub async fn grep(&self, needle: &str) -> Result<Vec<ScriptMatch>> {
self.grep_with(needle, false, false).await
}
pub async fn grep_with(
&self,
query: &str,
case_sensitive: bool,
is_regex: bool,
) -> Result<Vec<ScriptMatch>> {
let scripts = self.list().await?;
let mut out = Vec::new();
for s in &scripts {
if s.is_wasm {
continue;
}
let r = self
.core
.send(
"Debugger.searchInContent",
json!({
"scriptId": s.script_id,
"query": query,
"caseSensitive": case_sensitive,
"isRegex": is_regex,
}),
)
.await;
let Ok(r) = r else { continue };
if let Some(list) = r["result"].as_array() {
for m in list {
let line_content = m["lineContent"].as_str().unwrap_or_default().to_string();
let snippet = snippet_around(&line_content, query, case_sensitive);
out.push(ScriptMatch {
url: s.url.clone(),
script_id: s.script_id.clone(),
line_number: m["lineNumber"].as_u64().unwrap_or(0) as u32,
line_content,
snippet,
});
}
}
}
Ok(out)
}
pub async fn list_wasm(&self) -> Result<Vec<ScriptInfo>> {
Ok(self
.list()
.await?
.into_iter()
.filter(|s| s.is_wasm)
.collect())
}
pub async fn wasm_bytes(&self, script_id: &str) -> Result<Vec<u8>> {
let r = self
.core
.send("Debugger.getScriptSource", json!({ "scriptId": script_id }))
.await?;
let b64 = r["bytecode"].as_str().unwrap_or_default();
Ok(crate::util::base64_decode(b64).unwrap_or_default())
}
pub async fn dump_wasm(&self, dir: impl AsRef<Path>) -> Result<Vec<PathBuf>> {
let dir = dir.as_ref();
tokio::fs::create_dir_all(dir).await?;
let scripts = self.list().await?;
let mut written = Vec::new();
let mut used: HashMap<String, u32> = HashMap::new();
for s in &scripts {
if !s.is_wasm {
continue;
}
let bytes = match self.wasm_bytes(&s.script_id).await {
Ok(b) if !b.is_empty() => b,
_ => continue,
};
let fname = unique_filename(&s.url, &s.script_id, "wasm", &mut used);
let path = dir.join(fname);
tokio::fs::write(&path, &bytes).await?;
written.push(path);
}
Ok(written)
}
pub async fn dump_all(&self, dir: impl AsRef<Path>) -> Result<Vec<PathBuf>> {
self.dump_all_with(dir, true).await
}
pub async fn dump_all_with(
&self,
dir: impl AsRef<Path>,
beautify: bool,
) -> Result<Vec<PathBuf>> {
let dir = dir.as_ref();
tokio::fs::create_dir_all(dir).await?;
let scripts = self.list().await?;
let mut written = Vec::new();
let mut used: HashMap<String, u32> = HashMap::new();
for s in &scripts {
if s.is_wasm {
continue;
}
let src = match self.source(&s.script_id).await {
Ok(s) => s,
Err(_) => continue,
};
if src.is_empty() {
continue;
}
let content = if beautify { beautify_js(&src) } else { src };
let fname = unique_filename(&s.url, &s.script_id, "js", &mut used);
let path = dir.join(fname);
tokio::fs::write(&path, content.as_bytes()).await?;
written.push(path);
}
Ok(written)
}
}
fn parse_script_parsed(p: &Value) -> ScriptInfo {
ScriptInfo {
script_id: p["scriptId"].as_str().unwrap_or_default().to_string(),
url: p["url"].as_str().unwrap_or_default().to_string(),
length: p["length"].as_u64().unwrap_or(0) as u32,
is_wasm: p["scriptLanguage"].as_str() == Some("WebAssembly"),
source_map_url: p["sourceMapURL"].as_str().unwrap_or_default().to_string(),
}
}
fn snippet_around(line: &str, needle: &str, case_sensitive: bool) -> String {
const PAD: usize = 60;
if needle.is_empty() {
return line.chars().take(PAD * 2).collect();
}
let (hay, ndl) = if case_sensitive {
(line.to_string(), needle.to_string())
} else {
(line.to_lowercase(), needle.to_lowercase())
};
let Some(byte_pos) = hay.find(&ndl) else {
return line.chars().take(PAD * 2).collect();
};
let char_pos = hay[..byte_pos].chars().count();
let start = char_pos.saturating_sub(PAD);
let chars: Vec<char> = line.chars().collect();
let end = (char_pos + needle.chars().count() + PAD).min(chars.len());
let mut s = String::new();
if start > 0 {
s.push('…');
}
s.extend(&chars[start..end]);
if end < chars.len() {
s.push('…');
}
s
}
fn unique_filename(
url: &str,
script_id: &str,
ext: &str,
used: &mut HashMap<String, u32>,
) -> String {
let base = filename_base(url, script_id);
let dot_ext = format!(".{ext}");
let stem = base.strip_suffix(&dot_ext).unwrap_or(&base).to_string();
let mut name = format!("{stem}.{ext}");
let count = used.entry(name.clone()).or_insert(0);
if *count > 0 {
let n = *count;
name = format!("{stem}_{n}.{ext}");
}
*count += 1;
name
}
fn filename_base(url: &str, script_id: &str) -> String {
let sanitized_id: String = script_id
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
if url.is_empty() {
return format!("inline_{sanitized_id}");
}
let path = if let Some((_, rest)) = url.split_once("://") {
rest.split_once('/').map(|(_, p)| p).unwrap_or("")
} else {
url
};
let path = path.split(['?', '#']).next().unwrap_or("");
let seg = path.trim_end_matches('/').rsplit('/').next().unwrap_or("");
let safe: String = seg
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
let safe = safe.trim_matches('_').to_string();
if safe.is_empty() {
format!("inline_{sanitized_id}")
} else {
safe
}
}
pub fn beautify_js(src: &str) -> String {
let b = src.as_bytes();
let n = b.len();
let mut out: Vec<u8> = Vec::with_capacity(n * 2);
let mut indent: usize = 0;
let mut last_sig: u8 = 0; let mut i = 0;
while i < n {
let c = b[i];
match c {
b'"' | b'\'' => {
let q = c;
out.push(c);
i += 1;
while i < n {
let d = b[i];
out.push(d);
i += 1;
if d == b'\\' {
if i < n {
out.push(b[i]);
i += 1;
}
continue;
}
if d == q {
break;
}
}
last_sig = q;
}
b'`' => {
out.push(b'`');
i += 1;
while i < n {
let d = b[i];
out.push(d);
i += 1;
if d == b'\\' {
if i < n {
out.push(b[i]);
i += 1;
}
continue;
}
if d == b'`' {
break;
}
}
last_sig = b'`';
}
b'/' if i + 1 < n && b[i + 1] == b'/' => {
while i < n && b[i] != b'\n' {
out.push(b[i]);
i += 1;
}
}
b'/' if i + 1 < n && b[i + 1] == b'*' => {
out.push(b'/');
out.push(b'*');
i += 2;
while i < n {
if b[i] == b'*' && i + 1 < n && b[i + 1] == b'/' {
out.push(b'*');
out.push(b'/');
i += 2;
break;
}
out.push(b[i]);
i += 1;
}
}
b'/' if is_regex_pos(last_sig) => {
out.push(b'/');
i += 1;
let mut in_class = false;
while i < n {
let d = b[i];
out.push(d);
i += 1;
if d == b'\\' {
if i < n {
out.push(b[i]);
i += 1;
}
continue;
}
match d {
b'[' => in_class = true,
b']' => in_class = false,
b'/' if !in_class => break,
_ => {}
}
}
while i < n && b[i].is_ascii_alphabetic() {
out.push(b[i]);
i += 1;
}
last_sig = b'/';
}
b'{' => {
out.push(b'{');
indent += 1;
newline_indent(&mut out, indent);
last_sig = b'{';
i += 1;
while i < n && (b[i] == b' ' || b[i] == b'\t') {
i += 1;
}
}
b'}' => {
trim_trailing_ws(&mut out);
indent = indent.saturating_sub(1);
newline_indent(&mut out, indent);
out.push(b'}');
last_sig = b'}';
i += 1;
}
b';' => {
out.push(b';');
newline_indent(&mut out, indent);
last_sig = b';';
i += 1;
while i < n && (b[i] == b' ' || b[i] == b'\t') {
i += 1;
}
}
b'\n' | b'\r' => {
i += 1; }
b' ' | b'\t' => {
if !matches!(out.last(), Some(b' ') | Some(b'\n') | None) {
out.push(b' ');
}
i += 1;
}
_ => {
out.push(c);
if !c.is_ascii_whitespace() {
last_sig = c;
}
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
fn is_regex_pos(last_sig: u8) -> bool {
matches!(
last_sig,
0 | b'('
| b','
| b'='
| b':'
| b'['
| b'!'
| b'&'
| b'|'
| b'?'
| b'{'
| b'}'
| b';'
| b'~'
| b'+'
| b'-'
| b'*'
| b'%'
| b'<'
| b'>'
| b'^'
| b'\n'
)
}
fn newline_indent(out: &mut Vec<u8>, indent: usize) {
out.push(b'\n');
for _ in 0..indent {
out.push(b' ');
out.push(b' ');
}
}
fn trim_trailing_ws(out: &mut Vec<u8>) {
while matches!(
out.last(),
Some(b' ') | Some(b'\n') | Some(b'\t') | Some(b'\r')
) {
out.pop();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_script_parsed_basic_and_wasm() {
let js = json!({ "scriptId": "7", "url": "https://x/app.js", "length": 1234 });
let s = parse_script_parsed(&js);
assert_eq!(s.script_id, "7");
assert_eq!(s.url, "https://x/app.js");
assert_eq!(s.length, 1234);
assert!(!s.is_wasm);
let w =
json!({ "scriptId": "8", "url": "https://x/m.wasm", "scriptLanguage": "WebAssembly" });
assert!(parse_script_parsed(&w).is_wasm);
}
#[test]
fn filename_from_url_and_inline() {
assert_eq!(
filename_base("https://x.com/a/b/app.min.js?v=1", "3"),
"app.min.js"
);
assert_eq!(filename_base("https://x.com/", "3"), "inline_3");
assert_eq!(filename_base("", "12"), "inline_12");
assert_eq!(filename_base("https://x.com/a b!c.js", "3"), "a_b_c.js");
}
#[test]
fn unique_filename_dedupes() {
let mut used = HashMap::new();
assert_eq!(
unique_filename("https://x/app.js", "1", "js", &mut used),
"app.js"
);
assert_eq!(
unique_filename("https://x/app.js", "2", "js", &mut used),
"app_1.js"
);
assert_eq!(
unique_filename("https://x/app.js", "3", "js", &mut used),
"app_2.js"
);
}
#[test]
fn unique_filename_wasm_ext() {
let mut used = HashMap::new();
assert_eq!(
unique_filename("https://x/m.wasm", "1", "wasm", &mut used),
"m.wasm"
);
assert_eq!(
unique_filename("https://x/mod", "2", "wasm", &mut used),
"mod.wasm"
);
assert_eq!(
unique_filename("https://x/mod", "3", "wasm", &mut used),
"mod_1.wasm"
);
}
#[test]
fn snippet_truncates_around_match() {
let long = format!("{}x-ca-sign{}", "a".repeat(200), "b".repeat(200));
let s = snippet_around(&long, "x-ca-sign", false);
assert!(s.contains("x-ca-sign"));
assert!(s.starts_with('…') && s.ends_with('…'));
assert!(s.chars().count() < long.chars().count());
}
#[test]
fn beautify_braces_and_semicolons() {
let out = beautify_js("function f(){var a=1;if(a){b()}}");
assert!(out.contains("{\n"));
assert!(out.contains(";\n"));
let lines: Vec<&str> = out.lines().collect();
assert!(lines.len() >= 4, "应被拆成多行: {out:?}");
}
#[test]
fn beautify_keeps_braces_inside_strings() {
let out = beautify_js(r#"var s="a{b};c";f();"#);
assert!(
out.contains(r#""a{b};c""#),
"字符串字面量必须原样保留: {out:?}"
);
}
#[test]
fn beautify_keeps_template_and_regex() {
let out = beautify_js("var r=/a{2}\\/b/g;var t=`x${y};z`;");
assert!(out.contains("/a{2}\\/b/g"), "正则应原样: {out:?}");
assert!(out.contains("`x${y};z`"), "模板串应原样: {out:?}");
}
#[test]
fn beautify_handles_division_not_regex() {
let out = beautify_js("var x=a/b/c;");
assert!(out.contains("a/b/c"), "除法应原样: {out:?}");
}
#[test]
fn beautify_is_utf8_safe() {
let out = beautify_js(r#"var s="签名密钥";f();"#);
assert!(out.contains("签名密钥"));
}
}