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
@@ -18,6 +18,9 @@
|
|||||||
//!
|
//!
|
||||||
//! 交互:主按钮 / 次按钮 / 点遮罩 / `Esc` 都关当前条(`DialogQueue::close_current`)。
|
//! 交互:主按钮 / 次按钮 / 点遮罩 / `Esc` 都关当前条(`DialogQueue::close_current`)。
|
||||||
//!
|
//!
|
||||||
|
//! M298 把**视图模型**那一段(`Modal` / `CardView` / `current` 与四个纯函数)搬到 [`model`],
|
||||||
|
//! `pub use` 保路径:本文件只留「卡片事实 + 消息枚举 + 与 `App` 的接线」。
|
||||||
|
//!
|
||||||
//! ## 崩溃卡片额外带什么
|
//! ## 崩溃卡片额外带什么
|
||||||
//!
|
//!
|
||||||
//! `DialogRequest`(presenter 的 DTO)只有标题/正文/按钮,而这次用户要的是**证据**:
|
//! `DialogRequest`(presenter 的 DTO)只有标题/正文/按钮,而这次用户要的是**证据**:
|
||||||
@@ -29,18 +32,22 @@
|
|||||||
//! 事实来源是 `unlucky_core::launch::crash_report`(启动线程收尾时登记的现场)。这里只读它,
|
//! 事实来源是 `unlucky_core::launch::crash_report`(启动线程收尾时登记的现场)。这里只读它,
|
||||||
//! 不重算:**界面不读盘、不猜原因**。
|
//! 不重算:**界面不读盘、不猜原因**。
|
||||||
|
|
||||||
use unlucky_core::launch::crash_report::{CrashReport, Evidence};
|
use unlucky_core::launch::crash_report::CrashReport;
|
||||||
use unlucky_i18n::catalog::{Catalog, Key};
|
use unlucky_i18n::catalog::{Catalog, Key};
|
||||||
use unlucky_i18n::Lang;
|
|
||||||
use unlucky_presenter::dialogs::{DialogId, DialogKind, DialogSource};
|
use unlucky_presenter::dialogs::{DialogId, DialogKind, DialogSource};
|
||||||
|
|
||||||
use crate::app::state::App;
|
use crate::app::state::App;
|
||||||
|
|
||||||
pub(super) mod export;
|
pub(super) mod export;
|
||||||
|
mod model;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
pub(super) mod view;
|
pub(super) mod view;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(super) use model::path_rows;
|
||||||
|
pub(super) use model::{current, DialogRequestView, Modal};
|
||||||
|
|
||||||
/// 崩溃卡片:一次失败的**事实**(不含任何结论)。
|
/// 崩溃卡片:一次失败的**事实**(不含任何结论)。
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct CrashCard {
|
pub struct CrashCard {
|
||||||
@@ -77,225 +84,6 @@ pub enum Message {
|
|||||||
CopyPath(usize),
|
CopyPath(usize),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 视图模型:**测试与渲染共用**的一份「这一屏该出现什么」。
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
||||||
pub(super) struct Modal {
|
|
||||||
/// 标题。
|
|
||||||
pub title: String,
|
|
||||||
/// 正文(每行一段;都是运行时拼出来的 owned 字符串)。
|
|
||||||
pub body: Vec<String>,
|
|
||||||
/// 主按钮文字。
|
|
||||||
pub primary: String,
|
|
||||||
/// 次按钮文字(`None` = 不画)。
|
|
||||||
pub secondary: Option<String>,
|
|
||||||
/// 是否有次按钮(`Progress` 没有)。
|
|
||||||
pub blocking_progress: bool,
|
|
||||||
/// 崩溃卡片(普通弹窗为 `None`)。
|
|
||||||
pub card: Option<CardView>,
|
|
||||||
/// 动作结论(导出路径 / 失败原因);`None` = 还没有动作。
|
|
||||||
pub notice: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 崩溃卡片的展示内容。
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
||||||
pub(super) struct CardView {
|
|
||||||
/// 展开 / 收起按钮的文字。
|
|
||||||
pub toggle: String,
|
|
||||||
/// 当前的展开态。
|
|
||||||
pub expanded: bool,
|
|
||||||
/// (标签, 路径)列表——**逐条可点**(点了复制该条)。
|
|
||||||
pub paths: Vec<(String, String)>,
|
|
||||||
/// 详细说明的小标题(`None` = 没有可展开的内容)。
|
|
||||||
pub detail_title: Option<String>,
|
|
||||||
/// 详细说明的正文行。
|
|
||||||
pub detail_lines: Vec<String>,
|
|
||||||
/// 三个按钮的文字。
|
|
||||||
pub actions: [String; 3],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Modal {
|
|
||||||
/// 正文全文(测试与「复制摘要」共用;换行拼起来)。
|
|
||||||
#[must_use]
|
|
||||||
#[cfg(test)]
|
|
||||||
pub fn body_text(&self) -> String {
|
|
||||||
self.body.join("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从队列当前条与卡片造视图模型(**纯函数**:不读盘、不看 App)。
|
|
||||||
#[must_use]
|
|
||||||
pub(super) fn current(
|
|
||||||
lang: Lang,
|
|
||||||
request: &DialogRequestView<'_>,
|
|
||||||
card: Option<&CrashCard>,
|
|
||||||
) -> Modal {
|
|
||||||
let catalog = Catalog::of(lang);
|
|
||||||
let mut modal = Modal {
|
|
||||||
title: request.title.to_string(),
|
|
||||||
body: vec![request.body.to_string()],
|
|
||||||
primary: request.primary.to_string(),
|
|
||||||
secondary: request.secondary.map(str::to_string),
|
|
||||||
blocking_progress: request.blocking_progress,
|
|
||||||
card: None,
|
|
||||||
notice: None,
|
|
||||||
};
|
|
||||||
if let Some(card) = card {
|
|
||||||
let detail = detail_of(&catalog, card);
|
|
||||||
modal.body = failure_body(&catalog, &card.report);
|
|
||||||
modal.primary = catalog.text(Key::CrashDialogExport).to_string();
|
|
||||||
modal.secondary = Some(catalog.text(Key::CrashDialogClose).to_string());
|
|
||||||
modal.card = Some(CardView {
|
|
||||||
toggle: catalog
|
|
||||||
.text(if card.expanded {
|
|
||||||
Key::CrashDialogHideDetail
|
|
||||||
} else {
|
|
||||||
Key::CrashDialogShowDetail
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
expanded: card.expanded,
|
|
||||||
paths: path_rows(&catalog, &card.report),
|
|
||||||
detail_title: detail.0,
|
|
||||||
detail_lines: detail.1,
|
|
||||||
actions: [
|
|
||||||
catalog.text(Key::CrashDialogExport).to_string(),
|
|
||||||
catalog.text(Key::CrashDialogOpenFolder).to_string(),
|
|
||||||
catalog.text(Key::CrashDialogCopyPaths).to_string(),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
modal.notice = card.outcome.clone();
|
|
||||||
}
|
|
||||||
modal
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 队列当前条的最小视图(避免把 `DialogRequest` 的细节漏进页面层)。
|
|
||||||
pub(super) struct DialogRequestView<'a> {
|
|
||||||
/// 标题。
|
|
||||||
pub title: &'a str,
|
|
||||||
/// 正文。
|
|
||||||
pub body: &'a str,
|
|
||||||
/// 主按钮。
|
|
||||||
pub primary: &'a str,
|
|
||||||
/// 次按钮。
|
|
||||||
pub secondary: Option<&'a str>,
|
|
||||||
/// 进度框不可点主按钮。
|
|
||||||
pub blocking_progress: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> DialogRequestView<'a> {
|
|
||||||
/// 从 presenter 的请求取视图。
|
|
||||||
pub(super) fn of(request: &'a unlucky_presenter::dialogs::DialogRequest) -> Self {
|
|
||||||
Self {
|
|
||||||
title: &request.title,
|
|
||||||
body: &request.body,
|
|
||||||
primary: request.kind.primary_label(),
|
|
||||||
secondary: request.kind.secondary_label(),
|
|
||||||
blocking_progress: request.kind.is_blocking_progress(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 失败正文:退出码 + **一句人话原因** + 证据形态。
|
|
||||||
///
|
|
||||||
/// 三件事都必须有——用户投诉的原话是「找半天没找到崩溃日志」,所以第三句不是锦上添花:
|
|
||||||
/// 它要么指向游戏写的那份,要么明确说「这次没有,下面是捕获到的输出」。
|
|
||||||
fn failure_body(catalog: &Catalog, report: &CrashReport) -> Vec<String> {
|
|
||||||
let mut body = Vec::new();
|
|
||||||
if let Some(code) = report.exit_code {
|
|
||||||
body.push(catalog.render(Key::CrashDialogExitCode, &[("code", &code.to_string())]));
|
|
||||||
}
|
|
||||||
if let Some(stage) = &report.stage {
|
|
||||||
let label = stage_label(catalog, stage);
|
|
||||||
body.push(catalog.render(Key::CrashDialogStageReason, &[("stage", label)]));
|
|
||||||
} else if report.exit_code == Some(0) {
|
|
||||||
body.push(catalog.text(Key::CrashDialogSilentFailure).to_string());
|
|
||||||
} else {
|
|
||||||
body.push(catalog.text(Key::CrashDialogStartedThenDied).to_string());
|
|
||||||
}
|
|
||||||
body.push(
|
|
||||||
match report.evidence() {
|
|
||||||
Evidence::GameCrashReport => catalog.text(Key::CrashDialogCrashReportFound),
|
|
||||||
Evidence::CapturedOutput => catalog.text(Key::CrashDialogNoGameLog),
|
|
||||||
Evidence::Nothing => catalog.text(Key::CrashDialogNoLogAtAll),
|
|
||||||
}
|
|
||||||
.to_string(),
|
|
||||||
);
|
|
||||||
body
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 装配阶段 slug → 文案(**不**复用 core 的中文诊断串)。
|
|
||||||
fn stage_label(catalog: &Catalog, slug: &str) -> &'static str {
|
|
||||||
let key = match slug {
|
|
||||||
"version_manifest" => Key::LaunchStageVersionManifest,
|
|
||||||
"inheritance" => Key::LaunchStageInheritance,
|
|
||||||
"instance_config" => Key::LaunchStageInstanceConfig,
|
|
||||||
"isolation" => Key::LaunchStageIsolation,
|
|
||||||
"identity" => Key::LaunchStageIdentity,
|
|
||||||
_ => Key::LaunchStageSpawn,
|
|
||||||
};
|
|
||||||
catalog.text(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 路径行(**启动器日志目录 + 该实例 latest.log + crash-reports 目录 + 会话 JSONL**)。
|
|
||||||
fn path_rows(catalog: &Catalog, report: &CrashReport) -> Vec<(String, String)> {
|
|
||||||
let mut rows = Vec::new();
|
|
||||||
if let Some(log) = &report.session_log {
|
|
||||||
rows.push((
|
|
||||||
catalog.text(Key::CrashDialogSessionLog).to_string(),
|
|
||||||
log.display().to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if let Some(dir) = &report.launcher_logs {
|
|
||||||
rows.push((
|
|
||||||
catalog.text(Key::CrashDialogLauncherLogs).to_string(),
|
|
||||||
dir.display().to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if let Some(path) = report.latest_log_path() {
|
|
||||||
rows.push((
|
|
||||||
catalog.text(Key::CrashDialogGameLog).to_string(),
|
|
||||||
path.display().to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if let Some(dir) = report.crash_reports_dir() {
|
|
||||||
rows.push((
|
|
||||||
catalog.text(Key::CrashDialogCrashReports).to_string(),
|
|
||||||
dir.display().to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
rows
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 详细说明:有崩溃报告贴 Description/栈片段,否则贴启动器捕获的尾巴。
|
|
||||||
fn detail_of(catalog: &Catalog, card: &CrashCard) -> (Option<String>, Vec<String>) {
|
|
||||||
let report = &card.report;
|
|
||||||
if let Some(artifact) = report
|
|
||||||
.artifacts
|
|
||||||
.iter()
|
|
||||||
.find(|artifact| artifact.source == "crash_report")
|
|
||||||
{
|
|
||||||
let mut lines = Vec::new();
|
|
||||||
if let Some(excerpt) = &artifact.excerpt {
|
|
||||||
lines.extend(excerpt.lines().map(str::to_string));
|
|
||||||
} else {
|
|
||||||
lines.push(artifact.path.display().to_string());
|
|
||||||
}
|
|
||||||
let title = format!(
|
|
||||||
"{} {}",
|
|
||||||
catalog.text(Key::LogsDiagnosisTitle),
|
|
||||||
artifact.path.display()
|
|
||||||
);
|
|
||||||
return (Some(title), lines);
|
|
||||||
}
|
|
||||||
if report.stderr_tail.is_empty() {
|
|
||||||
return (None, Vec::new());
|
|
||||||
}
|
|
||||||
let title = catalog.render(
|
|
||||||
Key::CrashDialogCapturedTail,
|
|
||||||
&[("count", &report.stderr_tail.len().to_string())],
|
|
||||||
);
|
|
||||||
(Some(title), report.stderr_tail.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
// --------------------------------------------------------------- 与 App 的接线
|
// --------------------------------------------------------------- 与 App 的接线
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
//! 弹窗的**视图模型**:队列当前条 + 崩溃卡片 → 「这一屏该出现什么」(M298 从 `dialog.rs` 拆出)。
|
||||||
|
//!
|
||||||
|
//! 纯函数、不读盘、不看 `App`:测试与渲染共用同一份(`crate::app::dialog::{current, Modal}`)。
|
||||||
|
//! 拆分的直接原因只有行数(`dialog.rs` 当时 390 行、贴着 400 行硬线),**没有行为变化**:
|
||||||
|
//! 每个函数体逐行搬移,符号经父模块 `pub use` 保路径。
|
||||||
|
|
||||||
|
use unlucky_core::launch::crash_report::{CrashReport, Evidence};
|
||||||
|
use unlucky_i18n::catalog::{Catalog, Key};
|
||||||
|
use unlucky_i18n::Lang;
|
||||||
|
|
||||||
|
use super::CrashCard;
|
||||||
|
|
||||||
|
/// 视图模型:**测试与渲染共用**的一份「这一屏该出现什么」。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub(crate) struct Modal {
|
||||||
|
/// 标题。
|
||||||
|
pub title: String,
|
||||||
|
/// 正文(每行一段;都是运行时拼出来的 owned 字符串)。
|
||||||
|
pub body: Vec<String>,
|
||||||
|
/// 主按钮文字。
|
||||||
|
pub primary: String,
|
||||||
|
/// 次按钮文字(`None` = 不画)。
|
||||||
|
pub secondary: Option<String>,
|
||||||
|
/// 是否有次按钮(`Progress` 没有)。
|
||||||
|
pub blocking_progress: bool,
|
||||||
|
/// 崩溃卡片(普通弹窗为 `None`)。
|
||||||
|
pub card: Option<CardView>,
|
||||||
|
/// 动作结论(导出路径 / 失败原因);`None` = 还没有动作。
|
||||||
|
pub notice: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 崩溃卡片的展示内容。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub(crate) struct CardView {
|
||||||
|
/// 展开 / 收起按钮的文字。
|
||||||
|
pub toggle: String,
|
||||||
|
/// 当前的展开态。
|
||||||
|
pub expanded: bool,
|
||||||
|
/// (标签, 路径)列表——**逐条可点**(点了复制该条)。
|
||||||
|
pub paths: Vec<(String, String)>,
|
||||||
|
/// 详细说明的小标题(`None` = 没有可展开的内容)。
|
||||||
|
pub detail_title: Option<String>,
|
||||||
|
/// 详细说明的正文行。
|
||||||
|
pub detail_lines: Vec<String>,
|
||||||
|
/// 三个按钮的文字。
|
||||||
|
pub actions: [String; 3],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Modal {
|
||||||
|
/// 正文全文(测试与「复制摘要」共用;换行拼起来)。
|
||||||
|
#[must_use]
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn body_text(&self) -> String {
|
||||||
|
self.body.join("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从队列当前条与卡片造视图模型(**纯函数**:不读盘、不看 App)。
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn current(
|
||||||
|
lang: Lang,
|
||||||
|
request: &DialogRequestView<'_>,
|
||||||
|
card: Option<&CrashCard>,
|
||||||
|
) -> Modal {
|
||||||
|
let catalog = Catalog::of(lang);
|
||||||
|
let mut modal = Modal {
|
||||||
|
title: request.title.to_string(),
|
||||||
|
body: vec![request.body.to_string()],
|
||||||
|
primary: request.primary.to_string(),
|
||||||
|
secondary: request.secondary.map(str::to_string),
|
||||||
|
blocking_progress: request.blocking_progress,
|
||||||
|
card: None,
|
||||||
|
notice: None,
|
||||||
|
};
|
||||||
|
if let Some(card) = card {
|
||||||
|
let detail = detail_of(&catalog, card);
|
||||||
|
modal.body = failure_body(&catalog, &card.report);
|
||||||
|
modal.primary = catalog.text(Key::CrashDialogExport).to_string();
|
||||||
|
modal.secondary = Some(catalog.text(Key::CrashDialogClose).to_string());
|
||||||
|
modal.card = Some(CardView {
|
||||||
|
toggle: catalog
|
||||||
|
.text(if card.expanded {
|
||||||
|
Key::CrashDialogHideDetail
|
||||||
|
} else {
|
||||||
|
Key::CrashDialogShowDetail
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
expanded: card.expanded,
|
||||||
|
paths: path_rows(&catalog, &card.report),
|
||||||
|
detail_title: detail.0,
|
||||||
|
detail_lines: detail.1,
|
||||||
|
actions: [
|
||||||
|
catalog.text(Key::CrashDialogExport).to_string(),
|
||||||
|
catalog.text(Key::CrashDialogOpenFolder).to_string(),
|
||||||
|
catalog.text(Key::CrashDialogCopyPaths).to_string(),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
modal.notice = card.outcome.clone();
|
||||||
|
}
|
||||||
|
modal
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 队列当前条的最小视图(避免把 `DialogRequest` 的细节漏进页面层)。
|
||||||
|
pub(crate) struct DialogRequestView<'a> {
|
||||||
|
/// 标题。
|
||||||
|
pub title: &'a str,
|
||||||
|
/// 正文。
|
||||||
|
pub body: &'a str,
|
||||||
|
/// 主按钮。
|
||||||
|
pub primary: &'a str,
|
||||||
|
/// 次按钮。
|
||||||
|
pub secondary: Option<&'a str>,
|
||||||
|
/// 进度框不可点主按钮。
|
||||||
|
pub blocking_progress: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> DialogRequestView<'a> {
|
||||||
|
/// 从 presenter 的请求取视图。
|
||||||
|
pub(crate) fn of(request: &'a unlucky_presenter::dialogs::DialogRequest) -> Self {
|
||||||
|
Self {
|
||||||
|
title: &request.title,
|
||||||
|
body: &request.body,
|
||||||
|
primary: request.kind.primary_label(),
|
||||||
|
secondary: request.kind.secondary_label(),
|
||||||
|
blocking_progress: request.kind.is_blocking_progress(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 失败正文:退出码 + **一句人话原因** + 证据形态。
|
||||||
|
///
|
||||||
|
/// 三件事都必须有——用户投诉的原话是「找半天没找到崩溃日志」,所以第三句不是锦上添花:
|
||||||
|
/// 它要么指向游戏写的那份,要么明确说「这次没有,下面是捕获到的输出」。
|
||||||
|
fn failure_body(catalog: &Catalog, report: &CrashReport) -> Vec<String> {
|
||||||
|
let mut body = Vec::new();
|
||||||
|
if let Some(code) = report.exit_code {
|
||||||
|
body.push(catalog.render(Key::CrashDialogExitCode, &[("code", &code.to_string())]));
|
||||||
|
}
|
||||||
|
if let Some(stage) = &report.stage {
|
||||||
|
let label = stage_label(catalog, stage);
|
||||||
|
body.push(catalog.render(Key::CrashDialogStageReason, &[("stage", label)]));
|
||||||
|
} else if report.exit_code == Some(0) {
|
||||||
|
body.push(catalog.text(Key::CrashDialogSilentFailure).to_string());
|
||||||
|
} else {
|
||||||
|
body.push(catalog.text(Key::CrashDialogStartedThenDied).to_string());
|
||||||
|
}
|
||||||
|
body.push(
|
||||||
|
match report.evidence() {
|
||||||
|
Evidence::GameCrashReport => catalog.text(Key::CrashDialogCrashReportFound),
|
||||||
|
Evidence::CapturedOutput => catalog.text(Key::CrashDialogNoGameLog),
|
||||||
|
Evidence::Nothing => catalog.text(Key::CrashDialogNoLogAtAll),
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
body
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 装配阶段 slug → 文案(**不**复用 core 的中文诊断串)。
|
||||||
|
fn stage_label(catalog: &Catalog, slug: &str) -> &'static str {
|
||||||
|
let key = match slug {
|
||||||
|
"version_manifest" => Key::LaunchStageVersionManifest,
|
||||||
|
"inheritance" => Key::LaunchStageInheritance,
|
||||||
|
"instance_config" => Key::LaunchStageInstanceConfig,
|
||||||
|
"isolation" => Key::LaunchStageIsolation,
|
||||||
|
"identity" => Key::LaunchStageIdentity,
|
||||||
|
_ => Key::LaunchStageSpawn,
|
||||||
|
};
|
||||||
|
catalog.text(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 路径行(**启动器日志目录 + 该实例 latest.log + crash-reports 目录 + 会话 JSONL**)。
|
||||||
|
pub(crate) fn path_rows(catalog: &Catalog, report: &CrashReport) -> Vec<(String, String)> {
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
if let Some(log) = &report.session_log {
|
||||||
|
rows.push((
|
||||||
|
catalog.text(Key::CrashDialogSessionLog).to_string(),
|
||||||
|
log.display().to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(dir) = &report.launcher_logs {
|
||||||
|
rows.push((
|
||||||
|
catalog.text(Key::CrashDialogLauncherLogs).to_string(),
|
||||||
|
dir.display().to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(path) = report.latest_log_path() {
|
||||||
|
rows.push((
|
||||||
|
catalog.text(Key::CrashDialogGameLog).to_string(),
|
||||||
|
path.display().to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(dir) = report.crash_reports_dir() {
|
||||||
|
rows.push((
|
||||||
|
catalog.text(Key::CrashDialogCrashReports).to_string(),
|
||||||
|
dir.display().to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 详细说明:有崩溃报告贴 Description/栈片段,否则贴启动器捕获的尾巴。
|
||||||
|
fn detail_of(catalog: &Catalog, card: &CrashCard) -> (Option<String>, Vec<String>) {
|
||||||
|
let report = &card.report;
|
||||||
|
if let Some(artifact) = report
|
||||||
|
.artifacts
|
||||||
|
.iter()
|
||||||
|
.find(|artifact| artifact.source == "crash_report")
|
||||||
|
{
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
if let Some(excerpt) = &artifact.excerpt {
|
||||||
|
lines.extend(excerpt.lines().map(str::to_string));
|
||||||
|
} else {
|
||||||
|
lines.push(artifact.path.display().to_string());
|
||||||
|
}
|
||||||
|
let title = format!(
|
||||||
|
"{} {}",
|
||||||
|
catalog.text(Key::LogsDiagnosisTitle),
|
||||||
|
artifact.path.display()
|
||||||
|
);
|
||||||
|
return (Some(title), lines);
|
||||||
|
}
|
||||||
|
if report.stderr_tail.is_empty() {
|
||||||
|
return (None, Vec::new());
|
||||||
|
}
|
||||||
|
let title = catalog.render(
|
||||||
|
Key::CrashDialogCapturedTail,
|
||||||
|
&[("count", &report.stderr_tail.len().to_string())],
|
||||||
|
);
|
||||||
|
(Some(title), report.stderr_tail.clone())
|
||||||
|
}
|
||||||
@@ -14,11 +14,13 @@
|
|||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use unlucky_core::launch::crash_report::{self, ArtifactFact, CrashReport};
|
use unlucky_core::launch::crash_report::{
|
||||||
|
self, ArtifactFact, CrashReport, ReportContext, SessionAttribution,
|
||||||
|
};
|
||||||
use unlucky_i18n::catalog::Key;
|
use unlucky_i18n::catalog::Key;
|
||||||
use unlucky_i18n::Lang;
|
use unlucky_i18n::Lang;
|
||||||
use unlucky_presenter::dialogs::{DialogKind, DialogQueue, DialogSource};
|
use unlucky_presenter::dialogs::{DialogKind, DialogQueue, DialogSource};
|
||||||
use unlucky_presenter::dto::{LaunchFinished, LogOutcome};
|
use unlucky_presenter::dto::{LaunchFailureStage, LaunchFailureView, LaunchFinished, LogOutcome};
|
||||||
|
|
||||||
use super::{current, path_rows, CrashCard, DialogRequestView};
|
use super::{current, path_rows, CrashCard, DialogRequestView};
|
||||||
|
|
||||||
@@ -32,6 +34,28 @@ const FAMILIES: Families = Families {
|
|||||||
mono: "Consolas",
|
mono: "Consolas",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// 串行闸:`crash_report` 的现场是**进程级单槽**,本文件里凡是 publish 的用例都要拿它。
|
||||||
|
///
|
||||||
|
/// 不拿会怎样:A 用例 publish 自己的现场之后、读之前,B 用例插进来 publish 一份 —— A 拿到的是
|
||||||
|
/// B 的会话,断言随机红(这条守卫是 M298 加重并发用例之后补的)。
|
||||||
|
fn slot_guard() -> std::sync::MutexGuard<'static, ()> {
|
||||||
|
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||||
|
LOCK.lock().unwrap_or_else(|error| error.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次启动的现场上下文(与 presenter 里那份同形:实例 / 运行目录 / 日志目录)。
|
||||||
|
fn context() -> ReportContext {
|
||||||
|
ReportContext::new(
|
||||||
|
"survival",
|
||||||
|
"生存",
|
||||||
|
"1.20.4",
|
||||||
|
std::path::Path::new("/instances/survival"),
|
||||||
|
std::path::Path::new("/logs"),
|
||||||
|
std::time::SystemTime::now(),
|
||||||
|
"os=windows",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn headless() -> App {
|
fn headless() -> App {
|
||||||
use unlucky_presenter::mock::MockBackend;
|
use unlucky_presenter::mock::MockBackend;
|
||||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||||
@@ -197,6 +221,7 @@ fn two_failure_shapes_say_two_different_things() {
|
|||||||
/// 弹窗开/关与卡片的绑定:关掉之后两样都要空(否则下一次失败会读到上一次的现场)。
|
/// 弹窗开/关与卡片的绑定:关掉之后两样都要空(否则下一次失败会读到上一次的现场)。
|
||||||
#[test]
|
#[test]
|
||||||
fn closing_the_dialog_clears_the_card() {
|
fn closing_the_dialog_clears_the_card() {
|
||||||
|
let _slot = slot_guard();
|
||||||
let mut app = headless();
|
let mut app = headless();
|
||||||
crash_report::publish(report(false, vec!["line".to_string()]));
|
crash_report::publish(report(false, vec!["line".to_string()]));
|
||||||
let finished = LaunchFinished {
|
let finished = LaunchFinished {
|
||||||
@@ -219,6 +244,7 @@ fn closing_the_dialog_clears_the_card() {
|
|||||||
/// 成功收尾不打扰用户。
|
/// 成功收尾不打扰用户。
|
||||||
#[test]
|
#[test]
|
||||||
fn a_successful_launch_does_not_pop_anything() {
|
fn a_successful_launch_does_not_pop_anything() {
|
||||||
|
let _slot = slot_guard();
|
||||||
let mut app = headless();
|
let mut app = headless();
|
||||||
crash_report::publish(report(false, Vec::new()));
|
crash_report::publish(report(false, Vec::new()));
|
||||||
let finished = LaunchFinished {
|
let finished = LaunchFinished {
|
||||||
@@ -342,3 +368,171 @@ fn boot_facts_are_recorded_into_the_launcher_log() {
|
|||||||
|
|
||||||
#[path = "tests/export.rs"]
|
#[path = "tests/export.rs"]
|
||||||
mod export;
|
mod export;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ M298:收尾失败必须弹
|
||||||
|
|
||||||
|
/// **真收尾失败必须弹出**(M298 修的那条静默漏弹)。
|
||||||
|
///
|
||||||
|
/// 出口形状取自 `crates/presenter/src/host/launch.rs` 的 `finish()` 失败分支,逐字段对齐:
|
||||||
|
/// 进程**起来过**(有会话)、`finish()` 报错 ⇒ `outcome = SilentFail` + `failure = Some(Spawn)`。
|
||||||
|
/// 现场由 core 的 `CrashReport::before_start` 登记,会话归因 = 收尾事件里那个会话。
|
||||||
|
///
|
||||||
|
/// ⚠️ 这一条出口**拿不到退出码**:`finish()` 只在「等进程出错」或「看门狗放弃等待」时失败,
|
||||||
|
/// 两种都没有 `ExitStatus`(`pending_ms` 那条路径进程还在跑)。所以「exit_code 有值」在本出口上
|
||||||
|
/// 不成立,判据改用「报告带会话 + 进程起来过」;带退出码的那条出口见下一条用例。
|
||||||
|
///
|
||||||
|
/// **变异证明**:把 `CrashReport::before_start` 的 `session` 改回写死空串(本 mission 之前的
|
||||||
|
/// 写法),`open_failure_dialog` 的并发守卫成立并 `return` ⇒ 本用例第一段立刻红。
|
||||||
|
#[test]
|
||||||
|
fn a_finish_failure_pops_the_dialog_with_its_own_session() {
|
||||||
|
let _slot = slot_guard();
|
||||||
|
let mut app = headless();
|
||||||
|
let log = PathBuf::from("/sessions/game/9f2c.jsonl");
|
||||||
|
crash_report::publish(CrashReport::before_start(
|
||||||
|
&context(),
|
||||||
|
"spawn",
|
||||||
|
"会话写入器不可用(锁中毒)",
|
||||||
|
"spawn",
|
||||||
|
Some(SessionAttribution {
|
||||||
|
id: "9f2c",
|
||||||
|
log: Some(log.as_path()),
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
let finished = LaunchFinished {
|
||||||
|
id: "survival".to_string(),
|
||||||
|
session: "9f2c".to_string(),
|
||||||
|
outcome: LogOutcome::SilentFail,
|
||||||
|
exit_code: None,
|
||||||
|
uptime_ms: 0,
|
||||||
|
failure: Some(LaunchFailureView::new(
|
||||||
|
LaunchFailureStage::Spawn,
|
||||||
|
"会话写入器不可用(锁中毒)",
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
app.open_failure_dialog(&finished);
|
||||||
|
assert!(
|
||||||
|
app.state.dialogs.current().is_some(),
|
||||||
|
"真收尾失败必须弹框——用户原话是「找半天没找到崩溃日志」"
|
||||||
|
);
|
||||||
|
let card = app.dialog_card.as_ref().expect("卡片");
|
||||||
|
assert_eq!(
|
||||||
|
card.report.session, finished.session,
|
||||||
|
"弹的是**这一场**的现场"
|
||||||
|
);
|
||||||
|
// 现场带着会话 ⇒ 事件流路径必须出现在可复制的路径行里(不是「进程没起来」那一套)。
|
||||||
|
let modal = app.dialog_modal().expect("模型");
|
||||||
|
let paths = modal.card.as_ref().expect("卡片视图").paths.clone();
|
||||||
|
assert!(
|
||||||
|
paths.iter().any(|(_, path)| path.ends_with("9f2c.jsonl")),
|
||||||
|
"会话 JSONL 必须可复制:{paths:?}"
|
||||||
|
);
|
||||||
|
crash_report::clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 带**退出码**的那条出口(真崩溃)照旧要弹:报告的 `session` 与收尾事件同源。
|
||||||
|
///
|
||||||
|
/// **变异证明**:把 `CrashReport::collect` 里的 `session: session.session_id.clone()` 改成空串,
|
||||||
|
/// 本用例红(并发守卫把"自己这一场"判成别人的)。
|
||||||
|
#[test]
|
||||||
|
fn a_crashed_launch_with_an_exit_code_pops_with_its_evidence() {
|
||||||
|
let _slot = slot_guard();
|
||||||
|
let mut app = headless();
|
||||||
|
crash_report::publish(report(true, vec!["Exception in thread main".to_string()]));
|
||||||
|
let finished = LaunchFinished {
|
||||||
|
id: "survival".to_string(),
|
||||||
|
session: "9f2c".to_string(),
|
||||||
|
outcome: LogOutcome::Crashed,
|
||||||
|
exit_code: Some(1),
|
||||||
|
uptime_ms: 1_000,
|
||||||
|
failure: None,
|
||||||
|
};
|
||||||
|
app.open_failure_dialog(&finished);
|
||||||
|
assert!(app.state.dialogs.current().is_some(), "真崩溃必须弹框");
|
||||||
|
let modal = app.dialog_modal().expect("模型");
|
||||||
|
assert!(
|
||||||
|
modal.body_text().contains("退出码 1"),
|
||||||
|
"正文必须带退出码:{}",
|
||||||
|
modal.body_text()
|
||||||
|
);
|
||||||
|
crash_report::clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **并发守卫的原意不许被松掉**:登记的是另一场 ⇒ 不许拿它解释这一场。
|
||||||
|
///
|
||||||
|
/// 这条与上一条是一对:修「自己这一场被误判」不能靠删守卫(删了这条就红)。
|
||||||
|
#[test]
|
||||||
|
fn a_report_from_another_session_is_never_reused() {
|
||||||
|
let _slot = slot_guard();
|
||||||
|
let mut app = headless();
|
||||||
|
crash_report::publish(CrashReport::before_start(
|
||||||
|
&context(),
|
||||||
|
"spawn",
|
||||||
|
"另一场的现场",
|
||||||
|
"spawn",
|
||||||
|
Some(SessionAttribution {
|
||||||
|
id: "aaaa",
|
||||||
|
log: None,
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
let finished = LaunchFinished {
|
||||||
|
id: "survival".to_string(),
|
||||||
|
session: "9f2c".to_string(),
|
||||||
|
outcome: LogOutcome::SilentFail,
|
||||||
|
exit_code: None,
|
||||||
|
uptime_ms: 0,
|
||||||
|
failure: Some(LaunchFailureView::new(LaunchFailureStage::Spawn, "x")),
|
||||||
|
};
|
||||||
|
app.open_failure_dialog(&finished);
|
||||||
|
assert!(
|
||||||
|
app.state.dialogs.current().is_none(),
|
||||||
|
"A 场的现场不许拿来解释 B 场"
|
||||||
|
);
|
||||||
|
crash_report::clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 没有会话的两个出口(装配失败 / spawn 失败)**照旧要弹**:那里空串是事实,守卫不该拦它。
|
||||||
|
///
|
||||||
|
/// 抓得住:为修「收尾失败被误判」而把守卫整条删掉 / 或把空串也编一个 id 出来(后者会让
|
||||||
|
/// 装配失败的现场指向一个不存在的会话)。
|
||||||
|
#[test]
|
||||||
|
fn a_session_less_failure_still_pops() {
|
||||||
|
let _slot = slot_guard();
|
||||||
|
for (stage, detail) in [
|
||||||
|
(LaunchFailureStage::VersionManifest, "缺 1.20.4.json"),
|
||||||
|
(LaunchFailureStage::Spawn, "javaw.exe 不存在"),
|
||||||
|
] {
|
||||||
|
let mut app = headless();
|
||||||
|
crash_report::publish(CrashReport::before_start(
|
||||||
|
&context(),
|
||||||
|
stage.slug(),
|
||||||
|
detail,
|
||||||
|
stage.slug(),
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
let finished = LaunchFinished {
|
||||||
|
id: "survival".to_string(),
|
||||||
|
session: String::new(),
|
||||||
|
outcome: LogOutcome::Crashed,
|
||||||
|
exit_code: None,
|
||||||
|
uptime_ms: 0,
|
||||||
|
failure: Some(LaunchFailureView::new(stage, detail)),
|
||||||
|
};
|
||||||
|
app.open_failure_dialog(&finished);
|
||||||
|
assert!(
|
||||||
|
app.state.dialogs.current().is_some(),
|
||||||
|
"{stage:?} 出口必须弹框(这条出口的 session 为空是事实)"
|
||||||
|
);
|
||||||
|
let card = app.dialog_card.as_ref().expect("卡片");
|
||||||
|
assert!(card.report.session.is_empty(), "{stage:?}");
|
||||||
|
assert_eq!(
|
||||||
|
card.report.exit_code, None,
|
||||||
|
"{stage:?}: 没有退出码就是 None"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
card.report.stage.as_deref(),
|
||||||
|
Some(stage.slug()),
|
||||||
|
"{stage:?}"
|
||||||
|
);
|
||||||
|
crash_report::clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,10 @@ mod boot_probe;
|
|||||||
mod construct;
|
mod construct;
|
||||||
mod mods_shot;
|
mod mods_shot;
|
||||||
mod refresh;
|
mod refresh;
|
||||||
|
// M298: toast 的动效时间轴(进/出进度;`view` 只采样)
|
||||||
|
pub(super) mod toast;
|
||||||
|
// M298: 出图取证用的 Toast 夹具(`ULL_TOAST_FIXTURE`)
|
||||||
|
mod toast_shot;
|
||||||
// M235: 出图取证用的 SAVES 夹具(`ULL_SAVES_FIXTURE=1`)
|
// M235: 出图取证用的 SAVES 夹具(`ULL_SAVES_FIXTURE=1`)
|
||||||
mod saves_shot;
|
mod saves_shot;
|
||||||
mod theme;
|
mod theme;
|
||||||
@@ -229,6 +233,11 @@ pub struct App {
|
|||||||
/// 为什么不像页面那样现算:`Element` 借字符串,现算的模型是局部变量,`view()` 一返回就
|
/// 为什么不像页面那样现算:`Element` 借字符串,现算的模型是局部变量,`view()` 一返回就
|
||||||
/// 悬空(编译期就过不去)。与 `view` / `account` 两张缓存同一条理由、同一个门控。
|
/// 悬空(编译期就过不去)。与 `view` / `account` 两张缓存同一条理由、同一个门控。
|
||||||
pub(super) dialog_model: Option<super::dialog::Modal>,
|
pub(super) dialog_model: Option<super::dialog::Modal>,
|
||||||
|
/// Toast 的动效时间轴(M298):每条提示的出生时刻 + 这一拍的进/出进度。
|
||||||
|
///
|
||||||
|
/// 为什么住在 `App` 而不是 presenter:它是**动效**(这一帧该缩放多少),不是业务事实。
|
||||||
|
/// 事实(文案 / 语义色 / 剩余寿命)全在 `state.toasts`,这边只记时间。
|
||||||
|
pub(super) toast_tracks: toast::ToastTracks,
|
||||||
/// 皮肤/头像解码缓存(M114:同一张皮肤只解一次)。
|
/// 皮肤/头像解码缓存(M114:同一张皮肤只解一次)。
|
||||||
pub(super) avatars: pages::account::avatar::Cache,
|
pub(super) avatars: pages::account::avatar::Cache,
|
||||||
/// 上一次**真下发过**的圆角判定指纹(M116 r1 复审 P2-4:判定没变就不重下、不重记日志)。
|
/// 上一次**真下发过**的圆角判定指纹(M116 r1 复审 P2-4:判定没变就不重下、不重记日志)。
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ impl App {
|
|||||||
mods_failed_seen: None,
|
mods_failed_seen: None,
|
||||||
dialog_card: None,
|
dialog_card: None,
|
||||||
dialog_model: None,
|
dialog_model: None,
|
||||||
|
toast_tracks: super::toast::ToastTracks::default(),
|
||||||
content: pages::content::PageState::default(),
|
content: pages::content::PageState::default(),
|
||||||
account_form: pages::account::Form::new(),
|
account_form: pages::account::Form::new(),
|
||||||
account: pages::account::Page::build(
|
account: pages::account::Page::build(
|
||||||
@@ -182,6 +183,8 @@ impl App {
|
|||||||
// 下一个人据此分得清「探测错了」还是「探测对了但没用上」。
|
// 下一个人据此分得清「探测错了」还是「探测对了但没用上」。
|
||||||
app.lang.log();
|
app.lang.log();
|
||||||
app.lang.log_cjk();
|
app.lang.log_cjk();
|
||||||
|
// M298:出图取证用的 Toast 夹具(`ULL_TOAST_FIXTURE`;产品路径读不到这个变量)。
|
||||||
|
super::toast_shot::seed(&mut app);
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,6 +119,18 @@ impl App {
|
|||||||
}
|
}
|
||||||
self.view_dirty = false;
|
self.view_dirty = false;
|
||||||
self.refreshes += 1;
|
self.refreshes += 1;
|
||||||
|
// M298:toast 的进/出场也要「每帧一次」——而 `refresh_view` 只在 `view_dirty` 时跑。
|
||||||
|
// 于是这里把「还有提示在动」翻译成**再续一帧**(`view_dirty` 重新置位);静止在位之后
|
||||||
|
// 它回到 `false`,与改造前一样不白重建(M192 的「空闲不烧帧」口径不变)。
|
||||||
|
self.view_dirty = self.sync_toasts();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// toast 的动效时间轴采样(**update 侧唯一入口**,见 `state/toast.rs` 的模块文档)。
|
||||||
|
///
|
||||||
|
/// 返回 `true` = 还有提示在进/出场,这一帧要续。
|
||||||
|
fn sync_toasts(&mut self) -> bool {
|
||||||
|
let toasts = self.state.toasts.items();
|
||||||
|
self.toast_tracks.observe(toasts, std::time::Instant::now())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 收掉「被确认的世界已经不在快照里」的那个删除确认框(M247,复审 r1 P2-1)。
|
/// 收掉「被确认的世界已经不在快照里」的那个删除确认框(M247,复审 r1 P2-1)。
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
//! Toast 的**动效时间轴**(M298):一条提示的进场 / 退场进度从哪来。
|
||||||
|
//!
|
||||||
|
//! ## 为什么不新造一条轨道
|
||||||
|
//!
|
||||||
|
//! `components::motion` 里的帧泵只认三类轨道(页面 / 模态 / 右栗折叠),而 toasts 的寿命不归
|
||||||
|
//! 它管:`ToastCenter` 已经按 33 ms 一拍递减 `remaining_ms`(`app/update/clock.rs`),到期自己
|
||||||
|
//! 消失。再往帧泵里塞一条「toast 轨道」等于把**同一件事的时钟记两遍**,两边一旦不同步就是
|
||||||
|
//! 「提示还在、动画已经结束」。
|
||||||
|
//!
|
||||||
|
//! 所以这里只做一件帧泵不做的事:记住**每一条是什么时候出现的**(`born`),然后每拍按 toast
|
||||||
|
//! 自己的两个已知量采样进度——`born` 到现在的时长(进场)与 `remaining_ms`(退场)。
|
||||||
|
//! 时长档与缓动档**复用模态那一对腿**([`motion::modal_spec`]):toast 与对话框同属 M3 的
|
||||||
|
//! 「用户刚做了一件事的回应」短档,没有第二条曲线可言。
|
||||||
|
//!
|
||||||
|
//! ## 为什么采样发生在 `update` 侧
|
||||||
|
//!
|
||||||
|
//! 与帧泵同一条纪律(`components::motion` 模块文档的五条契约):`view` 只采样、不起跑。这里
|
||||||
|
//! 的 [`App::sync_toasts`](super::App) 在 [`App::refresh_view`](super::App::refresh_view) 里跑,
|
||||||
|
//! 结果是「这一帧每条该缩放多少」——`view` 拿到的只是一份只读快照。
|
||||||
|
//!
|
||||||
|
//! ## 关掉动效 = 一帧都不动
|
||||||
|
//!
|
||||||
|
//! `MotionScale::NONE`(截图通道 / CPU 档 / 系统「减少动效」)时进度**恒为 1.0**,且
|
||||||
|
//! [`ToastTracks::observe`] 返回 `false` ⇒ 不会为了动画续帧。出图因此可复现(D30)。
|
||||||
|
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use unlucky_presenter::toast::{Toast, ToastId};
|
||||||
|
|
||||||
|
use crate::app::components::motion;
|
||||||
|
|
||||||
|
/// 一条 toast 的动效记录。
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct Entry {
|
||||||
|
/// toast id(`ToastCenter` 重新编号,进程内唯一)。
|
||||||
|
id: ToastId,
|
||||||
|
/// 第一次被看见的时刻。
|
||||||
|
born: Instant,
|
||||||
|
/// 这一拍算出来的进度(`0` = 刚起手 / 正在退场尾声,`1` = 完全在位)。
|
||||||
|
progress: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// toast 的动效时间轴(挂在 [`App`](super::App) 上,`Default` = 空)。
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub(crate) struct ToastTracks {
|
||||||
|
entries: Vec<Entry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToastTracks {
|
||||||
|
/// 这一拍动没动过(`false` = 全都静止在位,不必续帧)。
|
||||||
|
///
|
||||||
|
/// **update 侧每拍调一次**:登记新出现的、忘掉已经消失的、重算每条进度。
|
||||||
|
pub(crate) fn observe(&mut self, toasts: &[Toast], now: Instant) -> bool {
|
||||||
|
self.entries
|
||||||
|
.retain(|entry| toasts.iter().any(|toast| toast.id == entry.id));
|
||||||
|
let spec = motion::modal_spec();
|
||||||
|
let scale = motion::scale();
|
||||||
|
let mut moving = false;
|
||||||
|
for toast in toasts {
|
||||||
|
let born = match self.entries.iter().find(|entry| entry.id == toast.id) {
|
||||||
|
Some(entry) => entry.born,
|
||||||
|
None => {
|
||||||
|
self.entries.push(Entry {
|
||||||
|
id: toast.id,
|
||||||
|
born: now,
|
||||||
|
progress: 1.0,
|
||||||
|
});
|
||||||
|
now
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 关着动效:进度就是终值,且**不**算「在动」(不续帧、不出图差异)。
|
||||||
|
let progress = if scale.is_off() {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
let (progress, in_flight) = sample(toast, born, now, &spec, scale);
|
||||||
|
moving |= in_flight;
|
||||||
|
progress
|
||||||
|
};
|
||||||
|
if let Some(entry) = self.entries.iter_mut().find(|entry| entry.id == toast.id) {
|
||||||
|
entry.progress = progress;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
moving
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 这一条当前该缩放成什么样(`0..=1`;没登记过 = 在位,宁可不动也不许卡在中间)。
|
||||||
|
pub(crate) fn progress(&self, id: ToastId) -> f32 {
|
||||||
|
self.entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.id == id)
|
||||||
|
.map_or(1.0, |entry| entry.progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一条 toast 的进 / 出采样(纯函数,测试直接打这里)。
|
||||||
|
///
|
||||||
|
/// 进场:`born` 之后走完 `enter` 腿;退场:`remaining_ms` 掉进 `exit` 腿的时长里之后走到 0。
|
||||||
|
/// 两条腿同时跑(进场还没完就到期的极端情况取**小的那个**,于是它一开始就在退场,不会闪一下
|
||||||
|
/// 全尺寸再消失)。
|
||||||
|
fn sample(
|
||||||
|
toast: &Toast,
|
||||||
|
born: Instant,
|
||||||
|
now: Instant,
|
||||||
|
spec: &motion::ModalSpec,
|
||||||
|
scale: unlucky_design::motion::MotionScale,
|
||||||
|
) -> (f32, bool) {
|
||||||
|
let enter_ms = scale.ms(spec.enter.duration).max(1.0);
|
||||||
|
let exit_ms = scale.ms(spec.exit.duration).max(1.0);
|
||||||
|
let lived_ms = now.saturating_duration_since(born).as_secs_f32() * 1000.0;
|
||||||
|
let enter_t = (lived_ms / enter_ms).clamp(0.0, 1.0);
|
||||||
|
let exit_t = (1.0 - toast.remaining_ms as f32 / exit_ms).clamp(0.0, 1.0);
|
||||||
|
let progress = spec
|
||||||
|
.enter
|
||||||
|
.easing
|
||||||
|
.sample(enter_t)
|
||||||
|
.min(1.0 - spec.exit.easing.sample(exit_t))
|
||||||
|
.clamp(0.0, 1.0);
|
||||||
|
(progress, enter_t < 1.0 || exit_t > 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
//! **进/出两条腿的数值取证**:起点、终点、单调、以及「不动就一帧都不续」。
|
||||||
|
//!
|
||||||
|
//! 抓得住:进场方向做反(`progress` 从 1 掉到 0 即红)、退场没接上(`remaining_ms` 掉了
|
||||||
|
//! 进度却还是 1)、关掉动效仍然报「在动」(`NONE` 下 `observe` 必须返回 `false`)。
|
||||||
|
//! 抓不住:**观感**(截图见 `DevDocs/artifacts/m298/`)。
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use std::time::Duration;
|
||||||
|
use unlucky_design::motion::MotionScale;
|
||||||
|
use unlucky_presenter::toast::ToastKind;
|
||||||
|
|
||||||
|
fn toast(id: ToastId, remaining_ms: u64) -> Toast {
|
||||||
|
Toast {
|
||||||
|
id,
|
||||||
|
kind: ToastKind::Info,
|
||||||
|
text: "已取消安装".to_string(),
|
||||||
|
remaining_ms,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tracks(scale: MotionScale) -> ToastTracks {
|
||||||
|
motion::reset();
|
||||||
|
motion::configure_for_test(scale, None);
|
||||||
|
ToastTracks::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_fresh_toast_enters_from_zero_and_stays_at_one() {
|
||||||
|
let mut tracks = tracks(MotionScale::STANDARD);
|
||||||
|
let now = Instant::now();
|
||||||
|
let items = [toast(1, 4_000)];
|
||||||
|
// 第一拍:刚登记 = 进场起点。
|
||||||
|
assert!(tracks.observe(&items, now), "进场这一段必须在动");
|
||||||
|
assert!(
|
||||||
|
tracks.progress(1) < 0.05,
|
||||||
|
"刚出现必须是进场起点,实测 {}",
|
||||||
|
tracks.progress(1)
|
||||||
|
);
|
||||||
|
// 进场腿走完之后:停在 1.0,且不再报「在动」。
|
||||||
|
let later = now + Duration::from_millis(400);
|
||||||
|
assert!(!tracks.observe(&items, later), "进场走完就不该再续帧");
|
||||||
|
assert_eq!(tracks.progress(1), 1.0, "在位就必须是满进度");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_last_moments_play_the_exit_leg_down_to_zero() {
|
||||||
|
let mut tracks = tracks(MotionScale::STANDARD);
|
||||||
|
let now = Instant::now();
|
||||||
|
tracks.observe(&[toast(7, 4_000)], now);
|
||||||
|
let settled = now + Duration::from_millis(400);
|
||||||
|
tracks.observe(&[toast(7, 4_000)], settled);
|
||||||
|
assert_eq!(tracks.progress(7), 1.0);
|
||||||
|
|
||||||
|
// 寿命只剩 60 ms(小于退场腿 150 ms)⇒ 进度必须已经掉下来且仍在动。
|
||||||
|
assert!(
|
||||||
|
tracks.observe(&[toast(7, 60)], settled),
|
||||||
|
"退场这一段必须在动"
|
||||||
|
);
|
||||||
|
let mid = tracks.progress(7);
|
||||||
|
assert!(
|
||||||
|
mid < 1.0 && mid > 0.0,
|
||||||
|
"退场途中的进度应在 (0,1),实测 {mid}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 到期当拍(`remaining_ms` 归零):进度必须是 0 —— 它就在这一刻被 `expire` 抹掉,
|
||||||
|
// 中间不许留一帧「还满着」。
|
||||||
|
tracks.observe(&[toast(7, 0)], settled);
|
||||||
|
assert!(tracks.progress(7) < 0.05, "到期当拍必须归零");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn motion_off_is_bit_exact_still_and_never_asks_for_frames() {
|
||||||
|
let mut tracks = tracks(MotionScale::NONE);
|
||||||
|
let now = Instant::now();
|
||||||
|
assert!(
|
||||||
|
!tracks.observe(&[toast(1, 4_000)], now),
|
||||||
|
"关掉动效时一帧都不该续(截图 / CPU 档的可复现前提)"
|
||||||
|
);
|
||||||
|
assert_eq!(tracks.progress(1), 1.0, "NONE 下进度恒为终值");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_vanished_toast_is_forgotten() {
|
||||||
|
let mut tracks = tracks(MotionScale::STANDARD);
|
||||||
|
let now = Instant::now();
|
||||||
|
tracks.observe(&[toast(1, 4_000)], now);
|
||||||
|
assert!(tracks.observe(&[toast(2, 4_000)], now), "新的那条在进场");
|
||||||
|
assert_eq!(tracks.progress(1), 1.0, "已消失的按在位处理(不留半截)");
|
||||||
|
assert!(
|
||||||
|
!tracks.entries.iter().any(|entry| entry.id == 1),
|
||||||
|
"消失的条目必须被清掉(否则长时间常驻会线性增长)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
//! 出图取证用的 Toast 夹具(`ULL_TOAST_FIXTURE`;产品路径零影响)。
|
||||||
|
//!
|
||||||
|
//! # 它解决什么
|
||||||
|
//!
|
||||||
|
//! M298 的交付图要证明「提示真的画在窗口底部」,而产品路径的 toast 由事件推出来
|
||||||
|
//! (安装取消 / 启动收尾 / 复制成功),截图通道在第 3 帧就出图:等不到那条事件。
|
||||||
|
//! 处理方式与 `mods_shot` / `saves_shot` 一致——**状态注入**,注在「快照 → 页面模型」之前,
|
||||||
|
//! 于是覆盖层、胶囊几何、i18n 文案走的都是产品代码。
|
||||||
|
//!
|
||||||
|
//! # 边界(不要把它读成产品能力)
|
||||||
|
//!
|
||||||
|
//! - 只在环境变量存在时生效(`ULL_TOAST_FIXTURE` 非空且不为 `0`);
|
||||||
|
//! - 推的是**一条真实形状的提示**:文案由产品函数 `launch_toast` 按**生效语言**现求,
|
||||||
|
//! 不是夹具里写死的一句中文;
|
||||||
|
//! - 剩余寿命走 `ToastCenter` 的默认 TTL(4 s),过期仍由 `app/update/clock.rs` 的 `expire` 管。
|
||||||
|
|
||||||
|
use unlucky_presenter::dto::{LaunchFinished, LogOutcome};
|
||||||
|
use unlucky_presenter::toast::ToastKind;
|
||||||
|
|
||||||
|
use super::App;
|
||||||
|
|
||||||
|
/// 夹具开关是否打开(`ULL_TOAST_FIXTURE` 非空且不为 `0`)。
|
||||||
|
fn enabled() -> bool {
|
||||||
|
std::env::var("ULL_TOAST_FIXTURE")
|
||||||
|
.map(|value| {
|
||||||
|
let value = value.trim();
|
||||||
|
!value.is_empty() && value != "0"
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 推一条「游戏崩溃」的收尾提示(就是用户在真机上看到的那一句)。
|
||||||
|
pub(super) fn seed(app: &mut App) {
|
||||||
|
if !enabled() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 形状取自真实收尾事件:崩溃 + 退出码 1 + 跑了 12 秒。
|
||||||
|
let finished = LaunchFinished {
|
||||||
|
id: "survival".to_string(),
|
||||||
|
session: String::new(),
|
||||||
|
outcome: LogOutcome::Crashed,
|
||||||
|
exit_code: Some(1),
|
||||||
|
uptime_ms: 12_000,
|
||||||
|
failure: None,
|
||||||
|
};
|
||||||
|
let text = crate::app::view::instances::launch_toast(&finished, app.lang());
|
||||||
|
app.state.toasts.push(ToastKind::Error, text);
|
||||||
|
}
|
||||||
@@ -123,6 +123,9 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::ap
|
|||||||
.catalog()
|
.catalog()
|
||||||
.text(unlucky_i18n::catalog::Key::CrashDialogCopied),
|
.text(unlucky_i18n::catalog::Key::CrashDialogCopied),
|
||||||
);
|
);
|
||||||
|
// M298:提示有了渲染点之后,「推了不置脏」= 要等下一次事件才出现(拷贝成功尤其
|
||||||
|
// 刺眼:用户盯着屏幕等一句"已复制")。两条拷贝路径都在这里补上。
|
||||||
|
app.view_dirty = true;
|
||||||
iced::clipboard::write(export::paths_text(&card.report))
|
iced::clipboard::write(export::paths_text(&card.report))
|
||||||
}
|
}
|
||||||
DialogMessage::CopyPath(index) => {
|
DialogMessage::CopyPath(index) => {
|
||||||
@@ -138,6 +141,8 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::ap
|
|||||||
.catalog()
|
.catalog()
|
||||||
.text(unlucky_i18n::catalog::Key::CrashDialogCopied),
|
.text(unlucky_i18n::catalog::Key::CrashDialogCopied),
|
||||||
);
|
);
|
||||||
|
// M298:同 `CopyPaths`(推了必须置脏,否则这一条要等下一次事件)。
|
||||||
|
app.view_dirty = true;
|
||||||
iced::clipboard::write(path.display().to_string())
|
iced::clipboard::write(path.display().to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ mod account_page;
|
|||||||
pub(super) mod instances;
|
pub(super) mod instances;
|
||||||
mod logs;
|
mod logs;
|
||||||
mod rail;
|
mod rail;
|
||||||
|
mod toast;
|
||||||
mod update;
|
mod update;
|
||||||
|
|
||||||
use instances::{detail_of, runtime_status_row, supporting_line};
|
use instances::{detail_of, runtime_status_row, supporting_line};
|
||||||
@@ -186,6 +187,15 @@ impl App {
|
|||||||
self.lang(),
|
self.lang(),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
// M298:Toast 覆盖层。**接在页面树之上、模态之下**:一条提示不该盖住对话框
|
||||||
|
// (启动失败时两者会同时出现——Toast 说结局,对话框给证据),但必须盖住页面内容。
|
||||||
|
// 队列为空时 `layer` 原样返回,所以没有提示的那些帧树形与改造前逐节点相同。
|
||||||
|
let tree = toast::layer(
|
||||||
|
&self.ui,
|
||||||
|
tree,
|
||||||
|
self.state.toasts.items(),
|
||||||
|
&self.toast_tracks,
|
||||||
|
);
|
||||||
// 队列里有一条就叠一层模态(没有则原样返回)。模型由 `refresh_view` 维护。
|
// 队列里有一条就叠一层模态(没有则原样返回)。模型由 `refresh_view` 维护。
|
||||||
let tree = match self.dialog_model.as_ref() {
|
let tree = match self.dialog_model.as_ref() {
|
||||||
Some(modal) => super::dialog::view::layer(&self.ui, tree, modal),
|
Some(modal) => super::dialog::view::layer(&self.ui, tree, modal),
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
//! Toast 覆盖层(M298):`state.toasts` 的**唯一渲染点**。
|
||||||
|
//!
|
||||||
|
//! ## 缺口有多真
|
||||||
|
//!
|
||||||
|
//! M297 盘查:`state.toasts` 全仓只被 **push**(安装取消 / 启动收尾 / mod 启停失败 / 复制成功)
|
||||||
|
//! 与 **expire**(`app/update/clock.rs`),`view.rs` 与 `pages/**` 对 `toasts` 零命中 ⇒ 提示一条
|
||||||
|
//! 都看不见。与「收尾失败静默不弹框」叠在一起,启动失败可以**完全无反馈**。
|
||||||
|
//!
|
||||||
|
//! ## 这一层画什么
|
||||||
|
//!
|
||||||
|
//! MD3E 胶囊:`InverseSurface` 底、`InverseOnSurface` 字、**`ShapeScale::Full`**(高的一半 ⇒
|
||||||
|
//! 真胶囊)、贴**底部居中**、多条自下而上叠(最新的那条在最下面,与推送顺序一致)。
|
||||||
|
//! 几何取组件层 [`SnackbarGeometry`](高 52 / 横内距 16 / 竖 6 / 间距 8 / 外边距 12 / 上限宽 600),
|
||||||
|
//! 不在这里写第二份数字。**不画阴影**:D27(CPU 档禁模糊)+ 组件层 D-4 的同一口径。
|
||||||
|
//!
|
||||||
|
//! ## 进 / 出过渡:复用模态那一对腿,不新造弹簧
|
||||||
|
//!
|
||||||
|
//! 进度由 [`ToastTracks`](super::super::state::toast::ToastTracks) 在 **update 侧**采样
|
||||||
|
//! (toast 自己的出生时刻 + `remaining_ms`),这里只把进度换成缩放:`motion::modal_scale`
|
||||||
|
//! 与 [`motion::scaled`]。档位、缓动、缩放起点全是既有令牌(D31:适配层不新造数)。
|
||||||
|
//! 关掉动效时进度恒 `1.0` ⇒ 恒等变换、出图逐像素不变。
|
||||||
|
//!
|
||||||
|
//! ## 为什么这一层不吃点击
|
||||||
|
//!
|
||||||
|
//! 它是一层铺满窗口的 `container`(`Fill`/`Fill`):iced 的 `Stack` 只在子控件
|
||||||
|
//! **捕获**事件时才停止向下派发,而普通容器不捕获 ⇒ 点到胶囊以外(以及胶囊本身)的按下照样
|
||||||
|
//! 落到下面的页面树。所以这一层不提供「点掉」交互:`ToastCenter::dismiss` 今天没有调用点,
|
||||||
|
//! 到期由 `clock.rs` 的 `expire` 统一处理(任务要求:过期仍走既有那条)。
|
||||||
|
|
||||||
|
use iced::advanced::layout::{self, Layout};
|
||||||
|
use iced::advanced::renderer;
|
||||||
|
use iced::advanced::widget::{tree, Tree};
|
||||||
|
use iced::advanced::{mouse, Clipboard, Shell, Widget};
|
||||||
|
use iced::widget::{container, row, text, Space};
|
||||||
|
use iced::{
|
||||||
|
Alignment, Background, Border, Element, Event, Length, Padding, Rectangle, Size, Vector,
|
||||||
|
};
|
||||||
|
|
||||||
|
use unlucky_design::color::Role;
|
||||||
|
use unlucky_design::shape::ShapeScale;
|
||||||
|
use unlucky_design::typography::TypeRole;
|
||||||
|
use unlucky_presenter::toast::{Toast, ToastKind};
|
||||||
|
|
||||||
|
use crate::app::components::feedback::panel::SnackbarGeometry;
|
||||||
|
use crate::app::components::motion;
|
||||||
|
use crate::theme::Ui;
|
||||||
|
|
||||||
|
use super::super::message::Message;
|
||||||
|
use super::super::state::toast::ToastTracks;
|
||||||
|
|
||||||
|
/// 把 toast 覆盖层叠在 `base` 上(队列为空时原样返回 `base`)。
|
||||||
|
pub(super) fn layer<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
base: Element<'a, Message>,
|
||||||
|
toasts: &'a [Toast],
|
||||||
|
tracks: &'a ToastTracks,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
if toasts.is_empty() {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
let geometry = SnackbarGeometry::of(ui);
|
||||||
|
// 推送顺序 = 出现顺序:最早的在上,最新的贴底(MD3E 的堆叠方向)。
|
||||||
|
let mut column = iced::widget::Column::new()
|
||||||
|
.spacing(geometry.gap)
|
||||||
|
.align_x(Alignment::Center);
|
||||||
|
for toast in toasts {
|
||||||
|
column = column.push(capsule(ui, &geometry, toast, tracks.progress(toast.id)));
|
||||||
|
}
|
||||||
|
let plane = container(column)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.padding(Padding::from(geometry.margin))
|
||||||
|
.align_x(Alignment::Center)
|
||||||
|
.align_y(Alignment::End);
|
||||||
|
let texts = toasts
|
||||||
|
.iter()
|
||||||
|
.map(|toast| toast.text.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
iced::widget::stack![base, tagged(plane.into(), texts)].into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一条胶囊:语义色的点 + 一行字(高度固定,字按词折行)。
|
||||||
|
fn capsule<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
geometry: &SnackbarGeometry,
|
||||||
|
toast: &'a Toast,
|
||||||
|
progress: f32,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let height = geometry.height_for(1);
|
||||||
|
let style = ui.style(TypeRole::BodyMedium);
|
||||||
|
let surface = ui.color(Role::InverseSurface);
|
||||||
|
// 语义色**只**落在前面那个点上:底与字必须保持 inverse 那一对(否则深色档对比度会掉)。
|
||||||
|
let accent = match toast.kind {
|
||||||
|
ToastKind::Info | ToastKind::Success => Role::InversePrimary,
|
||||||
|
ToastKind::Warn | ToastKind::Error => Role::Error,
|
||||||
|
};
|
||||||
|
// 点的直径取组件层的 **间距档**(8),圆角 `Full` ⇒ 正圆;不另写一个点径数字。
|
||||||
|
let dot_side = geometry.gap;
|
||||||
|
let dot: Element<'a, Message> = container(
|
||||||
|
Space::new()
|
||||||
|
.width(Length::Fixed(dot_side))
|
||||||
|
.height(Length::Fixed(dot_side)),
|
||||||
|
)
|
||||||
|
.style(move |_theme: &iced::Theme| container::Style {
|
||||||
|
background: Some(Background::Color(ui.color(accent))),
|
||||||
|
border: Border {
|
||||||
|
radius: ui.radius(ShapeScale::Full, dot_side),
|
||||||
|
..Border::default()
|
||||||
|
},
|
||||||
|
..container::Style::default()
|
||||||
|
})
|
||||||
|
.into();
|
||||||
|
|
||||||
|
let body = row![
|
||||||
|
dot,
|
||||||
|
text(toast.text.as_str())
|
||||||
|
.size(style.size_px)
|
||||||
|
.font(ui.font_of(style))
|
||||||
|
.color(ui.color(Role::InverseOnSurface))
|
||||||
|
.width(Length::Fill),
|
||||||
|
]
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.spacing(geometry.gap);
|
||||||
|
|
||||||
|
let bar: Element<'a, Message> = container(body)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.max_width(geometry.max_width)
|
||||||
|
.height(Length::Fixed(height))
|
||||||
|
.padding(Padding {
|
||||||
|
top: geometry.pad_v,
|
||||||
|
right: geometry.pad_h,
|
||||||
|
bottom: geometry.pad_v,
|
||||||
|
left: geometry.pad_h,
|
||||||
|
})
|
||||||
|
.style(move |_theme: &iced::Theme| container::Style {
|
||||||
|
background: Some(Background::Color(surface)),
|
||||||
|
border: Border {
|
||||||
|
radius: capsule_radius(ui, height),
|
||||||
|
..Border::default()
|
||||||
|
},
|
||||||
|
..container::Style::default()
|
||||||
|
})
|
||||||
|
.into();
|
||||||
|
motion::scaled(bar, motion::modal_scale(progress))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 胶囊圆角:`ShapeScale::Full` 对高求值(= 高的一半 ⇒ 真胶囊)。
|
||||||
|
///
|
||||||
|
/// 与组件层 snackbar 的 **16** 不是同一个口径(那是规格卡的矩形提示条);MD3E 的 toast 是
|
||||||
|
/// 胶囊,任务点名要 `ShapeScale::Full`,所以这里按**高的一半**算,且只在这一处求值。
|
||||||
|
fn capsule_radius(ui: &Ui, height: f32) -> iced::border::Radius {
|
||||||
|
ui.radius(ShapeScale::Full, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 覆盖层的**自证状态**:树里带着它 = 这些提示真的进了控件树。
|
||||||
|
///
|
||||||
|
/// 为什么需要它:iced 0.14 没有「把整棵树遍历成文本」的入口(`Element` 不透出内容、
|
||||||
|
/// `Widget::children()` 只给 [`Tree`],而 `Tree` 不带文字)。所以这一层把自己的**文案清单**
|
||||||
|
/// 作为 [`Widget::state`] 交出去——用例顺着 `Tree::state` 就能读到「画出来的到底是哪几句」,
|
||||||
|
/// 而不是只看 `state.toasts.len()`(那证明不了覆盖层被接上)。
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(super) struct CapsuleState {
|
||||||
|
/// 这一帧真正画出去的文案(顺序 = 自下而上的堆叠顺序)。
|
||||||
|
///
|
||||||
|
/// 只有用例读它(产品路径把状态交给 iced 就完事),所以非测试构建里显式放过 dead_code。
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub texts: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 给覆盖层挂上自证状态(`tag` 用状态类型,见 iced 的 `container` / `text` 同一手法)。
|
||||||
|
pub(super) fn tagged<'a>(
|
||||||
|
content: Element<'a, Message>,
|
||||||
|
texts: Vec<String>,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
Element::new(Tagging { content, texts })
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Tagging<'a, Theme, Renderer> {
|
||||||
|
content: Element<'a, Message, Theme, Renderer>,
|
||||||
|
texts: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Theme, Renderer> Widget<Message, Theme, Renderer> for Tagging<'_, Theme, Renderer>
|
||||||
|
where
|
||||||
|
Renderer: iced::advanced::Renderer,
|
||||||
|
{
|
||||||
|
fn tag(&self) -> tree::Tag {
|
||||||
|
tree::Tag::of::<CapsuleState>()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state(&self) -> tree::State {
|
||||||
|
tree::State::new(CapsuleState {
|
||||||
|
texts: self.texts.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn children(&self) -> Vec<Tree> {
|
||||||
|
self.content.as_widget().children()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diff(&self, tree: &mut Tree) {
|
||||||
|
tree.state = self.state();
|
||||||
|
self.content.as_widget().diff(tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn size(&self) -> Size<Length> {
|
||||||
|
self.content.as_widget().size()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn size_hint(&self) -> Size<Length> {
|
||||||
|
self.content.as_widget().size_hint()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn layout(
|
||||||
|
&mut self,
|
||||||
|
tree: &mut Tree,
|
||||||
|
renderer: &Renderer,
|
||||||
|
limits: &layout::Limits,
|
||||||
|
) -> layout::Node {
|
||||||
|
self.content.as_widget_mut().layout(tree, renderer, limits)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(
|
||||||
|
&self,
|
||||||
|
tree: &Tree,
|
||||||
|
renderer: &mut Renderer,
|
||||||
|
theme: &Theme,
|
||||||
|
style: &renderer::Style,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
cursor: mouse::Cursor,
|
||||||
|
viewport: &Rectangle,
|
||||||
|
) {
|
||||||
|
self.content
|
||||||
|
.as_widget()
|
||||||
|
.draw(tree, renderer, theme, style, layout, cursor, viewport);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn operate(
|
||||||
|
&mut self,
|
||||||
|
tree: &mut Tree,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
renderer: &Renderer,
|
||||||
|
operation: &mut dyn iced::advanced::widget::Operation,
|
||||||
|
) {
|
||||||
|
self.content
|
||||||
|
.as_widget_mut()
|
||||||
|
.operate(tree, layout, renderer, operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(
|
||||||
|
&mut self,
|
||||||
|
tree: &mut Tree,
|
||||||
|
event: &Event,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
cursor: mouse::Cursor,
|
||||||
|
renderer: &Renderer,
|
||||||
|
clipboard: &mut dyn Clipboard,
|
||||||
|
shell: &mut Shell<'_, Message>,
|
||||||
|
viewport: &Rectangle,
|
||||||
|
) {
|
||||||
|
self.content.as_widget_mut().update(
|
||||||
|
tree, event, layout, cursor, renderer, clipboard, shell, viewport,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mouse_interaction(
|
||||||
|
&self,
|
||||||
|
tree: &Tree,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
cursor: mouse::Cursor,
|
||||||
|
viewport: &Rectangle,
|
||||||
|
renderer: &Renderer,
|
||||||
|
) -> mouse::Interaction {
|
||||||
|
self.content
|
||||||
|
.as_widget()
|
||||||
|
.mouse_interaction(tree, layout, cursor, viewport, renderer)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overlay<'a>(
|
||||||
|
&'a mut self,
|
||||||
|
tree: &'a mut Tree,
|
||||||
|
layout: Layout<'a>,
|
||||||
|
renderer: &Renderer,
|
||||||
|
viewport: &Rectangle,
|
||||||
|
translation: Vector,
|
||||||
|
) -> Option<iced::overlay::Element<'a, Message, Theme, Renderer>> {
|
||||||
|
self.content
|
||||||
|
.as_widget_mut()
|
||||||
|
.overlay(tree, layout, renderer, viewport, translation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
//! 覆盖层的守门用例(M298)。
|
||||||
|
//!
|
||||||
|
//! ## 这一组抓得住什么 / 抓不住什么(说在明处)
|
||||||
|
//!
|
||||||
|
//! | 用例 | 抓得住 | 抓不住 |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | [`a_pushed_toast_really_reaches_the_widget_tree`] | 覆盖层**接在 `view()` 上**,且真的把那句话交给了控件树(把 `view.rs` 里那行 `toast::layer(..)` 删掉即红) | 像素:胶囊长什么样(`DevDocs/artifacts/m298/toast-capsule.png` 守) |
|
||||||
|
//! | [`the_capsule_is_a_full_radius_pill`] | 圆角是 `ShapeScale::Full`(高的一半 = 26),不是组件层的 16 | 观感对不对 |
|
||||||
|
//! | [`an_empty_queue_adds_no_layer_at_all`] | 没有提示时树形与改造前**逐节点相同**(覆盖层不是常驻空壳) | —— |
|
||||||
|
//!
|
||||||
|
//! ## 为什么不是「只断 `state.toasts.len()`」
|
||||||
|
//!
|
||||||
|
//! 长度能证明队列里有东西,证明不了**有人画它**(M297 的缺口正是这样活下来的:队列里一直有
|
||||||
|
//! 数据,渲染点一处都没有)。所以这里顺着 `Widget::children()` 走真控件树,靠
|
||||||
|
//! [`CapsuleState`] 把「画出去的文案」读回来。
|
||||||
|
|
||||||
|
use iced::advanced::widget::Tree;
|
||||||
|
|
||||||
|
use unlucky_design::color::SchemeFlavor;
|
||||||
|
use unlucky_design::shape::ShapeScale;
|
||||||
|
use unlucky_design::typography::Platform;
|
||||||
|
use unlucky_presenter::mock::MockBackend;
|
||||||
|
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||||
|
use unlucky_presenter::toast::ToastKind;
|
||||||
|
|
||||||
|
use crate::app::message::Message;
|
||||||
|
use crate::app::state::App;
|
||||||
|
use crate::cli::Options;
|
||||||
|
use crate::theme::Families;
|
||||||
|
|
||||||
|
use super::{capsule_radius, CapsuleState};
|
||||||
|
|
||||||
|
const FAMILIES: Families = Families {
|
||||||
|
text: "Segoe UI",
|
||||||
|
display: "Segoe UI",
|
||||||
|
mono: "Consolas",
|
||||||
|
};
|
||||||
|
|
||||||
|
fn headless() -> App {
|
||||||
|
App::with_presenter(
|
||||||
|
Options::default(),
|
||||||
|
FAMILIES,
|
||||||
|
SchemeFlavor::Light,
|
||||||
|
Box::new(MockBackend::new()),
|
||||||
|
SettingsBridge::memory_only(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 顺着真控件树把所有 `CapsuleState` 里的文案收集出来(`Tree::state` 是 iced 给控件挂
|
||||||
|
/// 自证状态的官方位置,`container` / `text` 用它存自己的缓存)。
|
||||||
|
fn painted_texts(element: &iced::Element<'_, Message>) -> Vec<String> {
|
||||||
|
fn walk(tree: &Tree, out: &mut Vec<String>) {
|
||||||
|
if tree.tag == iced::advanced::widget::tree::Tag::of::<CapsuleState>() {
|
||||||
|
let state = tree.state.downcast_ref::<CapsuleState>();
|
||||||
|
out.extend(state.texts.iter().cloned());
|
||||||
|
}
|
||||||
|
for child in &tree.children {
|
||||||
|
walk(child, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let tree = Tree::new(element.as_widget());
|
||||||
|
let mut out = Vec::new();
|
||||||
|
walk(&tree, &mut out);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 守卫①:**push 一条 toast ⇒ 视图树里真的出现那句话**。
|
||||||
|
///
|
||||||
|
/// **变异证明**:把 `app/view.rs` 里 `let tree = toast::layer(...)` 那一行删掉 / 改成直接返回
|
||||||
|
/// `tree`,本用例第二段立刻红(`painted_texts` 拿到空表)。
|
||||||
|
#[test]
|
||||||
|
fn a_pushed_toast_really_reaches_the_widget_tree() {
|
||||||
|
let mut app = headless();
|
||||||
|
app.refresh_view();
|
||||||
|
assert!(
|
||||||
|
painted_texts(&app.view()).is_empty(),
|
||||||
|
"出厂态不该有覆盖层节点(那时队列是空的)"
|
||||||
|
);
|
||||||
|
|
||||||
|
app.state.toasts.push(ToastKind::Info, "已取消安装");
|
||||||
|
app.refresh_view();
|
||||||
|
let texts = painted_texts(&app.view());
|
||||||
|
assert!(
|
||||||
|
texts.iter().any(|text| text == "已取消安装"),
|
||||||
|
"push 之后控件树里必须有这句话——实际拿到 {texts:?}"
|
||||||
|
);
|
||||||
|
// 队列里那一条仍然是**事实来源**,覆盖层只是它的投影(不是第二份真相)。
|
||||||
|
assert_eq!(app.state.toasts.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 守卫②:胶囊是真胶囊(`ShapeScale::Full` ⇒ 高的一半)。
|
||||||
|
///
|
||||||
|
/// **变异证明**:把 `capsule` 里那一行改成 `geometry.radius`(组件层的 16),本用例立刻红。
|
||||||
|
#[test]
|
||||||
|
fn the_capsule_is_a_full_radius_pill() {
|
||||||
|
let ui = crate::theme::Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES);
|
||||||
|
let height = super::SnackbarGeometry::of(&ui).height_for(1);
|
||||||
|
let radius = capsule_radius(&ui, height);
|
||||||
|
assert_eq!(
|
||||||
|
radius,
|
||||||
|
iced::border::Radius::from(height / 2.0),
|
||||||
|
"MD3E 胶囊的圆角必须等于高的一半(ShapeScale::Full),实测高 {height}"
|
||||||
|
);
|
||||||
|
// 同一件事的第二种说法:就是 `ShapeScale::Full` 在这一档上的求值。
|
||||||
|
assert_eq!(radius, ui.radius(ShapeScale::Full, height));
|
||||||
|
// 反向守卫:组件层 snackbar 的 16 不是胶囊(改回 `geometry.radius` 时上面两条立刻红)。
|
||||||
|
assert_ne!(radius, iced::border::Radius::from(16.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 守卫③:空队列 ⇒ **不叠任何东西**(没有提示的那些帧树形与改造前一致)。
|
||||||
|
///
|
||||||
|
/// **变异证明**:把 `layer` 开头的 `if toasts.is_empty() { return base; }` 删掉,本用例立刻红
|
||||||
|
/// ——那时每帧都会多一层空容器(白白多一棵子树、也让「有没有提示」在树上不可辨)。
|
||||||
|
#[test]
|
||||||
|
fn an_empty_queue_adds_no_layer_at_all() {
|
||||||
|
fn nodes(element: &iced::Element<'_, Message>) -> usize {
|
||||||
|
fn walk(tree: &Tree) -> usize {
|
||||||
|
1 + tree.children.iter().map(walk).sum::<usize>()
|
||||||
|
}
|
||||||
|
walk(&Tree::new(element.as_widget()))
|
||||||
|
}
|
||||||
|
let mut app = headless();
|
||||||
|
app.refresh_view();
|
||||||
|
let empty = nodes(&app.view());
|
||||||
|
app.state.toasts.push(ToastKind::Info, "已取消安装");
|
||||||
|
app.refresh_view();
|
||||||
|
let with_toast = nodes(&app.view());
|
||||||
|
assert!(
|
||||||
|
with_toast > empty,
|
||||||
|
"有提示时树上必须多出覆盖层(空队列 {empty} 个节点,有提示 {with_toast} 个)"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,11 +18,17 @@
|
|||||||
//! 进程级槽位只存**已脱敏**的文本([`CrashReport::collect`] 的出口就是脱敏边界)。
|
//! 进程级槽位只存**已脱敏**的文本([`CrashReport::collect`] 的出口就是脱敏边界)。
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Mutex, OnceLock};
|
|
||||||
|
|
||||||
use super::crash_scan::{self, CrashArtifact, CrashSource};
|
use super::crash_scan::{self, CrashSource};
|
||||||
use super::launch_session::SessionHandle;
|
use super::launch_session::SessionHandle;
|
||||||
use super::redact::Redactor;
|
|
||||||
|
mod context;
|
||||||
|
mod slot;
|
||||||
|
|
||||||
|
pub use context::ReportContext;
|
||||||
|
pub use slot::{clear, failing_launch, publish};
|
||||||
|
|
||||||
|
use context::{fact, scrub_tail};
|
||||||
|
|
||||||
/// 弹窗里贴「捕获输出」的行数上限(够看清 JVM 报错,又不把弹窗撑破)。
|
/// 弹窗里贴「捕获输出」的行数上限(够看清 JVM 报错,又不把弹窗撑破)。
|
||||||
pub const TAIL_LINES: usize = 30;
|
pub const TAIL_LINES: usize = 30;
|
||||||
@@ -83,6 +89,19 @@ pub struct CrashReport {
|
|||||||
pub stderr_tail: Vec<String>,
|
pub stderr_tail: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 「进程确实起来过」的那一场会话归因([`CrashReport::before_start`] 的可选入参)。
|
||||||
|
///
|
||||||
|
/// 只带两样东西:**会话 id**(必须与收尾事件里的 `session` 是同一个值)与事件流落点。
|
||||||
|
/// 收尾失败时进程真的起来过,所以这份现场**有主人**;写成空串等于说「这场失败不属于任何人」,
|
||||||
|
/// 界面侧只能把它丢掉(M298)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct SessionAttribution<'a> {
|
||||||
|
/// 会话 id。
|
||||||
|
pub id: &'a str,
|
||||||
|
/// 该会话的事件流 JSONL(`None` = 拿不到落点,仍然记得住归属)。
|
||||||
|
pub log: Option<&'a std::path::Path>,
|
||||||
|
}
|
||||||
|
|
||||||
/// 证据形态(界面按它选 i18n key,**不许**再自己判断)。
|
/// 证据形态(界面按它选 i18n key,**不许**再自己判断)。
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Evidence {
|
pub enum Evidence {
|
||||||
@@ -208,19 +227,39 @@ impl CrashReport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 收集一次**进程没起来**的现场(装配 / 起进程阶段失败)。
|
/// 收集一次**没有 `SessionHandle`** 的现场。
|
||||||
|
///
|
||||||
|
/// 两个用法,判据是同一个参数(`session`):
|
||||||
|
///
|
||||||
|
/// | 出口 | `session` | 为什么 |
|
||||||
|
/// |---|---|---|
|
||||||
|
/// | 装配失败 / spawn 失败 | `None` | 进程根本没起来,这一场**确实**没有会话 |
|
||||||
|
/// | 收尾失败(`finish()` 报错) | `Some(归因)` | 进程起来过,这次失败属于那一场 |
|
||||||
|
///
|
||||||
|
/// 第二种是 M298 修的缺口:此前两个出口都写空串,而界面侧的并发守卫
|
||||||
|
/// (`CrashReport::session != LaunchFinished::session`)会因此把「自己这一场」判成
|
||||||
|
/// 「别人的报告」而**静默不弹**。归因必须由调用方给(只有它知道是哪一场),本函数只负责
|
||||||
|
/// 把它原样记进报告——两边同源,守卫才可能判对。
|
||||||
///
|
///
|
||||||
/// 这种失败最常见的形态就是「找不到 crash-report」——游戏根本没被执行到,所以
|
/// 这种失败最常见的形态就是「找不到 crash-report」——游戏根本没被执行到,所以
|
||||||
/// [`Evidence`] 落到 [`Evidence::Nothing`],界面必须说「游戏还没来得及写日志」。
|
/// [`Evidence`] 落到 [`Evidence::Nothing`],界面必须说「游戏还没来得及写日志」。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn before_start(ctx: &ReportContext, stage: &str, detail: &str, exit_type: &str) -> Self {
|
pub fn before_start(
|
||||||
|
ctx: &ReportContext,
|
||||||
|
stage: &str,
|
||||||
|
detail: &str,
|
||||||
|
exit_type: &str,
|
||||||
|
session: Option<SessionAttribution<'_>>,
|
||||||
|
) -> Self {
|
||||||
let redactor = ctx.redactor();
|
let redactor = ctx.redactor();
|
||||||
|
let session_id = session.map_or_else(String::new, |it| it.id.to_string());
|
||||||
|
let session_log = session.and_then(|it| it.log.map(PathBuf::from));
|
||||||
Self {
|
Self {
|
||||||
instance: ctx.instance.clone(),
|
instance: ctx.instance.clone(),
|
||||||
name: ctx.name.clone(),
|
name: ctx.name.clone(),
|
||||||
version: ctx.version.clone(),
|
version: ctx.version.clone(),
|
||||||
session: String::new(),
|
session: session_id,
|
||||||
session_log: None,
|
session_log,
|
||||||
version_json: ctx.version_json.clone(),
|
version_json: ctx.version_json.clone(),
|
||||||
launcher_logs: Some(ctx.launcher_logs.clone()),
|
launcher_logs: Some(ctx.launcher_logs.clone()),
|
||||||
run_dir: Some(ctx.run_dir.clone()),
|
run_dir: Some(ctx.run_dir.clone()),
|
||||||
@@ -275,120 +314,5 @@ impl CrashReport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`CrashReport::collect`] / [`CrashReport::before_start`] 的输入(全是启动侧已有的东西)。
|
|
||||||
///
|
|
||||||
/// 字段是**拥有的**:构造一次、在四个失败出口上反复用(借用版会让每个出口都要重算一遍
|
|
||||||
/// 主目录与脱敏器,而这条路径低频、克隆代价可以忽略)。
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ReportContext {
|
|
||||||
/// 实例 id。
|
|
||||||
pub instance: String,
|
|
||||||
/// 实例名。
|
|
||||||
pub name: String,
|
|
||||||
/// 版本 id。
|
|
||||||
pub version: String,
|
|
||||||
/// 实例运行目录。
|
|
||||||
pub run_dir: PathBuf,
|
|
||||||
/// 启动器日志目录(`.ULL/logs`)。
|
|
||||||
pub launcher_logs: PathBuf,
|
|
||||||
/// 本次会话的开始时刻(现场文件的时间闸)。
|
|
||||||
pub started_at: std::time::SystemTime,
|
|
||||||
/// 该实例绑定的版本 JSON。
|
|
||||||
pub version_json: Option<PathBuf>,
|
|
||||||
/// Java 一行说明。
|
|
||||||
pub java: String,
|
|
||||||
/// 系统一行说明。
|
|
||||||
pub system: String,
|
|
||||||
/// 要额外抹掉的串(会话 id、access token 等)。
|
|
||||||
pub secrets: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ReportContext {
|
|
||||||
/// 建一份(`java` 可以后补:装配阶段还没选 Java)。
|
|
||||||
#[must_use]
|
|
||||||
pub fn new(
|
|
||||||
instance: impl Into<String>,
|
|
||||||
name: impl Into<String>,
|
|
||||||
version: impl Into<String>,
|
|
||||||
run_dir: impl Into<PathBuf>,
|
|
||||||
launcher_logs: impl Into<PathBuf>,
|
|
||||||
started_at: std::time::SystemTime,
|
|
||||||
system: impl Into<String>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
instance: instance.into(),
|
|
||||||
name: name.into(),
|
|
||||||
version: version.into(),
|
|
||||||
run_dir: run_dir.into(),
|
|
||||||
launcher_logs: launcher_logs.into(),
|
|
||||||
started_at,
|
|
||||||
version_json: None,
|
|
||||||
java: String::new(),
|
|
||||||
system: system.into(),
|
|
||||||
secrets: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn redactor(&self) -> Redactor {
|
|
||||||
let mut redactor = Redactor::new();
|
|
||||||
for key in ["USERPROFILE", "HOME"] {
|
|
||||||
if let Ok(home) = std::env::var(key) {
|
|
||||||
if !home.trim().is_empty() {
|
|
||||||
redactor = redactor.with_home_str(home);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for secret in &self.secrets {
|
|
||||||
redactor = redactor.with_secret(secret.clone());
|
|
||||||
}
|
|
||||||
redactor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn scrub_tail(lines: &[String], redactor: &Redactor) -> Vec<String> {
|
|
||||||
lines
|
|
||||||
.iter()
|
|
||||||
.map(|line| redactor.scrub(line).into_owned())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fact(artifact: &CrashArtifact, redactor: &Redactor) -> ArtifactFact {
|
|
||||||
let excerpt = artifact.excerpt.as_ref().map(|text| {
|
|
||||||
let scrubbed = redactor.scrub(text);
|
|
||||||
let trimmed: String = scrubbed.chars().take(MAX_EXCERPT_CHARS).collect();
|
|
||||||
trimmed
|
|
||||||
});
|
|
||||||
ArtifactFact {
|
|
||||||
source: artifact.source.as_str().to_string(),
|
|
||||||
path: artifact.path.clone(),
|
|
||||||
excerpt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn slot() -> &'static Mutex<Option<CrashReport>> {
|
|
||||||
static SLOT: OnceLock<Mutex<Option<CrashReport>>> = OnceLock::new();
|
|
||||||
SLOT.get_or_init(|| Mutex::new(None))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 登记一次失败启动的现场(启动线程收尾时调用)。
|
|
||||||
pub fn publish(report: CrashReport) {
|
|
||||||
if let Ok(mut guard) = slot().lock() {
|
|
||||||
*guard = Some(report);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 读最近一次失败启动的现场。
|
|
||||||
#[must_use]
|
|
||||||
pub fn failing_launch() -> Option<CrashReport> {
|
|
||||||
slot().lock().ok().and_then(|guard| guard.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 清空槽位(测试用)。
|
|
||||||
pub fn clear() {
|
|
||||||
if let Ok(mut guard) = slot().lock() {
|
|
||||||
*guard = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
//! 现场的**输入**与脱敏(M298 从 `crash_report.rs` 拆出,纯搬移)。
|
||||||
|
//!
|
||||||
|
//! 这个文件只回答两件事:①收集一次现场要知道哪几样事实([`ReportContext`]);
|
||||||
|
//! ②它们在被写进 [`CrashReport`](super::CrashReport) 之前必须先过哪一道脱敏闸
|
||||||
|
//! ([`fact`] / [`scrub_tail`])。
|
||||||
|
//!
|
||||||
|
//! 脱敏器**不缓存**:每次 [`ReportContext::redactor`] 都重新读一遍主目录环境变量。这条路径
|
||||||
|
//! 只在启动收尾时走一次,缓存省不下什么,却会引入「进程启动后 HOME 变了」的第二份真相。
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use super::super::crash_scan::CrashArtifact;
|
||||||
|
use super::super::redact::Redactor;
|
||||||
|
use super::{ArtifactFact, MAX_EXCERPT_CHARS};
|
||||||
|
|
||||||
|
/// [`CrashReport::collect`](super::CrashReport::collect) /
|
||||||
|
/// [`CrashReport::before_start`](super::CrashReport::before_start) 的输入(全是启动侧已有的东西)。
|
||||||
|
///
|
||||||
|
/// 字段是**拥有的**:构造一次、在四个失败出口上反复用(借用版会让每个出口都要重算一遍
|
||||||
|
/// 主目录与脱敏器,而这条路径低频、克隆代价可以忽略)。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ReportContext {
|
||||||
|
/// 实例 id。
|
||||||
|
pub instance: String,
|
||||||
|
/// 实例名。
|
||||||
|
pub name: String,
|
||||||
|
/// 版本 id。
|
||||||
|
pub version: String,
|
||||||
|
/// 实例运行目录。
|
||||||
|
pub run_dir: PathBuf,
|
||||||
|
/// 启动器日志目录(`.ULL/logs`)。
|
||||||
|
pub launcher_logs: PathBuf,
|
||||||
|
/// 本次会话的开始时刻(现场文件的时间闸)。
|
||||||
|
pub started_at: std::time::SystemTime,
|
||||||
|
/// 该实例绑定的版本 JSON。
|
||||||
|
pub version_json: Option<PathBuf>,
|
||||||
|
/// Java 一行说明。
|
||||||
|
pub java: String,
|
||||||
|
/// 系统一行说明。
|
||||||
|
pub system: String,
|
||||||
|
/// 要额外抹掉的串(会话 id、access token 等)。
|
||||||
|
pub secrets: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReportContext {
|
||||||
|
/// 建一份(`java` 可以后补:装配阶段还没选 Java)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
instance: impl Into<String>,
|
||||||
|
name: impl Into<String>,
|
||||||
|
version: impl Into<String>,
|
||||||
|
run_dir: impl Into<PathBuf>,
|
||||||
|
launcher_logs: impl Into<PathBuf>,
|
||||||
|
started_at: std::time::SystemTime,
|
||||||
|
system: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
instance: instance.into(),
|
||||||
|
name: name.into(),
|
||||||
|
version: version.into(),
|
||||||
|
run_dir: run_dir.into(),
|
||||||
|
launcher_logs: launcher_logs.into(),
|
||||||
|
started_at,
|
||||||
|
version_json: None,
|
||||||
|
java: String::new(),
|
||||||
|
system: system.into(),
|
||||||
|
secrets: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本次现场用的脱敏器(主目录 + `secrets` 两把刀)。
|
||||||
|
pub(super) fn redactor(&self) -> Redactor {
|
||||||
|
let mut redactor = Redactor::new();
|
||||||
|
for key in ["USERPROFILE", "HOME"] {
|
||||||
|
if let Ok(home) = std::env::var(key) {
|
||||||
|
if !home.trim().is_empty() {
|
||||||
|
redactor = redactor.with_home_str(home);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for secret in &self.secrets {
|
||||||
|
redactor = redactor.with_secret(secret.clone());
|
||||||
|
}
|
||||||
|
redactor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 捕获输出尾巴的脱敏(逐行过同一把刀)。
|
||||||
|
pub(super) fn scrub_tail(lines: &[String], redactor: &Redactor) -> Vec<String> {
|
||||||
|
lines
|
||||||
|
.iter()
|
||||||
|
.map(|line| redactor.scrub(line).into_owned())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 现场文件 → 报告里的一条事实(摘要抹平 + 截断到 [`MAX_EXCERPT_CHARS`])。
|
||||||
|
pub(super) fn fact(artifact: &CrashArtifact, redactor: &Redactor) -> ArtifactFact {
|
||||||
|
let excerpt = artifact.excerpt.as_ref().map(|text| {
|
||||||
|
let scrubbed = redactor.scrub(text);
|
||||||
|
let trimmed: String = scrubbed.chars().take(MAX_EXCERPT_CHARS).collect();
|
||||||
|
trimmed
|
||||||
|
});
|
||||||
|
ArtifactFact {
|
||||||
|
source: artifact.source.as_str().to_string(),
|
||||||
|
path: artifact.path.clone(),
|
||||||
|
excerpt,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
//! 进程级**单槽**:最近一次失败启动的现场(M298 从 `crash_report.rs` 拆出,纯搬移)。
|
||||||
|
//!
|
||||||
|
//! ## 为什么是一格而不是一条队列
|
||||||
|
//!
|
||||||
|
//! 失败弹窗的消费方是「收尾事件到达的那一拍」——一个 [`LaunchFinished`] 只解释**一场**失败。
|
||||||
|
//! 多存几份没有读者,反而会让「这份现场是哪一场的」变成要靠时间猜的问题。
|
||||||
|
//!
|
||||||
|
//! 归属的正确性**不靠这个槽**:报告自己带 `session`,界面侧的并发守卫拿它与收尾事件比
|
||||||
|
//! (见 `crates/app-iced/src/app/dialog.rs`;M298 把 session 归属做成单一来源)。
|
||||||
|
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
use super::CrashReport;
|
||||||
|
|
||||||
|
fn slot() -> &'static Mutex<Option<CrashReport>> {
|
||||||
|
static SLOT: OnceLock<Mutex<Option<CrashReport>>> = OnceLock::new();
|
||||||
|
SLOT.get_or_init(|| Mutex::new(None))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登记一次失败启动的现场(启动线程收尾时调用)。
|
||||||
|
pub fn publish(report: CrashReport) {
|
||||||
|
if let Ok(mut guard) = slot().lock() {
|
||||||
|
*guard = Some(report);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读最近一次失败启动的现场。
|
||||||
|
#[must_use]
|
||||||
|
pub fn failing_launch() -> Option<CrashReport> {
|
||||||
|
slot().lock().ok().and_then(|guard| guard.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空槽位(测试用)。
|
||||||
|
pub fn clear() {
|
||||||
|
if let Ok(mut guard) = slot().lock() {
|
||||||
|
*guard = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use super::{clear, failing_launch, publish, ArtifactFact, CrashReport, Evidence, ReportContext};
|
use super::{
|
||||||
|
clear, failing_launch, publish, ArtifactFact, CrashReport, Evidence, ReportContext,
|
||||||
|
SessionAttribution,
|
||||||
|
};
|
||||||
|
|
||||||
fn report(artifacts: Vec<ArtifactFact>, tail: Vec<String>) -> CrashReport {
|
fn report(artifacts: Vec<ArtifactFact>, tail: Vec<String>) -> CrashReport {
|
||||||
CrashReport {
|
CrashReport {
|
||||||
@@ -104,6 +107,7 @@ fn before_start_marks_the_launch_as_never_started_and_scrubs_the_detail() {
|
|||||||
"spawn",
|
"spawn",
|
||||||
&format!("{home}/java/bin/javaw.exe 不存在"),
|
&format!("{home}/java/bin/javaw.exe 不存在"),
|
||||||
"spawn",
|
"spawn",
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
assert!(!report.started(), "进程没起来");
|
assert!(!report.started(), "进程没起来");
|
||||||
assert_eq!(report.evidence(), Evidence::Nothing);
|
assert_eq!(report.evidence(), Evidence::Nothing);
|
||||||
@@ -123,3 +127,43 @@ fn slot_keeps_the_last_failure_only() {
|
|||||||
clear();
|
clear();
|
||||||
assert!(failing_launch().is_none());
|
assert!(failing_launch().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// M298:收尾失败出口的现场**必须记得自己是哪一场**。
|
||||||
|
///
|
||||||
|
/// **变异证明**:把 `before_start` 里的 `session` 参数改回写死 `String::new()`(本 mission 修之前
|
||||||
|
/// 的写法),本用例第一段立刻红。产品上那条变异的表现是「真收尾失败静默不弹框」——
|
||||||
|
/// 界面侧的并发守卫会拿空串去和收尾事件里的真实会话比,判成「别人的报告」后 `return`。
|
||||||
|
///
|
||||||
|
/// 第二段是方向相反的守卫:装配失败 / spawn 失败**确实**没有会话,那里不许为了"凑一个非空"
|
||||||
|
/// 而编一个 id 出来(那会让那些出口的现场指向不存在的会话)。
|
||||||
|
#[test]
|
||||||
|
fn a_finish_failure_report_keeps_its_session() {
|
||||||
|
let ctx = ReportContext::new(
|
||||||
|
"survival",
|
||||||
|
"生存",
|
||||||
|
"1.20.4",
|
||||||
|
std::path::Path::new("/instances/survival"),
|
||||||
|
std::path::Path::new("/.ULL/logs"),
|
||||||
|
std::time::SystemTime::now(),
|
||||||
|
"os=windows",
|
||||||
|
);
|
||||||
|
let log = PathBuf::from("/sessions/game/9f2c.jsonl");
|
||||||
|
let session = SessionAttribution {
|
||||||
|
id: "9f2c",
|
||||||
|
log: Some(log.as_path()),
|
||||||
|
};
|
||||||
|
let finished =
|
||||||
|
CrashReport::before_start(&ctx, "spawn", "会话写入器不可用", "spawn", Some(session));
|
||||||
|
assert_eq!(finished.session, "9f2c", "现场必须带着自己那一场会话");
|
||||||
|
assert_eq!(finished.session_log.as_deref(), Some(log.as_path()));
|
||||||
|
// 会话在,就不该在清单里说「进程没起来」。
|
||||||
|
assert!(!finished
|
||||||
|
.missing_lines()
|
||||||
|
.iter()
|
||||||
|
.any(|line| line.contains("会话 JSONL")));
|
||||||
|
|
||||||
|
let never_started =
|
||||||
|
CrashReport::before_start(&ctx, "version_manifest", "缺清单", "version_manifest", None);
|
||||||
|
assert!(never_started.session.is_empty(), "装配失败确实没有会话");
|
||||||
|
assert!(never_started.session_log.is_none());
|
||||||
|
}
|
||||||
@@ -7,20 +7,13 @@
|
|||||||
//! 本模块内被读一次,绝不进 DTO。
|
//! 本模块内被读一次,绝不进 DTO。
|
||||||
//!
|
//!
|
||||||
//! 停止:`kill_tree(pid)`(core 的三段式进程树回收),`finish()` 会照常收尾写索引。
|
//! 停止:`kill_tree(pid)`(core 的三段式进程树回收),`finish()` 会照常收尾写索引。
|
||||||
|
//!
|
||||||
|
//! M298 把装配那一段(`build_request` 与它的两个私有助手)搬到 [`build`],`pub use` 保路径:
|
||||||
|
//! 本文件只留「起一次启动 / 停一次启动 / 失败出口怎么登记现场」这条生命周期。
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use unlucky_core::game::isolation::IsolationMode;
|
||||||
|
use unlucky_core::launch::crash_report::{self, CrashReport, ReportContext, SessionAttribution};
|
||||||
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::launch::launcher_log;
|
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::tree_kill;
|
||||||
use unlucky_core::launch::{Launcher, LauncherConfig};
|
use unlucky_core::launch::{Launcher, LauncherConfig};
|
||||||
|
|
||||||
@@ -31,9 +24,11 @@ use crate::dto::{
|
|||||||
|
|
||||||
use super::env::HostEnv;
|
use super::env::HostEnv;
|
||||||
|
|
||||||
|
mod build;
|
||||||
mod java;
|
mod java;
|
||||||
|
|
||||||
use super::job::{HostTask, JobSender};
|
use super::job::{HostTask, JobSender};
|
||||||
|
pub use build::build_request;
|
||||||
pub use java::pick_java;
|
pub use java::pick_java;
|
||||||
|
|
||||||
/// 启动器名(与 CLI `meta::LAUNCHER_NAME` 同值)。
|
/// 启动器名(与 CLI `meta::LAUNCHER_NAME` 同值)。
|
||||||
@@ -52,178 +47,6 @@ pub struct LaunchTarget {
|
|||||||
pub account: Option<String>,
|
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`)。
|
/// 起一次启动(后台线程;进度/日志/收尾全部走 `HostTask`)。
|
||||||
pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
||||||
let id = target.id.clone();
|
let id = target.id.clone();
|
||||||
@@ -259,7 +82,7 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
|||||||
Err(failure) => {
|
Err(failure) => {
|
||||||
let line = format!("instance={} 装配失败:{}", target.id, failure.detail);
|
let line = format!("instance={} 装配失败:{}", target.id, failure.detail);
|
||||||
launcher_log::record("launch", &line);
|
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 {
|
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
|
||||||
id,
|
id,
|
||||||
session: String::new(),
|
session: String::new(),
|
||||||
@@ -300,7 +123,7 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
|||||||
"launch",
|
"launch",
|
||||||
&format!("instance={} spawn 失败:{error}", target.id),
|
&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 {
|
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
|
||||||
id,
|
id,
|
||||||
session: String::new(),
|
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 session_id = session.session_id().to_string();
|
||||||
let pid = session.pid();
|
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 {
|
let _ = tx.send(HostTask::LaunchStarted {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
session_id: session_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));
|
crash_report::publish(CrashReport::collect(&handle, &report, &lines));
|
||||||
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
|
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
|
||||||
id,
|
id,
|
||||||
session: session_id,
|
// 与 `CrashReport::collect` 取自同一处(`handle.session_id`)。
|
||||||
|
session: handle.session_id.clone(),
|
||||||
outcome,
|
outcome,
|
||||||
exit_code: Some(handle.exit.code),
|
exit_code: Some(handle.exit.code),
|
||||||
uptime_ms: handle.uptime_ms,
|
uptime_ms: handle.uptime_ms,
|
||||||
@@ -350,10 +181,14 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
|||||||
target.id
|
target.id
|
||||||
);
|
);
|
||||||
launcher_log::record("launch", &line);
|
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 {
|
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
|
||||||
id,
|
id,
|
||||||
session: session_id,
|
session,
|
||||||
outcome: LogOutcome::SilentFail,
|
outcome: LogOutcome::SilentFail,
|
||||||
exit_code: None,
|
exit_code: None,
|
||||||
uptime_ms: 0,
|
uptime_ms: 0,
|
||||||
@@ -364,9 +199,23 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 登记一次「进程没起来」的现场(四个失败出口共用这一处,避免各写一遍 exit_type)。
|
/// 登记一次**没有 handle** 的失败现场,并返回该写进 `LaunchFinished.session` 的值。
|
||||||
fn publish_before_start(report: &ReportContext, stage: &str, detail: &str) {
|
///
|
||||||
crash_report::publish(CrashReport::before_start(report, stage, detail, stage));
|
/// **会话归属的单一来源**(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()` 会在后台线程里照常收尾)。
|
/// 停止一次启动(按 pid 回收进程树;`finish()` 会在后台线程里照常收尾)。
|
||||||
@@ -375,21 +224,6 @@ pub fn stop(pid: u32) -> Result<(), String> {
|
|||||||
.map_err(|error| format!("停止进程 {pid} 失败:{error}"))
|
.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`)。
|
/// 隔离档位的展示串(`isolated` / `shared`)。
|
||||||
pub fn isolation_label(mode: IsolationMode) -> &'static str {
|
pub fn isolation_label(mode: IsolationMode) -> &'static str {
|
||||||
mode.as_str()
|
mode.as_str()
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
//! 装配 [`LaunchRequest`]:实例 → 版本 JSON → 继承链 → 参数展开 → 适配器(M298 从
|
||||||
|
//! `host/launch.rs` 拆出,纯搬移 + `pub use` 保路径)。
|
||||||
|
//!
|
||||||
|
//! 这一层与 CLI 的 `snapshot::prepare` 同源同序,差异只在「实例信息从哪来」:CLI 从命令行,
|
||||||
|
//! 这里从 GUI 选中的实例([`LaunchTarget`])。
|
||||||
|
//!
|
||||||
|
//! 拆分的直接原因只有行数(`host/launch.rs` 当时 399 行、贴着 400 行硬线),**没有行为变化**:
|
||||||
|
//! 函数体与顺序逐行一致,调用点仍是 `super::launch::build_request`(父模块 `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};
|
||||||
|
use unlucky_core::game::{self, VersionJson};
|
||||||
|
use unlucky_core::launch::adapter::{
|
||||||
|
self, AdapterOptions, AdapterWarning, LibraryInput, SnapshotInput,
|
||||||
|
};
|
||||||
|
use unlucky_core::launch::request::{LaunchRequest, MemoryOptions};
|
||||||
|
use unlucky_core::launch::time::{format_rfc3339_millis, now};
|
||||||
|
|
||||||
|
use crate::dto::{LaunchFailureStage, LaunchFailureView};
|
||||||
|
|
||||||
|
use super::super::env::HostEnv;
|
||||||
|
use super::super::{game as host_game, identity, launch_args};
|
||||||
|
use super::{pick_java, LaunchTarget, LAUNCHER_NAME};
|
||||||
|
|
||||||
|
/// 装配 [`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 = host_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, _) = host_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 = 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) = 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::super::game::base_mode`])。
|
||||||
|
///
|
||||||
|
/// 基底档对自动发现的实例是「盘上有没有痕迹」,对其它实例就是全局 `instances.mode`——
|
||||||
|
/// 于是这里不需要知道实例是从哪来的,只有一处判据。
|
||||||
|
fn run_dir(
|
||||||
|
env: &HostEnv,
|
||||||
|
game: &unlucky_core::game::GameLayout,
|
||||||
|
target: &LaunchTarget,
|
||||||
|
) -> Result<PathBuf, LaunchFailureView> {
|
||||||
|
let base = host_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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 适配层告警 → 人读一行。
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,3 +94,47 @@ fn weekly_loader_instance_assembles_against_the_mc_root_version() {
|
|||||||
"周快照刻意不可排序(is_known=false),门限只能靠库表兜底"
|
"周快照刻意不可排序(is_known=false),门限只能靠库表兜底"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// M298:收尾失败出口的**会话归属单一来源**。
|
||||||
|
///
|
||||||
|
/// 抓得住:`publish_before_start` 的返回值与它登记的那份现场的 `session` 不是同一个值——
|
||||||
|
/// 那正是「报告空串 / 收尾事件非空 ⇒ 界面侧并发守卫判成别人的报告 ⇒ 静默不弹」的成因。
|
||||||
|
/// 抓不住:界面那一步真的弹了(那条在 `crates/app-iced/src/app/dialog/tests.rs`)。
|
||||||
|
#[test]
|
||||||
|
fn a_failure_with_a_session_registers_and_returns_the_same_session() {
|
||||||
|
let dir = TempDir::new("attribution");
|
||||||
|
let env = HostEnv::at_root(dir.path());
|
||||||
|
let ctx = ReportContext::new(
|
||||||
|
"inst-1",
|
||||||
|
"生存",
|
||||||
|
"1.20.4",
|
||||||
|
env.layout.data().join("instances/inst-1"),
|
||||||
|
env.layout.logs(),
|
||||||
|
std::time::SystemTime::now(),
|
||||||
|
"os=windows",
|
||||||
|
);
|
||||||
|
let log = env.sessions_root().join("9f2c.jsonl");
|
||||||
|
let attribution = SessionAttribution {
|
||||||
|
id: "9f2c",
|
||||||
|
log: Some(log.as_path()),
|
||||||
|
};
|
||||||
|
let session = publish_before_start(
|
||||||
|
&ctx,
|
||||||
|
Some(&attribution),
|
||||||
|
"spawn",
|
||||||
|
"会话写入器不可用(锁中毒)",
|
||||||
|
);
|
||||||
|
assert_eq!(session, "9f2c", "发出去的会话必须是归属的那一条");
|
||||||
|
let report = crash_report::failing_launch().expect("登记过现场");
|
||||||
|
assert_eq!(report.session, session, "报告与收尾事件必须同源");
|
||||||
|
assert_eq!(report.session_log.as_deref(), Some(log.as_path()));
|
||||||
|
|
||||||
|
// 反向守卫:进程没起来的出口,空串是事实(不许为了"凑非空"编一个 id)。
|
||||||
|
let none = publish_before_start(&ctx, None, "version_manifest", "缺清单");
|
||||||
|
assert!(none.is_empty());
|
||||||
|
assert!(crash_report::failing_launch()
|
||||||
|
.expect("登记过")
|
||||||
|
.session
|
||||||
|
.is_empty());
|
||||||
|
crash_report::clear();
|
||||||
|
}
|
||||||
Reference in new issue
Block a user