D50 的「检查 → 提示 → 可拒绝 → 下载校验 → 下一次启动生效」整环接线,GUI 与
CLI 共用 core 的一条内核(`update::Job`),并顺带修掉 M157 最后一轮复审的三处 P3。
内核(core::update)
- 新增 job.rs:`Job { source, target, staging, dest }` + `check` / `apply`(复查 →
取包 → 长度/SHA256 双检 → 原子替换),`default_source`(GitHub,`ULL_UPDATE_DIR`
可切本地目录夹具)、`process_dest`、`staging_dir`。
- `mod.rs` 导出 job 一族;`source.rs` 为 `Box<dyn UpdateSource>` 补 trait 转发
(壳层要运行期选源,`UpdateChecker` 是泛型的)。
presenter(宿主通道)
- `dto/update.rs`:`UpdateView` / `UpdateOutcome`(Idle/Checking/UpToDate/Available/
MissingArtifact/Failed 六态,**失败与最新不折叠**)/ `UpdateBanner` 优先级判据。
- `host/update.rs`:Factory(生产 GitHub / 单测本地目录)+ core 结论 → DTO 的唯一映射
+ 更新域偏好(`cache/update/prefs.json`)。
- `host/update_state.rs`:Runtime(会话状态机:begin_check/adopt/begin_apply/dismiss/
set_auto_check)+ `CoreHost` 派发与后台线程。
- `AppCommand::{CheckUpdate, ApplyUpdate, DismissUpdate, SetUpdateAutoCheck}`、
`CoreEvent::Update`、`AppState.update`;`refresh_all` 按开关派发静默检查。
app-iced
- 设置页新增第 5 组「更新」(当前版本 / 渠道 / 自动检查开关 / 立即检查 / 上次检查与
结果 / 下载并安装 / 就位后立即重启),走 `settings::view` 的 `update` 形参 + 按下标
分发(与 M106 语言行同一取舍)。
- 顶部提示条(每页可见):有新版本(版本号 + 体积 + 说明摘要,可安装/可忽略)、下载中、
已就位(立即重启);静默检查失败**不**弹条(原因进设置页)。
- `pages::Message::Update(UpdateAction)` → `app/update/update_action.rs`;「立即重启」
拉同参数的新进程后退出(截图通道不重启)。
CLI
- `ull update check` / `ull update apply [--dest <path>]`:复用同一 `Job`;四种结论分开
报(`--json` 的 `status`),失败退出码 1(`update_check_failed` / `update_apply_failed`)。
i18n
- 新增 `catalog/update.rs`(30 条,链尾追加)+ 金表对应 30 行。
M157 复审 P3 三件
- `app/update/instances.rs` 模块文档改成「清脏的唯一时机是回程快照等于表单」。
- `pages/instances/demo.rs` 的 `demo_form` 注释改成现状(两个参数框早已是单行输入)。
- `pages/instances/launch_args.rs`:门限不满足时只灰 `opengl`/`vulkan`,`default` 仍可点
(它是「不下发」的安全档);判据抽成 `backend_chip_enabled` 并补 3 条用例。浅色 + 灰化
两张图:`C:/Temp/m159-shots/instances-launch-args-{light,gated}.png`。
顺带:harness 支持 `ULL_SETTINGS_GROUP=<下标>`(拍第 5 组的验收图需要它)。
验证:cargo fmt --check 绿;cargo clippy --workspace --all-targets -D warnings 绿;
cargo test --workspace --all-targets 51 个测试二进制全绿(唯一例外:core::addon::pack
一族要造 4 GiB 稀疏文件,本机磁盘 99% 满 ⇒ StorageFull,与本次改动无关);CLI 用本地
目录夹具跑通「发现 9.9.9 → 下载 → 校验 → 替换运行中的 exe → 备份留旧文件」,并验证
摘要不符时拒绝应用、目标文件零改动、暂存目录零残留。
382 lines
14 KiB
Rust
382 lines
14 KiB
Rust
//! 真实后端:把 core(game/launch/install/auth/java/diagnose/setting)装配成 GUI 的 host。
|
||
//!
|
||
//! 边界:本模块是**整个 presenter crate 里唯一**允许 `use unlucky_core::…` 的地方(连同
|
||
//! `settings_bridge`);出口只有 [`crate::dto`] 的快照/事件。线程模型:
|
||
//!
|
||
//! ```text
|
||
//! dispatch(AppCommand) ──► 立即动作(读盘/写设置)或 spawn 后台线程
|
||
//! 后台线程 ──HostTask──► rx ──tick()──► CoreEvent ──► state.apply()
|
||
//! tick() 另外每 ~600ms 尾随一次运行中会话的 JSONL(实时日志滚动)
|
||
//! ```
|
||
//!
|
||
//! AI 对话暂时仍由 [`MockBackend`] 提供脚本回复(agent 的 GUI host 是后续 mission);
|
||
//! 其余页面全部走真实 core。
|
||
|
||
mod accounts;
|
||
mod addon;
|
||
mod dispatch;
|
||
mod engine;
|
||
pub mod env;
|
||
mod game;
|
||
mod handle;
|
||
mod identity;
|
||
mod install;
|
||
mod install_journal;
|
||
mod install_processors;
|
||
mod java;
|
||
mod job;
|
||
mod launch;
|
||
mod launch_args;
|
||
mod loader_versions;
|
||
mod logs;
|
||
mod meter;
|
||
mod opener;
|
||
mod settings;
|
||
mod update;
|
||
mod update_state;
|
||
mod versions;
|
||
|
||
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||
use std::time::Duration;
|
||
|
||
use crate::dto::{
|
||
AccountView, AppCommand, AppEvent, Backend, CoreEvent, DiagnosisReport, InstallProgressView,
|
||
InstanceRuntime, JavaChainStep, NoticeView, SkinImage,
|
||
};
|
||
use crate::mock::MockBackend;
|
||
use crate::toast::{Toast, ToastKind};
|
||
|
||
use accounts::LoginJob;
|
||
pub use env::HostEnv;
|
||
use job::{HostTask, JobReceiver, JobSender};
|
||
|
||
/// 每拍最多处理的后台消息数(防止一次 tick 吃掉整帧预算)。
|
||
const MAX_TASKS_PER_TICK: usize = 64;
|
||
|
||
/// 实时日志尾随间隔(拍数,33ms/拍 → 约 600ms 一次)。
|
||
const TAIL_POLL_TICKS: u64 = 18;
|
||
|
||
/// 一次在跑的启动(并发启动时按实例 id 区分,停止/尾随都不会串到别的实例)。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
struct LiveLaunch {
|
||
instance: String,
|
||
session: String,
|
||
pid: u32,
|
||
}
|
||
|
||
/// 真实 host。
|
||
pub struct CoreHost {
|
||
env: HostEnv,
|
||
events: VecDeque<AppEvent>,
|
||
tx: JobSender,
|
||
rx: JobReceiver,
|
||
ticks: u64,
|
||
/// 当前启动账户 id(内存态;跨会话持久化要 core schema 增键,见 handoff)
|
||
active_account: Option<String>,
|
||
accounts: Vec<AccountView>,
|
||
login: Option<LoginJob>,
|
||
/// 第三方轨(authlib)登录任务句柄(与微软两条流共用取消语义,但**不复用槽位**:
|
||
/// 两条轨同时点「登录」时各自的结果不能互相踩)
|
||
authlib: Option<LoginJob>,
|
||
/// 已抓到的皮肤(账户 id → 原图;`None` = 试过但没取到,不再重试)
|
||
skins: BTreeMap<String, Option<SkinImage>>,
|
||
/// 已经发起过皮肤抓取的账户 id(防每次刷新都重新抓一遍)
|
||
skin_requested: BTreeSet<String>,
|
||
java_chain: Vec<JavaChainStep>,
|
||
java_busy: bool,
|
||
java_message: String,
|
||
install: Option<InstallProgressView>,
|
||
notices: Vec<NoticeView>,
|
||
/// 在跑的启动(实例 → 会话 → pid;支持多个实例同时启动,停止按 id 找正确的 pid)
|
||
launches: Vec<LiveLaunch>,
|
||
/// 已受理但后台线程还没回报 `LaunchStarted` 的实例(防「启动中重复点启动」起第二个进程)
|
||
pending: BTreeSet<String>,
|
||
/// 上次启动失败的实例与原因(刷新快照时保住磁贴的「失败/重试」外观)
|
||
failed: BTreeMap<String, String>,
|
||
/// 实时日志尾随器
|
||
tail: logs::Tail,
|
||
/// AI 对话的临时后端(agent GUI host 落地前的占位)
|
||
chat: MockBackend,
|
||
/// 当前安装任务的取消标记(`execute_with_cancel` 与段边界检查共同消费)
|
||
install_cancel: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||
/// 当前安装任务的代次。
|
||
///
|
||
/// 开工(`InstallVersion` / `CreateInstance`)与取消都 +1:还在飞的后台线程把事件带回来时
|
||
/// 一眼能看出「这是旧任务的」,于是既不会被取消掉的安装重新点亮面板,也不会串到下一轮安装
|
||
/// (两轮安装同时存在时,旧任务的事件曾经把新任务的进度条顶掉)。
|
||
install_epoch: u64,
|
||
/// 自更新(M159):检查结论 / 应用状态 / 更新域偏好。
|
||
update: update_state::Runtime,
|
||
}
|
||
|
||
impl CoreHost {
|
||
/// 按便携数据根装配;解析不出数据根返回 `None`(调用方退回 mock)。
|
||
pub fn open() -> Option<Self> {
|
||
HostEnv::resolve().map(Self::new)
|
||
}
|
||
|
||
/// 用给定环境装配(截图/单测通道)。
|
||
pub fn new(env: HostEnv) -> Self {
|
||
Self::build(env, true)
|
||
}
|
||
|
||
/// 单测用:不跑 `refresh_all`(不起版本清单网络线程),其余装配一致。
|
||
#[cfg(test)]
|
||
pub(crate) fn headless(env: HostEnv) -> Self {
|
||
Self::build(env, false)
|
||
}
|
||
|
||
fn build(env: HostEnv, refresh: bool) -> Self {
|
||
let (tx, rx) = job::channel();
|
||
game::ensure_root(&env);
|
||
// M159:更新状态要在构造时就有一份(提示条/设置页都读它),静默检查在 `refresh_all`
|
||
// 里按开关派发——`headless` 走 `refresh=false`,于是单测不会发任何网络请求。
|
||
let update = update_state::Runtime::for_env(&env);
|
||
let mut host = Self {
|
||
env,
|
||
events: VecDeque::new(),
|
||
tx,
|
||
rx,
|
||
ticks: 0,
|
||
active_account: None,
|
||
accounts: Vec::new(),
|
||
login: None,
|
||
authlib: None,
|
||
skins: BTreeMap::new(),
|
||
skin_requested: BTreeSet::new(),
|
||
java_chain: Vec::new(),
|
||
java_busy: false,
|
||
java_message: String::new(),
|
||
install: None,
|
||
notices: Vec::new(),
|
||
launches: Vec::new(),
|
||
pending: BTreeSet::new(),
|
||
failed: BTreeMap::new(),
|
||
tail: logs::Tail::default(),
|
||
chat: MockBackend::default(),
|
||
install_cancel: None,
|
||
install_epoch: 0,
|
||
update,
|
||
};
|
||
if refresh {
|
||
host.refresh_all();
|
||
}
|
||
// M109:CF 源可用与否只在装配时打一行(**不含密钥内容**,见 `addon` 模块文档)。
|
||
addon::log_curseforge_state();
|
||
host
|
||
}
|
||
|
||
/// 数据根(截图/诊断展示用)。
|
||
pub fn env(&self) -> &HostEnv {
|
||
&self.env
|
||
}
|
||
|
||
fn send(&mut self, event: CoreEvent) {
|
||
self.events.push_back(AppEvent::Core(event));
|
||
}
|
||
|
||
/// 开一轮安装:代次 +1 并把新代次交出去(后台线程随每条进度带回来)。
|
||
///
|
||
/// 返回**新**代次而不是旧代次:调用方拿它去 `spawn_install`,于是「谁是新任务」只有一处答案。
|
||
fn next_install_epoch(&mut self) -> u64 {
|
||
self.install_epoch = self.install_epoch.wrapping_add(1);
|
||
self.install_epoch
|
||
}
|
||
|
||
fn toast(&mut self, kind: ToastKind, text: impl Into<String>) {
|
||
self.events.push_back(AppEvent::Toast(Toast {
|
||
id: 0,
|
||
kind,
|
||
text: text.into(),
|
||
remaining_ms: 0,
|
||
}));
|
||
}
|
||
|
||
/// 全量刷新:实例 / 会话 / 账户 / 设置 / Java / 更新,并触发一次版本清单拉取。
|
||
pub fn refresh_all(&mut self) {
|
||
self.refresh_instances();
|
||
self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env)));
|
||
self.refresh_accounts();
|
||
self.send(CoreEvent::SettingsChanged(settings::groups(&self.env)));
|
||
self.refresh_java();
|
||
versions::spawn_list(self.env.clone(), self.tx.clone());
|
||
// M159:更新快照先发一份(设置页首帧就有「当前版本 / 上次结果」),
|
||
// 再按开关决定要不要静默查一次。关掉开关 ⇒ 这里一行网络都不发。
|
||
self.publish_update();
|
||
if self.update.should_check_on_start() {
|
||
self.start_update_check(false);
|
||
}
|
||
}
|
||
|
||
/// 重建实例快照,并把**真实运行时**盖回去。
|
||
///
|
||
/// `game::instances` 只读盘(每条都是 `Idle`),所以在跑的实例必须在重建后按
|
||
/// host 自己的启动表覆写 `Running{pid}`/`Busy`,否则任意一次刷新(装版本成功、
|
||
/// 加删账户、另一实例收尾)都会把磁贴打回 Idle:停止键与「运行中」条消失,
|
||
/// 进程还在跑却再也停不下来。
|
||
fn refresh_instances(&mut self) {
|
||
let mut instances = game::instances(&self.env);
|
||
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(reason) = self.failed.get(&instance.id) {
|
||
instance.runtime = InstanceRuntime::Failed {
|
||
reason: reason.clone(),
|
||
};
|
||
}
|
||
}
|
||
self.send(CoreEvent::InstancesChanged(instances));
|
||
}
|
||
|
||
fn refresh_java(&mut self) {
|
||
let page = java::page(
|
||
&self.env,
|
||
self.java_chain.clone(),
|
||
self.java_busy,
|
||
&self.java_message,
|
||
);
|
||
self.send(CoreEvent::JavaChanged(page));
|
||
}
|
||
|
||
fn push_notice(&mut self, notice: NoticeView) {
|
||
self.notices.retain(|existing| existing.id != notice.id);
|
||
self.notices.push(notice);
|
||
self.send(CoreEvent::NoticesChanged(self.notices.clone()));
|
||
}
|
||
|
||
/// 打开一个会话(日志行 + 诊断)。
|
||
fn open_session(&mut self, id: &str) {
|
||
let (lines, diagnosis) = logs::open(&self.env, id);
|
||
self.send(CoreEvent::LogLinesChanged(lines));
|
||
self.send(CoreEvent::DiagnosisReady(diagnosis.unwrap_or(
|
||
DiagnosisReport {
|
||
session: id.to_string(),
|
||
diagnosis: crate::dto::Diagnosis {
|
||
rule: String::new(),
|
||
title: "该会话没有命中已知规则".to_string(),
|
||
confidence: 0,
|
||
hint: "可点「让 AI 详解」把日志交给模型进一步分析".to_string(),
|
||
fix: None,
|
||
},
|
||
},
|
||
)));
|
||
}
|
||
|
||
/// 由实例名/版本 id 找启动目标。
|
||
fn launch_target(&self, id: &str) -> Option<launch::LaunchTarget> {
|
||
let instance = game::instances(&self.env)
|
||
.into_iter()
|
||
.find(|instance| instance.id == id || instance.name == id)?;
|
||
Some(launch::LaunchTarget {
|
||
id: instance.id,
|
||
name: instance.name,
|
||
version_id: instance.version_id,
|
||
account: self.active_account.clone(),
|
||
})
|
||
}
|
||
|
||
/// 某实例是否已在启动/运行(`pending` 与 `launches` 的并集)。
|
||
///
|
||
/// 只查 `launches` 不够:`launch::start` 到后台线程回报 `LaunchStarted` 之间有窗口,
|
||
/// 这期间重复点启动会起第二个进程、并把旧 pid 在启动表里顶掉(旧进程变孤儿)。
|
||
fn in_flight(&self, key: &str) -> bool {
|
||
self.pending.contains(key) || self.launches.iter().any(|live| live.instance == key)
|
||
}
|
||
|
||
/// 标记「已受理、等后台线程回报」(`launch::start` 之后的窗口)。
|
||
///
|
||
/// 单测也走这个入口而不是直接改 `pending`,保证测的是真实状态流转。
|
||
fn mark_pending(&mut self, id: &str) {
|
||
self.pending.insert(id.to_string());
|
||
self.failed.remove(id);
|
||
}
|
||
|
||
fn start_launch(&mut self, id: &str) {
|
||
if self.in_flight(id) {
|
||
self.toast(ToastKind::Warn, "这个实例已经在启动/运行中");
|
||
return;
|
||
}
|
||
let Some(target) = self.launch_target(id) else {
|
||
self.toast(ToastKind::Error, format!("找不到实例 {id}"));
|
||
return;
|
||
};
|
||
let instance_id = target.id.clone();
|
||
if self.in_flight(&instance_id) {
|
||
self.toast(ToastKind::Warn, "这个实例已经在启动/运行中");
|
||
return;
|
||
}
|
||
self.mark_pending(&instance_id);
|
||
if let Some(notice) = game::mode_change_notice(&self.env, &instance_id) {
|
||
self.push_notice(notice);
|
||
}
|
||
self.send(CoreEvent::InstanceRuntimeChanged {
|
||
id: instance_id.clone(),
|
||
runtime: InstanceRuntime::Busy {
|
||
stage: crate::dto::LaunchStage::Launch,
|
||
progress: crate::dto::Progress { done: 0, total: 1 },
|
||
},
|
||
});
|
||
launch::start(self.env.clone(), self.tx.clone(), target);
|
||
}
|
||
|
||
/// 某实例在跑的 pid(按实例 id 查启动表;不再有「最近一次启动」这种单槽)。
|
||
///
|
||
/// 兜底分支读实例快照的 `Running { pid }`:host 自己发的运行时事件会写回快照,
|
||
/// 但快照是重建的(`game::instances` 每次 Idle),所以这层只是给将来留的口。
|
||
fn running_pid(&self, id: &str) -> Option<u32> {
|
||
if let Some(live) = self.launches.iter().find(|live| live.instance == id) {
|
||
return Some(live.pid);
|
||
}
|
||
game::instances(&self.env)
|
||
.into_iter()
|
||
.find(|instance| instance.id == id)
|
||
.and_then(|instance| match instance.runtime {
|
||
InstanceRuntime::Running { pid } => Some(pid),
|
||
_ => None,
|
||
})
|
||
}
|
||
}
|
||
|
||
impl Backend for CoreHost {
|
||
fn drain_events(&mut self) -> Vec<AppEvent> {
|
||
self.events.drain(..).collect()
|
||
}
|
||
|
||
fn dispatch(&mut self, command: AppCommand) {
|
||
self.dispatch_command(command);
|
||
}
|
||
|
||
fn tick(&mut self, elapsed: Duration) {
|
||
self.chat.tick(elapsed);
|
||
for event in self.chat.drain_events() {
|
||
self.events.push_back(event);
|
||
}
|
||
for _ in 0..MAX_TASKS_PER_TICK {
|
||
let Ok(task) = self.rx.try_recv() else {
|
||
break;
|
||
};
|
||
self.handle(task);
|
||
}
|
||
self.ticks += 1;
|
||
if self.ticks.is_multiple_of(TAIL_POLL_TICKS) {
|
||
self.poll_tail();
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[path = "cancel_tests.rs"]
|
||
mod cancel_tests;
|
||
#[cfg(test)]
|
||
#[path = "launch_state_tests.rs"]
|
||
mod launch_state_tests;
|