Unlucky Launcher v0.0.1-alpha
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s

Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
This commit is contained in:
AskaEth committed 2026-10-31 21:54:10 +08:00
commit bc41dfcf7d
517 files changed
+92844

No files matched your search

+79
View File
@@ -0,0 +1,79 @@
//! 原子写:临时文件 → fsync → rename。
//!
//! 出处:`DevDocs/analyse/07-settings-packaging-update.md` §1.6(HMCL `FileUtils.saveSafely`:
//! 写 tmp + `Files.move(REPLACE_EXISTING)`)。配置与下载落盘都该走这里,禁止直接 `fs::write`。
//!
//! `std::fs::rename` 在 Unix 与 Windows 上都是「替换式」语义
//! (Windows 侧 std 用 `MoveFileExW(MOVEFILE_REPLACE_EXISTING)`),因此不需要先删目标文件。
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use super::error::{io_at, PlatformError, PlatformResult};
/// 同进程内的 tmp 名去重计数器。
static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
/// 原子地把 `bytes` 写到 `path`(父目录不存在会创建)。
///
/// # Errors
/// 建目录、写临时文件、fsync、rename 任一步失败都返回 [`PlatformError::Io`],
/// 且失败时会尽力清理临时文件,不留垃圾。
pub fn write_atomic(path: &Path, bytes: &[u8]) -> PlatformResult<()> {
let parent = path.parent().unwrap_or_else(|| Path::new(""));
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(io_at(parent))?;
}
let tmp = tmp_path(path);
let write_result = (|| -> io::Result<()> {
let mut file = std::fs::File::create(&tmp)?;
file.write_all(bytes)?;
file.sync_all()?;
Ok(())
})();
if let Err(source) = write_result {
std::fs::remove_file(&tmp).ok();
return Err(PlatformError::Io { path: tmp, source });
}
if let Err(source) = std::fs::rename(&tmp, path) {
std::fs::remove_file(&tmp).ok();
return Err(PlatformError::Io {
path: path.to_path_buf(),
source,
});
}
Ok(())
}
/// 读文件;不存在返回 `None`,其它错误照原样上报(用于区分「没配过」和「读不了」)。
///
/// # Errors
/// 除 `NotFound` 以外的读失败返回 [`PlatformError::Io`]。
pub fn read_optional(path: &Path) -> PlatformResult<Option<Vec<u8>>> {
match std::fs::read(path) {
Ok(bytes) => Ok(Some(bytes)),
Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(None),
Err(source) => Err(PlatformError::Io {
path: path.to_path_buf(),
source,
}),
}
}
/// 同目录下的临时文件名(`launcher.json` → `launcher.json.tmp<pid>-<n>`)。
#[must_use]
pub fn tmp_path(path: &Path) -> PathBuf {
let counter = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let name = path.file_name().map_or_else(
|| String::from("ull"),
|name| name.to_string_lossy().into_owned(),
);
let tmp_name = format!(".{name}.tmp{}-{counter}", std::process::id());
match path.parent() {
Some(parent) => parent.join(tmp_name),
None => PathBuf::from(tmp_name),
}
}
+235
View File
@@ -0,0 +1,235 @@
//! 三模式数据目录解析(Portable 默认 / Standard / Custom)。
//!
//! 设计出处:`DevDocs/PlanDoc/04-data-and-config.md` §1——
//! 用户原话定案:「1.默认在运行目录下生成 .ULL .minecraft 目录 2.设置内可以更改到标准的用户应用数据目录
//! 3.用户可手动指定数据目录」。
//!
//! 附加约束:`.ULL` 与 `.minecraft` 同根但**可分离**(`launcher_home` / `game_home` 覆盖项),
//! macOS `.app` 只读所以默认走 Standard。
use std::fmt;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use super::error::{PlatformError, PlatformResult};
use super::layout::{GAME_HOME_DIR, LAUNCHER_HOME_DIR};
use super::paths::{self, EnvSource, Platform, ProcessEnv};
use super::redirect;
/// 数据目录三模式。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DataMode {
/// exe 运行目录下生成 `.ULL/` 与 `.minecraft/`(首次运行的默认)。
#[default]
Portable,
/// 各 OS 标准应用数据目录。
Standard,
/// 用户手动指定目录。
Custom,
}
impl DataMode {
/// 稳定名(写进 launcher.json 与日志)。
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Portable => "portable",
Self::Standard => "standard",
Self::Custom => "custom",
}
}
/// 从配置字符串解析。
#[must_use]
pub fn parse(text: &str) -> Option<Self> {
match text {
"portable" => Some(Self::Portable),
"standard" => Some(Self::Standard),
"custom" => Some(Self::Custom),
_ => None,
}
}
/// 该平台的默认模式:macOS 的 `.app` 内是只读的 → 默认 Standard(§1 细节)。
#[must_use]
pub const fn default_for(platform: Platform) -> Self {
match platform {
Platform::MacOs => Self::Standard,
_ => Self::Portable,
}
}
}
impl fmt::Display for DataMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// 设置里关于数据目录的原始输入。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DataDirRequest {
/// 三模式。
pub mode: DataMode,
/// Custom 模式的根目录(必须绝对路径)。
pub custom_root: Option<PathBuf>,
/// 高级:显式覆盖启动器数据目录(`.ULL` 的等价物)。
pub launcher_home: Option<PathBuf>,
/// 高级:游戏目录与数据目录分离指定(整合包用户:游戏在 D 盘,配置在用户目录)。
pub game_home: Option<PathBuf>,
}
/// 解析结果:实际生效的两个 home。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedData {
/// 生效模式。
pub mode: DataMode,
/// 启动器数据目录(四分区之家)。
pub launcher_home: PathBuf,
/// 游戏目录(`.minecraft`)。
pub game_home: PathBuf,
/// 经 `redirect.json` 跳转时的原始目录(未跳转为 `None`)。
pub redirected_from: Option<PathBuf>,
}
impl ResolvedData {
/// 生效的启动器布局视图。
#[must_use]
pub fn layout(&self) -> super::layout::LauncherLayout {
super::layout::LauncherLayout::new(self.launcher_home.clone())
}
/// 是否和另一份解析结果指向同一组目录(迁移判定用)。
#[must_use]
pub fn same_target(&self, other: &Self) -> bool {
self.launcher_home == other.launcher_home && self.game_home == other.game_home
}
}
/// 三模式解析器。环境与 exe 目录都可注入,单测里能确定性地覆盖各 OS 落点。
pub struct Resolver<'env> {
platform: Platform,
exe_dir: Option<PathBuf>,
env: &'env dyn EnvSource,
}
impl Resolver<'static> {
/// 用真实进程环境构造。
#[must_use]
pub fn new(platform: Platform, exe_dir: Option<PathBuf>) -> Self {
Self {
platform,
exe_dir,
env: &ProcessEnv,
}
}
}
impl<'env> Resolver<'env> {
/// 注入环境来源(测试 / 便携化封装)。
#[must_use]
pub fn with_env(
platform: Platform,
exe_dir: Option<PathBuf>,
env: &'env dyn EnvSource,
) -> Self {
Self {
platform,
exe_dir,
env,
}
}
/// 平台。
#[must_use]
pub const fn platform(&self) -> Platform {
self.platform
}
/// 该平台的默认模式。
#[must_use]
pub const fn default_mode(&self) -> DataMode {
DataMode::default_for(self.platform)
}
/// 按三模式解析出实际目录(不跟随 `redirect.json`)。
///
/// # Errors
/// Portable 拿不到 exe 目录、Standard 拿不到标准目录、Custom 没给绝对路径时返回错误。
pub fn resolve(&self, request: &DataDirRequest) -> PlatformResult<ResolvedData> {
let (launcher_home, game_home) = match request.mode {
DataMode::Portable => {
let exe_dir = self.exe_dir.clone().ok_or(PlatformError::NoExecutableDir)?;
(exe_dir.join(LAUNCHER_HOME_DIR), exe_dir.join(GAME_HOME_DIR))
}
DataMode::Standard => {
let launcher = paths::standard_launcher_home(self.platform, self.env).ok_or(
PlatformError::NoStandardDataDir {
platform: self.platform.as_str(),
},
)?;
let game = paths::standard_game_home(self.platform, self.env).ok_or(
PlatformError::NoStandardDataDir {
platform: self.platform.as_str(),
},
)?;
(launcher, game)
}
DataMode::Custom => {
let root = request.custom_root.clone().ok_or_else(|| {
PlatformError::InvalidCustomRoot {
root: PathBuf::new(),
}
})?;
if !root.is_absolute() {
return Err(PlatformError::InvalidCustomRoot { root });
}
(root.join(LAUNCHER_HOME_DIR), root.join(GAME_HOME_DIR))
}
};
Ok(ResolvedData {
mode: request.mode,
launcher_home: request.launcher_home.clone().unwrap_or(launcher_home),
game_home: request.game_home.clone().unwrap_or(game_home),
redirected_from: None,
})
}
/// 解析并跟随 `<launcher_home>/redirect.json`(便携盘被搬走后旧目录不再自己长出新实例)。
///
/// # Errors
/// 解析失败、指针损坏、跳转成环/超限时返回错误。
pub fn resolve_effective(
&self,
request: &DataDirRequest,
max_hops: usize,
) -> PlatformResult<ResolvedData> {
let resolved = self.resolve(request)?;
let followed = redirect::follow(&resolved.launcher_home, max_hops)?;
match followed {
Some(followed) => Ok(ResolvedData {
mode: resolved.mode,
launcher_home: followed.pointer.launcher_home.clone(),
game_home: followed.pointer.game_home.clone(),
redirected_from: Some(resolved.launcher_home),
}),
None => Ok(resolved),
}
}
}
/// 默认跟随跳数上限(防成环)。
pub const DEFAULT_REDIRECT_MAX_HOPS: usize = 8;
impl<'env> Resolver<'env> {
/// [`Self::resolve_effective`] 的默认跳数版本。
///
/// # Errors
/// 同 [`Self::resolve_effective`]。
pub fn resolve_default(&self, request: &DataDirRequest) -> PlatformResult<ResolvedData> {
self.resolve_effective(request, DEFAULT_REDIRECT_MAX_HOPS)
}
}
+252
View File
@@ -0,0 +1,252 @@
//! 数据目录迁移:计划 → 执行 → 失败回滚 → 收据 + `redirect.json` 指针。
//!
//! 设计出处:`DevDocs/PlanDoc/04-data-and-config.md` §1 细节——
//! 「迁移 = 移动 + 原地留 redirect.json 指针;迁移失败自动回滚(HMCL `MigrationReceipt` 指纹防重放思想)」。
//!
//! 安全取舍(都是显式拒绝,不猜用户意图):
//! 1. 目标目录已有数据 → 拒绝(不合并,避免半合并状态);
//! 2. 任一步失败 → 逆序回滚已搬的分区;回滚也失败 → [`PlatformError::RollbackFailed`] 要求人工介入;
//! 3. `cache/` 是纯缓存,默认一起搬但可以关掉。
use std::io;
use std::path::{Path, PathBuf};
use super::data_dir::ResolvedData;
use super::error::{io_at, PlatformError, PlatformResult};
use super::layout::{LauncherLayout, Partition};
use super::receipt::{self, MigrationReceipt};
use super::redirect::{self, RedirectPointer, REDIRECT_SCHEMA_VERSION};
/// 迁移选项。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DirMigrationOptions {
/// 是否连 `cache/` 一起搬(关掉则目标侧重建缓存)。
pub migrate_cache: bool,
}
impl Default for DirMigrationOptions {
fn default() -> Self {
Self {
migrate_cache: true,
}
}
}
/// 一步分区搬运。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MoveStep {
/// 分区。
pub partition: Partition,
/// 源目录。
pub source: PathBuf,
/// 目标目录。
pub target: PathBuf,
}
/// 已校验过的迁移计划。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirMigrationPlan {
/// 源布局。
pub from: ResolvedData,
/// 目标布局。
pub to: ResolvedData,
/// 要搬的分区(按搬动顺序:config → data → logs → cache)。
pub steps: Vec<MoveStep>,
}
/// 迁移结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirMigrationOutcome {
/// 已搬的分区(按搬动顺序)。
pub moved: Vec<Partition>,
/// 写入目标的收据。
pub receipt: MigrationReceipt,
/// 写入旧目录的指针。
pub pointer: RedirectPointer,
/// 收据落盘路径。
pub receipt_path: PathBuf,
/// 指针落盘路径。
pub pointer_path: PathBuf,
}
/// 搬动顺序:配置文件最重要、缓存最后(且可跳过)。
const ORDER: [Partition; 4] = [
Partition::Config,
Partition::Data,
Partition::Logs,
Partition::Cache,
];
/// 生成计划(不改动磁盘,只校验)。目标目录已存在且非空 → 拒绝。
///
/// # Errors
/// 源目标相同、源目录不存在、目标非空、没有可搬分区时返回 [`PlatformError::MigrationRefused`]。
pub fn plan(
from: &ResolvedData,
to: &ResolvedData,
options: &DirMigrationOptions,
) -> PlatformResult<DirMigrationPlan> {
if from.same_target(to) {
return Err(PlatformError::MigrationRefused {
reason: format!("源与目标相同: {}", from.launcher_home.display()),
});
}
if !from.launcher_home.is_dir() {
return Err(PlatformError::MigrationRefused {
reason: format!("源数据目录不存在: {}", from.launcher_home.display()),
});
}
if is_non_empty_dir(&to.launcher_home) {
return Err(PlatformError::MigrationRefused {
reason: format!("目标数据目录已有数据: {}", to.launcher_home.display()),
});
}
let from_layout = LauncherLayout::new(from.launcher_home.clone());
let to_layout = LauncherLayout::new(to.launcher_home.clone());
let mut steps = Vec::new();
for partition in ORDER {
if partition.is_disposable() && !options.migrate_cache {
continue;
}
let source = from_layout.partition(partition);
if !source.is_dir() {
continue;
}
steps.push(MoveStep {
partition,
source,
target: to_layout.partition(partition),
});
}
if steps.is_empty() {
return Err(PlatformError::MigrationRefused {
reason: format!(
"源数据目录里没有可搬的分区: {}",
from.launcher_home.display()
),
});
}
Ok(DirMigrationPlan {
from: from.clone(),
to: to.clone(),
steps,
})
}
/// 执行计划:逐分区搬 → 失败逆序回滚 → 成功后写收据 + 旧目录指针。
///
/// # Errors
/// 写/搬失败且回滚成功 → 返回原始错误(磁盘已还原);回滚也失败 → [`PlatformError::RollbackFailed`]。
pub fn execute(plan: &DirMigrationPlan, now_ms: u64) -> PlatformResult<DirMigrationOutcome> {
let mut applied: Vec<&MoveStep> = Vec::new();
for step in &plan.steps {
match move_tree(&step.source, &step.target) {
Ok(()) => applied.push(step),
Err(err) => return Err(rollback(&applied, err)),
}
}
let from_layout = LauncherLayout::new(plan.from.launcher_home.clone());
let to_layout = LauncherLayout::new(plan.to.launcher_home.clone());
std::fs::create_dir_all(from_layout.home()).map_err(io_at(from_layout.home()))?;
std::fs::create_dir_all(to_layout.config()).map_err(io_at(to_layout.config()))?;
let receipt = MigrationReceipt::data_dir_move(
plan.from.launcher_home.clone(),
plan.to.launcher_home.clone(),
now_ms,
);
let receipt_path = to_layout.migration_receipt();
receipt::write(&receipt_path, &receipt)?;
let pointer = RedirectPointer {
schema_version: REDIRECT_SCHEMA_VERSION,
mode: plan.to.mode,
launcher_home: plan.to.launcher_home.clone(),
game_home: plan.to.game_home.clone(),
migrated_at_ms: now_ms,
};
let pointer_path = redirect::write(from_layout.home(), &pointer)?;
Ok(DirMigrationOutcome {
moved: plan.steps.iter().map(|step| step.partition).collect(),
receipt,
pointer,
receipt_path,
pointer_path,
})
}
/// 逆序回滚已搬分区;回滚本身失败时升级为 [`PlatformError::RollbackFailed`]。
fn rollback(applied: &[&MoveStep], cause: PlatformError) -> PlatformError {
for step in applied.iter().rev() {
if let Err(rollback_err) = move_tree(&step.target, &step.source) {
return PlatformError::RollbackFailed {
reason: format!(
"分区 {} 搬运失败({cause}),且回滚 {} 失败({rollback_err})",
step.partition.as_str(),
step.partition.as_str()
),
};
}
}
cause
}
/// 搬一棵目录树:优先 `rename`(同盘瞬时);跨盘失败回落「复制 + 校验 + 删源」。
///
/// 目标已存在则拒绝(绝不合并)。源不存在视为无事可做。
fn move_tree(source: &Path, target: &Path) -> PlatformResult<()> {
if !source.exists() {
return Ok(());
}
if target.exists() {
return Err(PlatformError::MigrationRefused {
reason: format!("目标已存在,拒绝覆盖: {}", target.display()),
});
}
let parent = target.parent().unwrap_or_else(|| Path::new(""));
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(io_at(parent))?;
}
match std::fs::rename(source, target) {
Ok(()) => Ok(()),
Err(_) => {
// 跨盘(便携盘 → 内置盘)时 rename 会失败,改用复制 + 删源。
copy_tree(source, target).map_err(|err| {
std::fs::remove_dir_all(target).ok();
PlatformError::Io {
path: target.to_path_buf(),
source: err,
}
})?;
std::fs::remove_dir_all(source).map_err(io_at(source))
}
}
}
/// 递归复制(不跟随符号链接,避免把外部大目录拖进来)。
fn copy_tree(from: &Path, to: &Path) -> io::Result<()> {
std::fs::create_dir_all(to)?;
for entry in std::fs::read_dir(from)? {
let entry = entry?;
let file_type = entry.file_type()?;
let destination = to.join(entry.file_name());
if file_type.is_dir() {
copy_tree(&entry.path(), &destination)?;
} else if file_type.is_file() {
std::fs::copy(entry.path(), &destination)?;
}
}
Ok(())
}
/// 目录存在且非空。
fn is_non_empty_dir(path: &Path) -> bool {
if !path.is_dir() {
return false;
}
std::fs::read_dir(path).is_ok_and(|mut entries| entries.next().is_some())
}
+124
View File
@@ -0,0 +1,124 @@
//! platform 层错误类型与结果别名。
//!
//! 设计出处:`DevDocs/PlanDoc/04-data-and-config.md` §1(三模式 + 迁移收据)、
//! `DevDocs/analyse/07-settings-packaging-update.md` §1.6(保存通道)、§1.8(遗留迁移收据)。
use std::fmt;
use std::io;
use std::path::PathBuf;
/// platform 层的结果别名。
pub type PlatformResult<T> = Result<T, PlatformError>;
/// 数据目录解析、文件系统原语、目录迁移可能出的问题。
#[derive(Debug)]
pub enum PlatformError {
/// 文件系统操作失败(带出错路径)。
Io {
/// 出错的文件或目录。
path: PathBuf,
/// 底层 IO 错误。
source: io::Error,
},
/// Portable 模式拿不到可执行文件所在目录。
NoExecutableDir,
/// Standard 模式拿不到宿主标准数据目录(环境变量缺失)。
NoStandardDataDir {
/// 平台名(`windows` / `macos` / `linux` / `other`)。
platform: &'static str,
},
/// Custom 模式没给目录,或给的不是绝对路径。
InvalidCustomRoot {
/// 用户给的目录。
root: PathBuf,
},
/// 实例 id 非法(只允许 `[A-Za-z0-9_-]`,挡掉 `../` 路径穿越)。
InvalidInstanceId {
/// 用户/档案里给的 id。
id: String,
},
/// `redirect.json` 链超过跳数上限或成环。
RedirectCycle {
/// 起始目录。
dir: PathBuf,
/// 实际跳数。
hops: usize,
},
/// `redirect.json` 内容损坏。
RedirectCorrupt {
/// 指针文件路径。
path: PathBuf,
/// 原因。
reason: String,
},
/// 拒绝迁移(目标已有数据、源不存在等),原因写在消息里。
MigrationRefused {
/// 拒绝原因。
reason: String,
},
/// 迁移失败且回滚也失败——需要人工介入,不能当成普通失败吞掉。
RollbackFailed {
/// 原始失败 + 回滚失败串起来的原因。
reason: String,
},
/// 迁移收据损坏。
ReceiptCorrupt {
/// 收据文件路径。
path: PathBuf,
/// 原因。
reason: String,
},
}
impl fmt::Display for PlatformError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io { path, source } => write!(f, "IO 失败 @ {}: {source}", path.display()),
Self::NoExecutableDir => write!(f, "Portable 模式无法定位可执行文件所在目录"),
Self::NoStandardDataDir { platform } => {
write!(
f,
"Standard 模式在 {platform} 上拿不到标准数据目录(环境变量缺失)"
)
}
Self::InvalidCustomRoot { root } => {
write!(
f,
"Custom 数据目录不合法(必须是绝对路径): {}",
root.display()
)
}
Self::InvalidInstanceId { id } => {
write!(f, "实例 id 非法(只允许 [A-Za-z0-9_-]): {id:?}")
}
Self::RedirectCycle { dir, hops } => write!(
f,
"redirect.json 跳转成环或超限({hops} 跳)@ {}",
dir.display()
),
Self::RedirectCorrupt { path, reason } => {
write!(f, "redirect.json 损坏 @ {}: {reason}", path.display())
}
Self::MigrationRefused { reason } => write!(f, "拒绝迁移: {reason}"),
Self::RollbackFailed { reason } => write!(f, "迁移回滚失败,需人工介入: {reason}"),
Self::ReceiptCorrupt { path, reason } => {
write!(f, "迁移收据损坏 @ {}: {reason}", path.display())
}
}
}
}
impl std::error::Error for PlatformError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
_ => None,
}
}
}
/// 把 `io::Error` 绑上路径,给 `map_err` 用。
pub(crate) fn io_at<P: Into<PathBuf>>(path: P) -> impl FnOnce(io::Error) -> PlatformError {
let path = path.into();
move |source| PlatformError::Io { path, source }
}
+95
View File
@@ -0,0 +1,95 @@
//! 迁移收据的源文件指纹(SHA-256),委托给 `sha2`。
//!
//! 出处:`DevDocs/analyse/07-settings-packaging-update.md` §1.8——收据里存源文件 `sourceSHA256`,
//! 这样「删掉新文件但旧文件没变」时不会重复迁移,旧文件变了才重放。
//!
//! 阶段A 在「不许动 Cargo.toml」的约束下自带过一版 FIPS 180-4 实现;阶段B 按 tower 批复
//! 改用 `sha2`(安全哈希不自维护),**公开签名不变**,单测从「自带实现的标准向量」
//! 变为「wrapper 与 `sha2` 一致 + 标准向量兜底」。
use std::io;
use std::path::Path;
use sha2::{Digest, Sha256};
/// 计算 32 字节摘要。
#[must_use]
pub fn sha256(data: &[u8]) -> [u8; 32] {
let digest = Sha256::digest(data);
let mut out = [0u8; 32];
out.copy_from_slice(digest.as_slice());
out
}
/// 计算小写十六进制摘要。
#[must_use]
pub fn sha256_hex(data: &[u8]) -> String {
to_hex(&sha256(data))
}
/// 读文件算十六进制摘要(配置文件很小,整读即可)。
///
/// # Errors
/// 文件读失败时返回原始 `io::Error`。
pub fn sha256_hex_file(path: &Path) -> io::Result<String> {
let bytes = std::fs::read(path)?;
Ok(sha256_hex(&bytes))
}
fn to_hex(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(char::from(HEX[usize::from(byte >> 4)]));
out.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
out
}
#[cfg(test)]
mod tests {
use sha2::Digest;
use super::{sha256, sha256_hex, sha256_hex_file};
#[test]
fn matches_fips_vectors() {
assert_eq!(
sha256_hex(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
assert_eq!(
sha256_hex(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
assert_eq!(
sha256_hex(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"),
"248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"
);
}
#[test]
fn agrees_with_sha2_at_block_boundaries() {
// 55 / 56 / 63 / 64 / 65 是补齐逻辑最容易出错的长度,wrapper 必须与 sha2 完全一致。
for len in [55usize, 56, 63, 64, 65, 128, 1000] {
let data = vec![b'a'; len];
let expected = sha2::Sha256::digest(&data);
assert_eq!(&sha256(&data)[..], expected.as_slice(), "length {len}");
assert_eq!(sha256_hex(&data).len(), 64);
}
}
#[test]
fn hashes_file_contents() {
let dir = std::env::temp_dir().join(format!("ull-hash-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("create temp dir");
let path = dir.join("a.txt");
std::fs::write(&path, b"abc").expect("write");
let hex = sha256_hex_file(&path).expect("hash file");
std::fs::remove_dir_all(&dir).ok();
assert_eq!(
hex,
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
}
+223
View File
@@ -0,0 +1,223 @@
//! `.ULL/` 四分区布局——所有模块取路径的唯一入口,禁止各自手拼路径。
//!
//! 布局依据 `DevDocs/PlanDoc/04-data-and-config.md` §2(承 HMCL 四分区):
//! `config/`(schema 化 JSON)、`data/`(可重建但优先保留)、`cache/`(随时可删)、`logs/`。
use std::path::{Path, PathBuf};
use super::error::{io_at, PlatformError, PlatformResult};
/// Portable 模式下启动器数据目录名(exe 同目录)。
pub const LAUNCHER_HOME_DIR: &str = ".ULL";
/// 游戏数据目录名。
pub const GAME_HOME_DIR: &str = ".minecraft";
/// 分区:配置。
pub const CONFIG_DIR: &str = "config";
/// 分区:状态数据。
pub const DATA_DIR: &str = "data";
/// 分区:纯缓存。
pub const CACHE_DIR: &str = "cache";
/// 分区:启动器日志。
pub const LOGS_DIR: &str = "logs";
/// 全局设置文件名。
pub const LAUNCHER_CONFIG_FILE: &str = "launcher.json";
/// 实例档案列表文件名。
pub const PROFILES_CONFIG_FILE: &str = "profiles.json";
/// 每实例设置所在子目录。
pub const INSTANCES_DIR: &str = "instances";
/// 迁移后留在旧目录的指针文件名。
pub const REDIRECT_FILE: &str = "redirect.json";
/// 目录迁移收据文件名(写在目标 `config/`)。
pub const MIGRATION_RECEIPT_FILE: &str = "migration-receipt.json";
/// `.ULL/` 下的四个分区。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Partition {
/// `config/`:配置(迁移时优先搬)。
Config,
/// `data/`:状态数据(不可丢)。
Data,
/// `cache/`:纯缓存(可丢,迁移可选)。
Cache,
/// `logs/`:启动器日志。
Logs,
}
impl Partition {
/// 全部四个分区。
pub const ALL: [Self; 4] = [Self::Config, Self::Data, Self::Cache, Self::Logs];
/// 分区目录名。
#[must_use]
pub const fn dir_name(&self) -> &'static str {
match self {
Self::Config => CONFIG_DIR,
Self::Data => DATA_DIR,
Self::Cache => CACHE_DIR,
Self::Logs => LOGS_DIR,
}
}
/// 稳定名(日志 / 收据用)。
#[must_use]
pub const fn as_str(&self) -> &'static str {
self.dir_name()
}
/// 是否为可丢弃分区(迁移时可跳过)。
#[must_use]
pub const fn is_disposable(&self) -> bool {
matches!(self, Self::Cache)
}
}
/// 一个 launcher home(`.ULL` 的等价物)的路径视图。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LauncherLayout {
home: PathBuf,
}
impl LauncherLayout {
/// 绑定到某个 home 目录(不创建任何东西,创建用 [`Self::ensure`])。
#[must_use]
pub fn new(home: impl Into<PathBuf>) -> Self {
Self { home: home.into() }
}
/// home 根目录。
#[must_use]
pub fn home(&self) -> &Path {
&self.home
}
/// 任一分区目录。
#[must_use]
pub fn partition(&self, partition: Partition) -> PathBuf {
self.home.join(partition.dir_name())
}
/// `config/`。
#[must_use]
pub fn config(&self) -> PathBuf {
self.partition(Partition::Config)
}
/// `data/`。
#[must_use]
pub fn data(&self) -> PathBuf {
self.partition(Partition::Data)
}
/// `cache/`。
#[must_use]
pub fn cache(&self) -> PathBuf {
self.partition(Partition::Cache)
}
/// `logs/`。
#[must_use]
pub fn logs(&self) -> PathBuf {
self.partition(Partition::Logs)
}
/// `config/<name>`。
#[must_use]
pub fn config_file(&self, name: &str) -> PathBuf {
self.config().join(name)
}
/// `config/launcher.json`。
#[must_use]
pub fn launcher_config(&self) -> PathBuf {
self.config_file(LAUNCHER_CONFIG_FILE)
}
/// `config/profiles.json`。
#[must_use]
pub fn profiles_config(&self) -> PathBuf {
self.config_file(PROFILES_CONFIG_FILE)
}
/// `config/instances/<id>.json`。
///
/// # Errors
/// 实例 id 只允许 `[A-Za-z0-9_-]`(正常是稳定 UUID),否则返回
/// [`super::error::PlatformError::InvalidInstanceId`],挡掉 `../` 穿越。
pub fn instance_config(&self, id: &str) -> PlatformResult<PathBuf> {
if !is_valid_instance_id(id) {
return Err(PlatformError::InvalidInstanceId { id: id.to_owned() });
}
Ok(self.config().join(INSTANCES_DIR).join(format!("{id}.json")))
}
/// `cache/sha1/<h2>`——内容寻址缓存的两级分片(避开 NTFS 单目录塌陷)。
#[must_use]
pub fn sha1_cache(&self, shard: &str) -> PathBuf {
self.cache().join("sha1").join(shard)
}
/// `cache/etag.json`——共享 ETag 表。
#[must_use]
pub fn etag_cache(&self) -> PathBuf {
self.cache().join("etag.json")
}
/// `cache/meta/`——列表 API 响应缓存(带 TTL)。
#[must_use]
pub fn meta_cache(&self) -> PathBuf {
self.cache().join("meta")
}
/// `data/java-managed/`——自动下载的 JDK。
#[must_use]
pub fn java_managed(&self) -> PathBuf {
self.data().join("java-managed")
}
/// `data/auth/`——账户元数据与凭据。
#[must_use]
pub fn auth_data(&self) -> PathBuf {
self.data().join("auth")
}
/// `data/agent/sessions/`——AI 会话 jsonl 与审计日志。
#[must_use]
pub fn agent_sessions(&self) -> PathBuf {
self.data().join("agent").join("sessions")
}
/// `<home>/redirect.json`——「我已迁走」指针。
#[must_use]
pub fn redirect_path(&self) -> PathBuf {
self.home.join(REDIRECT_FILE)
}
/// `config/migration-receipt.json`——迁移收据。
#[must_use]
pub fn migration_receipt(&self) -> PathBuf {
self.config_file(MIGRATION_RECEIPT_FILE)
}
/// 一次性建齐四分区。
///
/// # Errors
/// 目录创建失败时返回带路径的 [`super::error::PlatformError::Io`]。
pub fn ensure(&self) -> PlatformResult<()> {
for partition in Partition::ALL {
let dir = self.partition(partition);
std::fs::create_dir_all(&dir).map_err(io_at(&dir))?;
}
Ok(())
}
}
/// 实例 id 白名单:字母数字与 `_`/`-`。
#[must_use]
pub fn is_valid_instance_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& id
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
}
+225
View File
@@ -0,0 +1,225 @@
//! Linux 探测:**能力**而非版本号(D19 · `PlanDoc/11-os-detection.md` §2)。
//!
//! 发行版版本对能不能跑没有意义,所以这里分三类事实:
//! - `/etc/os-release` 的 `PRETTY_NAME` —— **仅供显示/日志**;
//! - 内核 release(`/proc/sys/kernel/osrelease`,退化 `uname -r`)—— 喂 MC 的
//! `rules.os.version`,同时给出最低内核判定用的 `major.minor`;
//! - 能力探测:图形会话类型(Wayland / X11 / 无显示)与 GL 能力钩子(见 [`probe_gl`])。
//!
//! glibc 版本用 `ldd --version` 探测(musl / Termux 上取不到,此时不阻塞)。
//! fontconfig 可用性与 `xdg-desktop-portal` 同理留到 app 层(字体回退、主题跟随、文件选择器),
//! core 只报环境事实,不做渲染后端决策。
//! 解析函数是纯函数且跨平台编译,单测用注入样本覆盖(见 `tests/platform/`)。
/// 图形会话类型(`Unknown` 是派生默认值:未探测)。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum SessionKind {
/// Wayland 会话(`WAYLAND_DISPLAY` 或 `XDG_SESSION_TYPE=wayland`)。
Wayland,
/// X11 会话(`DISPLAY` 或 `XDG_SESSION_TYPE=x11`)。
X11,
/// 没有图形会话(服务器 / 容器 / SSH)。GUI 用不了,CLI 照常。
Headless,
/// 未探测(非 Linux 平台,或探测未执行)。
#[default]
Unknown,
}
impl SessionKind {
/// 简短名(日志用)。
pub fn as_str(self) -> &'static str {
match self {
Self::Wayland => "wayland",
Self::X11 => "x11",
Self::Headless => "headless",
Self::Unknown => "unknown",
}
}
/// 是否存在可用的显示服务器。
pub fn has_display(self) -> bool {
matches!(self, Self::Wayland | Self::X11)
}
}
/// GL/EGL 能力探测结果。
///
/// P1 只留钩子:真探测要 `dlopen("libEGL.so")` + `eglQueryString`,那是 FFI,
/// 而 core 是 `#![forbid(unsafe_code)]`;因此真实探测放在 app 层「渲染后端选择」里做,
/// core 只负责把 `Unknown` 如实报出来,不阻塞任何流程。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum GlProbe {
/// 未探测(P1 常态)。
#[default]
Unknown,
/// 有可用的 GL/EGL。
Available,
/// 没有可用 GL(调用方应降级到软渲染)。
Missing,
}
impl GlProbe {
/// 简短名(日志用)。
pub fn as_str(self) -> &'static str {
match self {
Self::Unknown => "unknown",
Self::Available => "available",
Self::Missing => "missing",
}
}
}
/// Linux 探测结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LinuxCaps {
/// 发行版展示名(`PRETTY_NAME`,仅显示)。
pub distro: Option<String>,
/// 内核 release 原始串(`6.8.0-45-generic`),喂 `rules.os.version`。
pub kernel: Option<String>,
/// 内核 `major.minor`(最低内核判定用)。
pub kernel_version: Option<(u32, u32)>,
/// glibc `major.minor`;musl / 取不到时为 `None`。
pub glibc: Option<(u32, u32)>,
/// 图形会话类型。
pub session: SessionKind,
/// GL 能力(P1 恒为 [`GlProbe::Unknown`])。
pub gl: GlProbe,
}
/// 探测当前 Linux 环境;非 Linux 平台返回全空的默认值。
#[cfg(target_os = "linux")]
pub fn probe() -> LinuxCaps {
let distro = std::fs::read_to_string("/etc/os-release")
.ok()
.and_then(|text| parse_os_release(&text));
let kernel = std::fs::read_to_string("/proc/sys/kernel/osrelease")
.ok()
.map(|text| text.trim().to_string())
.filter(|value| !value.is_empty())
.or_else(|| run("uname", &["-r"]));
let glibc = run("ldd", &["--version"]).and_then(|text| parse_glibc_version(&text));
LinuxCaps {
distro,
kernel_version: kernel.as_deref().and_then(parse_kernel_version),
kernel,
glibc,
session: current_session(),
gl: probe_gl(),
}
}
/// 探测当前 Linux 环境;非 Linux 平台返回全空的默认值。
#[cfg(not(target_os = "linux"))]
pub fn probe() -> LinuxCaps {
LinuxCaps {
distro: None,
kernel: None,
kernel_version: None,
glibc: None,
session: SessionKind::Unknown,
gl: GlProbe::Unknown,
}
}
/// GL/EGL 探测钩子(P1 返回 [`GlProbe::Unknown`],见类型文档)。
pub fn probe_gl() -> GlProbe {
GlProbe::Unknown
}
/// 按当前环境变量判定会话类型。
pub fn current_session() -> SessionKind {
session_from_env(|key| std::env::var(key).ok())
}
/// 按注入的环境变量读取函数判定会话类型(便于单测)。
///
/// 判定顺序:`WAYLAND_DISPLAY` → `XDG_SESSION_TYPE` → `DISPLAY` → 无显示。
pub fn session_from_env(get: impl Fn(&str) -> Option<String>) -> SessionKind {
let has = |key: &str| get(key).map(|v| !v.trim().is_empty()).unwrap_or(false);
if has("WAYLAND_DISPLAY") {
return SessionKind::Wayland;
}
match get("XDG_SESSION_TYPE").as_deref().map(str::trim) {
Some(value) if value.eq_ignore_ascii_case("wayland") => return SessionKind::Wayland,
Some(value) if value.eq_ignore_ascii_case("x11") => return SessionKind::X11,
_ => {}
}
if has("DISPLAY") {
return SessionKind::X11;
}
SessionKind::Headless
}
/// 从 `/etc/os-release` 取 `PRETTY_NAME`(去引号)。
pub fn parse_os_release(text: &str) -> Option<String> {
let mut fallback = None;
for line in text.lines() {
let line = line.trim();
let Some((key, value)) = line.split_once('=') else {
continue;
};
let value = value.trim().trim_matches('"').trim_matches('\'').trim();
if value.is_empty() {
continue;
}
match key.trim() {
"PRETTY_NAME" => return Some(value.to_string()),
// 没有 PRETTY_NAME 时用 NAME + VERSION_ID 拼一个(某些裁剪发行版只给这两个)。
"NAME" => {
let name = value.to_string();
fallback = Some(match fallback.take() {
Some(prev) => format!("{prev} {name}"),
None => name,
});
}
"VERSION_ID" => {
let version = value.to_string();
fallback = Some(match fallback.take() {
Some(prev) => format!("{prev} {version}"),
None => version,
});
}
_ => {}
}
}
fallback
}
/// 从内核 release 串取 `major.minor`(`6.8.0-45-generic` → `(6, 8)`)。
pub fn parse_kernel_version(release: &str) -> Option<(u32, u32)> {
let trimmed = release.trim();
let mut parts = trimmed.split(|c: char| !c.is_ascii_digit());
let major: u32 = parts.next()?.parse().ok()?;
let minor: u32 = parts.next().unwrap_or("0").parse().unwrap_or(0);
Some((major, minor))
}
/// 从 `ldd --version` 输出取 glibc `major.minor`
/// (`ldd (Ubuntu GLIBC 2.39-0ubuntu8.3) 2.39` → `(2, 39)`)。
pub fn parse_glibc_version(text: &str) -> Option<(u32, u32)> {
// 只看第一行:后面是版权声明,可能含有别的数字。
let first = text.lines().next().unwrap_or_default();
first.split_whitespace().rev().find_map(|token| {
let (major, minor) = token.split_once('.')?;
let minor = minor
.split(|c: char| !c.is_ascii_digit())
.next()
.unwrap_or_default();
Some((major.parse().ok()?, minor.parse().ok()?))
})
}
/// `(2, 31)` → `"2.31"`(展示用)。
pub fn format_version(version: (u32, u32)) -> String {
format!("{}.{}", version.0, version.1)
}
#[cfg(target_os = "linux")]
fn run(program: &str, args: &[&str]) -> Option<String> {
use std::process::Command;
let output = Command::new(program).args(args).output().ok()?;
if output.stdout.is_empty() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).into_owned())
}
+100
View File
@@ -0,0 +1,100 @@
//! macOS 探测:产品版本 + 运行架构(D19 · `PlanDoc/11-os-detection.md` §2)。
//!
//! - 产品版本:`sw_vers -productVersion` → `12.3.1`(最低部署目标判定 + MC `os.version` 规则);
//! - 运行架构:`sysctl -n hw.optional.arm64` 输出 `1` 表示**当前运行在 Apple Silicon 上**
//! (关键:Rosetta 2 下 `cfg!(target_arch)` 仍是 `x86_64`,但原生渲染路径与 MC 原生库
//! 要的是真实架构,所以必须运行期探);
//! - 内核版本:`uname -r`(`22.4.0`)只进日志,不参与判定。
//!
//! 解析函数是纯函数且跨平台编译,单测用注入样本覆盖(见 `tests/platform/`)。
/// macOS 版本 + 运行架构。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MacOsVersion {
/// 主版本号(macOS 12 Monterey = 12)。
pub major: u32,
/// 次版本号。
pub minor: u32,
/// 补丁号。
pub patch: u32,
/// 是否运行在 Apple Silicon(含 Rosetta 2 场景)。
pub arm64: bool,
}
impl MacOsVersion {
/// 构造。
pub fn new(major: u32, minor: u32, patch: u32, arm64: bool) -> Self {
Self {
major,
minor,
patch,
arm64,
}
}
/// 产品版本串(`rules.os.version` 用,例如 `12.3.1`)。
pub fn version_string(&self) -> String {
format!("{}.{}.{}", self.major, self.minor, self.patch)
}
/// 展示名(`macOS 12.3.1 (arm64)`)。
pub fn display_name(&self) -> String {
let arch = if self.arm64 { "arm64" } else { "x86_64" };
format!("macOS {} ({arch})", self.version_string())
}
/// `rules.os.arch` 取值:Apple Silicon 上必须是 `arm64`。
pub fn runtime_arch(&self) -> &'static str {
if self.arm64 {
"arm64"
} else {
"x86_64"
}
}
}
/// 真实探测;非 macOS 平台恒为 `None`。
#[cfg(target_os = "macos")]
pub fn probe() -> Option<MacOsVersion> {
let (major, minor, patch) =
run("sw_vers", &["-productVersion"]).and_then(|text| parse_product_version(&text))?;
// sysctl 在 Intel 机器上以非零码退出(键不存在)→ stdout 为空 → false。
let arm64 = run("sysctl", &["-n", "hw.optional.arm64"])
.map(|text| parse_sysctl_bool(&text))
.unwrap_or(false);
Some(MacOsVersion::new(major, minor, patch, arm64))
}
/// 真实探测;非 macOS 平台恒为 `None`。
#[cfg(not(target_os = "macos"))]
pub fn probe() -> Option<MacOsVersion> {
None
}
/// 解析 `12.3.1` / `12.3` / `12`(`sw_vers -productVersion` 的输出)。
pub fn parse_product_version(text: &str) -> Option<(u32, u32, u32)> {
let trimmed = text.trim();
let token = trimmed
.split_whitespace()
.find(|token| token.starts_with(|c: char| c.is_ascii_digit()))?;
let mut parts = token.split('.').map(str::parse::<u32>);
let major = parts.next()?.ok()?;
let minor = parts.next().map(|value| value.unwrap_or(0)).unwrap_or(0);
let patch = parts.next().map(|value| value.unwrap_or(0)).unwrap_or(0);
Some((major, minor, patch))
}
/// 解析 `sysctl -n hw.optional.arm64` 的输出(`1` 为真)。
pub fn parse_sysctl_bool(text: &str) -> bool {
text.trim() == "1"
}
#[cfg(target_os = "macos")]
fn run(program: &str, args: &[&str]) -> Option<String> {
use std::process::Command;
let output = Command::new(program).args(args).output().ok()?;
if !output.status.success() || output.stdout.is_empty() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).into_owned())
}
+42
View File
@@ -0,0 +1,42 @@
//! OS 抽象层:路径规则、平台目录差异、进程与文件锁、软渲染/显示能力探测辅助。
//!
//! 设计定稿:`DevDocs/PlanDoc/04-data-and-config.md` §1–§2(三模式路径与 `.ULL` 四分区)。
//! Win7 单独适配(决策 D11)在此收敛平台差异判断,避免散落各模块打补丁。
//!
//! 公共面(M12 已落地):
//! - [`paths`]:宿主平台族与标准目录探测(环境变量可注入),可搬运路径字符串;
//! - [`layout`]:`.ULL/` 四分区(config/data/cache/logs)与各已知路径的唯一入口;
//! - [`data_dir`]:三模式数据目录解析(Portable 默认 / Standard / Custom + 高级覆盖);
//! - [`redirect`]:迁移后留在旧目录的 `redirect.json` 指针(防便携盘旧目录被再次初始化);
//! - [`receipt`]:迁移收据(源指纹防重放);
//! - [`dir_migration`]:数据目录迁移(分区逐个搬 + 失败逆序回滚);
//! - [`atomic`]:原子写(tmp → fsync → rename);[`hash`]:迁移收据用的 SHA-256。
//!
//! 运行期系统探测(M20 / D19 · `11-os-detection.md`):
//! - [`os_info`]:`OsInfo` 快照 + 最低支持判定(One / Unsupported,基线 **Windows 10+ /
//! macOS 12+ / Linux 达标**)+ 启动自检;
//! - [`windows_version`]:真实 `major.minor.build`(注册表 `CurrentBuildNumber` 快路径 →
//! 整键 `CurrentVersion` 家族 → `cmd /c ver` 兜底);
//! - [`macos_version`]:`sw_vers` 产品版本 + `sysctl hw.optional.arm64` 运行架构;
//! - [`linux_caps`]:发行版(仅显示)+ 内核 release(喂 `os.version`)+ 会话/GL 能力探测;
//! - [`vk_probe`]:Vulkan 能力的安全层探测(loader 文件 / ICD 注册 / 环境变量,无 `unsafe`);
//! - [`theme_probe`]:系统主题(浅色/深色)探测,供主题三态的 `auto` 初始化对齐与兜底跟随。
//!
//! 尚未落地(后续 mission):文件锁、进程工具、真实 GL/Vulkan 能力探测(钩子见
//! [`linux_caps::probe_gl`] 与 [`vk_probe::probe_vk`],FFI 受限,实际探测归 app 层渲染后端选择)。
pub mod atomic;
pub mod data_dir;
pub mod dir_migration;
pub mod error;
pub mod hash;
pub mod layout;
pub mod linux_caps;
pub mod macos_version;
pub mod os_info;
pub mod paths;
pub mod receipt;
pub mod redirect;
pub mod theme_probe;
pub mod vk_probe;
pub mod windows_version;
+388
View File
@@ -0,0 +1,388 @@
//! 运行期系统探测与最低支持判定(D19 · `DevDocs/PlanDoc/11-os-detection.md`)。
//!
//! 事实通道按平台分流(各文件里的 `probe()`):[`windows_version`] 注册表 `CurrentBuildNumber`
//! 快路径 → 整键 `CurrentVersion` 家族 → `cmd /c ver` 兜底;[`macos_version`] `sw_vers` +
//! `sysctl`;[`linux_caps`] `/etc/os-release`(仅显示)+ 内核 release + 会话/GL 能力探测。
//! 不用 `RtlGetVersion` FFI 是因为 core 是 `#![forbid(unsafe_code)]`,而这些路径读的是同一份
//! 真实事实,代价只是一次性几十毫秒。
//!
//! 支持基线(用户拍板):**Windows 10 起、macOS 12 起、Linux(内核/glibc 达标)**。
//! 因此等级只有两档:[`SupportLevel::One`] 与 [`SupportLevel::Unsupported`]——Win8.1/8/7
//! 不承诺 GUI,也不承诺 CLI(识别出旧版本正是为了报「不支持」)。
//!
//! 入口:[`detect`](真探)· [`current`](进程内缓存)· [`check`](CLI 启动自检);
//! 等级映射是纯函数([`support_level_for`] 及三平台分解),单测用注入式假数据覆盖。
use std::sync::OnceLock;
use crate::platform::linux_caps::{self, GlProbe, SessionKind};
use crate::platform::macos_version;
use crate::platform::vk_probe::{self, VkProbe};
use crate::platform::windows_version;
/// 最低支持的 Windows 版本(10.0 = Windows 10,build 10240 起)。
pub const MIN_WINDOWS: (u32, u32) = (10, 0);
/// 最低支持的 macOS 主版本(Slint / winit 的现实部署下限,见 D19 §4)。
pub const MIN_MACOS_MAJOR: u32 = 12;
/// 最低 Linux 内核(Rust std 的 Linux 下限)。
pub const MIN_LINUX_KERNEL: (u32, u32) = (3, 2);
/// 最低 glibc(`x86_64-unknown-linux-gnu` 构建产物能跑的下限)。
pub const MIN_GLIBC: (u32, u32) = (2, 17);
/// 操作系统家族。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OsFamily {
/// Windows。
Windows,
/// macOS。
MacOs,
/// Linux 及其它 POSIX 系统。
Linux,
}
impl OsFamily {
/// 当前编译目标的系统家族。
pub fn current() -> Self {
if cfg!(target_os = "windows") {
Self::Windows
} else if cfg!(target_os = "macos") {
Self::MacOs
} else {
Self::Linux
}
}
/// Mojang 清单里 `rules.os.name` 的取值。
pub fn manifest_name(self) -> &'static str {
match self {
Self::Windows => "windows",
Self::MacOs => "osx",
Self::Linux => "linux",
}
}
/// `rules.os.arch` 的取值(按编译目标;Apple Silicon 的真实架构另见
/// [`MacOsVersion::runtime_arch`](crate::platform::macos_version::MacOsVersion::runtime_arch))。
pub fn manifest_arch(self) -> &'static str {
if cfg!(target_arch = "x86_64") {
"x86_64"
} else if cfg!(target_arch = "x86") {
"x86"
} else if cfg!(target_arch = "aarch64") {
"arm64"
} else {
"unknown"
}
}
/// 是否为 POSIX 系统(决定 `nice`、进程组 kill 等分支)。
pub fn is_posix(self) -> bool {
!matches!(self, Self::Windows)
}
}
/// 支持等级(两档:满足基线 / 不满足)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SupportLevel {
/// 一等:基线以上(Windows 10+ / macOS 12+ / Linux 达标),GUI + CLI。
One,
/// 不支持:低于基线,启动时拒绝并给升级指引。
Unsupported,
}
impl SupportLevel {
/// 简短名(日志/事件用)。
pub fn as_str(self) -> &'static str {
match self {
Self::One => "one",
Self::Unsupported => "unsupported",
}
}
/// 是否允许运行。
pub fn is_usable(self) -> bool {
matches!(self, Self::One)
}
}
/// 运行期系统信息(不可变快照,可跨线程共享;字段全是探测出来的事实)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OsInfo {
/// 系统家族。
pub family: OsFamily,
/// `rules.os.arch` 用的架构名。
pub arch: String,
/// 版本 `major.minor`(探测失败为 `None`)。
pub major_minor: Option<(u32, u32)>,
/// 构建号(Windows build,其它平台 `None`)。
pub build: Option<u32>,
/// 展示名(`Windows 11` / `macOS 12.3.1 (arm64)` / `Ubuntu 24.04.1 LTS`)。
pub product: String,
/// 版本串(`rules.os.version` 用;Linux 上是内核 release)。
pub version_string: String,
/// 图形会话类型(Linux 有意义,其它平台 `Unknown`)。
pub session: SessionKind,
/// GL 能力(P1 恒为 `Unknown`,见 [`GlProbe`])。
pub gl: GlProbe,
/// Vulkan 能力(文件/注册表层,无 `unsafe`;真探测归 app 层,见 [`VkProbe`])。
pub vk: VkProbe,
/// Linux 内核 `major.minor`。
pub kernel: Option<(u32, u32)>,
/// glibc `major.minor`(musl / 取不到为 `None`)。
pub glibc: Option<(u32, u32)>,
/// 支持等级(由 [`support_level_for`] 从上面的事实算出)。
pub support_level: SupportLevel,
}
impl OsInfo {
/// `rules.os.version` 的取值(探测失败时为空串 —— 规则求值会走保守分支)。
pub fn os_version(&self) -> &str {
&self.version_string
}
/// 一行日志摘要(`§5.3` 每次启动写日志 / 诊断包环境快照)。
pub fn summary_line(&self) -> String {
let version = if self.version_string.is_empty() {
"unknown"
} else {
self.version_string.as_str()
};
format!(
"os={} version={version} arch={} level={} product=\"{}\" session={} gl={} vk={}",
self.family.manifest_name(),
self.arch,
self.support_level.as_str(),
self.product,
self.session.as_str(),
self.gl.as_str(),
self.vk.as_str(),
)
}
/// 用户可读文案(中英双语)。
pub fn describe(&self) -> SupportText {
match self.support_level {
SupportLevel::One => self.text_supported(),
SupportLevel::Unsupported => self.text_unsupported(),
}
}
fn text_supported(&self) -> SupportText {
let mut zh = format!("系统满足最低要求:{}({})。", self.product, self.arch);
let mut en = format!("Meets minimum requirements: {}.", self.product);
if self.family == OsFamily::Linux && !self.session.has_display() {
zh.push_str("\n注意:未检测到 Wayland/X11 图形会话,GUI 需要图形会话;CLI 不受影响。");
en.push_str(
"\nNote: no Wayland/X11 graphical session; the GUI needs one. CLI is fine.",
);
}
SupportText { zh, en }
}
fn text_unsupported(&self) -> SupportText {
let product = if self.product.is_empty() {
"未知系统"
} else {
self.product.as_str()
};
let (need_zh, need_en) = match self.family {
OsFamily::Windows => (
" Windows 10 或更高版本".to_string(),
"Windows 10 or newer".to_string(),
),
OsFamily::MacOs => (
format!(" macOS {MIN_MACOS_MAJOR} 或更高版本"),
format!("macOS {MIN_MACOS_MAJOR} or newer"),
),
OsFamily::Linux => {
let kernel = format!("{}.{}", MIN_LINUX_KERNEL.0, MIN_LINUX_KERNEL.1);
let glibc = MIN_GLIBC.0;
(
format!(" Linux 内核 ≥ {kernel}(glibc ≥ {glibc})"),
format!("Linux kernel >= {kernel} (glibc >= {glibc})"),
)
}
};
// Windows 上版本探测失败时要说清楚是「探不到」而不是「探到旧版本」。
let hint = if self.family == OsFamily::Windows && self.major_minor.is_none() {
"(未能探测到系统版本)"
} else {
""
};
SupportText {
zh: format!("不支持的系统:{product}{hint}。本启动器要求{need_zh}。"),
en: format!("Unsupported system: {product}. This launcher requires {need_en}."),
}
}
}
/// 双语用户文案。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SupportText {
/// 中文文案。
pub zh: String,
/// 英文文案。
pub en: String,
}
impl SupportText {
/// 中文 + 换行 + 英文(CLI 直接打印的形态)。
pub fn combined(&self) -> String {
format!("{}\n{}", self.zh, self.en)
}
}
/// 启动自检结果(CLI 打印 + 写日志用)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SupportCheck {
/// 支持等级。
pub level: SupportLevel,
/// 用户可读文案(中英双语,多行)。
pub message: String,
/// 探测到的系统信息(写日志 / 诊断包用)。
pub os: OsInfo,
}
impl SupportCheck {
/// 是否允许继续启动。
pub fn is_usable(&self) -> bool {
self.level.is_usable()
}
}
/// 按家族选对应的等级判定(探测与单测走同一条路径)。
pub fn support_level_for(
family: OsFamily,
major_minor: Option<(u32, u32)>,
kernel: Option<(u32, u32)>,
glibc: Option<(u32, u32)>,
) -> SupportLevel {
match family {
OsFamily::Windows => windows_level(major_minor),
OsFamily::MacOs => macos_level(major_minor.map(|(major, _)| major)),
OsFamily::Linux => linux_level(kernel, glibc),
}
}
/// Windows 等级:≥ Windows 10 → 一等;Win8.1/8/7 及更旧 → 不支持;探测不到 → 不支持(保守)。
pub fn windows_level(major_minor: Option<(u32, u32)>) -> SupportLevel {
match major_minor {
Some(version) if version >= MIN_WINDOWS => SupportLevel::One,
_ => SupportLevel::Unsupported,
}
}
/// macOS 等级:≥ 12 → 一等;更低或不认识 → 不支持。
pub fn macos_level(major: Option<u32>) -> SupportLevel {
match major {
Some(major) if major >= MIN_MACOS_MAJOR => SupportLevel::One,
_ => SupportLevel::Unsupported,
}
}
/// Linux 等级:内核/glibc 探得到但过低 → 不支持;取不到(容器、musl、Termux)不阻塞 → 一等。
pub fn linux_level(kernel: Option<(u32, u32)>, glibc: Option<(u32, u32)>) -> SupportLevel {
if kernel.is_some_and(|version| version < MIN_LINUX_KERNEL) {
return SupportLevel::Unsupported;
}
if glibc.is_some_and(|version| version < MIN_GLIBC) {
return SupportLevel::Unsupported;
}
SupportLevel::One
}
/// 真探一次当前系统(会起子进程,别在热路径反复调用;常规路径用 [`current`])。
pub fn detect() -> OsInfo {
let family = OsFamily::current();
let facts = probe(family);
let arch = if facts.arm64 {
"arm64".to_string()
} else {
family.manifest_arch().to_string()
};
let product = facts
.product
.unwrap_or_else(|| family.manifest_name().to_string());
OsInfo {
family,
arch,
major_minor: facts.major_minor,
build: facts.build,
product,
version_string: facts.version_string.unwrap_or_default(),
session: facts.session,
gl: facts.gl,
vk: vk_probe::probe_vk(),
kernel: facts.kernel,
glibc: facts.glibc,
support_level: support_level_for(family, facts.major_minor, facts.kernel, facts.glibc),
}
}
/// 三平台探测结果统一成同一形状(内部用;`None` 表示该平台没有这项事实)。
#[derive(Default)]
struct Facts {
major_minor: Option<(u32, u32)>,
build: Option<u32>,
product: Option<String>,
version_string: Option<String>,
kernel: Option<(u32, u32)>,
glibc: Option<(u32, u32)>,
session: SessionKind,
gl: GlProbe,
arm64: bool,
}
fn probe(family: OsFamily) -> Facts {
match family {
OsFamily::Windows => {
let version = windows_version::probe();
Facts {
major_minor: version.map(|v| v.major_minor()),
build: version.map(|v| v.build),
product: version.map(|v| v.display_name().to_string()),
version_string: version.map(|v| v.version_string()),
..Facts::default()
}
}
OsFamily::MacOs => {
let version = macos_version::probe();
Facts {
major_minor: version.map(|v| (v.major, v.minor)),
product: version.map(|v| v.display_name()),
version_string: version.map(|v| v.version_string()),
arm64: version.is_some_and(|v| v.arm64),
..Facts::default()
}
}
OsFamily::Linux => {
let caps = linux_caps::probe();
Facts {
major_minor: caps.kernel_version,
product: caps.distro,
version_string: caps.kernel,
kernel: caps.kernel_version,
glibc: caps.glibc,
session: caps.session,
gl: caps.gl,
..Facts::default()
}
}
}
}
/// 进程内缓存的一次探测结果(启动时多处以它为准,避免重复起子进程)。
pub fn current() -> &'static OsInfo {
static CACHED: OnceLock<OsInfo> = OnceLock::new();
CACHED.get_or_init(detect)
}
/// CLI 启动自检:等级 + 双语文案 + 系统信息。
pub fn check() -> SupportCheck {
let os = current().clone();
SupportCheck {
level: os.support_level,
message: os.describe().combined(),
os,
}
}
+186
View File
@@ -0,0 +1,186 @@
//! 宿主平台探测与标准目录解析。
//!
//! 环境变量读取走 [`EnvSource`] 注入,因此三模式落点可以在单测里确定性地覆盖各 OS。
//! 设计出处:`DevDocs/PlanDoc/04-data-and-config.md` §1(三模式落点)。
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// 需要分支处理的宿主平台族。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Platform {
/// Windows。
Windows,
/// macOS。
MacOs,
/// Linux 及其它 Unix。
Linux,
/// 其它(按 Linux 的 XDG 习惯兜底)。
Other,
}
impl Platform {
/// 编译目标决定的当前平台。
#[must_use]
pub const fn current() -> Self {
if cfg!(windows) {
Self::Windows
} else if cfg!(target_os = "macos") {
Self::MacOs
} else if cfg!(unix) {
Self::Linux
} else {
Self::Other
}
}
/// 稳定名(写日志 / 序列化用)。
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Windows => "windows",
Self::MacOs => "macos",
Self::Linux => "linux",
Self::Other => "other",
}
}
}
/// 环境变量来源(生产用 [`ProcessEnv`],测试用 [`MapEnv`])。
pub trait EnvSource {
/// 读一个环境变量;空串按不存在处理。
fn var(&self, name: &str) -> Option<String>;
}
/// 真实进程环境。
#[derive(Debug, Clone, Copy, Default)]
pub struct ProcessEnv;
impl ProcessEnv {
/// 构造。
#[must_use]
pub const fn new() -> Self {
Self
}
}
impl EnvSource for ProcessEnv {
fn var(&self, name: &str) -> Option<String> {
std::env::var(name)
.ok()
.filter(|value| !value.trim().is_empty())
}
}
/// 固定环境(测试 / 便携化封装用)。
#[derive(Debug, Clone, Default)]
pub struct MapEnv {
vars: BTreeMap<String, String>,
}
impl MapEnv {
/// 空环境。
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// 链式塞一条变量:`MapEnv::new().with("HOME", "/home/u")`。
#[must_use]
pub fn with(mut self, name: &str, value: &str) -> Self {
self.vars.insert(name.to_owned(), value.to_owned());
self
}
}
impl EnvSource for MapEnv {
fn var(&self, name: &str) -> Option<String> {
self.vars.get(name).cloned()
}
}
/// 用户主目录。
#[must_use]
pub fn home_dir(env: &dyn EnvSource) -> Option<PathBuf> {
if cfg!(windows) {
env.var("USERPROFILE")
.or_else(|| env.var("HOME"))
.map(PathBuf::from)
} else {
env.var("HOME").map(PathBuf::from)
}
}
/// 可执行文件所在目录(Portable 模式的根)。
///
/// Linux 上 `/proc/self/exe` 由 `std` 处理;AppImage 场景下指向挂载点内的 exe,
/// 因此 AppImage 想要「便携到 exe 旁边」需由打包层设置环境变量接管(见 §1 细节)。
#[must_use]
pub fn exe_dir() -> Option<PathBuf> {
std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(Path::to_path_buf))
}
/// Standard 模式下的启动器数据目录(`.ULL` 的等价物)。
///
/// - Windows:`%APPDATA%\ULL`
/// - macOS:`~/Library/Application Support/ULL`
/// - Linux / 其它:`$XDG_DATA_HOME/ull`,回落 `~/.local/share/ull`
#[must_use]
pub fn standard_launcher_home(platform: Platform, env: &dyn EnvSource) -> Option<PathBuf> {
match platform {
Platform::Windows => roaming_app_data(env).map(|dir| dir.join("ULL")),
Platform::MacOs => home_dir(env).map(|home| home.join("Library/Application Support/ULL")),
Platform::Linux | Platform::Other => xdg_data_home(env).map(|dir| dir.join("ull")),
}
}
/// Standard 模式下的游戏目录(Mojang 约定,与 HMCL 的 HOME 目录一致)。
///
/// - Windows:`%APPDATA%\.minecraft`
/// - macOS:`~/Library/Application Support/minecraft`
/// - Linux / 其它:`~/.minecraft`
#[must_use]
pub fn standard_game_home(platform: Platform, env: &dyn EnvSource) -> Option<PathBuf> {
match platform {
Platform::Windows => roaming_app_data(env).map(|dir| dir.join(".minecraft")),
Platform::MacOs => {
home_dir(env).map(|home| home.join("Library/Application Support/minecraft"))
}
Platform::Linux | Platform::Other => home_dir(env).map(|home| home.join(".minecraft")),
}
}
/// `%APPDATA%`,回落 `%USERPROFILE%\AppData\Roaming`。
fn roaming_app_data(env: &dyn EnvSource) -> Option<PathBuf> {
env.var("APPDATA").map(PathBuf::from).or_else(|| {
env.var("USERPROFILE")
.map(|profile| PathBuf::from(profile).join("AppData/Roaming"))
})
}
/// `$XDG_DATA_HOME`,回落 `~/.local/share`。
fn xdg_data_home(env: &dyn EnvSource) -> Option<PathBuf> {
env.var("XDG_DATA_HOME")
.map(PathBuf::from)
.filter(|dir| dir.is_absolute())
.or_else(|| home_dir(env).map(|home| home.join(".local/share")))
}
/// 写成可搬运字符串:相对路径统一用 `/`(HMCL `PortablePath` 做法),绝对路径原样保留。
#[must_use]
pub fn to_portable_string(path: &Path) -> String {
let text = path.to_string_lossy();
if path.is_absolute() {
text.into_owned()
} else {
text.replace('\\', "/")
}
}
/// 解析可搬运字符串。
#[must_use]
pub fn from_portable_string(text: &str) -> PathBuf {
PathBuf::from(text)
}
+202
View File
@@ -0,0 +1,202 @@
//! 迁移收据:指纹 + 幂等,防重复迁移 / 半迁移(HMCL `MigrationReceipt` 思想)。
//!
//! 出处:`DevDocs/analyse/07-settings-packaging-update.md` §1.8——
//! 收据里存源文件 `sourceSHA256`,这样「用户删掉新文件、但旧文件没变」时不会重复迁移,旧文件变了才重放。
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::atomic;
use super::error::PlatformError;
use super::error::PlatformResult;
use super::hash;
/// 本实现能读懂的收据版本。
pub const RECEIPT_SCHEMA_VERSION: u32 = 1;
/// 收据类型。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReceiptKind {
/// 数据目录迁移。
DataDirMove,
/// 遗留配置格式迁移。
LegacyConfig,
}
impl ReceiptKind {
/// 稳定名。
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::DataDirMove => "data_dir_move",
Self::LegacyConfig => "legacy_config",
}
}
}
/// 一次已完成的迁移记录。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MigrationReceipt {
/// 格式版本。
pub schema_version: u32,
/// 收据类型。
pub kind: ReceiptKind,
/// 源指纹(普通配置迁移 = 源文件内容 SHA-256;目录迁移 = 源/目标路径签名)。
pub source_fingerprint: String,
/// 源路径。
pub source: PathBuf,
/// 目标路径。
pub target: PathBuf,
/// 完成时间(毫秒 since UNIX_EPOCH)。
pub applied_at_ms: u64,
}
impl MigrationReceipt {
/// 构造数据目录迁移收据。
#[must_use]
pub fn data_dir_move(source: PathBuf, target: PathBuf, applied_at_ms: u64) -> Self {
let fingerprint = fingerprint_data_dir_move(&source, &target);
Self {
schema_version: RECEIPT_SCHEMA_VERSION,
kind: ReceiptKind::DataDirMove,
source_fingerprint: fingerprint,
source,
target,
applied_at_ms,
}
}
/// 构造遗留配置迁移收据。
#[must_use]
pub fn legacy_config(
source: PathBuf,
target: PathBuf,
source_fingerprint: impl Into<String>,
applied_at_ms: u64,
) -> Self {
Self {
schema_version: RECEIPT_SCHEMA_VERSION,
kind: ReceiptKind::LegacyConfig,
source_fingerprint: source_fingerprint.into(),
source,
target,
applied_at_ms,
}
}
}
/// 是否该重放这次迁移。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReplayDecision {
/// 收据在且源指纹相同 → 已经迁过,跳过(防重放)。
SkipSameSource,
/// 收据在但源指纹变了 → 允许重放。
ReplayChangedSource,
/// 没有收据 → 正常迁移。
ReplayNoReceipt,
}
impl ReplayDecision {
/// 是否需要执行迁移。
#[must_use]
pub const fn should_replay(&self) -> bool {
!matches!(self, Self::SkipSameSource)
}
/// 稳定名(日志 / 审计)。
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::SkipSameSource => "skip_same_source",
Self::ReplayChangedSource => "replay_changed_source",
Self::ReplayNoReceipt => "replay_no_receipt",
}
}
}
/// 根据已有收据与当前源指纹决定是否重放。
#[must_use]
pub fn decide(existing: Option<&MigrationReceipt>, fingerprint: &str) -> ReplayDecision {
match existing {
None => ReplayDecision::ReplayNoReceipt,
Some(receipt) if receipt.source_fingerprint == fingerprint => {
ReplayDecision::SkipSameSource
}
Some(_) => ReplayDecision::ReplayChangedSource,
}
}
/// 读收据;不存在返回 `None`。
///
/// # Errors
/// 读失败或 JSON 损坏、版本不认识时返回 [`PlatformError::ReceiptCorrupt`] / [`PlatformError::Io`]。
pub fn read(path: &Path) -> PlatformResult<Option<MigrationReceipt>> {
let Some(bytes) = atomic::read_optional(path)? else {
return Ok(None);
};
let receipt: MigrationReceipt =
serde_json::from_slice(&bytes).map_err(|err| PlatformError::ReceiptCorrupt {
path: path.to_path_buf(),
reason: err.to_string(),
})?;
if receipt.schema_version > RECEIPT_SCHEMA_VERSION {
return Err(PlatformError::ReceiptCorrupt {
path: path.to_path_buf(),
reason: format!(
"收据版本 {} 高于本实现支持的 {RECEIPT_SCHEMA_VERSION}",
receipt.schema_version
),
});
}
Ok(Some(receipt))
}
/// 原子写收据。
///
/// # Errors
/// 序列化或写盘失败时返回 [`PlatformError`]。
pub fn write(path: &Path, receipt: &MigrationReceipt) -> PlatformResult<()> {
let bytes =
serde_json::to_vec_pretty(receipt).map_err(|err| PlatformError::ReceiptCorrupt {
path: path.to_path_buf(),
reason: format!("无法序列化收据: {err}"),
})?;
atomic::write_atomic(path, &bytes)
}
/// 内容指纹(SHA-256 十六进制)。
#[must_use]
pub fn fingerprint_bytes(bytes: &[u8]) -> String {
hash::sha256_hex(bytes)
}
/// 文件内容指纹;文件不存在返回 `None`。
///
/// # Errors
/// 读失败时返回 [`PlatformError::Io`]。
pub fn fingerprint_file(path: &Path) -> PlatformResult<Option<String>> {
match hash::sha256_hex_file(path) {
Ok(hex) => Ok(Some(hex)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(PlatformError::Io {
path: path.to_path_buf(),
source: err,
}),
}
}
/// 目录迁移的源指纹:源/目标路径签名。
///
/// 目录内容的哈希代价太高且天然易变(缓存/日志一直在动),因此目录迁移的抗重放主力是
/// [`super::redirect`] 指针 + 「目标非空则拒绝」,收据负责留痕与「同源同目标不重做」。
#[must_use]
pub fn fingerprint_data_dir_move(source: &Path, target: &Path) -> String {
let signature = format!(
"{}|{}",
super::paths::to_portable_string(source),
super::paths::to_portable_string(target)
);
fingerprint_bytes(signature.as_bytes())
}
+137
View File
@@ -0,0 +1,137 @@
//! `redirect.json` 指针文件——迁移后留在旧目录,防止便携盘被搬走后旧目录重新长出一个新实例。
//!
//! 出处:`DevDocs/PlanDoc/04-data-and-config.md` §1 细节(「迁移 = 移动 + 原地留 redirect.json 指针」)。
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::atomic;
use super::data_dir::DataMode;
use super::error::{io_at, PlatformError, PlatformResult};
use super::layout::REDIRECT_FILE;
/// 本实现能读懂的指针文件版本;更高版本视为不认识(避免瞎读新格式)。
pub const REDIRECT_SCHEMA_VERSION: u32 = 1;
/// 指针内容:旧目录 → 新目录。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RedirectPointer {
/// 格式版本。
pub schema_version: u32,
/// 迁移时采用的模式。
pub mode: DataMode,
/// 新的启动器数据目录。
pub launcher_home: PathBuf,
/// 新的游戏目录。
pub game_home: PathBuf,
/// 迁移时间(毫秒 since UNIX_EPOCH)。
pub migrated_at_ms: u64,
}
/// 跟随指针的结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Followed {
/// 最终命中的指针。
pub pointer: RedirectPointer,
/// 实际跟随跳数(1 = 只跟了一跳)。
pub hops: usize,
}
/// `<dir>/redirect.json`。
#[must_use]
pub fn redirect_path(dir: &Path) -> PathBuf {
dir.join(REDIRECT_FILE)
}
/// 读指针;没有指针文件返回 `None`。
///
/// # Errors
/// 读失败或 JSON 损坏、版本不认识时返回 [`PlatformError`]。
pub fn read(dir: &Path) -> PlatformResult<Option<RedirectPointer>> {
let path = redirect_path(dir);
let Some(bytes) = atomic::read_optional(&path)? else {
return Ok(None);
};
let pointer: RedirectPointer =
serde_json::from_slice(&bytes).map_err(|err| PlatformError::RedirectCorrupt {
path: path.clone(),
reason: err.to_string(),
})?;
if pointer.schema_version > REDIRECT_SCHEMA_VERSION {
return Err(PlatformError::RedirectCorrupt {
path,
reason: format!(
"指针版本 {} 高于本实现支持的 {REDIRECT_SCHEMA_VERSION}",
pointer.schema_version
),
});
}
Ok(Some(pointer))
}
/// 原子写指针(父目录自动创建)。
///
/// # Errors
/// 序列化或写盘失败时返回 [`PlatformError`]。
pub fn write(dir: &Path, pointer: &RedirectPointer) -> PlatformResult<PathBuf> {
let path = redirect_path(dir);
let bytes =
serde_json::to_vec_pretty(pointer).map_err(|err| PlatformError::RedirectCorrupt {
path: path.clone(),
reason: format!("无法序列化指针: {err}"),
})?;
std::fs::create_dir_all(dir).map_err(io_at(dir))?;
atomic::write_atomic(&path, &bytes)?;
Ok(path)
}
/// 删除指针(`None` = 本来就没有)。
///
/// # Errors
/// 删除失败时返回 [`PlatformError::Io`]。
pub fn remove(dir: &Path) -> PlatformResult<bool> {
let path = redirect_path(dir);
match std::fs::remove_file(&path) {
Ok(()) => Ok(true),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(err) => Err(PlatformError::Io { path, source: err }),
}
}
/// 跟随指针链(最多 `max_hops` 跳,成环或超限报错)。
///
/// # Errors
/// 指针损坏或跳转成环/超限时返回 [`PlatformError`]。
pub fn follow(dir: &Path, max_hops: usize) -> PlatformResult<Option<Followed>> {
let mut current = dir.to_path_buf();
let mut visited: BTreeSet<PathBuf> = BTreeSet::new();
visited.insert(current.clone());
let mut hops = 0usize;
loop {
let Some(pointer) = read(&current)? else {
return Ok(None);
};
hops += 1;
if hops > max_hops {
return Err(PlatformError::RedirectCycle {
dir: dir.to_path_buf(),
hops,
});
}
let next = pointer.launcher_home.clone();
if !visited.insert(next.clone()) {
return Err(PlatformError::RedirectCycle {
dir: dir.to_path_buf(),
hops,
});
}
// 还有下一跳就继续跟,否则这就是终点。
if !redirect_path(&next).exists() {
return Ok(Some(Followed { pointer, hops }));
}
current = next;
}
}
+182
View File
@@ -0,0 +1,182 @@
//! 系统主题(浅色/深色)探测:给主题三态的 `auto` 模式做**初始化对齐**与后端不支持时的兜底跟随。
//!
//! 出处:`DevDocs/PlanDoc/09-ui-layout.md` §2「主题模式三态」(D17 用户定案:Win/mac 用 Slint
//! `Palette.color-scheme` 原生跟随 OS 热切;Linux 依 DE 的 xdg-desktop-portal,缺省回落浅色并
//! 允许手动钉死)。Slint 后端会在运行期自己监听 OS 主题变化,所以这里**只补两件事**:
//!
//! 1. 启动时先读一次真实系统主题,避免「auto 模式下首帧按浅色画、几百毫秒后突然翻黑」的闪变;
//! 2. 后端把 `Palette.color-scheme` 报成 `unknown`(无 portal 的 Linux / 软渲染后端)时,
//! 由 app 轮询本模块把系统主题补上,`auto` 仍然可用。
//!
//! 与 platform 层其他探测一致(见 [`super::windows_version`]):core 是 `#![forbid(unsafe_code)]`,
//! 一律走外部命令 + 纯函数解析。解析函数跨平台编译,任意宿主都能单测(`tests/platform/theme_cases.rs`)。
/// 系统主题三态。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SystemTheme {
/// 浅色。
Light,
/// 深色。
Dark,
/// 读不到(无桌面环境 / 命令缺失 / 输出无法解析)。
Unknown,
}
impl SystemTheme {
/// 稳定名(日志用)。
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Light => "light",
Self::Dark => "dark",
Self::Unknown => "unknown",
}
}
/// 是否深色(`Unknown` 为 `None`,调用方决定回落)。
#[must_use]
pub const fn is_dark(&self) -> Option<bool> {
match self {
Self::Light => Some(false),
Self::Dark => Some(true),
Self::Unknown => None,
}
}
}
/// Windows 个性化键(`AppsUseLightTheme` = 应用/任务栏主题,与 `SystemUsesLightTheme` 区分)。
pub const WINDOWS_PERSONALIZE_KEY: &str =
r"HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize";
/// 真实探测系统主题。
///
/// - Windows:注册表 `AppsUseLightTheme`(DWORD,1 = 浅色 / 0 = 深色);
/// - macOS:`defaults read -g AppleInterfaceStyle`(深色时输出 `Dark`,浅色时该键不存在);
/// - Linux:`gsettings get org.gnome.desktop.interface color-scheme`,退到 `gtk-theme` 名字。
#[must_use]
pub fn probe() -> SystemTheme {
#[cfg(target_os = "windows")]
{
run(
"reg",
&["query", WINDOWS_PERSONALIZE_KEY, "/v", "AppsUseLightTheme"],
)
.and_then(|text| parse_apps_use_light_theme(&text))
.unwrap_or(SystemTheme::Unknown)
}
#[cfg(target_os = "macos")]
{
// 键不存在 = 浅色(defaults 会非零退出,stdout 为空 → 回落 Light)。
match run("defaults", &["read", "-g", "AppleInterfaceStyle"]) {
Some(text) => parse_macos_interface_style(&text).unwrap_or(SystemTheme::Unknown),
None => SystemTheme::Light,
}
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
probe_linux()
}
}
/// Linux:先问 GNOME/portal 的 `color-scheme`,再退到 `gtk-theme` 名字里是否带 `-dark`。
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
fn probe_linux() -> SystemTheme {
if let Some(text) = run(
"gsettings",
&["get", "org.gnome.desktop.interface", "color-scheme"],
) {
if let Some(theme) = parse_gsettings_color_scheme(&text) {
return theme;
}
}
if let Some(text) = run(
"gsettings",
&["get", "org.gnome.desktop.interface", "gtk-theme"],
) {
if let Some(theme) = parse_gtk_theme(&text) {
return theme;
}
}
SystemTheme::Unknown
}
/// 解析 `reg query ... /v AppsUseLightTheme` 的输出。
///
/// 形如 ` AppsUseLightTheme REG_DWORD 0x1`;`0x0` = 深色。
#[must_use]
pub fn parse_apps_use_light_theme(text: &str) -> Option<SystemTheme> {
text.lines().find_map(|line| {
let mut fields = line.split_whitespace();
let name = fields.next()?;
if !name.eq_ignore_ascii_case("AppsUseLightTheme") {
return None;
}
let _kind = fields.next()?;
let raw = fields.next().unwrap_or_default();
parse_dword(raw).map(|value| {
if value == 0 {
SystemTheme::Dark
} else {
SystemTheme::Light
}
})
})
}
/// 解析 `defaults read -g AppleInterfaceStyle` 的输出(深色 = `Dark`;浅色时该键不存在)。
#[must_use]
pub fn parse_macos_interface_style(text: &str) -> Option<SystemTheme> {
match text.trim() {
"" => None,
value if value.eq_ignore_ascii_case("dark") => Some(SystemTheme::Dark),
_ => Some(SystemTheme::Light),
}
}
/// 解析 `gsettings get ... color-scheme`:`'prefer-dark'` / `'prefer-light'` / `'default'`。
///
/// `default` 表示「未指定」,要继续看 `gtk-theme`,故返回 `None`。
#[must_use]
pub fn parse_gsettings_color_scheme(text: &str) -> Option<SystemTheme> {
let value = text.trim().trim_matches('\'').trim_matches('"');
if value.eq_ignore_ascii_case("prefer-dark") {
Some(SystemTheme::Dark)
} else if value.eq_ignore_ascii_case("prefer-light") {
Some(SystemTheme::Light)
} else {
None
}
}
/// 解析 `gsettings get ... gtk-theme`:主题名带 `dark` 即深色(`Yaru-dark`、`Adwaita-dark`)。
#[must_use]
pub fn parse_gtk_theme(text: &str) -> Option<SystemTheme> {
let value = text.trim().trim_matches('\'').trim_matches('"');
if value.is_empty() {
return None;
}
if value.to_ascii_lowercase().contains("dark") {
Some(SystemTheme::Dark)
} else {
Some(SystemTheme::Light)
}
}
fn parse_dword(raw: &str) -> Option<u32> {
let raw = raw.trim();
if let Some(hex) = raw.strip_prefix("0x").or_else(|| raw.strip_prefix("0X")) {
u32::from_str_radix(hex, 16).ok()
} else {
raw.parse().ok()
}
}
/// 跑一个外部命令,取 stdout(命令缺失 / 非零退出 / 空输出都返回 `None`)。
fn run(program: &str, args: &[&str]) -> Option<String> {
use std::process::Command;
let output = Command::new(program).args(args).output().ok()?;
if output.stdout.is_empty() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).into_owned())
}
+305
View File
@@ -0,0 +1,305 @@
//! Vulkan 能力探测的**安全层**(无 `unsafe`,无 FFI):只报可观测事实,不下「能不能玩」的结论。
//!
//! 依据 `DevDocs/PlanDoc/research/05-mc-vulkan.md`(M26)§2.3 与 §4.3:
//! MC 的 Vulkan 后端在 Windows/Linux 上**不打包** `vulkan-1.dll` / `libvulkan.so.1`,
//! 而是运行期去加载系统的 loader(natives 只给 macOS 发 MoltenVK)。所以「版本支持 Vulkan」
//! 与「这台机器能跑 Vulkan」是两件事,前置探测不可省。
//!
//! 分工照抄 [`super::linux_caps::probe_gl`]:core 只做「文件存在性 / 注册表 / 环境变量 /
//! 标准目录」这一层,真探测(建 instance、枚举物理设备、查 `VK 1.2` +
//! `VK_KHR_dynamic_rendering` + `VK_KHR_push_descriptor` 三项硬门槛)归 app 层。
//!
//! # ⚠️ 注册表键缺失 ≠ 没有 Vulkan(真机证据)
//!
//! 实测机(Windows 11 + AMD 独显,Vulkan 1.3.217 可用)上
//! `HKLM\SOFTWARE\Khronos\Vulkan\Drivers` **压根不存在**——ICD 由 loader 自己按
//! `%SystemRoot%\System32` 与驱动安装目录自寻。因此该键只作**加分项**([`VkProbe::Available`]),
//! 缺失时降级为 [`VkProbe::LoaderOnly`]、**绝不**判 [`VkProbe::Missing`]。
//!
//! 解析函数是纯函数且跨平台编译,单测用注入样本覆盖三平台(见 `tests/platform/vk_cases.rs`)。
use std::path::{Path, PathBuf};
use crate::platform::os_info::OsFamily;
/// Windows 上的 Vulkan loader 文件名(系统目录内)。
pub const WINDOWS_LOADER: &str = "vulkan-1.dll";
/// `HKLM\SOFTWARE\Khronos\Vulkan\Drivers`:显式 ICD 注册键(**只作加分**,见模块文档)。
///
/// ICD 注册为这个键下的**值**(值名 = manifest 绝对路径,数据是 `dword:00000000`),
/// 不是子键(Vulkan-Loader `LoaderDriverInterface.md` 的 Windows 驱动发现一节)。
pub const VULKAN_DRIVERS_KEY: &str = r"HKLM\SOFTWARE\Khronos\Vulkan\Drivers";
/// 同一个键在 `reg query` 输出里的**键头行**形态。
///
/// `reg query` 即使传入缩写 hive 名(`HKLM`),输出也用完整 hive 名
/// (`HKEY_LOCAL_MACHINE`)且不带尾反斜杠——解析时必须按这个形态比对。
pub const VULKAN_DRIVERS_KEY_HEADER: &str = r"HKEY_LOCAL_MACHINE\SOFTWARE\Khronos\Vulkan\Drivers";
/// Khronos loader 认的 ICD 路径环境变量(旧名在前,新名在后)。
pub const ICD_ENV_VARS: &[&str] = &["VK_ICD_FILENAMES", "VK_DRIVER_FILES"];
/// Linux 的 ICD 清单目录(`*.json`,loader 启动时按此枚举驱动)。
pub const ICD_DIRS: &[&str] = &[
"/usr/share/vulkan/icd.d",
"/usr/local/share/vulkan/icd.d",
"/etc/vulkan/icd.d",
];
/// Vulkan 能力(四档;`Unknown` 是派生默认值,「没探过」不等于「没有」)。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum VkProbe {
/// 未探测(非探测路径构造的快照)。
#[default]
Unknown,
/// loader 在,但没有看到任何 ICD 注册信号。
///
/// **不是**「没有 Vulkan」:Windows 上注册表键缺失是常态(模块文档的真机证据),
/// 设备是否存在要靠 app 层的真探测确认。
LoaderOnly,
/// loader + 驱动信号都在(或 macOS 上 MoltenVK 在场)——大概率可用。
Available,
/// 连 loader 都找不到:Vulkan 后端一定起不来。
Missing,
}
impl VkProbe {
/// 简短名(日志/摘要行用)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Unknown => "unknown",
Self::LoaderOnly => "loader_only",
Self::Available => "available",
Self::Missing => "missing",
}
}
/// loader 是否在场(`Available` 与 `LoaderOnly` 都为真)。
#[must_use]
pub const fn has_loader(self) -> bool {
matches!(self, Self::Available | Self::LoaderOnly)
}
/// 是否已能确定「这台机器跑不了 Vulkan」(只有 [`Self::Missing`])。
#[must_use]
pub const fn is_missing(self) -> bool {
matches!(self, Self::Missing)
}
/// 是否足够确定「可以用 Vulkan」(只有 [`Self::Available`])。
///
/// UI/建议路径用它可以保守一点:`LoaderOnly` 应当提示「先试一次再说」而不是承诺可用。
#[must_use]
pub const fn is_available(self) -> bool {
matches!(self, Self::Available)
}
}
/// 平台无关的探测证据(单测直接注入,不读真机)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VkEvidence {
/// 系统家族(决定判定口径)。
pub family: OsFamily,
/// loader 文件是否找到(`vulkan-1.dll` / `libvulkan.so.1` / `libvulkan.1.dylib` / MoltenVK)。
pub loader_present: bool,
/// 是否看到驱动信号:Windows 注册表键、Linux 的 ICD 目录/环境变量、macOS 无此项。
pub icd_signal: bool,
}
impl VkEvidence {
/// 构造。
#[must_use]
pub const fn new(family: OsFamily, loader_present: bool, icd_signal: bool) -> Self {
Self {
family,
loader_present,
icd_signal,
}
}
}
/// 证据 → 结论(纯函数,三平台共用;macOS 单独口径见函数体注释)。
#[must_use]
pub fn classify(evidence: VkEvidence) -> VkProbe {
if evidence.family == OsFamily::MacOs {
// macOS 没有系统 Vulkan:LWJGL 自带的 MoltenVK 既是 loader 又是 ICD(Vulkan→Metal 翻译),
// 所以「MoltenVK/libvulkan.1.dylib 在」本身就是可用证据,没有 LoaderOnly 这一档。
return if evidence.loader_present {
VkProbe::Available
} else {
VkProbe::Missing
};
}
if !evidence.loader_present {
return VkProbe::Missing;
}
if evidence.icd_signal {
VkProbe::Available
} else {
// 注册表键缺失是常态,只降级不否定(模块文档的真机证据)。
VkProbe::LoaderOnly
}
}
/// 探测当前机器的 Vulkan 能力(一次性,结果由 [`crate::platform::os_info::current`] 缓存)。
#[must_use]
pub fn probe_vk() -> VkProbe {
classify(probe_evidence())
}
/// 采集当前机器的探测证据(真读盘/注册表;单测请直接用 [`classify`])。
#[must_use]
pub fn probe_evidence() -> VkEvidence {
let family = OsFamily::current();
VkEvidence::new(family, loader_present(family), icd_signal(family))
}
/// loader 候选绝对路径(按平台;`SystemRoot` 缺失时回落到 `C:\Windows`)。
#[must_use]
pub fn loader_candidates(family: OsFamily) -> Vec<PathBuf> {
match family {
OsFamily::Windows => {
let root = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_owned());
vec![Path::new(&root).join("System32").join(WINDOWS_LOADER)]
}
OsFamily::MacOs => [
"/usr/local/lib/libvulkan.1.dylib",
"/opt/homebrew/lib/libvulkan.1.dylib",
"/usr/local/lib/libvulkan.dylib",
"/opt/homebrew/lib/libvulkan.dylib",
// MC 的 lwjgl-vulkan natives 里带的就是这个。
"/usr/local/lib/libMoltenVK.dylib",
"/opt/homebrew/lib/libMoltenVK.dylib",
]
.iter()
.map(PathBuf::from)
.collect(),
OsFamily::Linux => [
"/usr/lib/x86_64-linux-gnu/libvulkan.so.1",
"/usr/lib/aarch64-linux-gnu/libvulkan.so.1",
"/usr/lib64/libvulkan.so.1",
"/usr/lib/libvulkan.so.1",
"/usr/local/lib/libvulkan.so.1",
// 开发包只给不带版本号的名字(`libvulkan.so`)。
"/usr/lib/x86_64-linux-gnu/libvulkan.so",
"/usr/lib64/libvulkan.so",
"/usr/local/lib/libvulkan.so",
]
.iter()
.map(PathBuf::from)
.collect(),
}
}
fn loader_present(family: OsFamily) -> bool {
if loader_candidates(family).iter().any(|path| path.is_file()) {
return true;
}
// 标准路径没命中时,Linux 用 `ldconfig -p` 兜底(发行版把 loader 放在别处时更权威)。
#[cfg(target_os = "linux")]
{
ldconfig_has_libvulkan(&run("ldconfig", &["-p"]).unwrap_or_default())
}
#[cfg(not(target_os = "linux"))]
{
false
}
}
fn icd_signal(family: OsFamily) -> bool {
match family {
OsFamily::Windows => {
let text = run("reg", &["query", VULKAN_DRIVERS_KEY]).unwrap_or_default();
registry_has_driver_entry(&text)
}
OsFamily::Linux => {
env_icd_path(|key| std::env::var(key).ok()).is_some()
|| ICD_DIRS
.iter()
.any(|dir| count_icd_manifests(Path::new(dir)) > 0)
}
// macOS 的 ICD 就是 MoltenVK 本身,已在 loader 判据里。
OsFamily::MacOs => false,
}
}
/// 从环境变量取 ICD 清单路径(`VK_ICD_FILENAMES` 优先,其次 `VK_DRIVER_FILES`)。
#[must_use]
pub fn env_icd_path(get: impl Fn(&str) -> Option<String>) -> Option<String> {
ICD_ENV_VARS.iter().find_map(|key| {
get(key)
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
})
}
/// 目录下 `*.json` ICD 清单的条数(目录不存在返回 0)。
#[must_use]
pub fn count_icd_manifests(dir: &Path) -> usize {
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
entries
.flatten()
.filter(|entry| {
entry
.path()
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
})
.count()
}
/// `reg query <键>`(**不带** `/s`)的输出里,该键是否存在**值项**。
///
/// 真实格式(本机实测,Win11 22H2):一个空行 → 键头行(顶格、用完整 hive 名、无尾反斜杠)
/// → 缩进的值行 ` <值名> REG_DWORD 0x0`。Vulkan 的 ICD 注册正是「**值名 = manifest
/// 绝对路径**」(`"C:\vendor\vk.json"=dword:00000000`,见 Vulkan-Loader
/// `LoaderDriverInterface.md` 的 Windows 驱动发现一节),不是子键。
///
/// 所以判据是「键头行 + 其后至少一条带 `REG_` 类型标记的缩进行」:键存在但没有任何值时
/// 只有键头行;子键在没有 `/s` 时也缩进打印,但**不带** `REG_` 标记,据此区分。键不存在时
/// 输出只有本地化错误行(`ERROR: The system was unable to find…` /
/// `错误: 系统找不到指定的注册表项或值。`),既无键头也无数值行。
///
/// 注:值名可能含空格(manifest 绝对路径),所以不能按列切分,只能按 token 找类型标记。
#[must_use]
pub fn registry_has_driver_entry(text: &str) -> bool {
let mut in_key = false;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if !line.starts_with([' ', '\t']) {
// 顶格行 = 键头行(或错误行);遇到别的键就重置状态。
in_key = trimmed
.trim_end_matches('\\')
.eq_ignore_ascii_case(VULKAN_DRIVERS_KEY_HEADER);
continue;
}
if in_key && trimmed.split_whitespace().any(|t| t.starts_with("REG_")) {
return true;
}
}
false
}
/// `ldconfig -p` 输出里是否列了 `libvulkan.so.1`。
#[must_use]
pub fn ldconfig_has_libvulkan(text: &str) -> bool {
text.lines()
.any(|line| line.contains("libvulkan.so.1") || line.contains("libvulkan.so "))
}
/// 跑一个只读命令取 stdout(`reg query` / `ldconfig -p`;失败返回 `None`)。
fn run(program: &str, args: &[&str]) -> Option<String> {
use std::process::Command;
let output = Command::new(program).args(args).output().ok()?;
if output.stdout.is_empty() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).into_owned())
}
+215
View File
@@ -0,0 +1,215 @@
//! Windows 版本探测:真实的 `major.minor.build`(D19 · `PlanDoc/11-os-detection.md` §2)。
//!
//! 数据源(从快到慢,探测发生在启动路径上):
//!
//! 1. `reg query ... /v CurrentBuildNumber` 取 build 号,由 [`from_build`] 反推 `major.minor`
//! (build 号在版本族内单调;实测单值查询约 50ms,整键查询约 150ms);
//! 2. 整键 `HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion`:Win10+ 有
//! `CurrentMajorVersionNumber` / `CurrentMinorVersionNumber`(REG_DWORD,`reg query` 以
//! `0x..` 十六进制输出),Win7 / 8 / 8.1 只有 `CurrentVersion`(`6.1` / `6.2` / `6.3`,
//! Win10+ 上它是过期值);
//! 3. 兜底 `cmd /c ver`:输出 `Microsoft Windows [Version 10.0.19045.4046]`(中文系统为
//! `[版本 …]`)。只取方括号内的数字段,因此与系统语言无关。
//!
//! # 为什么不用 `RtlGetVersion`
//!
//! D19 原文方案是 `RtlGetVersion`(ntdll),但 core 是 `#![forbid(unsafe_code)]`
//! (`lib.rs`,D3 架构纪律 + workspace lint `unsafe_code = deny`),FFI 一律不可用。
//! 上面两条路径读的是同一份真实事实(`cmd.exe` 自带 manifest,报真实版本,不受
//! `GetVersionEx` 兼容层欺骗),差别只是启动时一次性几毫秒 —— 结果由
//! [`crate::platform::os_info::current`] 缓存,不会重复付这个代价。
//!
//! 解析函数是纯函数且**跨平台编译**,单测用注入样本覆盖各版本输出(见 `tests/platform/`)。
/// Windows 11 的起始 build 号(22H2 之前就有 22000)。
pub const WIN11_MIN_BUILD: u32 = 22_000;
/// 存放版本值的注册表键。
pub const VERSION_KEY: &str = r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion";
/// Windows 真实版本。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WindowsVersion {
/// 主版本号(Win7 = 6,Win10/11 = 10)。
pub major: u32,
/// 次版本号(Win7 = 1,Win10/11 = 0)。
pub minor: u32,
/// 构建号(Win7 SP1 = 7601,Win10 22H2 = 19045,Win11 = 22000+)。
pub build: u32,
}
impl WindowsVersion {
/// 构造。
pub fn new(major: u32, minor: u32, build: u32) -> Self {
Self {
major,
minor,
build,
}
}
/// `major.minor` 元组(最低版本判定用)。
pub fn major_minor(&self) -> (u32, u32) {
(self.major, self.minor)
}
/// 版本串(`rules.os.version` 用,例如 `10.0.19045`)。
pub fn version_string(&self) -> String {
format!("{}.{}.{}", self.major, self.minor, self.build)
}
/// 产品名(由版本号推导,不用本地化的注册表 `ProductName`)。
pub fn display_name(&self) -> &'static str {
match self.major_minor() {
(10, _) if self.build >= WIN11_MIN_BUILD => "Windows 11",
(10, _) => "Windows 10",
(6, 3) => "Windows 8.1",
(6, 2) => "Windows 8",
(6, 1) => "Windows 7",
(6, 0) => "Windows Vista",
(5, _) => "Windows XP",
_ => "Windows",
}
}
}
/// 真实探测;非 Windows 平台恒为 `None`。
///
/// 三档,从快到慢(探测在启动路径上,实测整键 `reg query` 会枚举上百个子键,约 150ms:
/// 单值查询约 50ms):
/// 1. `reg query ... /v CurrentBuildNumber` 取 build,再由 [`from_build`] 反推 `major.minor`;
/// 2. 整键查询(Win7 的 `CurrentVersion`、Win10+ 的数字键都在这里解析,见 [`parse_reg_query`]);
/// 3. `cmd /c ver`(注册表被策略禁用时的兜底)。
#[cfg(target_os = "windows")]
pub fn probe() -> Option<WindowsVersion> {
let fast = run("reg", &["query", VERSION_KEY, "/v", "CurrentBuildNumber"])
.and_then(|text| parse_build_number(&text))
.and_then(from_build);
if fast.is_some() {
return fast;
}
if let Some(version) =
run("reg", &["query", VERSION_KEY]).and_then(|text| parse_reg_query(&text))
{
return Some(version);
}
run("cmd", &["/c", "ver"]).and_then(|text| parse_ver_output(&text))
}
/// 真实探测;非 Windows 平台恒为 `None`。
#[cfg(not(target_os = "windows"))]
pub fn probe() -> Option<WindowsVersion> {
None
}
/// 由 build 号反推 `major.minor`(build 号在版本族内单调,是唯一一条免整键查询的快路径)。
///
/// 7600/7601 = 6.1(Win7 / 2008 R2)、9200 = 6.2(Win8 / 2012)、9600 = 6.3(Win8.1 / 2012 R2)、
/// 10240+ = 10.0(Win10 / Win11 / Server 2016+)。认不出的 build 返回 `None`,
/// 由 [`parse_reg_query`] / [`parse_ver_output`] 兜底。
pub fn from_build(build: u32) -> Option<WindowsVersion> {
let (major, minor) = match build {
6000..=7599 => (6, 0),
7600..=9199 => (6, 1),
9200..=9599 => (6, 2),
9600..=10239 => (6, 3),
10240.. => (10, 0),
// Vista 之前的 build 号太小,不猜(交给整键/`ver` 解析)。
_ => return None,
};
Some(WindowsVersion::new(major, minor, build))
}
/// 从 `reg query ... /v CurrentBuildNumber` 的输出取 build 号。
pub fn parse_build_number(text: &str) -> Option<u32> {
text.lines().find_map(|line| {
let mut fields = line.split_whitespace();
let name = fields.next()?;
if !name.eq_ignore_ascii_case("currentbuildnumber") {
return None;
}
let _kind = fields.next();
parse_number(fields.next().unwrap_or_default())
})
}
/// 解析 `reg query <VERSION_KEY>` 的输出。
///
/// 形如 ` CurrentBuildNumber REG_SZ 19045`;DWORD 值以 `0x..` 输出。
pub fn parse_reg_query(text: &str) -> Option<WindowsVersion> {
let mut major = None;
let mut minor = None;
let mut build = None;
let mut legacy = None;
for line in text.lines() {
let mut fields = line.split_whitespace();
let Some(name) = fields.next() else {
continue;
};
let _kind = fields.next();
let value = fields.next().unwrap_or_default();
match name.to_ascii_lowercase().as_str() {
"currentmajorversionnumber" => major = parse_number(value),
"currentminorversionnumber" => minor = parse_number(value),
"currentbuildnumber" => build = parse_number(value),
"currentversion" => legacy = parse_version_pair(value),
_ => {}
}
}
let build = build?;
let (major, minor) = match major {
Some(major) => (major, minor.unwrap_or(0)),
// Win7 / 8 / 8.1:没有 CurrentMajorVersionNumber,只能读 CurrentVersion。
None => legacy?,
};
Some(WindowsVersion::new(major, minor, build))
}
/// 解析 `cmd /c ver` 的输出(`Microsoft Windows [Version 10.0.19045.4046]`,
/// 中文系统 `Microsoft Windows [版本 10.0.19045.4046]`)。
pub fn parse_ver_output(text: &str) -> Option<WindowsVersion> {
let start = text.find('[')? + 1;
let rest = &text[start..];
let inner = &rest[..rest.find(']')?];
// 只保留数字与点,语言相关的字样(`Version` / `版本`)会被切成空段。
let digits = inner
.split(|c: char| !(c.is_ascii_digit() || c == '.'))
.find(|segment| segment.contains('.'))?;
let parts: Vec<u32> = digits
.split('.')
.take(3)
.map(str::parse)
.collect::<Result<_, _>>()
.ok()?;
match parts.as_slice() {
[major, minor, build] => Some(WindowsVersion::new(*major, *minor, *build)),
_ => None,
}
}
fn parse_number(value: &str) -> Option<u32> {
let trimmed = value.trim();
if let Some(hex) = trimmed
.strip_prefix("0x")
.or_else(|| trimmed.strip_prefix("0X"))
{
u32::from_str_radix(hex, 16).ok()
} else {
trimmed.parse().ok()
}
}
fn parse_version_pair(value: &str) -> Option<(u32, u32)> {
let (major, minor) = value.trim().split_once('.')?;
Some((major.parse().ok()?, minor.parse().ok()?))
}
#[cfg(target_os = "windows")]
fn run(program: &str, args: &[&str]) -> Option<String> {
use std::process::Command;
let output = Command::new(program).args(args).output().ok()?;
if output.stdout.is_empty() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).into_owned())
}