chore: wave-0 baseline (Tauri2+React shell, platform detect, fake catalog)
This commit is contained in:
@@ -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"
|
||||
@@ -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/.bat(CreateProcess 无法直接执行无扩展名文件,
|
||||
/// 例如 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());
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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 PowerShell(5.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 | missing(Linux 依赖 secret-tool / DBus Secret Service)
|
||||
pub keyring: String,
|
||||
pub can_elevate: bool,
|
||||
}
|
||||
Reference in New Issue
Block a user