fix(launch): M298 收尾失败不再静默、Toast 接上渲染点
三条提示通道各漏一半,用户才会「启动失败但找不到崩溃日志」: 1) 收尾失败静默不弹:finish() 报错那条出口把 LaunchFinished.session 发成真实 会话,而它登记的现场走 before_start 写死空串 ⇒ dialog.rs 的并发守卫 (!finished.session.is_empty() && report.session != finished.session)成立后 return。改成**会话归属单一来源**:before_start 新增可选 SessionAttribution, presenter 的 publish_before_start 把它写进报告后**返回同一个值**给 DTO; 两处不可能再各写一遍。守卫原样保留(A 场的现场不许解释 B 场,有用例钉住)。 2) Toast 只 push 不渲染:新增 app/view/toast.rs 覆盖层(MD3E 胶囊 = ShapeScale::Full,底部居中,InverseSurface 底),接在页面树之上、模态之下; 过期仍走 clock.rs 的 expire;进/出过渡复用模态那一对腿(motion::modal_spec / modal_scale / scaled),时间轴在 update 侧由 state/toast.rs 采样,关掉动效时 恒为终值(出图可复现)。两个拷贝路径补上 view_dirty(推了不置脏要等下一次事件)。 3) 三个贴线文件先拆再改(纯搬移 + pub use 保路径,机器核对:搬出的 14 个函数体 逐行相同):crash_report.rs 394→318(+context.rs / slot.rs)、 host/launch.rs 399→233(+build.rs)、app/dialog.rs 390→178(+model.rs)。 证据:DevDocs/artifacts/m298/toast-capsule.png(--screenshot + ULL_TOAST_FIXTURE, 图上可见「游戏崩溃 · 12.0 秒」胶囊)。
This commit is contained in:
1 parent
fc8f5c6794
commit
97768b7e3f
19 files changed
+1699
-553
No files matched your search
@@ -7,20 +7,13 @@
|
||||
//! 本模块内被读一次,绝不进 DTO。
|
||||
//!
|
||||
//! 停止:`kill_tree(pid)`(core 的三段式进程树回收),`finish()` 会照常收尾写索引。
|
||||
//!
|
||||
//! M298 把装配那一段(`build_request` 与它的两个私有助手)搬到 [`build`],`pub use` 保路径:
|
||||
//! 本文件只留「起一次启动 / 停一次启动 / 失败出口怎么登记现场」这条生命周期。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::game::arguments::Environment;
|
||||
use unlucky_core::game::install::instance::read_instance_config;
|
||||
use unlucky_core::game::isolation::{derive_game_dir, observe_mode, IsolationMode};
|
||||
use unlucky_core::game::{self, VersionJson};
|
||||
use unlucky_core::launch::adapter::{
|
||||
self, AdapterOptions, AdapterWarning, LibraryInput, SnapshotInput,
|
||||
};
|
||||
use unlucky_core::launch::crash_report::{self, CrashReport, ReportContext};
|
||||
use unlucky_core::game::isolation::IsolationMode;
|
||||
use unlucky_core::launch::crash_report::{self, CrashReport, ReportContext, SessionAttribution};
|
||||
use unlucky_core::launch::launcher_log;
|
||||
use unlucky_core::launch::request::{LaunchRequest, MemoryOptions};
|
||||
use unlucky_core::launch::time::{format_rfc3339_millis, now};
|
||||
use unlucky_core::launch::tree_kill;
|
||||
use unlucky_core::launch::{Launcher, LauncherConfig};
|
||||
|
||||
@@ -31,9 +24,11 @@ use crate::dto::{
|
||||
|
||||
use super::env::HostEnv;
|
||||
|
||||
mod build;
|
||||
mod java;
|
||||
|
||||
use super::job::{HostTask, JobSender};
|
||||
pub use build::build_request;
|
||||
pub use java::pick_java;
|
||||
|
||||
/// 启动器名(与 CLI `meta::LAUNCHER_NAME` 同值)。
|
||||
@@ -52,178 +47,6 @@ pub struct LaunchTarget {
|
||||
pub account: Option<String>,
|
||||
}
|
||||
|
||||
/// 装配 [`LaunchRequest`](与 CLI `snapshot::prepare` 同序)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 任一步失败都返回 [`LaunchFailureView`]:**阶段**(版本 JSON / 继承链 / 实例设置 / 隔离目录 /
|
||||
/// 账户)+ 那一步的原文。文案不在这一层拼(presenter 不依赖 `unlucky-i18n`)。
|
||||
pub fn build_request(
|
||||
env: &HostEnv,
|
||||
target: &LaunchTarget,
|
||||
) -> Result<(LaunchRequest, Vec<String>), LaunchFailureView> {
|
||||
let version_id = target.version_id.clone();
|
||||
// **这个实例自己的根**(M274):官方根 / 另一个 `.minecraft` 里的版本,清单、库、natives、
|
||||
// assets 全在它自己那个根下。拿 `env.game` 去读会读空(甚至读到同名的另一个版本)。
|
||||
let game = super::game::game_for(env, &target.id);
|
||||
let manifest_path = game.version_json(&version_id);
|
||||
let text = std::fs::read_to_string(&manifest_path).map_err(|error| {
|
||||
LaunchFailureView::new(
|
||||
LaunchFailureStage::VersionManifest,
|
||||
format!("{}:{error}", manifest_path.display()),
|
||||
)
|
||||
})?;
|
||||
let manifest = VersionJson::from_json_str(&text).map_err(|error| {
|
||||
LaunchFailureView::new(
|
||||
LaunchFailureStage::VersionManifest,
|
||||
format!("{}:{error}", manifest_path.display()),
|
||||
)
|
||||
})?;
|
||||
let source = |id: &str| -> Option<VersionJson> {
|
||||
std::fs::read_to_string(game.version_json(id))
|
||||
.ok()
|
||||
.and_then(|text| VersionJson::from_json_str(&text).ok())
|
||||
};
|
||||
let resolved = game::resolve(&source, &manifest).map_err(|error| {
|
||||
LaunchFailureView::new(LaunchFailureStage::Inheritance, error.to_string())
|
||||
})?;
|
||||
|
||||
let environment = Environment::host();
|
||||
let expanded = resolved
|
||||
.arguments
|
||||
.as_ref()
|
||||
.map(|arguments| arguments.expand(&environment))
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut libraries: Vec<LibraryInput> = Vec::new();
|
||||
for library in resolved.libraries.as_deref().unwrap_or_default() {
|
||||
if !library.applies(&environment) {
|
||||
continue;
|
||||
}
|
||||
// 1.19+ 的 natives 是独立库条目(name 带分类器),必须进 classpath;
|
||||
// ≤1.18 的 `natives` 表条目归 natives::collect,不进 classpath。
|
||||
// Linux arm64:独立条目改写成 natives-linux-arm64(路径与展示名一起改)。
|
||||
let arm64 = library.linux_arm64_native_name(&environment);
|
||||
if arm64.is_none() && library.native_classifier(&environment).is_some() {
|
||||
continue;
|
||||
}
|
||||
let (coordinate, name) = match arm64 {
|
||||
Some(rewritten) => {
|
||||
let Ok(coordinate) =
|
||||
unlucky_core::game::library::MavenCoordinate::parse(&rewritten)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
(coordinate, rewritten)
|
||||
}
|
||||
None => {
|
||||
let Ok(coordinate) = library.coordinate() else {
|
||||
continue;
|
||||
};
|
||||
(coordinate, library.name.clone())
|
||||
}
|
||||
};
|
||||
let path = if library.is_local() {
|
||||
game.version_library(&version_id, &coordinate)
|
||||
} else {
|
||||
game.library(&coordinate)
|
||||
};
|
||||
libraries.push(LibraryInput::new(path, name));
|
||||
}
|
||||
let natives = unlucky_core::launch::natives::collect(
|
||||
&game,
|
||||
&version_id,
|
||||
resolved.libraries.as_deref().unwrap_or_default(),
|
||||
&environment,
|
||||
);
|
||||
|
||||
let game_dir = run_dir(env, &game, target)?;
|
||||
let jar_id = resolved.jar.clone().unwrap_or_else(|| version_id.clone());
|
||||
let assets_root = game.assets_dir();
|
||||
let assets_index = resolved
|
||||
.assets
|
||||
.clone()
|
||||
.or_else(|| resolved.asset_index.as_ref().map(|index| index.id.clone()));
|
||||
// 装配的门限 / 分支按**继承链的 MC 根版本**判,不是带加载器后缀的 version_id:
|
||||
// `26w14a-fabric-0.16.5` 这种 id 在 `GameVersion::is_weekly_snapshot`(6 字符形态)上判不出来,
|
||||
// 周快照的 `--graphicsBackend` 会在拼装链里被静默跳过——而详情页的门槛
|
||||
// (`host::launch_args::backend_gate`)与 CLI 用的都是根版本,两边必须同一口径。
|
||||
let (mc_version, _) = super::game::describe_version(&game, &version_id);
|
||||
let snapshot = SnapshotInput {
|
||||
version_id: &version_id,
|
||||
version_name: Some(&target.name),
|
||||
version_type: None,
|
||||
game_version: Some(mc_version.as_str()),
|
||||
main_class: resolved.main_class.as_deref(),
|
||||
jvm_arguments: &expanded.jvm,
|
||||
game_arguments: &expanded.game,
|
||||
legacy_arguments: resolved.minecraft_arguments.as_deref(),
|
||||
libraries: &libraries,
|
||||
natives: &natives,
|
||||
primary_jar: game.version_jar(&jar_id),
|
||||
game_dir,
|
||||
assets_index: assets_index.as_deref(),
|
||||
natives_dir: game.natives_dir(&version_id),
|
||||
library_dir: game.libraries_dir(),
|
||||
assets_root: assets_root.clone(),
|
||||
game_assets: assets_root,
|
||||
};
|
||||
|
||||
let overrides = super::launch_args::resolved(env, &target.id);
|
||||
// 挑 Java 的**版本要求**也按实例自己的根读(M274 r1 复审 P1-2)。
|
||||
let java = pick_java(env, &game, &version_id, overrides.java_path.as_deref());
|
||||
let (auth, identity_line) = super::identity::resolve(env, target.account.as_deref())?;
|
||||
let options = AdapterOptions {
|
||||
java: java.selection,
|
||||
memory: MemoryOptions {
|
||||
min_mb: None,
|
||||
max_mb: Some(overrides.memory_mb),
|
||||
},
|
||||
auth,
|
||||
launcher_name: LAUNCHER_NAME.to_string(),
|
||||
launcher_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
language: "zh-CN".to_string(),
|
||||
..AdapterOptions::strict()
|
||||
};
|
||||
let output = adapter::build_request(&snapshot, options);
|
||||
let mut warnings: Vec<String> = output.warnings.iter().map(describe_warning).collect();
|
||||
warnings.push(java.summary);
|
||||
warnings.push(identity_line);
|
||||
let mut request = output.request;
|
||||
request.instance.id = target.id.clone();
|
||||
request.instance.name = target.name.clone();
|
||||
// 实例级启动参数(三级回落后的生效值)最后落位:user.* / memory / graphics_backend。
|
||||
unlucky_core::launch::overrides::apply(&mut request, &overrides);
|
||||
Ok((request, warnings))
|
||||
}
|
||||
|
||||
/// 派生运行目录(D20 覆盖链:实例 `isolation` > **基底档**,见 [`super::game::base_mode`])。
|
||||
///
|
||||
/// 基底档对自动发现的实例是「盘上有没有痕迹」,对其它实例就是全局 `instances.mode`——
|
||||
/// 于是这里不需要知道实例是从哪来的,只有一处判据。
|
||||
fn run_dir(
|
||||
env: &HostEnv,
|
||||
game: &unlucky_core::game::GameLayout,
|
||||
target: &LaunchTarget,
|
||||
) -> Result<PathBuf, LaunchFailureView> {
|
||||
let base = super::game::base_mode(env, game, &target.id, &target.version_id);
|
||||
let config = read_instance_config(&env.layout, &target.id).map_err(|error| {
|
||||
LaunchFailureView::new(LaunchFailureStage::InstanceConfig, error.to_string())
|
||||
})?;
|
||||
let derivation = derive_game_dir(game, &target.version_id, base, config.as_ref(), None)
|
||||
.map_err(|error| {
|
||||
LaunchFailureView::new(LaunchFailureStage::Isolation, error.to_string())
|
||||
})?;
|
||||
if let Some(mut config) = config {
|
||||
let at = format_rfc3339_millis(now());
|
||||
if observe_mode(&mut config, game, &at, derivation.isolation).unwrap_or(false) {
|
||||
let _ =
|
||||
unlucky_core::game::install::instance::write_instance_config(&env.layout, &config);
|
||||
}
|
||||
}
|
||||
Ok(derivation.game_dir)
|
||||
}
|
||||
|
||||
/// 起一次启动(后台线程;进度/日志/收尾全部走 `HostTask`)。
|
||||
pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
||||
let id = target.id.clone();
|
||||
@@ -259,7 +82,7 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
||||
Err(failure) => {
|
||||
let line = format!("instance={} 装配失败:{}", target.id, failure.detail);
|
||||
launcher_log::record("launch", &line);
|
||||
publish_before_start(&report, failure.stage.slug(), &failure.detail);
|
||||
publish_before_start(&report, None, failure.stage.slug(), &failure.detail);
|
||||
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
|
||||
id,
|
||||
session: String::new(),
|
||||
@@ -300,7 +123,7 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
||||
"launch",
|
||||
&format!("instance={} spawn 失败:{error}", target.id),
|
||||
);
|
||||
publish_before_start(&report, "spawn", &failure.detail);
|
||||
publish_before_start(&report, None, "spawn", &failure.detail);
|
||||
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
|
||||
id,
|
||||
session: String::new(),
|
||||
@@ -314,6 +137,13 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
||||
};
|
||||
let session_id = session.session_id().to_string();
|
||||
let pid = session.pid();
|
||||
// **归属的唯一一份**(M298):收尾失败那条出口要把「这次失败属于哪一场」写进现场,
|
||||
// 收尾事件也要带同一个值——两处都从这里取,界面侧的并发守卫才可能判对。
|
||||
let session_log = session.log_path().to_path_buf();
|
||||
let attribution = SessionAttribution {
|
||||
id: &session_id,
|
||||
log: Some(session_log.as_path()),
|
||||
};
|
||||
let _ = tx.send(HostTask::LaunchStarted {
|
||||
id: id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
@@ -335,7 +165,8 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
||||
crash_report::publish(CrashReport::collect(&handle, &report, &lines));
|
||||
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
|
||||
id,
|
||||
session: session_id,
|
||||
// 与 `CrashReport::collect` 取自同一处(`handle.session_id`)。
|
||||
session: handle.session_id.clone(),
|
||||
outcome,
|
||||
exit_code: Some(handle.exit.code),
|
||||
uptime_ms: handle.uptime_ms,
|
||||
@@ -350,10 +181,14 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
||||
target.id
|
||||
);
|
||||
launcher_log::record("launch", &line);
|
||||
publish_before_start(&report, "spawn", &failure.detail);
|
||||
// **收尾失败时进程真的起来过** ⇒ 这份现场属于 `attribution` 那一场。
|
||||
// 返回值就是报告里写的那条会话 id(同一处取值),所以「报告空串 vs 事件非空」
|
||||
// 这种守卫必然误判的组合从构造上就不存在了(M298)。
|
||||
let session =
|
||||
publish_before_start(&report, Some(&attribution), "spawn", &failure.detail);
|
||||
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
|
||||
id,
|
||||
session: session_id,
|
||||
session,
|
||||
outcome: LogOutcome::SilentFail,
|
||||
exit_code: None,
|
||||
uptime_ms: 0,
|
||||
@@ -364,9 +199,23 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
||||
});
|
||||
}
|
||||
|
||||
/// 登记一次「进程没起来」的现场(四个失败出口共用这一处,避免各写一遍 exit_type)。
|
||||
fn publish_before_start(report: &ReportContext, stage: &str, detail: &str) {
|
||||
crash_report::publish(CrashReport::before_start(report, stage, detail, stage));
|
||||
/// 登记一次**没有 handle** 的失败现场,并返回该写进 `LaunchFinished.session` 的值。
|
||||
///
|
||||
/// **会话归属的单一来源**(M298):报告里的 `session` 与收尾事件里的 `session` 都由
|
||||
/// `attribution` 这一处产出——调用方直接拿返回值填 DTO,于是两处不会再各写一遍、也不会出现
|
||||
/// 「报告写空串、事件写真实会话」这种守卫必然误判的组合。
|
||||
///
|
||||
/// `attribution` 为 `None` = 这一场**确实**没有会话(装配失败 / spawn 失败,进程没起来)。
|
||||
fn publish_before_start(
|
||||
report: &ReportContext,
|
||||
attribution: Option<&SessionAttribution<'_>>,
|
||||
stage: &str,
|
||||
detail: &str,
|
||||
) -> String {
|
||||
let registered = CrashReport::before_start(report, stage, detail, stage, attribution.copied());
|
||||
let session = registered.session.clone();
|
||||
crash_report::publish(registered);
|
||||
session
|
||||
}
|
||||
|
||||
/// 停止一次启动(按 pid 回收进程树;`finish()` 会在后台线程里照常收尾)。
|
||||
@@ -375,21 +224,6 @@ pub fn stop(pid: u32) -> Result<(), String> {
|
||||
.map_err(|error| format!("停止进程 {pid} 失败:{error}"))
|
||||
}
|
||||
|
||||
/// 适配层告警 → 人读一行。
|
||||
fn describe_warning(warning: &AdapterWarning) -> String {
|
||||
match warning {
|
||||
AdapterWarning::MissingLibrary { name, path } => {
|
||||
format!("缺少库文件 {name}({})", path.display())
|
||||
}
|
||||
AdapterWarning::MissingGameArguments => "版本 JSON 没有游戏参数".to_string(),
|
||||
AdapterWarning::MissingAssetsIndex => "版本 JSON 没有资源索引".to_string(),
|
||||
AdapterWarning::MissingMainClass => "版本 JSON 没有 mainClass".to_string(),
|
||||
AdapterWarning::MissingNativeJar { name, path } => {
|
||||
format!("缺少 natives 构件 {name}({})", path.display())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 隔离档位的展示串(`isolated` / `shared`)。
|
||||
pub fn isolation_label(mode: IsolationMode) -> &'static str {
|
||||
mode.as_str()
|
||||
|
||||
Reference in new issue
Block a user