crates\nfarms-core\src\browser.rs¶
源码路径:
crates\nfarms-core\src\browser.rs· 522 行
//! 浏览器引擎探测 —— Browser 组的「引擎」「内核」两页共用。
//!
//! 这里**只探测、不内嵌**。内嵌 WebView2 要引 `webview2-com` + `windows` crate
//! (目前唯一需要重依赖的地方),那是下一步;而没有它之前,这两页至少要把
//! 「这台机器上有哪些引擎、各是什么版本」说清楚 —— 这本身就是有用信息:
//! WebView2 运行时没装的话,内嵌方案根本跑不起来,先看见比事后调试强。
use std::path::PathBuf;
/// `reg query` 的超时(毫秒)。
///
/// **为什么是 15 秒**:读注册表一个值是**本机**操作,正常几毫秒就回;给这么宽是为了
/// 兜住 `reg.exe` 被杀软/钩子挂住的情况 —— 探测跑在后台任务里,宁可慢一次,也不能
/// 因为一次挂起把这一页永远停在"探测中"。
/// **为什么不收紧**:冷启动、被杀软插一脚的本机命令真能到秒级,收紧了会把"装了"
/// 误判成"没装"。**为什么提成常量**:原来两个超时是散在函数体里的裸字面量,
/// 改一处忘一处没有任何提示。
const REG_TIMEOUT_MS: u64 = 15_000;
/// 起一次 `powershell -NoProfile` 的超时(毫秒)。
///
/// **为什么比 [`REG_TIMEOUT_MS`] 更宽**:这里每次都要冷启动 PowerShell(加载 CLR、
/// 初始化运行时),而且每个引擎各起一次 —— 不留出冷启动时间的话,机械盘或杀软全量
/// 扫描的机器上会稳定拿不到版本号,看起来就像"引擎没装"。
const PS_TIMEOUT_MS: u64 = 20_000;
/// 一个可用的浏览器引擎。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Engine {
/// 显示名
pub name: String,
/// 版本(拿不到就是空)
pub version: String,
/// 可执行文件;纯运行时(WebView2)没有 exe 时为 None
pub exe: Option<PathBuf>,
/// 说明它是什么、能干什么
pub note: String,
}
impl Engine {
pub fn available(&self) -> bool {
self.exe.is_some() || !self.version.is_empty()
}
/// 一行摘要。
pub fn summary(&self) -> String {
match (self.version.is_empty(), &self.exe) {
(false, _) => format!("v{}", self.version),
(true, Some(p)) => p.display().to_string(),
(true, None) => "(版本未知)".to_string(),
}
}
}
/// 读注册表某个键下的一个值(`reg query`)。读不到返回 None。
fn reg_value(key: &str, name: &str) -> Option<String> {
let text = crate::tools::capture(&format!("reg query \"{key}\" /v {name}"), REG_TIMEOUT_MS);
for line in text.lines() {
let cols: Vec<&str> = line.split_whitespace().collect();
if cols.len() >= 3 && cols[0].eq_ignore_ascii_case(name) {
// 按类型标记定位值,不按空白切列(值里可能有空格)
let kind = cols[1];
return line
.find(kind)
.map(|p| line[p + kind.len()..].trim().to_string());
}
}
None
}
/// WebView2 运行时版本。
///
/// 三处都要看:微软文档里说客户端装、机器装、以及 Edge 自带的那个会写在不同位置。
pub fn webview2_version() -> Option<String> {
const KEYS: [&str; 3] = [
r"HKLM\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}",
r"HKLM\SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}",
r"HKCU\SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}",
];
for k in KEYS {
if let Some(v) = reg_value(k, "pv") {
let v = v.trim().to_string();
// 全 0 表示「没装」,不是版本号
if !v.is_empty() && v != "0.0.0.0" {
return Some(v);
}
}
}
None
}
fn find_exe(cands: &[PathBuf]) -> Option<PathBuf> {
cands.iter().find(|p| p.is_file()).cloned()
}
/// 找引擎 exe 时依次看的环境变量。**数组顺序就是优先级**:[`find_exe`] 取第一个命中的,
/// 所以排在前面的赢。
///
/// 为什么是这个顺序:
/// 1. `ProgramFiles` —— 64 位系统级的常规安装位置(Edge / Chrome 默认装在这里);
/// 2. `ProgramFiles(x86)` —— 32 位视图。32 位进程里系统会把 `ProgramFiles` 重定向到
/// `(x86)`,所以两条都得留,不然换进程位数就换结果;
/// 3. `ProgramW6432` —— 32 位进程里唯一能拿到真实 64 位目录的变量(64 位进程里它和
/// `ProgramFiles` 取值相同,重复一项只多一次 `is_file`,不产生错误结果);
/// 4. `LOCALAPPDATA` —— 浏览器选"仅为我安装"时落在用户目录下。
const PROGRAM_DIR_VARS: [&str; 4] = [
"ProgramFiles",
"ProgramFiles(x86)",
"ProgramW6432",
"LOCALAPPDATA",
];
/// 同一个相对路径在几个「程序目录」下的候选位置(顺序见 [`PROGRAM_DIR_VARS`])。
fn program_files(rel: &str) -> Vec<PathBuf> {
program_files_from(
PROGRAM_DIR_VARS
.iter()
.filter_map(|name| std::env::var(name).ok()),
rel,
)
}
/// [`program_files`] 里不碰环境变量的那一半 —— 单独拆出来是为了能测。
///
/// **做法**:用 `PathBuf::join` 拼,而不是 `format!("{base}\\{rel}")`。
/// **理由**:字符串拼接把「路径」当「文本」:`base` 末尾带分隔符时会拼出 `\\`,
/// `rel` 是多段相对路径时也得靠人肉保证分隔符位置;`join` 按路径语义拼。
/// **不这么做会怎样**:Windows 容忍多余分隔符,所以当时并没有出错结果 ——
/// 这条是防后患,顺带让"候选从哪来"有唯一一处可断言的地方。
fn program_files_from<I: IntoIterator<Item = String>>(bases: I, rel: &str) -> Vec<PathBuf> {
bases
.into_iter()
.map(|base| PathBuf::from(base).join(rel))
.collect()
}
/// IE 的候选路径:**先走 [`program_files`] 的环境变量,再兜底原来那条硬编码绝对路径**。
///
/// 之前只认死写的 `C:\Program Files\...`,于是 `ProgramFiles` 被改写、或系统装在别的
/// 盘的机器上会漏检;但只用环境变量又会丢掉"老实现能找到的那些机器",所以两档都留,
/// 顺序是环境变量优先。
fn ie_exe_candidates() -> Vec<PathBuf> {
let mut v = program_files(r"Internet Explorer\iexplore.exe");
v.push(PathBuf::from(
r"C:\Program Files\Internet Explorer\iexplore.exe",
));
v
}
/// 探测全部引擎。`paths` 用来找 `libs\servo\` 这种随程序走的引擎。
pub fn detect_engines(paths: &crate::Paths) -> Vec<Engine> {
let mut out = Vec::new();
// 1) WebView2 运行时(Chromium)—— 内嵌方案真正依赖的那个
let wv2 = webview2_version();
out.push(Engine {
name: "Chromium(WebView2 运行时)".to_string(),
version: wv2.clone().unwrap_or_default(),
exe: None,
note: if wv2.is_some() {
"内嵌方案就用它。已安装 —— webview2-com 那个方案在这台机器上能跑起来。".to_string()
} else {
"没装 WebView2 运行时 —— 内嵌 Chromium 的方案在它之前无从谈起。\
Win10/11 通常随 Edge 自带;装不上就装「Microsoft Edge WebView2 Runtime」。"
.to_string()
},
});
// 2) Edge(Chromium 内核)
let edge = find_exe(&program_files(r"Microsoft\Edge\Application\msedge.exe"));
out.push(Engine {
name: "Microsoft Edge(Chromium)".to_string(),
version: edge
.as_ref()
.and_then(|p| exe_version(p))
.unwrap_or_default(),
exe: edge,
note: "系统自带。作为外链浏览器用;它的内核就是上面的 WebView2。".to_string(),
});
// 3) Chrome
let chrome = find_exe(&program_files(r"Google\Chrome\Application\chrome.exe"));
out.push(Engine {
name: "Google Chrome".to_string(),
version: chrome
.as_ref()
.and_then(|p| exe_version(p))
.unwrap_or_default(),
exe: chrome,
note: "有就用,没有也不影响。".to_string(),
});
// 4) IE(兼容内核)
//
// **注释订正**:这里从来就是**文件探测**,不读 CLSID —— 原来的注释写"靠 CLSID
// 而不是 exe",与本文件里没有任何 COM/CLSID 查询的事实对不上。保留文件探测的理由:
// 真去查 `InternetExplorer.Application` 的 CLSID,最后落到的还是 `iexplore.exe`;
// 而 Win10 起这个 exe 多半只是个"把你转交给 Edge"的存根 —— 所以"探测到"≠
// "IE 内核还能用",note 里照实写。
// 版本这一档**故意留空**:IE 没有独立版本号(随 Windows 版本走),读存根 exe 的
// 文件版本对"IE 内核能不能用"没有说明力,只会多一次 PowerShell 启动。
let ie = find_exe(&ie_exe_candidates());
out.push(Engine {
name: "IE(兼容内核)".to_string(),
version: String::new(),
exe: ie,
note: "只在极老的政务站点上还有意义。Win11 上通常已经没有了;\
Win10 起 iexplore.exe 往往只是转交 Edge 的存根,探测到 ≠ IE 内核可用。"
.to_string(),
});
// 5) Servo(实验,原版留的占位)
// 随程序走的引擎放 libs\servo\,所以要看程序根而不只是 Program Files
let servo = find_exe(&[paths.lib("servo").join("servo.exe")]);
out.push(Engine {
name: "Servo(实验)".to_string(),
// **探测到就顺手取版本**,和 Edge/Chrome 同一套口径。
// 原来无条件写空串,后果是"装了 Servo 但没报版本"和"没装"在界面上长得一样
// (`summary()` 会退化成打路径),`available()` 也说不清是凭什么判可用。
version: servo
.as_ref()
.and_then(|p| exe_version(p))
.unwrap_or_default(),
exe: servo,
note: "原版留的实验选项。把 servo.exe 放到 libs\\servo\\ 就会出现;没装就是没装,不假装它能用。".to_string(),
});
out
}
/// 把一个字符串安全地嵌进 PowerShell 的**单引号**字符串。
///
/// **做法**:把 `'` 写成两个 `''`(PowerShell 单引号串里唯一能表示单引号的写法)。
/// **理由**:原来的实现是 `.replace('\'', " ")` —— 把单引号**换成空格**,于是
/// `C:\Users\O'Brien\...` 变成 `C:\Users\O Brien\...`,那是**另一个不存在的路径**。
/// **不这么做会怎样**:`Get-Item` 报错,错误文本走 stderr,而 [`crate::tools::capture`]
/// 把 stderr 与 stdout 合并返回(只过滤三种"命令不存在"噪音),于是这段英文报错会被
/// 当成**版本号**显示在引擎行上 —— 用户名里带 `'` 的机器 100% 复现。
fn ps_quote(s: &str) -> String {
s.replace('\'', "''")
}
/// 取一个 exe 的文件版本(不去解析 PE 资源,直接问 shell —— 零依赖)。
fn exe_version(path: &std::path::Path) -> Option<String> {
// `-LiteralPath` 而不是默认的 `-Path`:后者把 `[` `]` 当通配符,
// `C:\a[1]\x.exe` 这种路径会被当成匹配模式而匹配不到。
let script = format!(
"(Get-Item -LiteralPath '{}').VersionInfo.FileVersion",
ps_quote(&path.display().to_string())
);
let out = crate::tools::capture(
&format!("powershell -NoProfile -Command \"{script}\""),
PS_TIMEOUT_MS,
);
let v = out.trim().to_string();
if v.is_empty() { None } else { Some(v) }
}
/// 校验并规整一个"交给系统浏览器"的网址。
///
/// **做法**:只放行 `http://` / `https://`(scheme 大小写不敏感 —— URL 语义上
/// `HTTPS://` 和 `https://` 是同一个),去掉两侧空白,并要求协议后面还有主机名;
/// 其余一律拒绝,连空串都拒。另外拒绝含空白/控制字符的串。
/// **理由**:[`open_external`] 是把这条字符串塞进命令行交给 `cmd /c start` 的,而输入
/// 来自设置里的自由文本(`browser_home`)和 Grafana 地址输入框 —— 不是"什么都能填"。
/// 放行任意字符串等于让这两个框决定"运行哪条命令"(裸填 `notepad` 会被 shell 当命令执行),
/// 也让 `file:` / `javascript:` 这类协议落到系统注册的默认处理程序上,后果取决于注册表。
/// **为什么不顺手做 cmd 转义**:`&` 之类的字符在 `cmd /c start` 里究竟怎么解析,需要有
/// 链接器的机器实测(本仓库当前环境测不了),没实测就加"转义"是猜。所以这里只做明确的、
/// 可断言的协议白名单;带 `&` 的**正常查询串**(`?a=1&ver`)是放行的。
fn normalize_external_url(url: &str) -> Result<String, String> {
let u = url.trim();
if u.is_empty() {
return Err("网址是空的".to_string());
}
let lower = u.to_ascii_lowercase();
let Some(rest) = lower
.strip_prefix("http://")
.or_else(|| lower.strip_prefix("https://"))
else {
return Err(format!("只接受 http:// 或 https:// 开头的网址,收到:{u}"));
};
if rest.is_empty() {
return Err(format!("网址里没有主机名:{u}"));
}
// 空白/控制字符既不是合法网址内容(真要空格得写成 %20),又会让交给 `cmd` 的那条
// 参数被拆开 —— 直接拒绝,不做"截到第一个空白为止"这种猜测。
if let Some(c) = u.chars().find(|c| c.is_whitespace() || c.is_control()) {
return Err(format!("网址里含空白或控制字符 {c:?},已拒绝:{u}"));
}
Ok(u.to_string())
}
/// 用系统默认程序打开一个 URL。
pub fn open_external(url: &str) -> Result<(), String> {
// 先过白名单:进来的可能是用户随手填的串,不能原样当命令行内容用
// (见 [`normalize_external_url`] 的理由)。
let url = normalize_external_url(url)?;
// `start` 是 cmd 的内建命令,第一个引号参数会被当成窗口标题 —— 所以要给个空标题
#[cfg(windows)]
{
std::process::Command::new("cmd")
.args(["/c", "start", "", url.as_str()])
.spawn()
.map_err(|e| format!("打开 {url} 失败: {e}"))?;
Ok(())
}
#[cfg(not(windows))]
{
let _ = url;
Err("非 Windows 平台没有默认浏览器打开逻辑".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detection_never_panics_and_always_reports_webview2() {
let dir = std::env::temp_dir().join("nfarms-browser-test");
let _ = std::fs::create_dir_all(&dir);
let list = detect_engines(&crate::Paths::from_exe_dir(&dir));
assert!(list.len() >= 5, "至少要报出五项");
assert_eq!(list[0].name, "Chromium(WebView2 运行时)");
// WebView2 那项的 exe 永远是 None(它是运行时,不是程序)
assert!(list[0].exe.is_none());
}
#[test]
fn summary_prefers_version_then_path() {
let e = Engine {
name: "x".into(),
version: "1.2.3".into(),
exe: Some(PathBuf::from(r"C:\a\b.exe")),
note: String::new(),
};
assert_eq!(e.summary(), "v1.2.3");
let e2 = Engine {
version: String::new(),
..e
};
assert!(e2.summary().ends_with("b.exe"));
}
#[test]
fn reg_miss_returns_none_not_panic() {
assert!(reg_value(r"HKLM\SOFTWARE\NoSuchKeyNfarms", "nope").is_none());
}
// ------------------------------------------------------------ 审计修复回归
/// 系统里"一定带文件版本资源"的 exe,给几条需要真实版本资源的测试当替身。
/// 找不到就返回 None —— 那几条测试**跳过**,而不是假装通过。
#[cfg(windows)]
fn system_cmd_exe() -> Option<PathBuf> {
let root = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_string());
let p = PathBuf::from(root).join("System32").join("cmd.exe");
p.is_file().then_some(p)
}
/// 把 `src` 复制成 `dst`(父目录一并建出来)。失败返回 None,调用方跳过。
#[cfg(windows)]
fn copy_into(src: &std::path::Path, dst: &std::path::Path) -> Option<()> {
std::fs::create_dir_all(dst.parent()?).ok()?;
std::fs::copy(src, dst).ok()?;
Some(())
}
#[test]
fn external_url_only_accepts_http_and_https() {
// 放行的三档:https / http(两侧空白 trim 掉,两个输入框都可能带尾巴)/ 大写 scheme
assert_eq!(
normalize_external_url("https://nfarms.cn/").unwrap(),
"https://nfarms.cn/"
);
assert_eq!(
normalize_external_url(" http://127.0.0.1:3000/ ").unwrap(),
"http://127.0.0.1:3000/"
);
assert_eq!(
normalize_external_url("HTTPS://Example.COM/x").unwrap(),
"HTTPS://Example.COM/x"
);
// 带 `&` 的查询串是**正常网址**,必须放行 —— 这条同时锁住"没做 cmd 转义"这个
// 有意的边界(见 normalize_external_url 注释),免得以后有人反手把它拒了。
assert!(normalize_external_url("http://x/?a=1&ver").is_ok());
for bad in [
"",
" ",
"www.bing.com",
"notepad",
"cmd /c calc",
"file:///C:/Windows/System32/calc.exe",
"javascript:alert(1)",
"http://",
"https://",
"http://a b/",
"http://x/\r\nver",
] {
assert!(
normalize_external_url(bad).is_err(),
"{bad:?} 不该被放行"
);
}
}
#[test]
fn ps_quote_doubles_single_quotes() {
// PowerShell 单引号串里 `''` 才表示一个 `'`;老实现把它换成空格,等于换了个路径
assert_eq!(
ps_quote(r"C:\Users\O'Brien\a.exe"),
r"C:\Users\O''Brien\a.exe"
);
assert_eq!(ps_quote(r"C:\plain\a.exe"), r"C:\plain\a.exe");
}
#[cfg(windows)]
#[test]
fn program_files_from_joins_instead_of_concatenating() {
let cands = program_files_from(
[
// 末尾带分隔符:老写法 `format!("{base}\\{rel}")` 会在这里多出一个 `\`
r"C:\Program Files\".to_string(),
r"C:\Users\me\AppData\Local".to_string(),
],
r"Internet Explorer\iexplore.exe",
);
assert_eq!(
cands[0].display().to_string(),
r"C:\Program Files\Internet Explorer\iexplore.exe"
);
assert_eq!(
cands[1].display().to_string(),
r"C:\Users\me\AppData\Local\Internet Explorer\iexplore.exe"
);
}
#[cfg(windows)]
#[test]
fn ie_candidates_try_program_files_first_and_keep_the_old_hardcoded_path() {
let cands = ie_exe_candidates();
// 最后一档必须还是原来那条绝对路径:`ProgramFiles` 缺失/被改写的机器靠它兜底
assert_eq!(
cands.last().unwrap().display().to_string(),
r"C:\Program Files\Internet Explorer\iexplore.exe"
);
let Some(pf) = std::env::var_os("ProgramFiles") else {
return; // 这台机器没这个变量 → 环境变量那一档断言不了,跳过
};
let want = PathBuf::from(pf).join(r"Internet Explorer\iexplore.exe");
let idx = cands.iter().position(|p| *p == want).expect(
"IE 的候选必须包含 program_files 拼出来的那条 —— 不能再只认硬编码路径",
);
assert!(idx + 1 < cands.len(), "环境变量那几档要排在硬编码兜底前面");
}
#[cfg(windows)]
#[test]
fn servo_gets_its_version_probed_when_the_exe_is_present() {
let Some(src) = system_cmd_exe() else {
return;
};
// 本机连一个系统 exe 的版本都读不出来(比如没有 PowerShell)→ 这一项测不了,
// 跳过,而不是把它算成通过。
if exe_version(&src).is_none() {
return;
}
let root = std::env::temp_dir().join("nfarms-browser-servo-version");
let _ = std::fs::remove_dir_all(&root);
// 用 cmd.exe 冒充 servo.exe:测的是"探测到 exe 就去取版本"这条接线,不是 Servo 本身
if copy_into(&src, &root.join("libs").join("servo").join("servo.exe")).is_none() {
return;
}
let list = detect_engines(&crate::Paths::from_exe_dir(&root));
let servo = list
.iter()
.find(|e| e.name == "Servo(实验)")
.expect("Servo 那一项永远在清单里");
assert!(servo.exe.is_some(), "libs\\servo\\servo.exe 在场就该被探测到");
assert!(
!servo.version.is_empty(),
"探测到 servo.exe 就必须取版本 —— 写死空串会让「装了」在界面上和「没装」一样"
);
}
#[cfg(windows)]
#[test]
fn exe_version_survives_a_single_quote_in_the_path() {
let Some(src) = system_cmd_exe() else {
return;
};
if exe_version(&src).is_none() {
return; // 读不出系统 exe 的版本 → 这条测不了
}
let root = std::env::temp_dir().join("nfarms-browser-quote-path");
let _ = std::fs::remove_dir_all(&root);
// 用户名 `O'Brien` 这种:老实现把 `'` 换成空格 → 换成了另一个不存在的路径,
// PowerShell 写到 stderr 的报错文本被 capture 合并回来,当成版本号返回。
let dst = root.join("O'Brien").join("cmd.exe");
if copy_into(&src, &dst).is_none() {
return;
}
let v = exe_version(&dst).expect("路径存在,就该拿到版本");
assert!(
!v.contains("Get-Item") && !v.contains("Cannot find path") && !v.contains("找不到路径"),
"拿到的不是版本号,而是 PowerShell 的报错文本:{v}"
);
}
#[test]
fn timeouts_are_named_constants_and_powershell_gets_the_wider_one() {
// 两个值原来分别是两处裸字面量(15_000 / 20_000),现在有了名字和理由。
// 这条断言锁住理由本身:注册表是本机查询,PowerShell 每次都要冷启动。
// **编译期断言(const block),不是运行期。**
// 这两条守的是"两个超时常量被人改坏"。放运行期等于每次测试
// 都验一遍同样的常量;放编译期则**改错的那一刻就编不过**。
const { assert!(REG_TIMEOUT_MS >= 5_000, "reg query 不该卡得太紧") };
const { assert!(PS_TIMEOUT_MS > REG_TIMEOUT_MS, "PowerShell 要留出冷启动时间") };
}
}