crates\nfarms-core\src\envprobe.rs¶
源码路径:
crates\nfarms-core\src\envprobe.rs· 1643 行
//! 开发环境全景探测 —— Environment 页的新数据源。
//!
//! ## 为什么不改 `tools.rs`
//!
//! `tools.rs` 的 `detect_all()` 喂的是 `--versions` / `--npm-apps` 两条命令,
//! 而它们是 **oracle 逐字节比对**的验收项。往里加组件 = 直接打破验收。
//! 所以这一层是**纯增量**的:新的组件、新的维度(环境变量 / 依赖库 / 镜像源)
//! 全在这里,`tools.rs` 一行不动。
//!
//! ## 十大类
//!
//! 「有哪几类、叫什么、按什么顺序排」只有一处权威定义:`CATALOG`。
//! 这里原来手抄过一遍类名清单,加了「模型运行环境」之后就再也没对上 ——
//! 所以改成指路,不再抄第二份。
//! 每类下面若干个**探测项**(可执行文件 + 版本),外加三个横向维度:
//!
//! * **环境变量**:这些工具链的行为**高度依赖**环境变量(`JAVA_HOME` 指错地方
//! 比不装还难查),所以要把「设了没有、值是什么」列出来;
//! * **镜像源**:npm / pip / cargo / nuget 各自查一遍 —— 国内网络下这是
//! 「装不上」的头号原因;
//! * **系统依赖库**:VC++ 运行库、OpenSSL、zlib 这些**装没装看不出来**的东西
//! (缺了只会在运行时报「找不到 xxx.dll」)。
//!
//! ## 为什么整份报告要跑在后台线程
//!
//! 六十来次子进程探测,串起来 3 秒上下。放 UI 线程里切到这一页就僵住 ——
//! 所以调用方一律用 `Job` 包起来(和 `tools::detect_all` 同样的处理)。
use std::path::{Path, PathBuf};
use crate::Paths;
/// 一个探测项(某个可执行文件 / 某个目录)。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Item {
/// 显示名,如 `cargo`
pub label: String,
/// 版本或摘要;拿不到就是空
pub version: String,
/// 落地路径(拿得到就填)
pub path: Option<PathBuf>,
/// 补充说明(比如「没装」「找到了但版本读不出来」)
pub note: String,
}
impl Item {
pub fn found(&self) -> bool {
self.path.is_some() || !self.version.is_empty()
}
/// 一行摘要:`1.98.1` 或 `C:\...\cargo.exe`,都没有就给 note。
pub fn summary(&self) -> String {
if !self.version.is_empty() {
self.version.clone()
} else if let Some(p) = &self.path {
p.display().to_string()
} else if !self.note.is_empty() {
self.note.clone()
} else {
"(未找到)".to_string()
}
}
}
/// 一类组件(有几类、叫什么见 `CATALOG`)。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Group {
pub key: &'static str,
pub name: &'static str,
/// 这类东西**是干什么用的** —— 界面上一句话说明,比一串版本号有用
pub purpose: &'static str,
pub items: Vec<Item>,
}
impl Group {
/// 找到了几项 / 共几项。
pub fn found_count(&self) -> (usize, usize) {
(self.items.iter().filter(|i| i.found()).count(), self.items.len())
}
/// 整类的状态:`可用` / `部分可用` / `缺失`。
pub fn state(&self) -> &'static str {
let (ok, total) = self.found_count();
if ok == 0 {
"缺失"
} else if ok == total {
"可用"
} else {
"部分可用"
}
}
}
/// 一个环境变量。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Var {
pub name: &'static str,
/// `None` = 进程环境里根本没有这个变量;
/// `Some("")` = 设了但值是空串(见 `var_value`)
pub value: Option<String>,
/// 它影响什么
pub note: &'static str,
/// 建议值(没设时给个参考)
pub hint: &'static str,
}
/// 一个包管理器的镜像源配置。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Mirror {
pub name: &'static str,
/// 当前生效的值
pub current: String,
/// 是不是官方源(国内网络下这个信息很关键)
pub official: bool,
}
/// 一份完整的探测报告。
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Report {
pub groups: Vec<Group>,
pub vars: Vec<Var>,
pub mirrors: Vec<Mirror>,
/// 系统层面的开关(UTF-8 代码页、长路径、开发者模式)
pub system: Vec<Item>,
}
impl Report {
/// 可用的类数(总共几类见 `CATALOG`)。
pub fn usable_groups(&self) -> usize {
self.groups.iter().filter(|g| g.state() != "缺失").count()
}
/// 设了的环境变量数 / 总数。
///
/// 「设了」按 `value.is_some()` 算 —— **设成空串也算设了**:那说明有人
/// (或某个安装脚本)显式清过它,与「从来没设」在子进程里的行为不同。
pub fn vars_set(&self) -> (usize, usize) {
(
self.vars.iter().filter(|v| v.value.is_some()).count(),
self.vars.len(),
)
}
}
// ===========================================================================
// 探测原语
// ===========================================================================
/// 从任意版本输出里抠出一个版本号。
///
/// 各家格式乱七八糟:`gcc (x86_64-w64-mingw32) 15.2.0`、`v20.11.1`、
/// `Python 3.12.10`、`rustc 1.98.1 (hash 2026-01-01)`。共同点是
/// **版本号以数字开头、含点**。所以统一扫一遍 token,取第一个满足的。
///
/// # 为什么「纯数字」不算版本
///
/// 这里以前多一个 `|| 全是数字` 的分支(为了 `chcp` 输出里的代码页 936)。
/// 代价是 `nvidia-smi --query-gpu=name` 吐出来的
/// `NVIDIA GeForce RTX 4060 Laptop GPU` 里的 `4060` 也被认成版本 ——
/// 界面上「NVIDIA 驱动」那一行于是显示成显卡型号,标的与填的不是一回事,
/// 而且**看不出是错的**(数字看起来很合理)。
/// 现在版本号一律要求带点,代码页那种「本来就不是版本号」的东西各自解析
/// (见 `code_page_number`)。
///
/// 注意 `--version` 有的往 **stderr** 写(java、ssh 都这样),调用方要合流。
pub fn extract_version(text: &str) -> Option<String> {
for tok in text.split_whitespace() {
// 去掉前缀 v / V、**两边的引号**、以及结尾的逗号/括号。
//
// 引号这条是测出来的:`java -version` 输出的是
// `openjdk version "21.0.1" 2024-10-15` —— 不剥引号就匹配不到,
// 于是 JDK 那一类会整类显示「找到了但版本读不出来」。
let t = tok
.trim_start_matches(['v', 'V', '"', '\''])
.trim_end_matches([',', ')', ']', '"', '\'']);
let looks =
t.starts_with(|c: char| c.is_ascii_digit()) && t.contains('.') && t.len() <= 24;
if looks {
return Some(t.to_string());
}
}
None
}
/// 跑一条命令并抠版本。
fn ver(label: &str, cmd: &str) -> Item {
let out = crate::tools::capture(cmd, 20_000);
let version = extract_version(&out).unwrap_or_default();
let note = if out.trim().is_empty() {
"没装,或者不在 PATH 上".to_string()
} else if version.is_empty() {
"找到了,但版本读不出来".to_string()
} else {
String::new()
};
Item {
label: label.to_string(),
version,
path: None,
note,
}
}
/// 只探测可执行文件在不在(`where`),不读版本 —— 用于版本输出很慢的工具。
fn where_is(label: &str, exe: &str) -> Item {
let out = crate::tools::capture(&format!("where {exe}"), 15_000);
let path = out
.lines()
.map(|l| l.trim())
.find(|l| !l.is_empty() && Path::new(l).is_file())
.map(PathBuf::from);
Item {
label: label.to_string(),
version: String::new(),
note: if path.is_none() {
"不在 PATH 上".to_string()
} else {
String::new()
},
path,
}
}
/// 看一个目录在不在(用于 SDK 布局而不是可执行文件)。
fn dir_item(label: &str, dir: &Path) -> Item {
let exists = dir.is_dir();
Item {
label: label.to_string(),
version: String::new(),
path: exists.then(|| dir.to_path_buf()),
note: if exists { String::new() } else { "目录不存在".to_string() },
}
}
/// 数一个目录下有几个子目录(build-tools\34.0.0 这种多版本布局)。
fn count_dirs(dir: &Path) -> usize {
std::fs::read_dir(dir)
.map(|it| it.flatten().filter(|e| e.path().is_dir()).count())
.unwrap_or(0)
}
/// 把 `std::env::var` 的结果归一成 `Var::value`。
///
/// **「设成空串」不能并进「没设」。** 这里原来有一句
/// `.filter(|v| !v.trim().is_empty())`,于是 `set PYTHONPATH=` 留下的空值
/// 在界面上显示成「未设 → 建议 …」,用户永远看不出自己(或某个安装脚本)
/// 把变量清掉了 —— 而两者在子进程里的行为**真的不同**:空值会覆盖掉
/// 父进程/系统里那个值。纯空白同样归一成空串:界面上它和空值画不出区别,
/// 语义上也是「设了但是空」。
///
/// 抽成独立函数是为了能测:`var()` 读的是**本进程**的环境,测试里造不出一个
/// 「设成空串」的变量(改本进程环境会污染并行跑的其它测试,而 Windows 上把
/// 变量设成空串等于删掉它)。
fn var_value(raw: Result<String, std::env::VarError>) -> Option<String> {
match raw {
Ok(v) if v.trim().is_empty() => Some(String::new()),
Ok(v) => Some(v),
Err(_) => None,
}
}
/// 读环境变量。
fn var(name: &'static str, note: &'static str, hint: &'static str) -> Var {
let value = var_value(std::env::var(name));
Var {
name,
value,
note,
hint,
}
}
/// 镜像源读不到时的占位文本。
///
/// 为什么不是朴素的 `(读不到)`,也不是顺手把 `official` 置成 `false`:
/// `official` 只有两态,界面(`core_pages.rs` 的镜像源那一栏)只会在
/// 「官方源」(红)和「已换源」(绿)之间挑一个。读不到时两个都不知道 ——
/// 这时显示「官方源」是**保守**的一侧(红色 = 你可能还在慢的官方源上,
/// 值得去查),而显示「已换源」会让用户以为已经换好了、从此不再管。
/// 所以徽章仍按官方源画,但把这句**假设写明在文本里**,
/// 而不是像以前那样让 `(读不到)` 悄悄算成官方源。
const MIRROR_UNREADABLE: &str = "未知(命令读不到,按官方源处理)";
/// 由「命令输出的首个非空行」判定镜像源。
///
/// 抽成纯函数是为了能测:`mirror()` 自己要起子进程,测试里没法稳定造出
/// 「读不到」和「读到官方源」这两种输入(取决于跑测试的机器装了什么)。
fn classify_mirror(first_line: Option<&str>, official_markers: &[&str]) -> (String, bool) {
match first_line {
Some(l) => (l.to_string(), official_markers.iter().any(|m| l.contains(m))),
None => (MIRROR_UNREADABLE.to_string(), true),
}
}
/// 查一个包管理器的镜像源(取一条命令的输出首行)。
fn mirror(name: &'static str, cmd: &str, official_markers: &[&str]) -> Mirror {
let out = crate::tools::capture(cmd, 20_000);
let first = out.lines().map(|l| l.trim()).find(|l| !l.is_empty());
let (current, official) = classify_mirror(first, official_markers);
Mirror {
name,
current,
official,
}
}
// ===========================================================================
// 采集
// ===========================================================================
/// 采一份全景报告。**必须跑在后台线程**(六十来次子进程)。
pub fn collect(paths: &Paths) -> Report {
let mut r = Report::default();
// **模型运行环境排第一** —— 对这个程序(本地模型中枢)来说它是最该先看的,
// 排在 Android 后面等于藏起来了:用户不滚到底就以为"没做"。
r.groups.push(group_model(paths));
r.groups.push(group_qt());
r.groups.push(group_dotnet());
r.groups.push(group_c_toolchain());
r.groups.push(group_rust());
r.groups.push(group_python());
r.groups.push(group_jdk());
r.groups.push(group_node());
r.groups.push(group_git());
r.groups.push(group_android(paths));
r.vars = collect_vars();
r.mirrors = collect_mirrors();
r.system = collect_system();
r
}
/// NVIDIA 驱动要问的字段。
///
/// **只能是 `driver_version`,不能是 `name`**:`--query-gpu=name` 返回的是
/// `NVIDIA GeForce RTX 4060 Laptop GPU`,抠版本会抠到型号 `4060` ——
/// 界面上「NVIDIA 驱动」那一行于是显示成显卡型号,而它看起来完全像一个版本号,
/// 没人能发现填错了。`extract_version` 那边现在要求版本号带点(纯数字不再认),
/// 这里再钉一层:命令本身就该问对字段。
const NVIDIA_DRIVER_CMD: &str = "nvidia-smi --query-gpu=driver_version --format=csv,noheader";
/// 模型运行环境 —— **不是模型文件本身**(那是 Model 页管的事),而是
/// 「跑本地大模型需要什么」:两个推理后端、GPU 运行时、显存。
///
/// 单列一类的理由:它和别的类**不是一回事**。其余九类是「装了没有」,
/// 这一类是「装了能不能跑起来」—— 后端缺一个,llama.cpp 就退化成纯 CPU,
/// 而现象是"能启动但慢得离谱",光看"已安装"根本看不出来。
fn group_model(paths: &Paths) -> Group {
let mut items = Vec::new();
for (label, dir, exe) in [
("llama.cpp(llama-server)", "llama.cpp", "llama-server.exe"),
("stable-diffusion.cpp(sd-cli)", "stable-diffusion.cpp", "sd-cli.exe"),
] {
let p = paths.libs().join(dir).join(exe);
let ok = p.is_file();
items.push(Item {
label: label.to_string(),
version: String::new(),
path: ok.then(|| p.clone()),
note: if ok {
String::new()
} else {
r"缺 —— .\libs\scripts\fetch-libs.ps1 -BinariesOnly".to_string()
},
});
}
let mdir = paths.libs().join("model");
let (files, bytes) = dir_stat(&mdir);
items.push(Item {
label: "模型目录".to_string(),
version: if bytes > 0 {
format!("{} 个文件 / {}", files, human_gb(bytes))
} else {
String::new()
},
path: mdir.is_dir().then(|| mdir.clone()),
note: if files == 0 {
r"空 —— .\libs\scripts\fetch-libs.ps1 -ModelsOnly".to_string()
} else {
String::new()
},
});
// GPU 运行时:这几个**装了没装决定推理快慢**,不是"有就行"
let sys32 = std::env::var("SystemRoot")
.map(|r| PathBuf::from(r).join("System32"))
.unwrap_or_else(|_| PathBuf::from(r"C:\Windows\System32"));
items.push(Item {
label: "Intel SYCL 运行时".to_string(),
version: String::new(),
path: ["sycl8.dll", "sycl7.dll", "sycl6.dll", "libsycl.dll"]
.iter()
.map(|d| sys32.join(d))
.find(|p| p.is_file()),
note: "Arc 核显走 SYCL 最快;缺了就退回 CPU".to_string(),
});
let vk = sys32.join("vulkan-1.dll");
items.push(Item {
label: "Vulkan 运行时".to_string(),
version: String::new(),
path: vk.is_file().then(|| vk.clone()),
note: "sd.cpp 的 GPU 路径靠它".to_string(),
});
items.push(ver("NVIDIA 驱动", NVIDIA_DRIVER_CMD));
items.push(ver("NVIDIA CUDA", "nvcc --version"));
// `vram_text` 是 MachineInfo 的**方法**,不是自由函数 —— 得先采集。
// 这一步比别的探测重(要查注册表/内存),但整份报告本来就在后台线程跑。
let vram = crate::cyinfo::collect(paths).vram_text();
items.push(Item {
label: "显存".to_string(),
version: vram,
path: None,
note: "UMA 机型上显存与内存是同一块,两个模型同时跑会互相挤".to_string(),
});
Group {
key: "model",
name: "模型运行环境",
purpose: "本地推理的后端与 GPU 运行时;缺一个就会「能启动但很慢」",
items,
}
}
/// 数一个目录里的文件数与总字节(模型都平铺,不算子目录)。
fn dir_stat(dir: &Path) -> (usize, u64) {
let mut files = 0usize;
let mut bytes = 0u64;
if let Ok(rd) = std::fs::read_dir(dir) {
for e in rd.flatten() {
if let Ok(md) = e.metadata()
&& md.is_file()
{
files += 1;
bytes += md.len();
}
}
}
(files, bytes)
}
fn human_gb(bytes: u64) -> String {
let gb = bytes as f64 / 1024.0 / 1024.0 / 1024.0;
if gb >= 1.0 {
format!("{gb:.2} GB")
} else {
format!("{:.0} MB", bytes as f64 / 1024.0 / 1024.0)
}
}
fn group_qt() -> Group {
let mut items = vec![ver("qmake", "qmake -query QT_VERSION"), where_is("qmake", "qmake")];
items.push(where_is("Qt Creator", "qtcreator"));
// 版本输出是空的说明没装;把 where 的结果并进来
if items[0].version.is_empty()
&& let Some(p) = items[1].path.clone()
{
items[0].path = Some(p);
}
items.push(dir_item(
"CMake(构建 Qt 要用)",
Path::new("C:\\Program Files\\CMake\\bin"),
));
Group {
key: "qt",
name: "Qt",
purpose: "跨平台 GUI 框架:qmake / CMake + Ninja + MinGW 或 MSVC",
items,
}
}
fn group_dotnet() -> Group {
// `--list-sdks` 一次给出全部 SDK,比 `--version` 有用
let sdks = crate::tools::capture("dotnet --list-sdks", 30_000);
let sdk_count = sdks.lines().filter(|l| !l.trim().is_empty()).count();
let latest = sdks
.lines()
.filter_map(extract_version)
.next()
.unwrap_or_default();
let mut items = vec![
Item {
label: "SDK(全部版本)".to_string(),
version: latest,
path: None,
note: if sdk_count == 0 {
"没装".to_string()
} else {
format!("{sdk_count} 个")
},
},
ver("dotnet 运行时", "dotnet --version"),
];
// workload:MAUI / Android 走的就是它
let wl = crate::tools::capture("dotnet workload list", 30_000);
let has_android = wl.to_ascii_lowercase().contains("android");
let has_maui = wl.to_ascii_lowercase().contains("maui");
items.push(Item {
label: "workload: android".to_string(),
version: String::new(),
path: None,
note: if has_android { "已装".into() } else { "未装(`dotnet workload install android`)".into() },
});
items.push(Item {
label: "workload: maui".to_string(),
version: String::new(),
path: None,
note: if has_maui { "已装".into() } else { "未装".into() },
});
Group {
key: "dotnet",
name: ".NET",
purpose: ".NET SDK / ASP.NET Core / MAUI;Android 与 MAUI 目标靠 workload",
items,
}
}
fn group_c_toolchain() -> Group {
let mut items = vec![
ver("gcc", "gcc --version"),
ver("clang", "clang --version"),
// label 里点明「需 VS 开发者环境」:无参 `cl` 在普通 cmd 里必然找不到,
// 与「没装」在界面上长得一模一样,不写清楚就会被当成缺组件。
ver("cl(MSVC,需 VS 开发者环境)", "cl"),
ver("make", "make --version"),
ver("cmake", "cmake --version"),
ver("ninja", "ninja --version"),
ver("gdb", "gdb --version"),
ver("pkg-config", "pkg-config --version"),
ver("vcpkg", "vcpkg version"),
ver("conan", "conan --version"),
];
// vcpkg 位置很关键(它的集成要写进 CMake),单独找一下
if items[8].path.is_none() {
let vcpkg_root = std::env::var("VCPKG_ROOT").ok();
if let Some(root) = vcpkg_root {
items[8].path = Some(PathBuf::from(&root).join("vcpkg.exe"));
items[8].note = format!("VCPKG_ROOT={root}");
}
}
Group {
key: "c",
name: "C/C++ 工具链",
purpose: "GCC/Clang/MSVC + Make/CMake/Ninja + 调试器 + 包管理(vcpkg/Conan)",
items,
}
}
fn group_rust() -> Group {
let mut items = vec![
ver("rustc", "rustc --version"),
ver("cargo", "cargo --version"),
ver("clippy", "cargo clippy --version"),
ver("rustfmt", "rustfmt --version"),
where_is("rustup", "rustup"),
];
// Android 目标装了没有 —— 交叉编译安卓就靠它
let targets = crate::tools::capture("rustup target list --installed", 30_000);
let android: Vec<String> = targets
.lines()
.map(|l| l.trim().to_string())
.filter(|l| l.contains("android"))
.collect();
items.push(Item {
label: "Android 目标".to_string(),
version: String::new(),
path: None,
note: if android.is_empty() {
"没装(`rustup target add aarch64-linux-android`)".to_string()
} else {
android.join(", ")
},
});
Group {
key: "rust",
name: "Rust",
purpose: "rustup 管工具链,cargo 构建,clippy/rustfmt 是质量门",
items,
}
}
fn group_python() -> Group {
let items = vec![
ver("python", "python --version"),
ver("pip", "pip --version"),
ver("poetry", "poetry --version"),
ver("conda", "conda --version"),
];
Group {
key: "python",
name: "Python",
purpose: "解释器 + pip;多版本隔离靠 venv / conda / poetry",
items,
}
}
fn group_jdk() -> Group {
let items = vec![
ver("java", "java -version"),
ver("javac", "javac -version"),
ver("maven", "mvn -v"),
ver("gradle", "gradle -v"),
];
Group {
key: "jdk",
name: "JDK / 构建",
purpose: "JDK + Maven/Gradle;Android 构建链的底座,`JAVA_HOME` 指错最难查",
items,
}
}
fn group_node() -> Group {
let items = vec![
ver("node", "node --version"),
ver("npm", "npm --version"),
ver("pnpm", "pnpm --version"),
ver("yarn", "yarn --version"),
ver("fnm", "fnm --version"),
ver("tsc", "tsc --version"),
];
Group {
key: "node",
name: "Node.js",
purpose: "Node + 包管理器;多版本切换靠 nvm / fnm",
items,
}
}
fn group_git() -> Group {
let items = vec![
ver("git", "git --version"),
ver("git-lfs", "git lfs version"),
ver("ssh", "ssh -V"),
ver("gh(GitHub CLI)", "gh --version"),
];
Group {
key: "git",
name: "Git 与凭据",
purpose: "Git + LFS + OpenSSH + gh;SSH 配置走 GIT_SSH_COMMAND",
items,
}
}
/// Android:这一类的探测和别的不同 —— **主要看目录布局,不是看 PATH**。
///
/// SDK 装在哪、里面有哪些包,全靠 `ANDROID_HOME` 指路。所以先读环境变量,
/// 再按标准布局逐个数。
fn group_android(paths: &Paths) -> Group {
let sdk = std::env::var("ANDROID_HOME")
.or_else(|_| std::env::var("ANDROID_SDK_ROOT"))
.ok()
.filter(|v| !v.trim().is_empty())
.map(PathBuf::from)
// 环境变量没设时退回 Android Studio 的默认位置
.or_else(|| {
let home = std::env::var("LOCALAPPDATA").ok()?;
let p = PathBuf::from(home).join("Android").join("Sdk");
p.is_dir().then_some(p)
});
let mut items = Vec::new();
items.push(Item {
label: "SDK 根目录".to_string(),
version: String::new(),
path: sdk.clone(),
note: if sdk.is_some() {
String::new()
} else {
"没找到(`ANDROID_HOME` 没设,也没有默认的 %LOCALAPPDATA%\\Android\\Sdk)".to_string()
},
});
if let Some(sdk) = &sdk {
for (label, sub) in [
("platform-tools(adb)", "platform-tools"),
("build-tools", "build-tools"),
("platforms", "platforms"),
("cmdline-tools", "cmdline-tools"),
("emulator", "emulator"),
("system-images", "system-images"),
] {
let d = sdk.join(sub);
let n = count_dirs(&d);
items.push(Item {
label: label.to_string(),
version: if n > 0 { format!("{n} 个") } else { String::new() },
path: d.is_dir().then(|| d.clone()),
note: if d.is_dir() {
String::new()
} else {
"目录不存在".to_string()
},
});
}
}
// NDK:环境变量优先,其次 SDK 下的 ndk\ 目录
let ndk = std::env::var("ANDROID_NDK_HOME")
.ok()
.filter(|v| !v.trim().is_empty())
.map(PathBuf::from)
.or_else(|| {
let s = sdk.as_ref()?;
let dir = s.join("ndk");
let mut v: Vec<PathBuf> = std::fs::read_dir(&dir)
.ok()?
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
v.sort();
v.pop()
});
// NDK 的版本号写在 source.properties 里的 Pkg.Revision
let ndk_ver = ndk
.as_ref()
.and_then(|d| std::fs::read_to_string(d.join("source.properties")).ok())
.and_then(|t| {
t.lines()
.find(|l| l.starts_with("Pkg.Revision"))
.and_then(|l| l.split('=').nth(1))
.map(|v| v.trim().to_string())
})
.unwrap_or_default();
items.push(Item {
label: "NDK".to_string(),
version: ndk_ver,
path: ndk,
note: String::new(),
});
items.push(ver("adb", "adb version"));
items.push(ver("sdkmanager", "sdkmanager --version"));
items.push(where_is("avdmanager", "avdmanager"));
let _ = paths;
Group {
key: "android",
name: "Android",
purpose: "SDK(platform-tools/build-tools/platforms)+ NDK + Gradle;交叉编译的底座",
items,
}
}
/// 环境变量:每个工具链的「隐形依赖」。
fn collect_vars() -> Vec<Var> {
vec![
// Qt
var("QTDIR", "Qt 安装根;qmake 找不到 Qt 时第一个该看的就是它", r"C:\Qt\6.6.2\mingw_64"),
var("QT_PLUGIN_PATH", "Qt 插件搜索路径(平台插件缺了就起不来窗口)", r"%QTDIR%\plugins"),
var("QML2_IMPORT_PATH", "QML 模块搜索路径", r"%QTDIR%\qml"),
var("CMAKE_PREFIX_PATH", "CMake 找 Qt 的地方(`find_package(Qt6)`)", r"%QTDIR%"),
// .NET
var("DOTNET_ROOT", "dotnet 安装根;多版本共存时靠它", r"C:\Program Files\dotnet"),
var("NUGET_PACKAGES", "NuGet 包缓存位置(默认在用户目录,可挪到大盘)", r"D:\nuget"),
var("DOTNET_CLI_TELEMETRY_OPTOUT", "关遥测(`1` 即关)", "1"),
// C 工具链
var("CC", "C 编译器;Rust 的 cc crate 也读它", "gcc"),
var("CXX", "C++ 编译器", "g++"),
var("CFLAGS", "C 编译附加参数", "-O2"),
var("CXXFLAGS", "C++ 编译附加参数", "-O2"),
var("LDFLAGS", "链接附加参数", "-L/usr/local/lib"),
var("INCLUDE", "MSVC 头文件搜索路径", r"%VCToolsInstallDir%\include"),
var("LIB", "MSVC 库搜索路径", r"%VCToolsInstallDir%\lib\x64"),
var("LIBPATH", "MSVC 链接器库路径", ""),
var("CPATH", "GCC/Clang 的头文件搜索路径", "/usr/local/include"),
var("LIBRARY_PATH", "GCC/Clang 的库搜索路径", "/usr/local/lib"),
var("PKG_CONFIG_PATH", "pkg-config 的 .pc 搜索路径", "/usr/local/lib/pkgconfig"),
// Rust
var("CARGO_HOME", "cargo 的注册表与工具缓存", r"%USERPROFILE%\.cargo"),
var("RUSTUP_HOME", "rustup 的工具链存放处", r"%USERPROFILE%\.rustup"),
// Python
var("PYTHONPATH", "额外的模块搜索路径", ""),
var("PYTHONHOME", "解释器根;**设错会让 python 完全起不来**", ""),
var("VIRTUAL_ENV", "当前激活的虚拟环境", ""),
var("PIP_INDEX_URL", "pip 镜像源;国内不设它基本装不动", "https://pypi.tuna.tsinghua.edu.cn/simple"),
// JDK
var("JAVA_HOME", "Android/Gradle/Maven 全靠它;指错比不装还难查", r"C:\Program Files\Java\jdk-21"),
var("JDK_HOME", "部分工具只认这个名字", r"%JAVA_HOME%"),
var("MAVEN_OPTS", "Maven 的 JVM 参数", "-Xmx2g"),
var("GRADLE_OPTS", "Gradle 的 JVM 参数", "-Xmx2g"),
// Node
var("NODE_PATH", "Node 的额外模块搜索路径", ""),
var("NPM_CONFIG_PREFIX", "全局包安装位置(默认在用户目录)", r"%APPDATA%\npm"),
var("NPM_CONFIG_REGISTRY", "npm 镜像源", "https://registry.npmmirror.com"),
var("NVM_HOME", "nvm 安装位置", r"C:\nvm"),
var("NVM_SYMLINK", "nvm 的当前版本软链", r"C:\nodejs"),
// Git
var("GIT_SSH", "ssh 可执行文件", r"C:\Windows\System32\OpenSSH\ssh.exe"),
var("GIT_SSH_COMMAND", "带参数的 ssh 命令(优先级高于 GIT_SSH)", "ssh -i ~/.ssh/id_ed25519"),
var("GIT_CONFIG_GLOBAL", "全局 gitconfig 位置", r"%USERPROFILE%\.gitconfig"),
// Android
var("ANDROID_HOME", "SDK 根;gradle 与 sdkmanager 都读它", r"%LOCALAPPDATA%\Android\Sdk"),
var("ANDROID_SDK_ROOT", "老名字,部分工具仍读", r"%ANDROID_HOME%"),
var("ANDROID_NDK_HOME", "NDK 根;交叉编译必须", r"%ANDROID_HOME%\ndk\27.0.12077973"),
var("GRADLE_HOME", "Gradle 安装根", r"C:\Gradle\gradle-8.7"),
// 通用
var("TEMP", "临时目录;编译中间产物堆这里", r"%LOCALAPPDATA%\Temp"),
var("LANG", "locale;**中文 Windows 默认 GBK**,不设 UTF-8 会出现乱码", "zh_CN.UTF-8"),
var("LC_ALL", "覆盖所有 locale 类别", "zh_CN.UTF-8"),
var("HTTP_PROXY", "HTTP 代理", "http://127.0.0.1:7890"),
var("HTTPS_PROXY", "HTTPS 代理", "http://127.0.0.1:7890"),
var("NO_PROXY", "不走代理的地址", "localhost,127.0.0.1,.local"),
var("SSL_CERT_FILE", "额外的根证书文件", ""),
var("SSL_CERT_DIR", "额外的根证书目录", ""),
]
}
/// 镜像源:国内网络下「装不上」的头号原因。
fn collect_mirrors() -> Vec<Mirror> {
vec![
mirror("npm", "npm config get registry", &["registry.npmjs.org"]),
mirror("pip", "pip config get global.index-url", &["pypi.org"]),
// cargo **不能**用 `cargo search` 探镜像:那条命令要走网络、还会拉
// crates.io 索引,实测能把整个检测卡在「检测中…」十几秒。改成纯读配置。
cargo_mirror(),
mirror("NuGet", "dotnet nuget list source", &["nuget.org"]),
]
}
/// 从 `chcp` 的输出里取代码页数字。
///
/// 代码页是**纯数字**,而 `extract_version` 现在只认带点的版本号(理由见那边),
/// 所以这里单独解析:取最后一个冒号后面那段(`Active code page: 936` /
/// `活动代码页: 936` 两种写法都能取到)。
/// 取不到就返回空串 —— 界面上「控制台代码页」那一行的版本格空着,但 `note`
/// 仍然会说是不是 UTF-8,该有的信息不丢;反过来,为了这一格去给
/// `extract_version` 重开「纯数字也算」的口子,就会把显卡型号当驱动版本
/// (已经踩过一次,见 `NVIDIA_DRIVER_CMD`)。
fn code_page_number(out: &str) -> String {
out.rsplit(':')
.next()
.map(str::trim)
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
.unwrap_or_default()
.to_string()
}
/// 系统层面的开关 —— 装完还要改系统设置,这一栏就是查那些。
fn collect_system() -> Vec<Item> {
let mut items = Vec::new();
// 长路径:超过 260 字符的路径在没开这个开关时**创建文件会失败**,
// 而 node_modules / .cargo 嵌套轻易就超了。
let long_paths = crate::tools::capture(
r#"reg query "HKLM\SYSTEM\CurrentControlSet\Control\FileSystem" /v LongPathsEnabled"#,
15_000,
);
let lp_on = long_paths.contains("0x1");
items.push(Item {
label: "长路径支持(LongPathsEnabled)".to_string(),
version: String::new(),
path: None,
note: if lp_on {
"已开启".to_string()
} else {
"未开启 —— 深目录下建文件会失败(`fsutil behavior set disable8dot3` 之外还要改这个)".to_string()
},
});
// 当前代码页:65001 = UTF-8
let cp = crate::tools::capture("chcp", 10_000);
let utf8 = cp.contains("65001");
items.push(Item {
label: "控制台代码页".to_string(),
version: code_page_number(&cp),
path: None,
note: if utf8 {
"UTF-8".to_string()
} else {
"不是 UTF-8 —— 中文输出会乱码(`chcp 65001`)".to_string()
},
});
// VC++ 运行库:缺了只会在运行时报「找不到 xxx.dll」
let sys32 = std::env::var("SystemRoot")
.map(|r| PathBuf::from(r).join("System32"))
.unwrap_or_else(|_| PathBuf::from(r"C:\Windows\System32"));
for (label, dll) in [
("VC++ 运行库(msvcp140)", "msvcp140.dll"),
("OpenSSL(libcrypto)", "libcrypto-3-x64.dll"),
("zlib(zlib1)", "zlib1.dll"),
("SQLite(sqlite3)", "sqlite3.dll"),
("libpng", "libpng16.dll"),
] {
let p = sys32.join(dll);
items.push(Item {
label: label.to_string(),
version: String::new(),
path: p.is_file().then(|| p.clone()),
note: if p.is_file() {
String::new()
} else {
format!("System32 下没有 {dll}")
},
});
}
// 只查 System32,**不查 `libs\`**。这里原来写着「本程序自己带的运行库目录
// 也算上(绿色版不走 System32)」,但一行实现都没有 —— 注释承诺了一件
// 不存在的事,读代码的人会以为绿色版已经被覆盖到了。
//
// 真去扫 `libs\` 也不对:仓库里没有任何脚本往那儿放这几个 DLL
// (`fetch-libs.ps1` 只拉 llama.cpp / sd.cpp 与模型),扫了只会多出五行
// 恒定的「缺」。哪天打包脚本开始自带这些 DLL,再把 `Paths` 传进来一起扫。
items
}
// ===========================================================================
// 安装 / 更新
//
// 页面副标题一直写着「检测 / 安装 / 更新」,但后两个从来没实现过 ——
// 这一层就是补上它们。
//
// ## 三条原则
//
// 1. **不自己实现下载安装**。每个组件都有官方安装器和它自己的坑(MSI 参数、
// PATH 写入、注册表项),重写一遍只会更差。我们只负责**挑对命令**。
// 2. **包管理器先探测**。同一件事在 winget / choco / scoop 下命令完全不同;
// 没探测到就说「没装包管理器」,而不是硬跑一条注定失败的命令。
// 3. **命令先给用户看**。装软件是重动作(要写 PATH、可能弹 UAC),
// 界面上要能把将执行的完整命令显示出来再让人按下去。
// ===========================================================================
/// 一个包管理器。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PkgMgr {
/// `winget` / `choco` / `scoop`
pub key: &'static str,
pub exe: &'static str,
/// 探测到的路径;`None` = 没装
pub path: Option<PathBuf>,
}
impl PkgMgr {
pub fn available(&self) -> bool {
self.path.is_some()
}
/// 版本(探测时才跑,所以单独一个函数)。
pub fn version(&self) -> String {
if !self.available() {
return String::new();
}
let cmd = match self.key {
"scoop" => "scoop --version",
_ => &format!("{} --version", self.exe),
};
extract_version(&crate::tools::capture(cmd, 20_000)).unwrap_or_default()
}
}
/// 探测本机有哪些包管理器。
///
/// 顺序即**优先级**:优先用已经装好的那个 —— 没装包管理器就什么都装不了,
/// 这一点要在界面上直说。
pub fn package_managers() -> Vec<PkgMgr> {
["winget", "choco", "scoop"]
.iter()
.map(|key| {
// `where` 找得到才算装了。注意 winget 在 Windows 11 上通常在
// WindowsApps 下,而那个目录的 ACL 有时会挡住 `is_file()`,
// 所以 `where` 有输出就认。
let out = crate::tools::capture(&format!("where {key}"), 15_000);
let path = out
.lines()
.map(|l| l.trim())
.find(|l| !l.is_empty())
.map(PathBuf::from);
PkgMgr {
key,
exe: key,
path,
}
})
.collect()
}
/// 一个可执行的动作(安装或更新一项)。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Action {
/// `安装` / `更新` / `打补丁`
pub kind: &'static str,
/// 装什么,如 `Qt Creator`
pub what: &'static str,
/// 完整命令(界面要原样显示给用户看)
pub cmd: String,
/// 备注:替代方案、坑
pub note: &'static str,
}
/// 界面上的动作名 → `libs\scripts\pkg-manage.ps1` 里的组件 id。
///
/// 返回空串表示**脚本不管这一项** —— 那些没有包管理器入口的(vcpkg / NDK /
/// cmdline-tools / MAUI workload)只能手动,保持原来的说明就好。
///
/// 为什么用「名字查表」而不是给每行加一列:那张表有三十多行,加一列要动
/// 每一行,而**改动面积越大越容易在某个不显眼的行上出错**。查表是增量的、
/// 改错了也只影响被查到的那一项。
fn script_id_for(what: &str) -> &'static str {
match what {
"CMake" => "cmake",
"Git" => "git",
"Node.js LTS" => "node",
"Python" => "python",
"rustup 工具链" => "rust",
"Temurin JDK 21" => "jdk",
"Qt Creator" => "qt",
".NET SDK" => "dotnet",
"Go" => "go",
_ => "",
}
}
/// 给每一类生成「安装」「更新」或「手动」动作。
///
/// **每一类都要有东西可看** —— 早先这里没有 `model` 分支,而兜底是 `_ => &[]`,
/// 于是「模型运行环境」整类一条动作都产不出来,界面上那一栏就是光秃秃的
/// (同页别的类都有按钮或说明,只有它是空的,看起来像页面坏了)。
///
/// # 管理器由**脚本**挑,不再由界面选
///
/// 脚本(`libs\scripts\pkg-manage.ps1`)自己探测 winget / choco / scoop 并选一个,
/// 所以这里的命令对三项是同一句。用户不需要先知道自己机器上有哪个管理器 ——
/// 而原来界面那个选择器正是在要求他先知道。
///
/// # 为什么 `mgr` 还在
///
/// 脚本**还不认识**的项(Ninja / LLVM / Gradle / Android Studio …)仍走原来的
/// 按管理器拼命令。一次全换会立刻造成回归:那些项会变得装不了。
/// 等脚本的表补齐了,`mgr` 这个参数就可以连根删掉。
pub fn actions_for(paths: &crate::paths::Paths, group_key: &str, mgr: &str) -> Vec<Action> {
// (装什么, winget id, choco 名, scoop 名, 安装备注, 更新备注)
let rows: &[(&str, &str, &str, &str, &str, &str)] = match group_key {
// 模型运行环境:这一类**没有包管理器入口**,它的安装物是本程序自己带的
// `libs\scripts\fetch-libs.ps1`(不是 `pkg-manage.ps1`),所以只产「手动」。
//
// 为什么不做成可点的「安装」按钮:`run_action` 是一次
// `capture(cmd, 10min)` —— 没有进度、到点就被杀,而拉后端是几十上百 MB 的
// 下载(模型几个 GB);更要紧的是 Local Model 页已经有一条带进度、能停、
// 并且**互斥**(同一时间只允许一个下载进程)的路径(`local_model.rs` 的
// 「下载缺失组件」),这里再开一个入口就等于绕过那道互斥,两个进程同时往
// `libs\` 里解压同一份目录。所以这里只把命令写清楚,让人去跑那条正规路径。
"model" => &[
("llama.cpp / sd.cpp 后端", "", "", "",
r"用 `.\libs\scripts\fetch-libs.ps1 -BinariesOnly`;或到 Local Model 页点「下载缺失组件」", ""),
("本地模型文件", "", "", "",
r"用 `.\libs\scripts\fetch-libs.ps1 -ModelsOnly`;或在 Local Model 页挑一个模型下载", ""),
("GPU 运行时(SYCL / Vulkan)", "", "", "",
"随显卡驱动装:Vulkan 跟 GPU 驱动走,SYCL 跟 Intel 显卡驱动 / oneAPI 走;装完回来重测这一类", ""),
],
"qt" => &[
("Qt Creator", "Qt.QtCreator", "qtcreator", "qtcreator",
"只装 Creator 和 Qt 库;具体 Kit(MinGW/MSVC、Qt 版本)还要在 Creator 里配",
"Kit 版本用 Creator 的 MaintenanceTool 升,winget 只管外壳"),
("CMake", "Kitware.CMake", "cmake", "cmake",
"Qt 用 CMake 构建;Kit 里也可以让它用自带的", ""),
("Ninja", "Ninja-build.Ninja", "ninja", "ninja",
"CMake 的默认生成器,比 make 快得多", ""),
],
"dotnet" => &[
(".NET SDK", "Microsoft.DotNet.SDK.8", "dotnet-sdk", "dotnet-sdk",
"装完还要 `dotnet workload install android` 才能编安卓",
"`dotnet workload update` 比换 SDK 更常用"),
(".NET MAUI workload", "", "", "",
"没有包管理器入口,只能 `dotnet workload install maui`", ""),
],
"c" => &[
("CMake", "Kitware.CMake", "cmake", "cmake", "", ""),
("Ninja", "Ninja-build.Ninja", "ninja", "ninja", "", ""),
("LLVM/Clang", "LLVM.LLVM", "llvm", "llvm",
"Windows 上还要装 VS Build Tools 才有 MSVC 头文件与库", ""),
("vcpkg", "", "", "",
"没有包管理器入口:`git clone https://github.com/microsoft/vcpkg && .\\bootstrap-vcpkg.bat`", ""),
],
"rust" => &[
("rustup 工具链", "Rustlang.Rustup", "rustup", "rustup",
"装完 `rustup default stable`;Android 目标要 `rustup target add aarch64-linux-android`",
"**用 `rustup update`,不要用包管理器** —— 包管理器会把 rustup 自己换掉"),
],
"python" => &[
("Python", "Python.Python.3.12", "python", "python",
"装完记得勾 Add to PATH;多版本用 venv 隔离",
"**别用包管理器升 Python** —— 直接装新版本、旧版本留着,用 venv 选"),
],
"jdk" => &[
("Temurin JDK 21", "EclipseAdoptium.Temurin.21.JDK", "temurin21", "temurin21",
"Android Gradle Plugin 8.x 要求 JDK 17+;装完设 `JAVA_HOME`",
"多 JDK 共存时靠 `JAVA_HOME` 切换,不要指望 PATH 顺序"),
("Gradle", "Gradle.Gradle", "gradle", "gradle",
"Android 工程一般用 wrapper(`gradlew`),全局 Gradle 只在手动构建时需要", ""),
("Maven", "Apache.Maven", "maven", "maven", "", ""),
],
"node" => &[
("Node.js LTS", "OpenJS.NodeJS.LTS", "nodejs-lts", "nodejs-lts",
"装完 `npm i -g pnpm`",
"多版本切换用 fnm/nvm,**不要**用包管理器升级(会打乱全局包)"),
("pnpm", "pnpm.pnpm", "pnpm", "pnpm", "", ""),
("fnm(版本切换)", "Schniz.fnm", "fnm", "fnm",
"比 nvm-windows 快,且和 WSL 里的用法一致", ""),
],
"git" => &[
("Git", "Git.Git", "git", "git",
"装完 `git lfs install` 把 LFS 钩子写进全局配置", ""),
("GitHub CLI", "GitHub.cli", "gh", "gh", "装完 `gh auth login`", ""),
],
"android" => &[
("Android Studio", "Google.AndroidStudio", "androidstudio", "androidstudio",
"装它会顺带铺好 SDK 与 cmdline-tools —— **首次装最省事的路**",
"Studio 自身走它内置的更新器"),
("Android cmdline-tools", "", "", "",
"没有包管理器入口:从 developer.android.com 下 commandlinetools zip 解到 `%ANDROID_HOME%\\cmdline-tools\\latest`",
"装好之后 **SDK/NDK/构建工具都用 `sdkmanager` 升**,不要用系统的包管理器"),
("platform-tools(adb)", "", "", "",
"同上:`sdkmanager \"platform-tools\"`", ""),
("NDK", "", "", "",
"`sdkmanager \"ndk;27.0.12077973\"`", ""),
],
_ => &[],
};
let mut out = Vec::new();
let script = paths.libs().join("scripts").join("pkg-manage.ps1");
// 路径可能带空格,所以加引号;`-ExecutionPolicy Bypass` 是必须的 ——
// 默认策略下这个脚本会因为「未签名」而拒绝加载(实测踩过)。
let call = |action: &str, id: &str| {
format!(
"powershell -NoProfile -ExecutionPolicy Bypass -File \"{}\" -Action {action} -Id {id}",
script.display()
)
};
for (what, winget_id, choco, scoop, install_note, update_note) in rows {
// **脚本认识的项优先走脚本**:管理器由它自己挑,不再由界面选。
let sid = script_id_for(what);
let (install_cmd, update_cmd) = if !sid.is_empty() {
(Some(call("install", sid)), Some(call("update", sid)))
} else {
match mgr {
"winget" => (
(!winget_id.is_empty()).then(|| format!("winget install --id {winget_id} -e --accept-source-agreements")),
(!winget_id.is_empty()).then(|| format!("winget upgrade --id {winget_id} -e")),
),
"choco" => (
(!choco.is_empty()).then(|| format!("choco install {choco} -y")),
(!choco.is_empty()).then(|| format!("choco upgrade {choco} -y")),
),
"scoop" => (
(!scoop.is_empty()).then(|| format!("scoop install {scoop}")),
(!scoop.is_empty()).then(|| format!("scoop update {scoop}")),
),
_ => (None, None),
}
};
// 有包管理器入口就出「安装/更新」两个;没有入口的(如 vcpkg)只出说明
if let Some(c) = &install_cmd {
out.push(Action {
kind: "安装",
what,
cmd: c.clone(),
note: install_note,
});
}
if let Some(c) = &update_cmd {
out.push(Action {
kind: "更新",
what,
cmd: c.clone(),
note: if update_note.is_empty() { install_note } else { update_note },
});
}
if install_cmd.is_none() && !install_note.is_empty() {
// 没有一键入口的也列出来 —— 让用户知道**为什么**没有按钮
out.push(Action {
kind: "手动",
what,
cmd: String::new(),
note: install_note,
});
}
}
out
}
/// 各类的 `(key, 显示名)`,顺序与 `collect()` 一致。
///
/// **当前没有生产代码读它**:Environment 页没有分类下拉,组名直接取
/// `Group::name`(`core_pages.rs` 是照报告画分组的)。原文写「界面顶部的分类
/// 下拉用它」是**一句不成立的话** —— 那句已删。
///
/// 那为什么还留着:它把「有哪几类、叫什么」钉在一处,配合
/// `catalog_matches_collected_groups` 这条测试,可以挡住「加了新类忘了登记」
/// (报告里有十类,这里也必须十类、顺序一致)。删掉它等于把这层护栏一起删掉。
/// 将来真要做分类下拉,直接用它就行,不用再补一遍。
pub const CATALOG: [(&str, &str); 10] = [
("model", "模型运行环境"),
("qt", "Qt"),
("dotnet", ".NET"),
("c", "C/C++ 工具链"),
("rust", "Rust"),
("python", "Python"),
("jdk", "JDK / 构建"),
("node", "Node.js"),
("git", "Git 与凭据"),
("android", "Android"),
];
/// 执行一条安装 / 更新命令,**带超时**并返回完整输出。
///
/// 为什么要有超时:`winget install` 在某些网络下会**挂在交互提示上**几十分钟 ——
/// 界面上就是一个永远转的圈。宁可超时后如实说「可能卡在交互提示」,
/// 也不要让用户对着一个转不停的圈猜。
///
/// 超时给到 10 分钟:装 Qt / Android Studio 这类几百 MB 的东西确实要几分钟。
pub fn run_action(cmd: &str) -> String {
if cmd.trim().is_empty() {
return "空命令,没执行".to_string();
}
const TIMEOUT_MS: u64 = 10 * 60 * 1000;
let out = crate::tools::capture(cmd, TIMEOUT_MS);
if out.trim().is_empty() {
format!(
"命令没有任何输出。可能原因:\n \
1. 命令不存在(包管理器被卸载/改名)\n \
2. 卡在交互提示上被超时杀掉(winget 常见,加 --accept-source-agreements)\n \
3. 权限不足(装到 Program Files 需要管理员)\n\
命令: {cmd}"
)
} else {
out
}
}
/// cargo 的镜像源 —— **纯读配置,不联网**。
///
/// 为什么不用 `cargo search`:那条命令会走网络并更新 crates.io 索引,
/// 实测让整个环境检测卡在「检测中…」。而我们要的信息(有没有换源)
/// 就写在 `config.toml` 里,读文件是毫秒级。
///
/// 两个位置都看:`$CARGO_HOME\config.toml`,以及默认的 `~\.cargo\config.toml`。
fn cargo_mirror() -> Mirror {
let mut cands: Vec<PathBuf> = Vec::new();
if let Ok(h) = std::env::var("CARGO_HOME") {
cands.push(PathBuf::from(h).join("config.toml"));
}
if let Ok(h) = std::env::var("USERPROFILE") {
cands.push(PathBuf::from(h).join(".cargo").join("config.toml"));
}
for p in cands {
let Ok(text) = std::fs::read_to_string(&p) else {
continue;
};
// 换源的写法是 `replace-with = "xxx"`,被替换的源里给 registry 地址
if text.contains("replace-with") || text.contains("registry =") {
let url = text
.lines()
.map(|l| l.trim())
.find(|l| l.starts_with("registry") && l.contains("http"))
.and_then(|l| l.split('"').nth(1))
.unwrap_or("(已换源,但没解析出地址)");
return Mirror {
name: "cargo",
current: format!("{url} [{}]", p.display()),
official: false,
};
}
// 有配置文件但没换源 = 官方源
return Mirror {
name: "cargo",
current: format!("官方 crates.io([{}] 没写 replace-with)", p.display()),
official: true,
};
}
Mirror {
name: "cargo",
current: "官方 crates.io(没有 config.toml)".to_string(),
official: true,
}
}
#[cfg(test)]
mod tests {
/// **脚本认识的组件要走脚本**,而不是再拼 winget / choco / scoop 命令。
///
/// 这条钉的是这次接线的核心行为:管理器由脚本挑,界面不该再拼命令。
/// 退化的话(有人把 match mgr 那段挪回来)这里会红。
#[test]
fn known_components_go_through_the_unified_script() {
let p = crate::paths::Paths::from_exe_dir("C:\\nfarms");
let acts = actions_for(&p, "git", "winget");
let git = acts.iter().find(|a| a.what == "Git").expect("Git 那条应当在");
assert!(git.cmd.contains("pkg-manage.ps1"), "没走脚本: {}", git.cmd);
assert!(git.cmd.contains("-Id git"), "组件 id 不对: {}", git.cmd);
assert!(
!git.cmd.contains("winget install"),
"不该再自己拼 winget 命令: {}",
git.cmd
);
}
/// 而**脚本还不认识的**组件仍走原来的按管理器拼命令 ——
/// 一次全换会让这些项直接变成装不了(回归)。
#[test]
fn components_the_script_does_not_know_still_use_the_manager() {
let p = crate::paths::Paths::from_exe_dir("C:\\nfarms");
let acts = actions_for(&p, "qt", "winget");
let ninja = acts.iter().find(|a| a.what == "Ninja").expect("Ninja 那条应当在");
assert!(
ninja.cmd.contains("winget install"),
"脚本不管的项应当保持原样: {}",
ninja.cmd
);
}
/// 命令行里必须带 `-ExecutionPolicy Bypass`。
///
/// 少了它,默认策略下这个脚本会因为「未签名」拒绝加载 ——
/// 而报错是 `cannot be loaded ... is not digitally signed`,
/// 用户完全看不出这是命令行少了个开关。
#[test]
fn the_script_call_bypasses_the_execution_policy() {
let p = crate::paths::Paths::from_exe_dir("C:\\nfarms");
let acts = actions_for(&p, "git", "winget");
let git = acts.iter().find(|a| a.what == "Git").expect("Git 那条应当在");
assert!(
git.cmd.contains("-ExecutionPolicy Bypass"),
"少了这个开关脚本根本加载不了: {}",
git.cmd
);
}
use super::*;
#[test]
fn version_extraction_handles_the_real_formats() {
// 各家 --version 的真实样子
assert_eq!(
extract_version("gcc (x86_64-w64-mingw32) 15.2.0").unwrap(),
"15.2.0"
);
assert_eq!(extract_version("v20.11.1").unwrap(), "20.11.1");
assert_eq!(extract_version("Python 3.12.10").unwrap(), "3.12.10");
assert_eq!(
extract_version("rustc 1.98.1 (abcdef 2026-01-01)").unwrap(),
"1.98.1"
);
assert_eq!(
extract_version("cmake version 3.29.2").unwrap(),
"3.29.2"
);
// java 往 stderr 写,格式是 `openjdk version "21.0.1" 2024-10-15`
assert_eq!(
extract_version("openjdk version \"21.0.1\" 2024-10-15").unwrap(),
"21.0.1"
);
// 读不出版本时返回 None,不许瞎编
assert!(extract_version("").is_none());
assert!(extract_version("command not found").is_none());
}
#[test]
fn group_state_reflects_how_many_items_were_found() {
let mk = |found: usize, total: usize| Group {
key: "t",
name: "T",
purpose: "",
items: (0..total)
.map(|i| Item {
label: format!("i{i}"),
version: if i < found { "1.0".into() } else { String::new() },
path: None,
note: String::new(),
})
.collect(),
};
assert_eq!(mk(0, 3).state(), "缺失");
assert_eq!(mk(1, 3).state(), "部分可用");
assert_eq!(mk(3, 3).state(), "可用");
assert_eq!(mk(0, 3).found_count(), (0, 3));
}
/// 纯数字**不是**版本号。
///
/// 守的是 `nvidia-smi --query-gpu=name` 那类输出:`NVIDIA GeForce RTX 4060
/// Laptop GPU` 里的 `4060` 曾经被当驱动版本填进界面 —— 它看起来完全像个
/// 版本号,所以这种错**没人能发现**。
#[test]
fn bare_numbers_are_not_versions() {
assert!(extract_version("NVIDIA GeForce RTX 4060 Laptop GPU").is_none());
assert!(extract_version("936").is_none());
// 反过来,真正的驱动版本号(带点)必须读得出来
assert_eq!(extract_version("566.36").unwrap(), "566.36");
// 命令本身也要问对字段:问 `name` 就会把型号当版本
assert!(
NVIDIA_DRIVER_CMD.contains("driver_version"),
"驱动版本要问 driver_version: {NVIDIA_DRIVER_CMD}"
);
assert!(
!NVIDIA_DRIVER_CMD.contains("=name"),
"不能问 name(那是显卡型号): {NVIDIA_DRIVER_CMD}"
);
}
/// 代码页是**纯数字**,`extract_version` 不再管它,得自己解析。
///
/// 这条同时钉住两边:代码页还能显示出来,而「纯数字」的口子没有为了它重新
/// 开到 `extract_version` 里去(那个口子会把显卡型号当版本)。
#[test]
fn code_page_is_parsed_without_extract_version() {
assert_eq!(code_page_number("Active code page: 936"), "936");
assert_eq!(code_page_number("活动代码页: 65001"), "65001");
// 读不到就空着,不许瞎编
assert_eq!(code_page_number(""), "");
assert_eq!(code_page_number("No code page here"), "");
}
/// 「设了但是空」与「没设」是两回事。
///
/// 界面靠 `value.is_some()` 决定画值还是画「未设 → 建议 …」(core_pages.rs),
/// 合并了就等于把「有人把它清空了」这条信息抹掉。
#[test]
fn an_empty_variable_is_not_an_unset_one() {
assert_eq!(var_value(Ok(String::new())), Some(String::new()));
assert_eq!(var_value(Ok(" ".to_string())), Some(String::new()));
assert_eq!(var_value(Ok("x".to_string())), Some("x".to_string()));
assert_eq!(var_value(Err(std::env::VarError::NotPresent)), None);
}
/// 镜像源读不到时必须**说未知**,不能悄悄算成官方源,更不能算成「已换源」。
#[test]
fn unreadable_mirror_is_reported_as_unknown() {
let (cur, official) = classify_mirror(None, &["registry.npmjs.org"]);
assert!(cur.contains("未知"), "读不到就要把话说清楚,实际: {cur}");
assert!(
official,
"徽章只有两态,这里必须选保守的一侧(理由见 `MIRROR_UNREADABLE`)"
);
let (cur, official) =
classify_mirror(Some("https://registry.npmmirror.com"), &["registry.npmjs.org"]);
assert_eq!(cur, "https://registry.npmmirror.com");
assert!(!official, "换了源就该报已换源");
let (_, official) =
classify_mirror(Some("https://registry.npmjs.org/"), &["registry.npmjs.org"]);
assert!(official, "官方源的标记要认得出来");
}
/// 各类一个都不能少 —— 这是「修改 Environment」的验收点。
#[test]
fn all_ten_categories_are_present() {
let dir = std::env::temp_dir().join("nfarms-envprobe-test");
let _ = std::fs::create_dir_all(&dir);
let r = collect(&crate::Paths::from_exe_dir(&dir));
let keys: Vec<&str> = r.groups.iter().map(|g| g.key).collect();
assert_eq!(
keys,
["model", "qt", "dotnet", "c", "rust", "python", "jdk", "node", "git", "android"]
);
assert_eq!(r.groups.len(), 10);
}
/// `CATALOG` 与 `collect()` 必须一一对应(顺序也要一致)。
///
/// `CATALOG` 的文档注释里一直写着「一致性由 `catalog_matches_collected_groups`
/// 那条测试守着」, 而**这条测试此前并不存在** —— 于是「加了新类忘了登记
/// CATALOG」这件事没有任何东西拦得住。这里把它补上。
#[test]
fn catalog_matches_collected_groups() {
let dir = std::env::temp_dir().join("nfarms-envprobe-test3");
let _ = std::fs::create_dir_all(&dir);
let r = collect(&crate::Paths::from_exe_dir(&dir));
let from_report: Vec<(&str, &str)> = r.groups.iter().map(|g| (g.key, g.name)).collect();
assert_eq!(
CATALOG.to_vec(),
from_report,
"CATALOG 与 collect() 的类目/顺序对不上了"
);
}
/// 采集要真能跑完,而且三个横向维度都要有内容。
#[test]
fn collect_produces_vars_mirrors_and_system_rows() {
let dir = std::env::temp_dir().join("nfarms-envprobe-test2");
let _ = std::fs::create_dir_all(&dir);
let r = collect(&crate::Paths::from_exe_dir(&dir));
// 环境变量清单要覆盖各类各自的关键变量
for must in [
"JAVA_HOME", "ANDROID_HOME", "CARGO_HOME", "DOTNET_ROOT", "QTDIR", "CC",
"NPM_CONFIG_REGISTRY", "GIT_SSH_COMMAND", "PIP_INDEX_URL",
] {
assert!(
r.vars.iter().any(|v| v.name == must),
"环境变量清单里缺 {must}"
);
}
assert!(!r.mirrors.is_empty());
// 长路径与代码页必须被检查(这两个最容易「装好了却用不了」)
assert!(r.system.iter().any(|i| i.label.contains("长路径")));
assert!(r.system.iter().any(|i| i.label.contains("代码页")));
}
#[test]
fn item_summary_prefers_version_then_path_then_note() {
let mut i = Item {
label: "x".into(),
version: "1.0".into(),
path: Some(PathBuf::from(r"C:\a\x.exe")),
note: "n".into(),
};
assert_eq!(i.summary(), "1.0");
i.version.clear();
assert!(i.summary().ends_with("x.exe"));
i.path = None;
assert_eq!(i.summary(), "n");
i.note.clear();
assert_eq!(i.summary(), "(未找到)");
}
// ---- 安装 / 更新层 ----
/// **脚本不管、又没有包管理器时,不能给出点得动的按钮。**
///
/// 这条约束本身没变:命令生成了但本机跑不了,界面就会变成
/// 「看起来能用其实点不动」。
///
/// 变的是**适用范围**:统一脚本自己会探测管理器、还能走直链兜底,所以
/// 它管的那些组件(如 rustup)即使界面这边没选管理器,也照样有命令 ——
/// 这也正是"管理器不该由界面选"的根据。这条守的是**脚本不管的那部分**
/// (android 那组全是 vcpkg/cmdline-tools 这类没有包管理器入口的)。
#[test]
fn no_package_manager_means_no_one_click_actions_for_unknown_ones() {
let p = crate::paths::Paths::from_exe_dir("C:\\nfarms");
let acts = actions_for(&p, "android", "none");
assert!(
acts.iter().all(|a| a.cmd.is_empty()),
"没有包管理器、脚本也不管,却生成了命令: {acts:?}"
);
assert!(acts.iter().any(|a| a.kind == "手动"));
}
/// **脚本还不认识的**组件仍按管理器拼命令 —— 三种管理器三种形状。
///
/// 用 Ninja 而不是 Git:Git 已经改走统一脚本了(见
/// `known_components_go_through_the_unified_script`),这条要守的是那条**兜底路径**。
/// 兜底还在,没被这次接线误伤。
#[test]
fn unknown_components_still_get_a_command_per_package_manager() {
let p = crate::paths::Paths::from_exe_dir("C:\\nfarms");
let w = actions_for(&p, "qt", "winget");
let install = w.iter().find(|a| a.what == "Ninja").unwrap();
assert!(
install.cmd.starts_with("winget install --id Ninja-build.Ninja"),
"实际: {}",
install.cmd
);
// winget 的静默安装要接受协议,不然会挂在交互提示上
assert!(install.cmd.contains("--accept-source-agreements"));
let c = actions_for(&p, "qt", "choco");
assert_eq!(
c.iter().find(|a| a.what == "Ninja").unwrap().cmd,
"choco install ninja -y"
);
let sc = actions_for(&p, "qt", "scoop");
assert_eq!(
sc.iter().find(|a| a.what == "Ninja").unwrap().cmd,
"scoop install ninja"
);
}
/// 「模型运行环境」这一类原来**一条动作都产不出来**:`actions_for` 里没有
/// `model` 分支,兜底 `_ => &[]` 把整类吞掉,界面上那一栏就是光秃秃的
/// (别的类都有按钮或说明,只有它空着,看起来像页面坏了)。
///
/// 修法有意**只给「手动」**:这一类要装的东西由本程序自带的
/// `fetch-libs.ps1` 拉,而 Local Model 页有一条带进度、能停、并且互斥的下载
/// 路径;在这里再放一个 `capture(cmd, 10min)` 的按钮会绕过那道互斥,
/// 两个进程同时往 `libs\` 里解压同一份目录。
#[test]
fn the_model_group_points_at_the_bundled_fetch_script() {
let p = crate::paths::Paths::from_exe_dir("C:\\nfarms");
let acts = actions_for(&p, "model", "winget");
assert!(!acts.is_empty(), "model 这一类不能一条动作都没有");
assert!(
acts.iter().all(|a| a.cmd.is_empty()),
"模型这一类不该给可点的一键命令(理由见 `actions_for` 的 model 分支): {acts:?}"
);
assert!(
acts.iter().any(|a| a.note.contains("fetch-libs.ps1")),
"至少要告诉用户跑哪个脚本: {acts:?}"
);
}
/// 每一类都要能产出动作(或明确的「手动」说明)。
/// 少一类就会出现「这一栏没有安装入口」的空洞 —— `model` 就是这么漏掉的,
/// 所以这里**必须把十类列全**,少一个就等于又给同一类问题留了门。
#[test]
fn every_group_has_some_action() {
for key in [
"model", "qt", "dotnet", "c", "rust", "python", "jdk", "node", "git", "android",
] {
let acts = actions_for(&crate::paths::Paths::from_exe_dir("C:\\nfarms"), key, "winget");
assert!(!acts.is_empty(), "{key} 这一类没有任何动作");
assert!(
acts.iter().any(|a| a.kind == "安装" || a.kind == "手动"),
"{key} 这一类既没有一键安装也没有手动说明"
);
}
}
/// 更新与安装是**两条不同**的命令 —— 只给安装等于没实现「更新」。
#[test]
fn update_is_a_distinct_command_from_install() {
let acts = actions_for(&crate::paths::Paths::from_exe_dir("C:\\nfarms"), "jdk", "winget");
let inst = acts.iter().find(|a| a.kind == "安装").unwrap();
let upd = acts.iter().find(|a| a.kind == "更新").unwrap();
assert_ne!(inst.cmd, upd.cmd);
assert!(upd.cmd.contains("-Action update"), "实际: {}", upd.cmd);
}
#[test]
fn rust_guidance_warns_against_package_manager_updates() {
// 有意写进数据里的经验:rustup 被包管理器换掉会很难查
let acts = actions_for(&crate::paths::Paths::from_exe_dir("C:\\nfarms"), "rust", "winget");
assert!(
acts.iter().any(|a| a.note.contains("rustup update")),
"Rust 那一类应当提示用 rustup update 而不是包管理器"
);
}
#[test]
fn package_manager_detection_does_not_panic() {
let mgrs = package_managers();
assert_eq!(mgrs.len(), 3);
assert_eq!(mgrs[0].key, "winget");
for m in &mgrs {
assert_eq!(m.available(), m.path.is_some());
if !m.available() {
assert!(m.version().is_empty(), "没装的包管理器不该报版本");
}
}
}}