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
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:
commit
bc41dfcf7d
517 files changed
+92844
No files matched your search
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 == '-')
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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(¤t)? 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;
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Reference in new issue
Block a user