//! `HostTask` → `CoreEvent` 的翻译(从 `host/mod.rs` 拆出,守 400 行硬限)。 //! //! 父模块的私有字段对子模块可见,所以这里直接读写 `self.tx` / `self.launches` 等。 use crate::dto::{ AppEvent, CoreEvent, InstanceRuntime, InstanceSummary, 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}")); } // M302:实例扫描回程。**代次不符一律丢**(先发后到的扫描可能带着已被删掉的实例)。 HostTask::Instances { epoch, instances, elapsed_ms, } => self.apply_instances(epoch, instances, elapsed_ms), 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))); } // M235:实例存档 / 截图的只读列表。成功 ⇒ 整份快照;失败 ⇒ 具名原因给 app 求文案。 // 「打开文件夹」也是这条:拉起文件管理器之后再回一份新鲜快照(用户在那边删了世界, // 切回来这一拍就跟上)。 HostTask::Saves(report) => { self.send(CoreEvent::Saves(Box::new(report.list))); } HostTask::Toast(toast) => self.events.push_back(AppEvent::Toast(*toast)), } } /// 把一份**当代次**的扫盘结果落地:丢旧代次 → 盖运行时态 → 记账 → 发快照。 /// /// ## 运行时态为什么在这里盖(M302) /// /// `game::instances` 只读盘,每条都是 `Idle`;在跑的实例必须按 host 自己的启动表覆写 /// `Running{pid}`/`Busy`/`Failed`,否则任意一次刷新(装版本成功、加删账户、另一实例收尾) /// 都会把磁贴打回 Idle:停止键与「运行中」条消失,进程还在跑却再也停不下来。 /// /// 盖的时机是**结果回来那一刻**而不是「发请求那一刻」:扫描期间发生的启动/收尾已经落在 /// host 上了,这一读就是最新的(老实现同步扫,两件事本来就是同一刻)。所以异步化没有改变 /// 这条结论——`launch_state_tests` 的 Running/Busy/Failed 三条照旧成立。 /// /// ## 代次 /// /// 不符就整份丢掉并计数:那份结果的盘面已经过期,采纳它会让「删掉的实例又回来」。 /// 丢完就 return,**不发事件**——界面上的旧快照原样留着(清空再重填只是把卡顿换成白屏)。 fn apply_instances( &mut self, epoch: u64, mut instances: Vec, elapsed_ms: u64, ) { if !self.instances_scan.admit(epoch) { // 丢掉的那一份也记一行:交错真的发生时,光看「采纳」那一行是看不出来的。 // // 两种成因必须分开写清楚(复审 N3):`CoreHost::build` 的 `refresh_all` 与 `on_boot` // 的 `AppCommand::RefreshAll` 各领一代,所以**每次冷启动都必然丢 1 份**——那是既有 // 双刷新的产物,不是交错。判据是「此前有没有任何快照落地过」。 eprintln!( "[instances] 丢弃旧代次扫描(epoch {epoch} ≠ 当代次 {},累计丢 {} 份{})", self.instances_scan.epoch(), self.instances_scan.dropped(), if self.instances_scan.adopted().is_none() { ";此前还没有任何快照落地 ⇒ 启动期双刷新,不是交错" } else { ";此前已有快照落地 ⇒ 这是真交错" } ); return; } for instance in &mut instances { if let Some(live) = self .launches .iter() .find(|live| live.instance == instance.id) { instance.runtime = InstanceRuntime::Running { pid: live.pid }; } else if self.pending.contains(&instance.id) { instance.runtime = InstanceRuntime::Busy { stage: crate::dto::LaunchStage::Launch, progress: crate::dto::Progress { done: 0, total: 1 }, }; } else if let Some(cause) = self.failed.get(&instance.id) { instance.runtime = InstanceRuntime::Failed { cause: cause.clone(), }; } } let count = instances.len(); self.instances_scan.adopt(epoch, count, elapsed_ms); // M302 的可观测口径:一次刷新一行(前缀与 `[mods]` / `[auth]` 同一种形状,好 grep)。 // 「扫了几条、扫了多久、丢过几份」三样都在这一行里——回归时它比"感觉快了"硬。 eprintln!( "[instances] {count} 个实例 · 扫盘 {elapsed_ms} ms · epoch {epoch} · 丢旧代次 {} 份", self.instances_scan.dropped() ); self.send(CoreEvent::InstancesChanged(instances)); } /// 尾随运行中会话(每 `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, })); } }