use super::cdp::{self, Endpoint};
use crate::utils::env;
use std::{
process::Stdio,
time::{Duration, Instant},
};
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind};
const POLL_INTERVAL: Duration = Duration::from_millis(200);
const DISCOVER_ATTEMPT: Duration = Duration::from_secs(2);
const CHROMIUM_SWITCHES: &[&str] = &["--no-first-run", "--no-default-browser-check"];
const FIREFOX_SWITCHES: &[&str] = &["--no-remote", "--new-instance"];
#[cfg(target_os = "linux")]
const BUILTIN: &[Builtin] = &[
Builtin {
program: "google-chrome",
process_names: &["google-chrome", "chrome"],
},
Builtin {
program: "google-chrome-stable",
process_names: &["google-chrome-stable", "chrome"],
},
Builtin {
program: "chromium",
process_names: &["chromium", "chrome"],
},
Builtin {
program: "chromium-browser",
process_names: &["chromium-browser", "chromium", "chrome"],
},
Builtin {
program: "microsoft-edge",
process_names: &["microsoft-edge", "msedge"],
},
Builtin {
program: "brave-browser",
process_names: &["brave-browser", "brave"],
},
Builtin {
program: "brave",
process_names: &["brave", "brave-browser"],
},
];
#[cfg(target_os = "macos")]
const BUILTIN: &[Builtin] = &[
Builtin {
program: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
process_names: &["google chrome", "chrome"],
},
Builtin {
program: "/Applications/Chromium.app/Contents/MacOS/Chromium",
process_names: &["chromium", "chrome"],
},
Builtin {
program: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
process_names: &["microsoft edge", "msedge"],
},
Builtin {
program: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
process_names: &["brave browser", "brave"],
},
];
#[cfg(target_os = "windows")]
const BUILTIN: &[Builtin] = &[
Builtin {
program: "${ProgramFiles}\\Google\\Chrome\\Application\\chrome.exe",
process_names: &["chrome"],
},
Builtin {
program: "${ProgramFiles(x86)}\\Google\\Chrome\\Application\\chrome.exe",
process_names: &["chrome"],
},
Builtin {
program: "${LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe",
process_names: &["chrome"],
},
Builtin {
program: "${ProgramFiles(x86)}\\Microsoft\\Edge\\Application\\msedge.exe",
process_names: &["msedge"],
},
Builtin {
program: "${ProgramFiles}\\BraveSoftware\\Brave-Browser\\Application\\brave.exe",
process_names: &["brave"],
},
];
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
const BUILTIN: &[Builtin] = &[
Builtin {
program: "google-chrome",
process_names: &["google-chrome", "chrome"],
},
Builtin {
program: "chromium",
process_names: &["chromium", "chrome"],
},
];
struct Builtin {
program: &'static str,
process_names: &'static [&'static str],
}
pub fn default_browsers() -> Vec<String> {
BUILTIN
.iter()
.map(|builtin| builtin.program.to_string())
.collect()
}
pub fn process_names(program: &str) -> Vec<String> {
let trimmed = program.trim();
match BUILTIN.iter().find(|builtin| builtin.program == trimmed) {
Some(builtin) => builtin
.process_names
.iter()
.map(|name| (*name).to_string())
.collect(),
None => vec![basename(trimmed)],
}
}
fn launch_switches(program: &str) -> &'static [&'static str] {
let is_firefox = process_names(program)
.iter()
.any(|name| name.starts_with("firefox"));
if is_firefox {
FIREFOX_SWITCHES
} else {
CHROMIUM_SWITCHES
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Running {
Absent,
NoDebugPort,
DebugPorts(Vec<u16>),
}
pub struct ProcessSnapshot {
processes: Vec<ProcessInfo>,
}
impl ProcessSnapshot {
pub fn scan() -> Self {
let mut system = System::new();
system.refresh_processes_specifics(
ProcessesToUpdate::All,
true,
ProcessRefreshKind::nothing()
.with_cmd(UpdateKind::Always)
.with_exe(UpdateKind::Always),
);
Self {
processes: system.processes().values().map(process_info).collect(),
}
}
pub fn state_of(&self, program: &str) -> Running {
classify(&self.processes, &process_names(program))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ProcessInfo {
exe: String,
argv0: String,
command_line: String,
}
impl ProcessInfo {
fn is_named(&self, name: &str) -> bool {
self.exe == name || self.argv0 == name
}
}
fn process_info(process: &sysinfo::Process) -> ProcessInfo {
let name_of = |value: Option<&std::ffi::OsStr>| {
value
.map(|value| basename(&value.to_string_lossy()))
.unwrap_or_default()
};
ProcessInfo {
exe: name_of(process.exe().map(|path| path.as_os_str())),
argv0: name_of(process.cmd().first().map(|arg| arg.as_os_str())),
command_line: process
.cmd()
.iter()
.map(|arg| arg.to_string_lossy())
.collect::<Vec<_>>()
.join(" "),
}
}
fn classify(processes: &[ProcessInfo], names: &[String]) -> Running {
let mut found = false;
let mut ports: Vec<u16> = Vec::new();
for process in processes {
if !names.iter().any(|name| process.is_named(name)) {
continue;
}
found = true;
if let Some(port) = debug_port(&process.command_line)
&& !ports.contains(&port)
{
ports.push(port);
}
}
if !ports.is_empty() {
Running::DebugPorts(ports)
} else if found {
Running::NoDebugPort
} else {
Running::Absent
}
}
fn debug_port(command_line: &str) -> Option<u16> {
command_line.split_whitespace().find_map(|token| {
token
.trim_matches(|c| c == '"' || c == '\'')
.strip_prefix("--remote-debugging-port=")
.and_then(|value| value.parse::<u16>().ok())
})
}
fn basename(token: &str) -> String {
let token = token.trim_matches(|c| c == '"' || c == '\'');
let name = token.rsplit(['/', '\\']).next().unwrap_or(token);
let name = name.to_ascii_lowercase();
match name.strip_suffix(".exe") {
Some(stripped) => stripped.to_string(),
None => name,
}
}
pub async fn spawn_detached(program: &str, port: u16) -> Result<u32, String> {
let program = env::expand(program.trim());
let mut cmd = tokio::process::Command::new(&program);
cmd.arg(format!("--remote-debugging-port={port}"))
.args(launch_switches(&program))
.arg("about:blank")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
#[cfg(unix)]
cmd.process_group(0);
#[cfg(windows)]
{
const DETACHED_PROCESS: u32 = 0x0000_0008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
}
let mut child = cmd
.spawn()
.map_err(|e| format!("browser: cannot start `{program}`: {e}"))?;
let pid = child
.id()
.ok_or_else(|| format!("browser: `{program}` exited right after being started"))?;
tokio::spawn(async move {
_ = child.wait().await;
});
Ok(pid)
}
pub fn cdp_hint(program: &str) -> Option<&'static str> {
let is_firefox = process_names(program)
.iter()
.any(|name| name.starts_with("firefox"));
is_firefox.then_some(
"Firefox's remote agent serves WebDriver BiDi only, not the CDP `/json/version` endpoint, \
so it cannot be driven by this tool; use a Chromium-based browser or an older Firefox \
whose remote agent still speaks CDP",
)
}
pub async fn wait_for_endpoint(
host: &str,
port: u16,
budget: Duration,
) -> Result<Endpoint, String> {
let deadline = Instant::now() + budget;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(format!(
"its debug endpoint at {host}:{port} did not come up within {}s",
budget.as_secs()
));
}
match cdp::discover(host, port, remaining.min(DISCOVER_ATTEMPT)).await {
Ok(endpoint) => return Ok(endpoint),
Err(_) => tokio::time::sleep(POLL_INTERVAL).await,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn process(name: &str, line: &str) -> ProcessInfo {
ProcessInfo {
exe: name.to_string(),
argv0: name.to_string(),
command_line: line.to_string(),
}
}
fn builtin_aliases() -> Vec<String> {
let program = default_browsers()
.into_iter()
.next()
.expect("内置候选列表非空");
process_names(&program)
}
#[test]
fn builtin_candidates_are_listed_with_aliases() {
let browsers = default_browsers();
assert!(!browsers.is_empty());
for program in &browsers {
let names = process_names(program);
assert!(!names.is_empty(), "{program} 应有进程名别名");
assert!(
names.iter().all(|name| name == &basename(name)),
"{program} 的别名应已规范化(小写、无目录、无 .exe):{names:?}"
);
}
}
#[test]
fn aliases_come_from_the_table_or_the_command_name() {
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
assert!(process_names("google-chrome").contains(&"chrome".to_string()));
assert_eq!(
process_names("/opt/weird/MyBrowser.exe"),
vec!["mybrowser".to_string()]
);
assert_eq!(process_names("firefox"), vec!["firefox".to_string()]);
}
#[test]
fn basename_normalises_paths_and_quotes() {
assert_eq!(
basename("\"C:\\Program Files\\Google\\Chrome\\chrome.exe\""),
"chrome"
);
assert_eq!(
basename("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
"google chrome"
);
assert_eq!(basename("Chrome"), "chrome");
}
#[test]
fn debug_port_is_read_from_the_command_line() {
assert_eq!(
debug_port("/opt/google/chrome/chrome --type=renderer --remote-debugging-port=9333"),
Some(9333)
);
assert_eq!(debug_port("chrome about:blank"), None);
assert_eq!(
debug_port("chrome --remote-debugging-port=not-a-port"),
None
);
}
#[test]
fn classify_distinguishes_child_processes_from_the_owner() {
let names = builtin_aliases();
let alias = names[0].clone();
let renderer_only = vec![process(&alias, "chrome --type=renderer")];
assert_eq!(classify(&renderer_only, &names), Running::NoDebugPort);
let with_port = vec![
process(&alias, "chrome --type=renderer"),
process(&alias, "chrome --remote-debugging-port=9333"),
];
assert_eq!(
classify(&with_port, &names),
Running::DebugPorts(vec![9333])
);
let pipe = vec![process(&alias, "chrome --remote-debugging-pipe")];
assert_eq!(classify(&pipe, &names), Running::NoDebugPort);
let unrelated = vec![process("firefox", "firefox --remote-debugging-port=9333")];
assert_eq!(classify(&unrelated, &names), Running::Absent);
}
#[test]
fn multi_call_binaries_match_through_argv0() {
let info = ProcessInfo {
exe: "coreutils".to_string(),
argv0: "sleep".to_string(),
command_line: "sleep 30".to_string(),
};
assert_eq!(
classify(&[info], &["sleep".to_string()]),
Running::NoDebugPort
);
}
#[test]
fn wrapper_scripts_match_through_the_real_binary() {
let info = ProcessInfo {
exe: "chrome".to_string(),
argv0: "google-chrome".to_string(),
command_line: "google-chrome".to_string(),
};
assert_eq!(
classify(&[info], &process_names("google-chrome")),
Running::NoDebugPort
);
}
#[test]
fn only_the_executable_name_counts_as_a_match() {
let names = vec!["browser".to_string()];
let processes = vec![process("grep", "grep -rn browser /somewhere")];
assert_eq!(classify(&processes, &names), Running::Absent);
}
#[test]
fn classify_deduplicates_ports() {
let names = builtin_aliases();
let alias = names[0].clone();
let commands = vec![
process(&alias, "chrome --remote-debugging-port=9222"),
process(&alias, "chrome --remote-debugging-port=9222"),
];
assert_eq!(classify(&commands, &names), Running::DebugPorts(vec![9222]));
}
#[test]
fn firefox_is_not_a_default_candidate_but_gets_its_own_switches() {
let browsers = default_browsers();
assert!(
!browsers.iter().any(|program| program.contains("firefox")),
"默认候选不应含 Firefox:{browsers:?}"
);
assert_eq!(launch_switches("firefox"), FIREFOX_SWITCHES);
assert_eq!(launch_switches("firefox-esr"), FIREFOX_SWITCHES);
assert_eq!(
launch_switches("/opt/ff/firefox"),
FIREFOX_SWITCHES,
"自定义路径也按家族判定"
);
assert_eq!(
launch_switches("google-chrome"),
&["--no-first-run", "--no-default-browser-check"]
);
assert_eq!(
launch_switches("/opt/my-chromium"),
CHROMIUM_SWITCHES,
"自定义项默认按 Chromium 系处理"
);
}
#[test]
fn only_firefox_carries_the_bidi_hint() {
assert!(cdp_hint("firefox").unwrap().contains("BiDi"));
assert!(cdp_hint("/usr/lib/firefox-esr/firefox-esr").is_some());
assert!(cdp_hint("google-chrome").is_none());
assert!(cdp_hint("chromium").is_none());
}
#[tokio::test]
async fn wait_for_endpoint_gives_up_on_schedule() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let started = Instant::now();
let error = wait_for_endpoint("127.0.0.1", port, Duration::from_millis(400))
.await
.unwrap_err();
assert!(error.contains(&port.to_string()), "{error}");
assert!(
started.elapsed() < Duration::from_secs(5),
"不该超出预算太多"
);
}
#[cfg(unix)]
#[test]
fn a_real_process_shows_up_in_the_snapshot() {
let Ok(mut child) = std::process::Command::new("sleep").arg("30").spawn() else {
return; };
std::thread::sleep(Duration::from_millis(300));
let snapshot = ProcessSnapshot::scan();
assert_eq!(snapshot.state_of("sleep"), Running::NoDebugPort);
assert_eq!(snapshot.state_of("zzz-nothing-here"), Running::Absent);
_ = child.kill();
_ = child.wait();
}
#[test]
fn the_snapshot_includes_this_test_process() {
let processes = ProcessSnapshot::scan().processes;
assert!(!processes.is_empty());
let self_name = basename(
&std::env::current_exe()
.expect("测试进程应有可执行文件路径")
.file_name()
.unwrap_or_default()
.to_string_lossy(),
);
assert!(
processes
.iter()
.any(|process| process.exe == self_name || process.argv0 == self_name),
"进程列表里应有本测试进程({self_name})"
);
}
}