三层一次做完:core 读写目录 → presenter DTO/host → 实例详情页 MODS 区。 r1 复审后 rebase 到 main 71144df(M223 已合入),并修掉四条文档疵。 core(crates/core/src/addon/mods/) - suffix:`.disabled` / `.old` 的**唯一判据**(四态枚举 + 展示名 + 双向改名目标名), 收敛原先两份同构实现——`export/scan.rs::is_disabled_path` 与 `import/plan/paths.rs::suffix_variant` 改为委派/再导出,签名与行为不变。 - list:单根语义(入口是 derive_game_dir 派生出的运行目录)列 `<run>/mods/`,不按扩展名过滤, 符号链接/目录/怪名字逐条登记原因;`mods/` 本身是符号链接时拒(不跟随)。 - toggle:启停 = 在 `mods/` 里改名。五条守卫(裸文件名 / 目录可用 / 普通文件 / 不覆盖 / 不做 copy+delete 兜底)+ 幂等;`.disabled` 只插在 `.old` 之前。 presenter - dto/mods:ModsCommand / ModListView / ModFailureView(不拼文案,只给结构 + OS 原文)。 - host/mods:后台线程列目录与启停,运行目录走与启动同一份 D20 派生、不写任何设置; 游戏在跑时 host 侧直接挡。 - state/mods:快照整体替换、迟到结果丢弃、在飞标记;`AppState::mods`。 app-iced(实例详情页) - MODS 区(`pages/instances/mods.rs`):名字 + 大小 + `.old` 徽标 + Switch;运行中/在飞只读。 - 进入详情页即后台列一次(不走 3 s 观察窗,避免打红 M192 的「空闲不烧帧」守卫)。 - MODS 卡排在启动参数**之前**(详情列是滚动区,排在后面 1180×1000 下永远看不见)。 - i18n:19 条中英文案(`catalog/instances.rs` + 金表)。 rebase 冲突解法(三个文件,全是"两侧同段新增",都保留): - `i18n/catalog/instances.rs` + `i18n/catalog/golden.rs`:M223 的 Adopt* 段在前、M224 的 InstancesMods* 段在后(= `Key::ALL` 顺序 = 金表顺序)。 - `presenter/src/state.rs`:`adopt` 与 `mods` 字段/初始化/apply_core 分支都留;`tiles()` 归 `state/tiles.rs`(本分支搬的),`apply_agent`/`agent_rail_visible` 归 `state/agent.rs` (M223 搬的)——两处搬移不重叠,state.rs 里都不再出现。 r1 四条文档疵: 1. `pages/instances/mods.rs` 模块文档:挂载位置写反("启动参数卡之后"→实际之前),并补上为什么; 2. `state/mods.rs`:引用不存在的 `CoreEvent::ModsChanged/ModsFailed` → 改成 `CoreEvent::Mods` + 文字说明 Ok 落快照 / Err 落具名失败; 3. `app/state/mods_shot.rs`:文档说"目录串取真实派生结果"但代码留空 → 改成"故意留空"; 4. `app/state/refresh.rs::note_mods_failure`:文档说"连点两次看见两次红字"与去重机制不符 → 改成按 `(实例, 文件, 大类)` 三元组去重(防刷屏),换文件/换实例/换大类仍会再弹。 顺带(r1 已确认的 main 红):`pages/download/options/view.rs:88-89` 两条 `clippy::doc_lazy_continuation` 只缩进修掉,文字未动。
256 lines
12 KiB
Rust
256 lines
12 KiB
Rust
//! `HostTask` → `CoreEvent` 的翻译(从 `host/mod.rs` 拆出,守 400 行硬限)。
|
||
//!
|
||
//! 父模块的私有字段对子模块可见,所以这里直接读写 `self.tx` / `self.launches` 等。
|
||
|
||
use crate::dto::{AppEvent, CoreEvent, InstanceRuntime, LaunchFailureCause, LogOutcome, PackEvent};
|
||
use crate::toast::ToastKind;
|
||
|
||
use super::{addon, logs, mods, CoreHost, HostTask, LiveLaunch};
|
||
|
||
impl CoreHost {
|
||
/// 后台消息 → 事件。
|
||
pub(super) fn handle(&mut self, task: HostTask) {
|
||
match task {
|
||
HostTask::Versions(Ok(versions)) => self.send(CoreEvent::VersionListChanged(versions)),
|
||
HostTask::Versions(Err(reason)) => {
|
||
self.toast(ToastKind::Warn, format!("拉取版本清单失败:{reason}"));
|
||
}
|
||
HostTask::LoaderVersions {
|
||
kind,
|
||
mc_version,
|
||
result,
|
||
} => {
|
||
// 失败既要落进快照(页面禁用确认 + 显示具名原因),也要一条 Toast:
|
||
// 用户可能正盯着上面的版本列表,下拉那一行的红字容易被忽略。
|
||
if let Err(reason) = &result {
|
||
self.toast(
|
||
ToastKind::Warn,
|
||
format!("取不到 {} 的加载器版本:{reason}", kind.label()),
|
||
);
|
||
}
|
||
self.send(CoreEvent::LoaderVersions {
|
||
kind,
|
||
mc_version,
|
||
result,
|
||
});
|
||
}
|
||
// M109:内容库检索。`Err` 不弹 toast——页面自己有失败态(四态之一),
|
||
// toast 只做「顺手提一句」会把可重试的错误变成一次性噪音。
|
||
HostTask::Content(result) => match *result {
|
||
Ok(results) => self.send(CoreEvent::ContentResults(Box::new(results))),
|
||
Err(failure) => self.send(CoreEvent::ContentFailed(Box::new(failure))),
|
||
},
|
||
// M162:详情/版本列表(与检索同一套失败分类)
|
||
HostTask::ContentDetail { project, result } => {
|
||
self.send(CoreEvent::ContentDetail { project, result });
|
||
}
|
||
// M113: import/export —— 导入/导出事件带代次,取消后回来的旧事件一律丢掉
|
||
HostTask::Pack { event, epoch } => {
|
||
// pack 用**自己**的代次(复审 r1 P2-7):共用安装的计数器会让
|
||
// 「关一次导入面板」把下载页正在跑的安装静默丢掉。
|
||
if epoch != addon::pack_epoch(&self.env) {
|
||
// 唯一例外(M195 r3):**刚被取消的那一次**交上来的终态报告。取消会把代次
|
||
// +1,若连报告一起丢,界面就永远学不到"取消太晚、其实已经装完/回滚没做完"
|
||
// 这个真相——只剩一句无条件的"已停手并回滚"。
|
||
// 只放行 `Report`、且同一代次只放行一条;进度事件照旧按代次丢。
|
||
if !(matches!(&*event, PackEvent::Report(_))
|
||
&& addon::take_terminal_epoch(&self.env, epoch))
|
||
{
|
||
return;
|
||
}
|
||
}
|
||
// 导入成功可能登记了新实例:与 `InstallDone` 同一条刷新(复审 r1 P2-1),
|
||
// 否则新实例要等下一次无关的 `RefreshAll` 才出现在实例页与目标 chips 里。
|
||
if matches!(*event, PackEvent::Report(_)) {
|
||
self.refresh_instances();
|
||
}
|
||
self.send(CoreEvent::Pack(event));
|
||
}
|
||
// M105 的形状(代次丢包)为准:旧任务(已取消 / 被下一轮顶掉)的进度一律丢掉。
|
||
// 这是「点了取消没反应」的根因:取消时 `InstallProgress(None)` 刚把面板收回,
|
||
// 旧任务的下一条「下载中」事件立刻又把它点亮。
|
||
HostTask::Adopt { event, epoch } => self.handle_adopt(*event, epoch),
|
||
HostTask::Install { progress, epoch } => {
|
||
if epoch != self.install_epoch {
|
||
return;
|
||
}
|
||
self.install = Some(*progress.clone());
|
||
self.send(CoreEvent::InstallProgress(Some(*progress)));
|
||
}
|
||
HostTask::InstallDone {
|
||
progress,
|
||
result,
|
||
epoch,
|
||
} => {
|
||
// 收尾同样看代次:取消之后那次「已取消」的收尾不该再动面板(面板早就回弹了),
|
||
// 也不该清掉**新一轮**安装的取消句柄。
|
||
if epoch != self.install_epoch {
|
||
return;
|
||
}
|
||
self.install_cancel = None;
|
||
self.install = Some(*progress.clone());
|
||
self.send(CoreEvent::InstallProgress(Some(*progress)));
|
||
match result {
|
||
Ok(_) => {
|
||
self.refresh_instances();
|
||
self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env)));
|
||
}
|
||
// 只把**一句人读原因**弹出去:大类已经随 `progress.failure_kind` 进了 DTO,
|
||
// 面板/调用方按大类决定怎么画(离线 vs 查无此项)。
|
||
Err(failure) => self.toast(ToastKind::Error, failure.detail),
|
||
}
|
||
}
|
||
HostTask::Accounts(accounts) => {
|
||
self.accounts = accounts.clone();
|
||
self.send(CoreEvent::AccountsChanged(accounts));
|
||
self.refresh_instances();
|
||
}
|
||
HostTask::Login(view) => self.send(CoreEvent::DeviceLoginChanged(*view)),
|
||
HostTask::Authlib(view) => self.send(CoreEvent::AuthlibLoginChanged(*view)),
|
||
HostTask::Avatar { id, skin } => {
|
||
// 抓到/没抓到都记下来(`None` = 试过了,不再重试),再把列表盖好发出去
|
||
self.skins.insert(id, skin);
|
||
self.attach_skins();
|
||
self.send(CoreEvent::AccountsChanged(self.accounts.clone()));
|
||
}
|
||
HostTask::Java(page) => self.send(CoreEvent::JavaChanged(*page)),
|
||
HostTask::Logs {
|
||
sessions,
|
||
lines,
|
||
diagnosis,
|
||
} => {
|
||
self.send(CoreEvent::LogSessionsChanged(sessions));
|
||
self.send(CoreEvent::LogLinesChanged(lines));
|
||
if let Some(report) = diagnosis {
|
||
self.send(CoreEvent::DiagnosisReady(report));
|
||
}
|
||
}
|
||
HostTask::Sessions(sessions) => self.send(CoreEvent::LogSessionsChanged(sessions)),
|
||
HostTask::Tail(batch) => {
|
||
if batch.running {
|
||
self.send(CoreEvent::LogLinesChanged(batch.lines));
|
||
} else {
|
||
self.send(CoreEvent::LogAppended(*batch));
|
||
}
|
||
}
|
||
HostTask::LaunchStarted {
|
||
id,
|
||
session_id,
|
||
pid,
|
||
} => {
|
||
self.pending.remove(&id);
|
||
self.failed.remove(&id);
|
||
self.launches.retain(|live| live.instance != id);
|
||
self.launches.push(LiveLaunch {
|
||
instance: id.clone(),
|
||
session: session_id.clone(),
|
||
pid,
|
||
});
|
||
// 实时尾随一次只跟一场(下载页/日志页共用一个滚动窗口);
|
||
// 已有别的实例在尾随时不抢占——它的日志仍在 JSONL 里,收尾后可回放。
|
||
if self.tail.session().is_none() {
|
||
self.tail.attach(
|
||
self.env
|
||
.layout
|
||
.game_logs()
|
||
.join(format!("{session_id}.jsonl")),
|
||
&session_id,
|
||
);
|
||
}
|
||
self.send(CoreEvent::InstanceRuntimeChanged {
|
||
id,
|
||
runtime: InstanceRuntime::Running { pid },
|
||
});
|
||
}
|
||
HostTask::Runtime { id, runtime } => {
|
||
self.send(CoreEvent::InstanceRuntimeChanged { id, runtime });
|
||
}
|
||
HostTask::LaunchEnd(finished) => {
|
||
// 收尾可能发生在 LaunchStarted 之前(build_request / spawn 失败只发
|
||
// LaunchEnd):pending 必须在这里也清掉,否则 in_flight 恒真、该实例
|
||
// 本会话内再也起不来。
|
||
self.pending.remove(&finished.id);
|
||
self.launches.retain(|live| live.instance != finished.id);
|
||
let runtime = if finished.outcome == LogOutcome::Ok {
|
||
self.failed.remove(&finished.id);
|
||
InstanceRuntime::Idle
|
||
} else {
|
||
// 记住失败:随后的 refresh_instances 会重建快照,
|
||
// 不记的话磁贴的「失败/重试」外观会被 Idle 盖掉。
|
||
//
|
||
// 原因**按形状**组装(复审 P1-1):`failure` 有值 = 进程没起来(阶段失败);
|
||
// 没值 = 跑起来了,那就必须看 `outcome`(崩溃 / 疑似静默失败),
|
||
// 不许再拿「启动游戏进程失败」去顶——那句话在进程明明起来时说不过去。
|
||
let cause = LaunchFailureCause::of(
|
||
finished.outcome,
|
||
finished.failure.as_ref(),
|
||
finished.exit_code,
|
||
);
|
||
self.failed.insert(finished.id.clone(), cause.clone());
|
||
InstanceRuntime::Failed { cause }
|
||
};
|
||
self.send(CoreEvent::InstanceRuntimeChanged {
|
||
id: finished.id.clone(),
|
||
runtime,
|
||
});
|
||
self.send(CoreEvent::LaunchFinished((*finished).clone()));
|
||
// 只拆自己那场的尾随:并发时另一实例的实时日志不该被这次收尾掐掉。
|
||
if self.tail.session() == Some(finished.session.as_str()) {
|
||
self.tail.detach();
|
||
}
|
||
self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env)));
|
||
self.refresh_instances();
|
||
}
|
||
HostTask::Crash(report) => {
|
||
self.send(CoreEvent::DiagnosisReady(*report));
|
||
}
|
||
HostTask::Notice(notice) => self.push_notice(*notice),
|
||
// M159:更新回报(检查结论 / 应用结局)→ 快照整体替换
|
||
HostTask::Update(report) => {
|
||
self.update.adopt(*report);
|
||
self.publish_update();
|
||
}
|
||
// M224:本地 Mod 列表 / 启停。列表成功 ⇒ 整份快照替换;失败 ⇒ 具名原因给 app 求文案。
|
||
// 启停**不做乐观回显**:开关跟着这一份快照翻(与启动/停止同一条纪律)。
|
||
HostTask::Mods(report) => {
|
||
let mods::Report {
|
||
instance,
|
||
list,
|
||
toggled,
|
||
} = *report;
|
||
if let Some(toggled) = toggled {
|
||
// 只留一行日志:开关自己会跟着快照翻,再弹 Toast 是噪音。
|
||
eprintln!(
|
||
"[mods] {instance}: {} → {} ({})",
|
||
toggled.from,
|
||
toggled.to,
|
||
if toggled.enabled {
|
||
"enabled"
|
||
} else {
|
||
"disabled"
|
||
}
|
||
);
|
||
}
|
||
self.send(CoreEvent::Mods(Box::new(list)));
|
||
}
|
||
HostTask::Toast(toast) => self.events.push_back(AppEvent::Toast(*toast)),
|
||
}
|
||
}
|
||
|
||
/// 尾随运行中会话(每 `TAIL_POLL_TICKS` 拍一次)。
|
||
pub(super) fn poll_tail(&mut self) {
|
||
let Some((session, lines)) = self.tail.poll() else {
|
||
return;
|
||
};
|
||
if lines.is_empty() {
|
||
return;
|
||
}
|
||
let running = self.launches.iter().any(|live| live.session == session);
|
||
self.send(CoreEvent::LogAppended(crate::dto::LiveLogBatch {
|
||
session,
|
||
lines,
|
||
running,
|
||
}));
|
||
}
|
||
}
|