用户投诉原话:「我们的启动日志放在哪?…他找半天没找到崩溃日志」「只有一个安装日志」。 启动器运行日志(.ULL/logs/launcher.log) - presenter 构造 HostEnv 时就登记日志目录:启动早期缓冲的 200 行([probe]/[font]/[theme]) 当场刷盘,不再是「只有开过一局游戏才有日志」。 - app/** 里所有 eprintln! 诊断改走 app::diag::diag!(stderr + 文件双写,行首 [tag] 即 scope), 含 boot/boot::fallback/state/update 各线。 - boot 那一拍补一行后端事实(request/effective/fallback)。 - 滚动上限(1 MiB → launcher.1.log,只留一份)写进 README 与 docs/logs-location.md。 会话日志搬家:data/agent/sessions → logs - LauncherLayout: sessions_root()/game_logs() 新增,agent_sessions() 降级为只读兼容。 - host/logs 与 cli/commands/sessions 两个根合并(同 id 新根优先),event_path 回落旧家。 - 不搬不删;日志目录写一份 session-log-location.json 记录(moved=0, policy=read-only-compat)。 入口与文案 - 设置页「通用」新增「日志文件夹」行:路径 + 打开目录 + 复制路径(i18n 中英)。 - 日志页页头的「打开目录」不再是占位:走 M187 的 open_dir,失败文本带路径并送进剪贴板。 - README.md / README.zh-CN.md 新增「日志在哪」章节(含 crash-reports 只在游戏走到那一步才有), docs/logs-location.md 给出上限、双写判据、迁移策略与没验的部分。 守门(每条都有变异证明,见报告) - 旧家会话仍列得出/打不开 ⇒ 只读新根变红;新会话写 logs/game ⇒ 只读旧根、写回旧家都变红。 - launcher.log 滚动 ⇒ 干掉 rename 变红。 - 打开日志文件夹指向真实目录 ⇒ 换成 cache/ 或去掉 create_dir_all 都变红。 - 日志根随三模式变化 ⇒ 把 HostEnv::logs_dir 写死成便携落点变红。
244 lines
7.3 KiB
Rust
244 lines
7.3 KiB
Rust
//! `.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";
|
||
/// `logs/` 下的游戏会话目录名(事件流与 `index.jsonl`;M188)。
|
||
pub const GAME_LOG_DIR: &str = "game";
|
||
|
||
/// 全局设置文件名。
|
||
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")
|
||
}
|
||
|
||
/// 会话根(M188 起 = `logs/`)。
|
||
///
|
||
/// `launch::LauncherConfig` 在会话根下自己建 `game/`,所以「会话根」与「事件流目录」差一层:
|
||
/// 根是这里,真落点是 [`Self::game_logs`]。
|
||
#[must_use]
|
||
pub fn sessions_root(&self) -> PathBuf {
|
||
self.logs()
|
||
}
|
||
|
||
/// `logs/game/`——游戏会话事件流(`<session-id>.jsonl`)与 `index.jsonl` 的真落点(M188)。
|
||
#[must_use]
|
||
pub fn game_logs(&self) -> PathBuf {
|
||
self.logs().join(GAME_LOG_DIR)
|
||
}
|
||
|
||
/// `data/agent/sessions/`——会话日志的**旧家**(M188 起只读兼容)。
|
||
///
|
||
/// 目录名里的 `agent` 是历史遗留:它从来只装游戏会话,与 AI 无关。M188 把新会话挪到
|
||
/// `logs/game/`,这里**不搬不删**(用户的数据不该被偷偷移动),只是继续被读出来。
|
||
#[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 == '-')
|
||
}
|