Files
Unlucky-Launcher/crates/presenter/src/host/handle.rs
T
AskaEth d616d65ea1
CI / runtime smoke (macos arm64, probe) (push) Skipped
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Canceled after 0s
Release / 选槽位(默认两个 Windows) (push) Canceled after 0s
Release / build (${{ matrix.slot }}) (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Unlucky Launcher v0.2.3
2026-11-08 00:15:58 +00:00

224 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `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, 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) {
return;
}
// 导入成功可能登记了新实例:与 `InstallDone` 同一条刷新(复审 r1 P2-1),
// 否则新实例要等下一次无关的 `RefreshAll` 才出现在实例页与目标 chips 里。
if matches!(*event, PackEvent::Report(_)) {
self.refresh_instances();
}
self.send(CoreEvent::Pack(event));
}
// M105 的形状(代次丢包)为准:旧任务(已取消 / 被下一轮顶掉)的进度一律丢掉。
// 这是「点了取消没反应」的根因:取消时 `InstallProgress(None)` 刚把面板收回,
// 旧任务的下一条「下载中」事件立刻又把它点亮。
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
.sessions_root()
.join("game")
.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();
}
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,
}));
}
}