chore: wave-0 baseline (Tauri2+React shell, platform detect, fake catalog)

This commit is contained in:
AgentDock 施工员
2026-08-25 00:21:13 +08:00
commit 296843215f
137 changed files with 6696 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "agentdock-platform"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Win/Linux 环境检测、PATH、发行版识别(架构 §5)"
[dependencies]
serde = { version = "1", features = ["derive"] }
[target.'cfg(windows)'.dependencies]
winreg = "0.52"
+315
View File
@@ -0,0 +1,315 @@
//! 环境检测实现(架构 §5
//!
//! 只做「探测」不执行用户命令:调用系统 `--version` 类只读命令并解析输出;
//! 进程执行统一走 `std::process::Command`,不做 shell 拼接。
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::model::{Capabilities, PlatformEnv, RuntimeInfo, Runtimes, Shells};
/// 全量环境检测(Windows / Linux 双平台)
pub fn detect_env() -> PlatformEnv {
#[cfg(windows)]
let (os, os_version) = (String::from("windows"), windows_os_version());
#[cfg(not(windows))]
let (os, os_version) = (String::from("linux"), linux_os_version());
PlatformEnv {
os,
os_version,
arch: std::env::consts::ARCH.to_string(),
shells: detect_shells(),
runtimes: detect_runtimes(),
path_entries: path_entries(),
capabilities: detect_capabilities(),
}
}
/// 从命令输出中提取首个版本号(如 "v24.18.0" -> "24.18.0")。
/// 规则:取第一段以数字开头、由数字与点组成的子串,尾部点剔除。
pub fn extract_version(output: &str) -> Option<String> {
let bytes = output.as_bytes();
let n = bytes.len();
let mut i = 0;
while i < n {
if bytes[i].is_ascii_digit() {
let mut j = i;
while j < n && (bytes[j].is_ascii_digit() || bytes[j] == b'.') {
j += 1;
}
let s = output[i..j].trim_end_matches('.');
if !s.is_empty() {
return Some(s.to_string());
}
i = j;
} else {
i += 1;
}
}
None
}
/// 按平台分隔符拆分 PATH
fn path_entries() -> Vec<String> {
std::env::var("PATH")
.unwrap_or_default()
.split(path_list_sep())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect()
}
#[cfg(windows)]
fn path_list_sep() -> char {
';'
}
#[cfg(not(windows))]
fn path_list_sep() -> char {
':'
}
/// 在 PATH 中解析可执行文件。
/// Windows 优先 .exe/.cmd/.batCreateProcess 无法直接执行无扩展名文件,
/// 例如 nodejs 目录下的 npm bash 脚本),最后才回退无扩展名。
fn resolve_program(program: &str) -> Option<PathBuf> {
let direct = Path::new(program);
if direct.is_absolute() && direct.is_file() {
return Some(direct.to_path_buf());
}
let exts: &[&str] = if cfg!(windows) {
&[".exe", ".cmd", ".bat", ""]
} else {
&[""]
};
for dir in path_entries() {
for ext in exts {
let cand = Path::new(&dir).join(format!("{program}{ext}"));
if cand.is_file() {
return Some(cand);
}
}
}
None
}
/// 探测一个可执行文件:返回状态 / 版本 / 路径。
/// 结果分类:installed / not_in_path / exec_failed / version_unparseable。
fn probe(program: &str, args: &[&str]) -> Option<RuntimeInfo> {
let path = resolve_program(program);
let path_str = path.as_ref().map(|p| p.to_string_lossy().to_string());
let Some(exe) = path else {
return Some(RuntimeInfo {
status: "not_in_path".into(),
version: None,
path: None,
});
};
let mut cmd = Command::new(&exe);
cmd.args(args);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
// CREATE_NO_WINDOW:探测命令不闪黑框
cmd.creation_flags(0x0800_0000);
}
let output = match cmd.output() {
Ok(o) => o,
Err(_) => {
return Some(RuntimeInfo {
status: "exec_failed".into(),
version: None,
path: path_str,
});
}
};
if !output.status.success() {
return Some(RuntimeInfo {
status: "exec_failed".into(),
version: None,
path: path_str,
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
let text = if stdout.trim().is_empty() {
String::from_utf8_lossy(&output.stderr).to_string()
} else {
stdout.to_string()
};
match extract_version(&text) {
Some(version) => Some(RuntimeInfo {
status: "installed".into(),
version: Some(version),
path: path_str,
}),
None => Some(RuntimeInfo {
status: "version_unparseable".into(),
version: None,
path: path_str,
}),
}
}
fn detect_runtimes() -> Runtimes {
Runtimes {
node: probe("node", &["--version"]),
npm: probe("npm", &["--version"]),
python: probe_python(),
uv: probe("uv", &["--version"]),
git: probe("git", &["--version"]),
winget: probe("winget", &["--version"]),
apt: probe("apt", &["--version"]),
}
}
/// Python:优先 `python --version`,失败退回 `py --version`Windows 启动器)
fn probe_python() -> Option<RuntimeInfo> {
let direct = probe("python", &["--version"]);
if matches!(direct, Some(ref i) if i.status == "installed") {
return direct;
}
probe("py", &["--version"])
}
fn detect_shells() -> Shells {
Shells {
powershell_version: probe_powershell(),
pwsh_version: probe("pwsh", &["-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"])
.and_then(|i| i.version),
bash_available: resolve_program("bash").is_some(),
}
}
#[cfg(windows)]
fn probe_powershell() -> Option<String> {
probe(
"powershell",
&["-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"],
)
.and_then(|i| i.version)
}
#[cfg(not(windows))]
fn probe_powershell() -> Option<String> {
None
}
fn detect_capabilities() -> Capabilities {
#[cfg(windows)]
{
Capabilities {
// Windows Credential Manager 常驻可用(架构 §4.1
keyring: "ok".into(),
// `net session` 仅在管理员上下文返回成功(提权探测,仅检测不执行用户命令)
can_elevate: run_success("net", &["session"]),
}
}
#[cfg(not(windows))]
{
Capabilities {
keyring: if resolve_program("secret-tool").is_some() {
"ok".into()
} else {
"missing".into()
},
can_elevate: std::env::var("USER").map(|u| u == "root").unwrap_or(false),
}
}
}
fn run_success(program: &str, args: &[&str]) -> bool {
let mut cmd = Command::new(program);
cmd.args(args);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000);
}
cmd.output().map(|o| o.status.success()).unwrap_or(false)
}
#[cfg(windows)]
fn windows_os_version() -> String {
use winreg::enums::HKEY_LOCAL_MACHINE;
use winreg::RegKey;
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
match hklm.open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion") {
Ok(key) => {
let product: String = key.get_value("ProductName").unwrap_or_else(|_| "Windows".into());
let display: String = key.get_value("DisplayVersion").unwrap_or_default();
let build: String = key.get_value("CurrentBuildNumber").unwrap_or_default();
let major: u32 = key.get_value("CurrentMajorVersionNumber").unwrap_or(0);
let minor: u32 = key.get_value("CurrentMinorVersionNumber").unwrap_or(0);
let ver = if !display.is_empty() {
display
} else {
format!("{major}.{minor}")
};
format!("{product} {ver} (Build {build})")
}
Err(_) => "Windows (unknown version)".into(),
}
}
#[cfg(not(windows))]
fn linux_os_version() -> String {
let content = std::fs::read_to_string("/etc/os-release").unwrap_or_default();
for line in content.lines() {
if let Some(v) = line.strip_prefix("PRETTY_NAME=") {
return v.trim().trim_matches('"').to_string();
}
}
"Linux (unknown distro)".into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_version_from_common_outputs() {
assert_eq!(extract_version("v24.18.0"), Some("24.18.0".into()));
assert_eq!(extract_version("11.16.0\n"), Some("11.16.0".into()));
assert_eq!(extract_version("Python 3.14.6"), Some("3.14.6".into()));
assert_eq!(extract_version("git version 2.47.1.windows.1"), Some("2.47.1".into()));
assert_eq!(extract_version("GNU bash, version 5.2.26(1)-release"), Some("5.2.26".into()));
assert_eq!(extract_version("v1.8.1911"), Some("1.8.1911".into()));
assert_eq!(extract_version("5.1.26100.1"), Some("5.1.26100.1".into()));
}
#[test]
fn extracts_none_when_no_digits() {
assert_eq!(extract_version("command not found"), None);
assert_eq!(extract_version(""), None);
}
#[test]
fn trims_trailing_dots() {
assert_eq!(extract_version("v1.2.3."), Some("1.2.3".into()));
}
#[test]
fn env_has_path_on_windows() {
// PATH 变量在任何真实 Windows/Linux 上都存在
assert!(!path_entries().is_empty(), "PATH 不应为空");
}
/// 实机自测:打印本机环境检测快照(cargo test -p agentdock-platform -- --ignored --nocapture
#[test]
#[ignore]
fn print_detect_env_snapshot() {
let env = detect_env();
println!("{:#?}", env);
assert_eq!(env.os, "windows");
assert!(!env.os_version.is_empty());
assert!(!env.arch.is_empty());
}
}
+10
View File
@@ -0,0 +1,10 @@
//! agentdock-platform —— 平台检测层
//!
//! 负责 Windows / Linux 的本机环境检测(架构 §5):
//! OS 版本、架构、PATH、shell、运行时(Node/npm/Python/uv/Git/winget/apt)、
//! 能力(密钥库、提权探测)。
pub mod detect;
pub mod model;
pub use model::{Capabilities, PlatformEnv, RuntimeInfo, Runtimes, Shells};
+56
View File
@@ -0,0 +1,56 @@
//! 平台环境数据结构(对齐架构 §5 `PlatformEnv`
use serde::{Deserialize, Serialize};
/// 平台环境快照
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct PlatformEnv {
/// os: windows | linux
pub os: String,
/// 人类可读的系统版本(如 "Windows 11 24H2 (Build 26100)" / "Ubuntu 24.04 LTS"
pub os_version: String,
/// 架构(std::env::consts::ARCH,如 x86_64
pub arch: String,
pub shells: Shells,
pub runtimes: Runtimes,
/// PATH 条目(Windows 用 ';' 分割,Linux 用 ':'
pub path_entries: Vec<String>,
pub capabilities: Capabilities,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct Shells {
/// Windows PowerShell5.x)版本
pub powershell_version: Option<String>,
/// PowerShell 7+pwsh)版本
pub pwsh_version: Option<String>,
pub bash_available: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct Runtimes {
pub node: Option<RuntimeInfo>,
pub npm: Option<RuntimeInfo>,
pub python: Option<RuntimeInfo>,
pub uv: Option<RuntimeInfo>,
pub git: Option<RuntimeInfo>,
pub winget: Option<RuntimeInfo>,
pub apt: Option<RuntimeInfo>,
}
/// 单个运行时探测结果
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct RuntimeInfo {
/// installed | not_installed | not_in_path | exec_failed | version_unparseable
pub status: String,
pub version: Option<String>,
/// 解析到的可执行文件绝对路径(PATH 搜索)
pub path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct Capabilities {
/// ok | missingLinux 依赖 secret-tool / DBus Secret Service
pub keyring: String,
pub can_elevate: bool,
}