Files
Unlucky-Launcher/crates/core/src/platform/layout.rs
T
worker-logplace 5fd5cd47dc feat(m188): launcher.log 早落盘 + 会话日志搬到 logs/game(旧家只读兼容)
用户投诉原话:「我们的启动日志放在哪?…他找半天没找到崩溃日志」「只有一个安装日志」。

启动器运行日志(.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 写死成便携落点变红。
2026-11-09 00:43:45 +08:00

244 lines
7.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `.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 == '-')
}