//! 数据根解析与目录视图。
//!
//! 三模式(D6 / `04-data-and-config.md` §1)全部交给 `core::platform::data_dir`:
//! - `--data-dir
` → custom 模式(`/.ULL` + `/.minecraft`);
//! - 否则先按便携目录的 `launcher.json` 读 `core.data_mode` / `core.data_custom_root` /
//! `core.launcher_home` / `core.game_home`,再交 [`Resolver`] 解析(含 `redirect.json` 跟随)。
//!
//! CLI 不自己拼路径:目录形状的真相在 `core::platform::layout`。
//!
//! `ull config get/set` 的落点也从这里出:[`Ctx::setting_path_for`] 让决定数据根的
//! `core.*` 键落在**便携目录那份** `launcher.json`(与解析同源),其余键落解析后的数据根。
use std::path::{Path, PathBuf};
use unlucky_core::game::GameLayout;
use unlucky_core::platform::data_dir::{DataDirRequest, DataMode, ResolvedData, Resolver};
use unlucky_core::platform::layout::{
LauncherLayout, CONFIG_DIR, LAUNCHER_CONFIG_FILE, LAUNCHER_HOME_DIR,
};
use unlucky_core::platform::paths::Platform;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use crate::cli::Globals;
use crate::error::CliError;
use crate::os;
/// 决定数据根本身的配置键(`request_from_portable_config` 读的就是它们)。
///
/// `ull config get/set` 对这几个键必须读写**同一份文件**(便携目录那份),否则会出现
/// 「`config get core.data_mode` 说 portable、实际按 standard 跑」这种两套真相
/// (finding 20261030-worker-java2-improve-ull-config-get-set-launcher-json)。
pub const DATA_ROOT_KEYS: [&str; 4] = [
"core.data_mode",
"core.data_custom_root",
"core.launcher_home",
"core.game_home",
];
/// 该键是否决定数据根。
#[must_use]
pub fn is_data_root_key(key: &str) -> bool {
DATA_ROOT_KEYS.contains(&key)
}
/// 便携目录那份 `launcher.json` 的路径(`/.ULL/config/launcher.json`)。
#[must_use]
pub fn portable_config_path(exe_dir: &Path) -> PathBuf {
exe_dir
.join(LAUNCHER_HOME_DIR)
.join(CONFIG_DIR)
.join(LAUNCHER_CONFIG_FILE)
}
/// 一次命令执行的上下文:数据根 + 平台 + 系统摘要。
#[derive(Debug, Clone)]
pub struct Ctx {
/// 解析后的数据目录(launcher home / game home)。
pub data: ResolvedData,
/// 宿主平台族。
pub platform: Platform,
/// 系统信息与支持等级(D19 探测 / D21 Win10+ 单基线)。
pub os: unlucky_core::platform::os_info::OsInfo,
/// exe 目录(便携目录那份 `launcher.json` 的根;拿不到时为 `None`)。
exe_dir: Option,
/// `--data-dir` 是否由命令行强制指定(此时数据根不看 `core.*` 键)。
data_dir_forced: bool,
}
impl Ctx {
/// 解析数据根。
///
/// # Errors
///
/// `--data-dir` 非绝对路径、exe 目录拿不到、`redirect.json` 损坏等返回用户错误。
pub fn resolve(globals: &Globals) -> Result {
let exe_dir = std::env::current_exe()
.ok()
.and_then(|path| path.parent().map(Path::to_path_buf));
Self::resolve_in(globals, exe_dir)
}
/// 指定 exe 目录的解析入口(单测用它把便携目录钉在临时目录里)。
///
/// # Errors
///
/// 同 [`Self::resolve`]。
pub fn resolve_in(globals: &Globals, exe_dir: Option) -> Result {
let platform = Platform::current();
let data_dir_forced = globals.data_dir.is_some();
let request = match &globals.data_dir {
Some(dir) => {
if !dir.is_absolute() {
return Err(CliError::usage(format!(
"--data-dir 需要绝对路径,收到 `{}`",
dir.display()
)));
}
DataDirRequest {
mode: DataMode::Custom,
custom_root: Some(dir.clone()),
..DataDirRequest::default()
}
}
None => request_from_portable_config(exe_dir.clone()),
};
let resolver = Resolver::new(platform, exe_dir.clone());
let data = resolver
.resolve_default(&request)
.map_err(|error| CliError::user("data_dir", error.to_string()))?;
Ok(Self {
data,
platform,
os: os::detect(),
exe_dir,
data_dir_forced,
})
}
/// 启动器数据布局(`.ULL/` 四分区)。
#[must_use]
pub fn layout(&self) -> LauncherLayout {
self.data.layout()
}
/// 游戏目录布局(`.minecraft/`)。
#[must_use]
pub fn game(&self) -> GameLayout {
GameLayout::new(self.data.game_home.clone())
}
/// `launcher.json` 路径。
#[must_use]
pub fn launcher_config(&self) -> PathBuf {
self.layout().launcher_config()
}
/// 便携目录那份 `launcher.json`(数据根解析的唯一输入;拿不到 exe 目录时为 `None`)。
#[must_use]
pub fn portable_launcher_config(&self) -> Option {
self.exe_dir.as_deref().map(portable_config_path)
}
/// `--data-dir` 是否由命令行强制指定(此时 `core.*` 数据根键本轮不参与解析)。
#[must_use]
pub const fn data_dir_forced(&self) -> bool {
self.data_dir_forced
}
/// 某个配置键的读写落点。
///
/// 数据根键([`DATA_ROOT_KEYS`])走便携目录那份——和 [`Self::resolve`] 读的是同一份文件,
/// 这样 `config get core.data_mode` 展示的就是真正生效的值,`config set` 也真的会影响
/// 下次解析;其余键走解析后的数据根(`/.ULL/config/launcher.json`)。
#[must_use]
pub fn setting_path_for(&self, key: &str) -> PathBuf {
if is_data_root_key(key) {
if let Some(path) = self.portable_launcher_config() {
return path;
}
}
self.launcher_config()
}
/// `profiles.json` 路径。
#[must_use]
pub fn profiles_config(&self) -> PathBuf {
self.layout().profiles_config()
}
/// 会话根(`/agent/sessions`,launch 的事件流与索引都在这下面)。
#[must_use]
pub fn sessions_root(&self) -> PathBuf {
self.layout().agent_sessions()
}
/// 账户数据目录(`data/auth/`:`accounts.json` + `credentials.dat`)。
#[must_use]
pub fn auth_data(&self) -> PathBuf {
self.layout().auth_data()
}
/// 会话索引文件(`/game/index.jsonl`)。
#[must_use]
pub fn session_index(&self) -> PathBuf {
unlucky_core::launch::session_index::index_path(&self.sessions_root())
}
/// D19/D21:低于基线(Win10+ / macOS 12+ / Linux 达标)时拒绝执行。
///
/// # Errors
///
/// 系统不支持时返回 `unsupported_os` 用户错误(文案与核心自检同一份)。
pub fn ensure_supported(&self) -> Result<(), CliError> {
let check = os::check();
if check.is_usable() {
return Ok(());
}
Err(CliError::user("unsupported_os", check.message)
.with_hint(format!("最低要求:{}", os::BASELINE)))
}
/// 环境级提示(低于基线时的升级指引;一等基线时为空)。
#[must_use]
pub fn notes(&self) -> Vec {
os::notes(&self.os)
}
}
/// 便携目录的 `launcher.json` → 三模式请求。
///
/// 只读 `effective`(文件值 → 默认值),坏文件也永不 panic(`SettingStore` 的加载降级)。
/// 路径由 [`portable_config_path`] 决定,`config get/set` 用的是同一个函数(同源)。
fn request_from_portable_config(exe_dir: Option) -> DataDirRequest {
let mut request = DataDirRequest::default();
let Some(exe_dir) = exe_dir else {
return request;
};
let path = portable_config_path(&exe_dir);
if !path.is_file() {
return request;
}
let store = SettingStore::load(&path, launcher_schema());
let text = |key: &str| store.doc().effective_str(key).map(str::to_string);
if let Some(mode) = text("core.data_mode").as_deref().and_then(DataMode::parse) {
request.mode = mode;
}
if let Some(root) = text("core.data_custom_root") {
request.custom_root = Some(PathBuf::from(root));
}
if let Some(home) = text("core.launcher_home") {
request.launcher_home = Some(PathBuf::from(home));
}
if let Some(home) = text("core.game_home") {
request.game_home = Some(PathBuf::from(home));
}
request
}