feat(presenter): 新建 unlucky-presenter,把 host/状态/DTO/设置桥从 app 迁出(M85 第 1 块)
D26(iced 重写)的关键一次性动作:UI 框架无关的宿主层独立成 crate, Slint 版(crates/app)与 iced 版(crates/app-iced)共用,Slint 退役后不再重写一遍。 - 新建 crates/presenter(package `unlucky-presenter` / lib `unlucky_presenter`), workspace 登记 + crate 文档头写清「这层为什么存在」、对外表面、UI 侧允许/禁止、 iced 接线骨架、以及哪些东西留在了 crates/app 及为什么。 - `git mv`(保留文件历史)迁入:host/(25 文件)、dto/(11 文件)、state.rs(+tests)、 prefs.rs、settings_bridge.rs、toast.rs、router.rs、dialogs.rs、wizard.rs、 mock.rs、mock_data.rs、backend.rs。模块路径与公开 API 名称不变(`host::install` 等), 内部实现零改动。 - 新增 `theme`:把原本混在 ui_bind/theme_runtime 里的**纯判据**(CONFIG_POLL_TICKS / SYSTEM_THEME_POLL_TICKS / TILE_REGENERATE_STEP / BackgroundKey / tile_viewport / system_theme_value / 两个 poll 判据)抽出来,附 5 个单测;app 侧改成调用它们。 - crates/app 改为消费者:删掉迁走的文件,`crate::host|state|dto|prefs|settings_bridge| toast|router|dialogs|wizard|mock|mock_data|backend` 全部改写为 `unlucky_presenter::*` (64 处),backend.rs 的 config_path/build_backend 提为 pub。 - crates/app-iced 只登记 unlucky-presenter 依赖,本 mission 不接线。 - prefs.rs 的 2 处 slint 文档引用改写;`grep -rn slint crates/presenter` 现在无输出。
This commit is contained in:
1 parent
00f132d8ce
commit
f159529936
69 files changed
+441
-129
No files matched your search
@@ -0,0 +1,36 @@
|
||||
//! 后端选择与启动选项:真实 core host 优先,解析不出数据根时退回 mock。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::dto::Backend;
|
||||
use crate::host::CoreHost;
|
||||
use crate::host::HostEnv;
|
||||
use crate::mock::MockBackend;
|
||||
|
||||
/// 启动选项(截图/演示通道用;正常启动全用默认值)。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AppOptions {
|
||||
/// 钉死数据根(`<root>/.ULL` + `<root>/.minecraft`);None = 按便携配置解析
|
||||
pub data_root: Option<PathBuf>,
|
||||
/// 强制 mock 后端(UI 回归/无数据根演示)
|
||||
pub mock: bool,
|
||||
}
|
||||
|
||||
/// `<root>/.ULL/config/launcher.json`(与 `SettingsBridge::open` 同落点)。
|
||||
pub fn config_path(root: &Path) -> PathBuf {
|
||||
root.join(".ULL").join("config").join("launcher.json")
|
||||
}
|
||||
|
||||
/// 选后端:真实 host 优先;数据根解析不出来或显式 `--mock` 时退回假后端。
|
||||
pub fn build_backend(options: &AppOptions) -> Box<dyn Backend> {
|
||||
if options.mock {
|
||||
return Box::new(MockBackend::new());
|
||||
}
|
||||
match &options.data_root {
|
||||
Some(root) => Box::new(CoreHost::new(HostEnv::at_root(root))),
|
||||
None => match CoreHost::open() {
|
||||
Some(host) => Box::new(host),
|
||||
None => Box::new(MockBackend::new()),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
//! 对话框队列(HMCL 队列语义 + Metro 化,09 §3)。
|
||||
//!
|
||||
//! 语义:**一次只显示一条**。新请求按来源插队(用户操作 > 后台任务 > 提示),同来源 FIFO;
|
||||
//! 当前条被确认/取消后由 [`DialogQueue::advance`] 推进到下一条,UI 只渲染 `current`。
|
||||
//! 非阻塞提示走 [`crate::toast`],不占模态队列。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 对话框标识。
|
||||
pub type DialogId = u64;
|
||||
|
||||
/// 对话框来源:决定插队优先级。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum DialogSource {
|
||||
/// 提示类(教程、首次使用说明)
|
||||
Hint = 0,
|
||||
/// 后台任务(下载完成、需要选择镜像源)
|
||||
BackgroundTask = 1,
|
||||
/// 用户操作触发(AI 敏感写操作的二次确认)
|
||||
UserAction = 2,
|
||||
}
|
||||
|
||||
impl DialogSource {
|
||||
/// 数值越大越优先。
|
||||
pub fn priority(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
}
|
||||
|
||||
/// 对话框类型(09 §3:确认 / 进度 / 输入 / 错误)。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum DialogKind {
|
||||
/// 确认:主按钮 + 次按钮
|
||||
Confirm {
|
||||
/// 主按钮文字(如「允许」)
|
||||
primary: String,
|
||||
/// 次按钮文字(如「拒绝」)
|
||||
secondary: String,
|
||||
},
|
||||
/// 输入:单行文本
|
||||
Input {
|
||||
/// 输入框标签
|
||||
label: String,
|
||||
/// 初始值
|
||||
value: String,
|
||||
/// 主按钮文字
|
||||
primary: String,
|
||||
},
|
||||
/// 进度:不可点主按钮(任务结束后由 [`crate::dto::AppEvent::DialogClosed`] 收起)
|
||||
Progress {
|
||||
/// 阶段文案
|
||||
label: String,
|
||||
/// 0.0~1.0
|
||||
progress: f32,
|
||||
},
|
||||
/// 错误:含诊断入口提示
|
||||
Error {
|
||||
/// 主按钮文字
|
||||
primary: String,
|
||||
/// 诊断/修复提示
|
||||
hint: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl DialogKind {
|
||||
/// 主按钮文字。
|
||||
pub fn primary_label(&self) -> &str {
|
||||
match self {
|
||||
Self::Confirm { primary, .. }
|
||||
| Self::Input { primary, .. }
|
||||
| Self::Error { primary, .. } => primary,
|
||||
Self::Progress { .. } => "取消",
|
||||
}
|
||||
}
|
||||
|
||||
/// 次按钮文字(None 表示不显示次按钮)。
|
||||
pub fn secondary_label(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Confirm { secondary, .. } => Some(secondary),
|
||||
Self::Error { .. } => Some("忽略"),
|
||||
Self::Input { .. } | Self::Progress { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否可交互(进度条不可点主按钮)。
|
||||
pub fn is_blocking_progress(&self) -> bool {
|
||||
matches!(self, Self::Progress { .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条对话框请求。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DialogRequest {
|
||||
/// 队列内唯一 id(由 [`DialogQueue::push_request`] 重新编号)
|
||||
pub id: DialogId,
|
||||
/// 来源(决定插队优先级)
|
||||
pub source: DialogSource,
|
||||
/// 标题
|
||||
pub title: String,
|
||||
/// 正文
|
||||
pub body: String,
|
||||
/// 类型与按钮
|
||||
pub kind: DialogKind,
|
||||
}
|
||||
|
||||
/// 模态队列:一次一条 + 来源插队。
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DialogQueue {
|
||||
current: Option<DialogRequest>,
|
||||
pending: Vec<DialogRequest>,
|
||||
next_id: DialogId,
|
||||
}
|
||||
|
||||
impl DialogQueue {
|
||||
/// 新建空队列。
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 当前应显示的对话框。
|
||||
pub fn current(&self) -> Option<&DialogRequest> {
|
||||
self.current.as_ref()
|
||||
}
|
||||
|
||||
/// 排队中(不含当前)条数。
|
||||
pub fn pending_len(&self) -> usize {
|
||||
self.pending.len()
|
||||
}
|
||||
|
||||
/// 是否完全没有对话框(当前 + 排队)。
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.current.is_none() && self.pending.is_empty()
|
||||
}
|
||||
|
||||
/// 入队一条请求(UI 内部调用方用,id 由队列自增分配)。没有当前条时立即成为当前条。
|
||||
///
|
||||
/// 返回值用于后续 [`Self::close`] / 进度更新。
|
||||
pub fn push(
|
||||
&mut self,
|
||||
source: DialogSource,
|
||||
title: impl Into<String>,
|
||||
body: impl Into<String>,
|
||||
kind: DialogKind,
|
||||
) -> DialogId {
|
||||
self.next_id += 1;
|
||||
self.push_request(DialogRequest {
|
||||
id: self.next_id,
|
||||
source,
|
||||
title: title.into(),
|
||||
body: body.into(),
|
||||
kind,
|
||||
})
|
||||
}
|
||||
|
||||
/// 入队一条已经组装好的请求(事件通道用)。
|
||||
///
|
||||
/// **id 归生产者**:这里原样保留 `request.id`(不重编号),因为关闭事件
|
||||
/// [`crate::dto::AppEvent::DialogClosed`] 携带的是生产者 id;重编号会让
|
||||
/// 「任务完成 → 自动收起进度框」在多来源弹框时静默失配。生产者负责自身 id 唯一。
|
||||
pub fn push_request(&mut self, request: DialogRequest) -> DialogId {
|
||||
let id = request.id;
|
||||
let source = request.source;
|
||||
if self.current.is_none() {
|
||||
self.current = Some(request);
|
||||
} else {
|
||||
let at = self
|
||||
.pending
|
||||
.iter()
|
||||
.position(|queued| queued.source < source)
|
||||
.unwrap_or(self.pending.len());
|
||||
self.pending.insert(at, request);
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
/// 关闭指定对话框:若是当前条则推进到下一条,否则从队列移除。
|
||||
///
|
||||
/// 返回 `true` 表示确实移除了。
|
||||
pub fn close(&mut self, id: DialogId) -> bool {
|
||||
if self
|
||||
.current
|
||||
.as_ref()
|
||||
.is_some_and(|current| current.id == id)
|
||||
{
|
||||
self.advance();
|
||||
return true;
|
||||
}
|
||||
if let Some(at) = self.pending.iter().position(|queued| queued.id == id) {
|
||||
self.pending.remove(at);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 推进到下一条排队请求(当前条已被处理)。
|
||||
pub fn advance(&mut self) {
|
||||
self.current = if self.pending.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.pending.remove(0))
|
||||
};
|
||||
}
|
||||
|
||||
/// 关闭当前条并推进到下一条(确认 / 取消 / 点遮罩都走这里)。
|
||||
///
|
||||
/// 返回 `false` 表示本来就没有当前条(幂等,双击不会误推进队列)。
|
||||
pub fn close_current(&mut self) -> bool {
|
||||
if self.current.is_none() {
|
||||
return false;
|
||||
}
|
||||
self.advance();
|
||||
true
|
||||
}
|
||||
|
||||
/// 更新进度对话框的进度(任务回调直接喂进来)。
|
||||
pub fn update_progress(&mut self, id: DialogId, progress: f32) {
|
||||
let Some(current) = &mut self.current else {
|
||||
return;
|
||||
};
|
||||
if current.id != id {
|
||||
return;
|
||||
}
|
||||
if let DialogKind::Progress {
|
||||
progress: value, ..
|
||||
} = &mut current.kind
|
||||
{
|
||||
*value = progress.clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{DialogKind, DialogQueue, DialogSource};
|
||||
|
||||
fn confirm() -> DialogKind {
|
||||
DialogKind::Confirm {
|
||||
primary: "确定".into(),
|
||||
secondary: "取消".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shows_one_dialog_at_a_time() {
|
||||
let mut queue = DialogQueue::new();
|
||||
let first = queue.push(DialogSource::BackgroundTask, "A", "", confirm());
|
||||
let second = queue.push(DialogSource::BackgroundTask, "B", "", confirm());
|
||||
|
||||
assert_eq!(queue.current().map(|d| d.id), Some(first));
|
||||
assert_eq!(queue.pending_len(), 1);
|
||||
|
||||
queue.advance();
|
||||
assert_eq!(queue.current().map(|d| d.id), Some(second));
|
||||
assert_eq!(queue.pending_len(), 0);
|
||||
|
||||
queue.advance();
|
||||
assert!(queue.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_action_jumps_the_queue() {
|
||||
let mut queue = DialogQueue::new();
|
||||
queue.push(DialogSource::Hint, "提示", "", confirm());
|
||||
queue.push(DialogSource::Hint, "提示2", "", confirm());
|
||||
queue.push(DialogSource::BackgroundTask, "后台", "", confirm());
|
||||
let urgent = queue.push(DialogSource::UserAction, "确认删除", "", confirm());
|
||||
|
||||
// 当前仍是队首(一次一条),但排队里用户操作插到最前 → 关掉当前条后立刻轮到它
|
||||
queue.advance();
|
||||
assert_eq!(queue.current().map(|d| d.id), Some(urgent));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_non_current_dialog_removes_it_from_pending() {
|
||||
let mut queue = DialogQueue::new();
|
||||
let first = queue.push(DialogSource::Hint, "A", "", confirm());
|
||||
let second = queue.push(DialogSource::Hint, "B", "", confirm());
|
||||
|
||||
assert!(queue.close(second));
|
||||
assert_eq!(queue.pending_len(), 0);
|
||||
assert_eq!(queue.current().map(|d| d.id), Some(first));
|
||||
assert!(!queue.close(999));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_dialog_can_be_updated() {
|
||||
let mut queue = DialogQueue::new();
|
||||
let id = queue.push(
|
||||
DialogSource::BackgroundTask,
|
||||
"下载中",
|
||||
"",
|
||||
DialogKind::Progress {
|
||||
label: "资源".into(),
|
||||
progress: 0.0,
|
||||
},
|
||||
);
|
||||
queue.update_progress(id, 1.7);
|
||||
match queue.current().map(|d| &d.kind) {
|
||||
Some(DialogKind::Progress { progress, .. }) => {
|
||||
assert!((*progress - 1.0).abs() < f32::EPSILON);
|
||||
}
|
||||
other => panic!("unexpected dialog kind: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
//! 账户页 DTO:三条轨道的统一抽象 + 微软登录(设备码 / 授权码浏览器)的可见状态。
|
||||
//!
|
||||
//! **凭据红线**:本模块(以及整个 `crate::dto`)只有 [`AccountView`] 这种公开字段视图。
|
||||
//! `core::auth` 的 `Credentials` / `MicrosoftSession` / token 永远不进 app 层——
|
||||
//! host 侧在拿到账户快照的那一刻就丢掉凭据,只留「能显示什么」。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 账户轨道(三轨统一抽象,`09 §G6` / `D9`)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AccountKind {
|
||||
/// 离线账户(HMCL 兼容 UUID)
|
||||
Offline,
|
||||
/// 微软正版
|
||||
Microsoft,
|
||||
/// authlib-injector(第三方皮肤站)
|
||||
Authlib,
|
||||
}
|
||||
|
||||
impl AccountKind {
|
||||
/// 徽标文字。
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Offline => "离线",
|
||||
Self::Microsoft => "微软",
|
||||
Self::Authlib => "第三方",
|
||||
}
|
||||
}
|
||||
|
||||
/// 稳定 id(事件/持久化用;与 core 的 `kind.as_str()` 同词表)。
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Offline => "offline",
|
||||
Self::Microsoft => "microsoft",
|
||||
Self::Authlib => "authlib_injector",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 core 的稳定 id 解析(未知轨道按离线处理,不让一个坏条目毁掉整个列表)。
|
||||
pub fn parse(raw: &str) -> Self {
|
||||
match raw {
|
||||
"microsoft" => Self::Microsoft,
|
||||
"authlib_injector" => Self::Authlib,
|
||||
_ => Self::Offline,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 账户列表行(**只含可公开字段**)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AccountView {
|
||||
/// 稳定 id(`account:...`)
|
||||
pub id: String,
|
||||
/// 展示名
|
||||
pub name: String,
|
||||
/// 轨道
|
||||
pub kind: AccountKind,
|
||||
/// 档案 UUID(公开)
|
||||
pub profile_id: String,
|
||||
/// 登录名(微软轨才有;可空)
|
||||
pub login_name: String,
|
||||
/// 创建时间展示串(`2026-10-31 20:12`)
|
||||
pub created: String,
|
||||
/// 是否是当前启动使用的账户
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
/// 微软登录阶段(UI 据此切换面板;两条流共用)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DeviceLoginPhase {
|
||||
/// 没有登录在进行
|
||||
Idle,
|
||||
/// 正在向微软申请设备码(短暂)
|
||||
Requesting,
|
||||
/// 已拿到用户码,等待用户在浏览器完成
|
||||
Waiting,
|
||||
/// 成功(账户已入库)
|
||||
Succeeded,
|
||||
/// 失败(原因在 `message`,已脱敏)
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl DeviceLoginPhase {
|
||||
/// 面板是否要显示(非 Idle 都显示)。
|
||||
pub fn visible(self) -> bool {
|
||||
!matches!(self, Self::Idle)
|
||||
}
|
||||
|
||||
/// 是否还在跑(Requesting / Waiting)。
|
||||
pub fn busy(self) -> bool {
|
||||
matches!(self, Self::Requesting | Self::Waiting)
|
||||
}
|
||||
}
|
||||
|
||||
/// 设备码登录的可见状态。
|
||||
///
|
||||
/// `user_code` / `verification_uri` 来自 `core::auth::microsoft::DeviceCodePrompt`——
|
||||
/// 它们本来就是给用户看的,不是凭据。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DeviceLoginView {
|
||||
/// 阶段
|
||||
pub phase: DeviceLoginPhase,
|
||||
/// 用户码(`ABCD-EFGH`)
|
||||
pub user_code: String,
|
||||
/// 验证地址(`https://www.microsoft.com/link`)
|
||||
pub verification_uri: String,
|
||||
/// 二维码内容(= 带码的链接;GUI 用文本展示,终端可直接打开)
|
||||
pub qr_link: String,
|
||||
/// 有效期(秒;0 = 未知)
|
||||
pub expires_in: u64,
|
||||
/// 状态/错误文案
|
||||
pub message: String,
|
||||
/// 成功后入库的账户展示名
|
||||
pub account_name: String,
|
||||
}
|
||||
|
||||
impl Default for DeviceLoginView {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceLoginView {
|
||||
/// 空闲态。
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
phase: DeviceLoginPhase::Idle,
|
||||
user_code: String::new(),
|
||||
verification_uri: String::new(),
|
||||
qr_link: String::new(),
|
||||
expires_in: 0,
|
||||
message: String::new(),
|
||||
account_name: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 已拿到用户码(`present` 回调时刻)。
|
||||
pub fn waiting(
|
||||
user_code: &str,
|
||||
verification_uri: &str,
|
||||
expires_in: u64,
|
||||
qr_hint: &str,
|
||||
) -> Self {
|
||||
let qr_link = if qr_hint.is_empty() {
|
||||
format!("{verification_uri}?code={user_code}")
|
||||
} else {
|
||||
qr_hint.to_string()
|
||||
};
|
||||
Self {
|
||||
phase: DeviceLoginPhase::Waiting,
|
||||
user_code: user_code.to_string(),
|
||||
verification_uri: verification_uri.to_string(),
|
||||
qr_link,
|
||||
expires_in,
|
||||
message: format!(
|
||||
"在浏览器打开 {verification_uri} 并输入用户码 {user_code}({} 秒内有效)",
|
||||
expires_in
|
||||
),
|
||||
account_name: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 授权码流:本地回调已就绪、浏览器已拉起(`present` 回调时刻)。
|
||||
///
|
||||
/// 面板不展示用户码,改展示**可复制的授权链接**(用户可能需要在另一台设备/
|
||||
/// 另一个浏览器里手动打开);`warning` 是「浏览器没拉起来」这类非致命提示。
|
||||
pub fn browser_waiting(
|
||||
authorization_url: &str,
|
||||
expires_in: u64,
|
||||
warning: Option<&str>,
|
||||
) -> Self {
|
||||
let mut message = format!(
|
||||
"已在系统浏览器打开微软登录页,请完成授权({} 秒内有效);没弹出就把下面的链接粘到浏览器里",
|
||||
expires_in.max(1)
|
||||
);
|
||||
if let Some(warning) = warning {
|
||||
message.push_str(&format!("({warning})"));
|
||||
}
|
||||
Self {
|
||||
phase: DeviceLoginPhase::Waiting,
|
||||
user_code: String::new(),
|
||||
verification_uri: authorization_url.to_string(),
|
||||
qr_link: authorization_url.to_string(),
|
||||
expires_in,
|
||||
message,
|
||||
account_name: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 失败态。
|
||||
pub fn failed(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
phase: DeviceLoginPhase::Failed,
|
||||
message: message.into(),
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 成功态。
|
||||
pub fn succeeded(account_name: impl Into<String>) -> Self {
|
||||
let account_name = account_name.into();
|
||||
Self {
|
||||
phase: DeviceLoginPhase::Succeeded,
|
||||
message: format!("已登录:{account_name}"),
|
||||
account_name,
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn kind_round_trips_through_stable_ids() {
|
||||
for kind in [
|
||||
AccountKind::Offline,
|
||||
AccountKind::Microsoft,
|
||||
AccountKind::Authlib,
|
||||
] {
|
||||
assert_eq!(AccountKind::parse(kind.as_str()), kind);
|
||||
assert!(!kind.label().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_login_view_never_carries_tokens() {
|
||||
let view = DeviceLoginView::waiting("ABCD-EFGH", "https://www.microsoft.com/link", 900, "");
|
||||
assert_eq!(view.phase, DeviceLoginPhase::Waiting);
|
||||
assert!(view.phase.busy());
|
||||
assert!(view.qr_link.contains("ABCD-EFGH"));
|
||||
let json = serde_json::to_string(&view).expect("序列化");
|
||||
for forbidden in ["token", "secret", "password", "refresh"] {
|
||||
assert!(
|
||||
!json.to_lowercase().contains(forbidden),
|
||||
"视图里不该出现 {forbidden}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_waiting_shows_a_copyable_link_instead_of_a_user_code() {
|
||||
let view = DeviceLoginView::browser_waiting(
|
||||
"https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=x",
|
||||
300,
|
||||
Some("拉起浏览器失败"),
|
||||
);
|
||||
assert_eq!(view.phase, DeviceLoginPhase::Waiting);
|
||||
assert!(view.phase.busy());
|
||||
assert!(view.user_code.is_empty(), "授权码流没有用户码");
|
||||
assert_eq!(view.qr_link, view.verification_uri);
|
||||
assert!(view.message.contains("浏览器"));
|
||||
assert!(view.message.contains("拉起浏览器失败"), "{}", view.message);
|
||||
let json = serde_json::to_string(&view).expect("序列化");
|
||||
for forbidden in ["refresh", "mc-access"] {
|
||||
assert!(!json.contains(forbidden), "视图里不该出现 {forbidden}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn succeeded_and_failed_clear_the_code() {
|
||||
let ok = DeviceLoginView::succeeded("Steve");
|
||||
assert_eq!(ok.phase, DeviceLoginPhase::Succeeded);
|
||||
assert!(ok.user_code.is_empty());
|
||||
let bad = DeviceLoginView::failed("网络超时");
|
||||
assert_eq!(bad.phase, DeviceLoginPhase::Failed);
|
||||
assert_eq!(bad.message, "网络超时");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! AI 会话 DTO:消息、工具调用可视化与 agent 事件。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 会话消息角色。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ChatRole {
|
||||
/// 用户
|
||||
User,
|
||||
/// 助手(可流式)
|
||||
Assistant,
|
||||
/// 工具调用(会话里可见,D4 要求)
|
||||
Tool,
|
||||
/// 系统提示(弱化展示)
|
||||
System,
|
||||
}
|
||||
|
||||
/// 工具调用可视化条目。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ToolCallView {
|
||||
/// 工具名(与 ToolSpec 注册表同名)
|
||||
pub name: String,
|
||||
/// 是否成功
|
||||
pub ok: bool,
|
||||
/// 一行结果摘要
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
/// 一条会话消息。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
/// 角色
|
||||
pub role: ChatRole,
|
||||
/// 正文(流式时持续追加)
|
||||
pub text: String,
|
||||
/// role == Tool 时带上工具调用详情
|
||||
pub tool: Option<ToolCallView>,
|
||||
/// 流式未收敛
|
||||
pub streaming: bool,
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
/// 用户消息。
|
||||
pub fn user(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: ChatRole::User,
|
||||
text: text.into(),
|
||||
tool: None,
|
||||
streaming: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 助手流式消息起点(空文本 + streaming)。
|
||||
pub fn assistant_stream() -> Self {
|
||||
Self {
|
||||
role: ChatRole::Assistant,
|
||||
text: String::new(),
|
||||
tool: None,
|
||||
streaming: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// agent 侧事件。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AgentEvent {
|
||||
/// 追加一条完整消息
|
||||
MessageAppended(ChatMessage),
|
||||
/// 给指定序号的流式消息追加增量
|
||||
MessageDelta {
|
||||
/// 目标消息在会话里的序号
|
||||
index: usize,
|
||||
/// 增量文本
|
||||
delta: String,
|
||||
},
|
||||
/// 流式收敛
|
||||
MessageFinished {
|
||||
/// 目标消息序号
|
||||
index: usize,
|
||||
},
|
||||
/// 开始一次工具调用(会话里插入工具条目)
|
||||
ToolCallStarted {
|
||||
/// 工具名
|
||||
name: String,
|
||||
/// 调用参数摘要
|
||||
summary: String,
|
||||
},
|
||||
/// 工具调用结束
|
||||
ToolCallFinished {
|
||||
/// 工具名
|
||||
name: String,
|
||||
/// 是否成功
|
||||
ok: bool,
|
||||
/// 结果摘要
|
||||
summary: String,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//! 事件与指令:core/agent → app 的事件队列,app → core/agent 的用户意图。
|
||||
//!
|
||||
//! 三条通道(01c §5):①不可变快照发布(`*Changed` 整体替换)②事件流(`LogAppended` /
|
||||
//! `InstallProgress` / `DeviceLoginChanged`)③注入的显示服务(先落成事件,见 `DialogRequested`)。
|
||||
//! 全部类型 `Serialize + Deserialize`:这是 IPC 契约(AGENTS §6)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dialogs::{DialogId, DialogRequest};
|
||||
use crate::dto::CreateRequest;
|
||||
use crate::dto::{
|
||||
AccountView, AgentEvent, DeviceLoginView, DiagnosisReport, InstallProgressView, InstanceId,
|
||||
InstanceRuntime, InstanceSummary, JavaPageView, LogLine, LogOutcome, LogSession, NoticeView,
|
||||
SessionId, SettingGroupView, VersionCategory, VersionSummary,
|
||||
};
|
||||
use crate::toast::Toast;
|
||||
|
||||
/// 启动会话的一段实时日志(host 尾随 JSONL 后按批次发)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LiveLogBatch {
|
||||
/// 会话 id
|
||||
pub session: SessionId,
|
||||
/// 新增行
|
||||
pub lines: Vec<LogLine>,
|
||||
/// 会话是否还在跑(false = 收尾,可以触发诊断/刷新列表)
|
||||
pub running: bool,
|
||||
}
|
||||
|
||||
/// 一次启动收尾(磁贴状态回写 + 日志页会话列表刷新)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LaunchFinished {
|
||||
/// 实例 id
|
||||
pub id: InstanceId,
|
||||
/// 会话 id
|
||||
pub session: SessionId,
|
||||
/// 结局
|
||||
pub outcome: LogOutcome,
|
||||
/// 一句人读结论(`正常退出` / `崩溃(exit 1)` / `启动失败:…`)
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
/// core 侧事件(不可变快照发布 + 事件流,01c §5 的三条通道之二)。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum CoreEvent {
|
||||
/// 实例列表整体刷新
|
||||
InstancesChanged(Vec<InstanceSummary>),
|
||||
/// 单个实例运行时状态变化(启动进度/运行中/失败)
|
||||
InstanceRuntimeChanged {
|
||||
/// 实例 id
|
||||
id: InstanceId,
|
||||
/// 新状态
|
||||
runtime: InstanceRuntime,
|
||||
},
|
||||
/// 下载页版本列表刷新
|
||||
VersionListChanged(Vec<VersionSummary>),
|
||||
/// 日志会话列表刷新
|
||||
LogSessionsChanged(Vec<LogSession>),
|
||||
/// 当前会话日志行刷新(整批替换)
|
||||
LogLinesChanged(Vec<LogLine>),
|
||||
/// 实时日志增量(启动中尾随 JSONL;`running=false` 表示会话收尾)
|
||||
LogAppended(LiveLogBatch),
|
||||
/// 启动收尾(磁贴状态回写 + toast)
|
||||
LaunchFinished(LaunchFinished),
|
||||
/// 诊断结论产出
|
||||
DiagnosisReady(DiagnosisReport),
|
||||
/// 账户列表刷新
|
||||
AccountsChanged(Vec<AccountView>),
|
||||
/// 微软登录状态刷新
|
||||
DeviceLoginChanged(DeviceLoginView),
|
||||
/// Java 页快照(发行版/回退链/运行时)
|
||||
JavaChanged(JavaPageView),
|
||||
/// 安装进度(`None` = 清掉进度面板)
|
||||
InstallProgress(Option<InstallProgressView>),
|
||||
/// 设置分组刷新(设置页右栏)
|
||||
SettingsChanged(Vec<SettingGroupView>),
|
||||
/// 横幅通知刷新(隔离切换提示/启动失败等)
|
||||
NoticesChanged(Vec<NoticeView>),
|
||||
}
|
||||
|
||||
/// 统一事件队列的元素:core / agent / UI 自产(对话框、Toast)都走这一条路,
|
||||
/// 这样 [`crate::state::AppState::apply`] 是唯一的状态变更入口。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum AppEvent {
|
||||
/// 来自 core
|
||||
Core(CoreEvent),
|
||||
/// 来自 agent
|
||||
Agent(AgentEvent),
|
||||
/// core/agent 请求弹对话框(会进队列,一次一条)
|
||||
DialogRequested(DialogRequest),
|
||||
/// 关闭指定对话框(任务完成收起进度条)
|
||||
DialogClosed(DialogId),
|
||||
/// 右下角 Toast
|
||||
Toast(Toast),
|
||||
}
|
||||
|
||||
/// app → core/agent 的用户意图。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AppCommand {
|
||||
/// 重新扫描实例目录 + 全部页面快照(启动时与手改文件后)
|
||||
RefreshAll,
|
||||
/// 启动实例
|
||||
LaunchInstance(InstanceId),
|
||||
/// 停止实例(含取消启动中的管线)
|
||||
StopInstance(InstanceId),
|
||||
/// 拉取某分类的版本列表
|
||||
ListVersions(VersionCategory),
|
||||
/// 安装指定版本(下载页行内「安装」按钮;原版)
|
||||
InstallVersion(String),
|
||||
/// 取消安装(阶段边界生效:当前阶段跑完就停,不再进下一阶段)
|
||||
CancelInstall,
|
||||
/// 新建实例(向导提交:版本 + 加载器 + 名字)
|
||||
CreateInstance(CreateRequest),
|
||||
/// 修改实例隔离档位(写实例设置文件)
|
||||
SetIsolation {
|
||||
/// 实例 id
|
||||
id: InstanceId,
|
||||
/// `isolated` / `shared`
|
||||
mode: String,
|
||||
},
|
||||
/// 刷新日志会话列表
|
||||
RefreshLogSessions,
|
||||
/// 打开某个会话(拉取日志行 + 触发诊断)
|
||||
OpenLogSession(SessionId),
|
||||
/// 导入崩溃日志文件做规则分析(`hs_err_pid*.log` / `latest.log`)
|
||||
ImportCrashLog(String),
|
||||
/// 加一个离线账户
|
||||
AddOfflineAccount(String),
|
||||
/// 删除账户(按 id 或名字)
|
||||
RemoveAccount(String),
|
||||
/// 切换当前启动账户
|
||||
SwitchAccount(String),
|
||||
/// 开始微软设备码登录(CLI/无浏览器场景、备用通道)
|
||||
StartDeviceLogin,
|
||||
/// 开始微软授权码登录(拉起系统默认浏览器 + 本地回调,GUI 的默认入口)
|
||||
StartBrowserLogin,
|
||||
/// 取消/关掉微软登录面板(已发出的请求会在收尾时丢弃结果)
|
||||
CancelDeviceLogin,
|
||||
/// 安装指定发行版的托管 JDK(`temurin` / `oracle` / `microsoft` / `zulu`)
|
||||
InstallJava(String),
|
||||
/// 写一条设置(`key` 必须是 core schema 里的键)
|
||||
SetSetting {
|
||||
/// schema 键
|
||||
key: String,
|
||||
/// 新值(字符串形态;host 按 schema 类型转换)
|
||||
value: String,
|
||||
},
|
||||
/// 关掉一条横幅通知
|
||||
DismissNotice(String),
|
||||
/// 向 AI 提问(会话状态在 app 侧,agent 只回事件)
|
||||
AskAgent(String),
|
||||
/// 中断当前 AI 流
|
||||
StopAgent,
|
||||
/// YOLO 开关(跳过确认,审计照记)
|
||||
SetYolo(bool),
|
||||
}
|
||||
|
||||
impl AppCommand {
|
||||
/// 构造「切换隔离档位」指令(磁贴/详情页共用)。
|
||||
pub fn set_isolation(id: &str, mode: &str) -> Self {
|
||||
Self::SetIsolation {
|
||||
id: id.to_string(),
|
||||
mode: mode.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 后端通道:core/agent host 实现它;[`crate::mock::MockBackend`] 验证通道语义。
|
||||
///
|
||||
/// 轮询模型(而非回调)是为了让 Slint 的定时器成为唯一调度者,
|
||||
/// 避免跨线程回调里碰 Slint 模型。
|
||||
pub trait Backend {
|
||||
/// 取走自上次调用以来积压的事件(FIFO,不可丢序号)。
|
||||
fn drain_events(&mut self) -> Vec<AppEvent>;
|
||||
|
||||
/// 下发一条用户意图。
|
||||
fn dispatch(&mut self, command: AppCommand);
|
||||
|
||||
/// 时间推进(真实后端在这里收后台线程的结果并尾随日志)。
|
||||
fn tick(&mut self, elapsed: std::time::Duration) {
|
||||
let _ = elapsed;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dto::{AccountKind, DeviceLoginPhase, InstallStage};
|
||||
|
||||
#[test]
|
||||
fn events_are_serializable_ipc_contracts() {
|
||||
let event = AppEvent::Core(CoreEvent::InstallProgress(Some(InstallProgressView {
|
||||
stage: InstallStage::Downloading,
|
||||
files_done: 3,
|
||||
files_total: 9,
|
||||
..InstallProgressView::new()
|
||||
})));
|
||||
let text = serde_json::to_string(&event).expect("序列化");
|
||||
let back: AppEvent = serde_json::from_str(&text).expect("反序列化");
|
||||
assert_eq!(back, event);
|
||||
|
||||
let account = AppEvent::Core(CoreEvent::AccountsChanged(vec![AccountView {
|
||||
id: "account:1".to_string(),
|
||||
name: "Steve".to_string(),
|
||||
kind: AccountKind::Offline,
|
||||
profile_id: "0f8f".to_string(),
|
||||
login_name: String::new(),
|
||||
created: "2026-10-31 20:12".to_string(),
|
||||
active: true,
|
||||
}]));
|
||||
let text = serde_json::to_string(&account).expect("序列化");
|
||||
let back: AppEvent = serde_json::from_str(&text).expect("反序列化");
|
||||
assert_eq!(back, account);
|
||||
// 凭据永不跨界:序列化文本里没有 token 类字段
|
||||
assert!(!text.to_lowercase().contains("token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_round_trip() {
|
||||
let command = AppCommand::CreateInstance(CreateRequest {
|
||||
version: "1.20.4".to_string(),
|
||||
loader: crate::dto::LoaderKind::Fabric,
|
||||
loader_version: "0.16.5".to_string(),
|
||||
name: "生存".to_string(),
|
||||
});
|
||||
let text = serde_json::to_string(&command).expect("序列化");
|
||||
let back: AppCommand = serde_json::from_str(&text).expect("反序列化");
|
||||
assert_eq!(back, command);
|
||||
|
||||
let login = AppCommand::StartDeviceLogin;
|
||||
assert_eq!(
|
||||
serde_json::from_str::<AppCommand>(&serde_json::to_string(&login).expect("序列化"))
|
||||
.expect("反序列化"),
|
||||
login
|
||||
);
|
||||
assert!(DeviceLoginPhase::Waiting.busy());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
//! 安装进度 DTO:下载页与新建向导共用的「一个安装任务」视图。
|
||||
//!
|
||||
//! 只放聚合量(文件计数 / 字节 / 速度 / 剩余时间)+ 一行状态文案:
|
||||
//! 逐文件的明细留在 core 的 `InstallReport`,UI 不需要也不该持有。
|
||||
//! 速度与 ETA 由 host 依据相邻两次字节数差分算(core 的事件只给绝对值)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 安装阶段。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum InstallStage {
|
||||
/// 解析版本清单 / 规划文件(联网取 metadata)
|
||||
Planning,
|
||||
/// 下载并校验文件
|
||||
Downloading,
|
||||
/// 落版本 JSON / 跑安装器
|
||||
Installing,
|
||||
/// 登记实例
|
||||
Registering,
|
||||
/// 完成
|
||||
Done,
|
||||
/// 失败
|
||||
Failed,
|
||||
/// 用户取消
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl InstallStage {
|
||||
/// 阶段文案。
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Planning => "解析版本",
|
||||
Self::Downloading => "下载文件",
|
||||
Self::Installing => "生成版本",
|
||||
Self::Registering => "登记实例",
|
||||
Self::Done => "已完成",
|
||||
Self::Failed => "失败",
|
||||
Self::Cancelled => "已取消",
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否已经收尾(Done/Failed/Cancelled)。
|
||||
pub fn is_terminal(self) -> bool {
|
||||
matches!(self, Self::Done | Self::Failed | Self::Cancelled)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次安装的进度快照。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InstallProgressView {
|
||||
/// 目标版本 id
|
||||
pub version: String,
|
||||
/// 实例名(默认 = 版本 id)
|
||||
pub instance: String,
|
||||
/// 加载器徽标文字(`原版` / `Fabric 0.16.5`)
|
||||
pub loader: String,
|
||||
/// 阶段
|
||||
pub stage: InstallStage,
|
||||
/// 已完成文件数
|
||||
pub files_done: u64,
|
||||
/// 文件总数
|
||||
pub files_total: u64,
|
||||
/// 已下载字节
|
||||
pub bytes_done: u64,
|
||||
/// 总字节(0 = 未知)
|
||||
pub bytes_total: u64,
|
||||
/// 当前速度(字节/秒;0 = 未知)
|
||||
pub speed_bps: u64,
|
||||
/// 剩余秒数(0 = 未知)
|
||||
pub eta_secs: u64,
|
||||
/// 状态/错误文案
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl Default for InstallProgressView {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl InstallProgressView {
|
||||
/// 规划中的空快照。
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
version: String::new(),
|
||||
instance: String::new(),
|
||||
loader: String::new(),
|
||||
stage: InstallStage::Planning,
|
||||
files_done: 0,
|
||||
files_total: 0,
|
||||
bytes_done: 0,
|
||||
bytes_total: 0,
|
||||
speed_bps: 0,
|
||||
eta_secs: 0,
|
||||
message: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 起一个任务的快照。
|
||||
pub fn start(version: &str, instance: &str, loader: &str) -> Self {
|
||||
Self {
|
||||
version: version.to_string(),
|
||||
instance: instance.to_string(),
|
||||
loader: loader.to_string(),
|
||||
stage: InstallStage::Planning,
|
||||
message: "正在解析版本清单…".to_string(),
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 整体进度 0.0~1.0(优先文件计数,其次字节;都没有时 0)。
|
||||
pub fn ratio(&self) -> f32 {
|
||||
if self.files_total > 0 {
|
||||
(self.files_done as f32 / self.files_total as f32).clamp(0.0, 1.0)
|
||||
} else if self.bytes_total > 0 {
|
||||
(self.bytes_done as f32 / self.bytes_total as f32).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// 进度是否已知(未知时进度条转不确定态)。
|
||||
pub fn is_known(&self) -> bool {
|
||||
self.files_total > 0 || self.bytes_total > 0
|
||||
}
|
||||
|
||||
/// 速度文案(`1.2 MB/s`;未知给空串)。
|
||||
pub fn speed_label(&self) -> String {
|
||||
if self.speed_bps == 0 {
|
||||
return String::new();
|
||||
}
|
||||
format!("{}/s", human_bytes(self.speed_bps))
|
||||
}
|
||||
|
||||
/// 剩余时间文案(`剩 12s`;未知给空串)。
|
||||
pub fn eta_label(&self) -> String {
|
||||
if self.eta_secs == 0 {
|
||||
return String::new();
|
||||
}
|
||||
if self.eta_secs >= 60 {
|
||||
format!("剩 {}m{:02}s", self.eta_secs / 60, self.eta_secs % 60)
|
||||
} else {
|
||||
format!("剩 {}s", self.eta_secs)
|
||||
}
|
||||
}
|
||||
|
||||
/// 文件计数文案(`128/512 文件`)。
|
||||
pub fn files_label(&self) -> String {
|
||||
if self.files_total == 0 {
|
||||
return String::new();
|
||||
}
|
||||
format!("{}/{} 文件", self.files_done, self.files_total)
|
||||
}
|
||||
}
|
||||
|
||||
/// 字节数 → 人读(`1.2 MB`)。
|
||||
pub fn human_bytes(bytes: u64) -> String {
|
||||
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
|
||||
let mut value = bytes as f64;
|
||||
let mut unit = 0;
|
||||
while value >= 1024.0 && unit + 1 < UNITS.len() {
|
||||
value /= 1024.0;
|
||||
unit += 1;
|
||||
}
|
||||
if unit == 0 {
|
||||
format!("{bytes} B")
|
||||
} else {
|
||||
format!("{value:.1} {}", UNITS[unit])
|
||||
}
|
||||
}
|
||||
|
||||
/// 用相邻两次采样算速度与 ETA(`None` = 本次无法计算,保持上次值)。
|
||||
pub fn derive_speed(
|
||||
previous: (u64, std::time::Instant),
|
||||
current: (u64, std::time::Instant),
|
||||
bytes_total: u64,
|
||||
) -> Option<(u64, u64)> {
|
||||
let elapsed = current.1.saturating_duration_since(previous.1);
|
||||
if elapsed.as_millis() < 50 {
|
||||
return None;
|
||||
}
|
||||
let delta = current.0.saturating_sub(previous.0);
|
||||
if delta == 0 {
|
||||
return None;
|
||||
}
|
||||
let elapsed_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
|
||||
let speed = delta.saturating_mul(1000) / elapsed_ms.max(1);
|
||||
let eta = if bytes_total > current.0 && speed > 0 {
|
||||
(bytes_total - current.0) / speed
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Some((speed, eta))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[test]
|
||||
fn ratio_prefers_file_counts() {
|
||||
let mut view = InstallProgressView::start("1.20.4", "1.20.4", "原版");
|
||||
assert_eq!(view.ratio(), 0.0);
|
||||
assert!(!view.is_known());
|
||||
|
||||
view.files_total = 4;
|
||||
view.files_done = 1;
|
||||
view.bytes_total = 1000;
|
||||
view.bytes_done = 900;
|
||||
assert!((view.ratio() - 0.25).abs() < f32::EPSILON);
|
||||
assert!(view.is_known());
|
||||
assert_eq!(view.files_label(), "1/4 文件");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_and_eta_labels_are_human_readable() {
|
||||
let view = InstallProgressView {
|
||||
speed_bps: 1_258_291,
|
||||
eta_secs: 75,
|
||||
..InstallProgressView::new()
|
||||
};
|
||||
assert_eq!(view.speed_label(), "1.2 MB/s");
|
||||
assert_eq!(view.eta_label(), "剩 1m15s");
|
||||
assert_eq!(InstallProgressView::new().speed_label(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_speed_needs_a_measurable_delta() {
|
||||
let start = Instant::now();
|
||||
let previous = (0, start);
|
||||
// 间隔太短 → 不算
|
||||
assert!(derive_speed(previous, (500, start + Duration::from_millis(10)), 1000).is_none());
|
||||
// 正常差分:1s 下 500 字节 → 500 B/s,剩 500 字节 → 1s
|
||||
let (speed, eta) =
|
||||
derive_speed(previous, (500, start + Duration::from_secs(1)), 1000).expect("应能算");
|
||||
assert_eq!(speed, 500);
|
||||
assert_eq!(eta, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_stages() {
|
||||
assert!(InstallStage::Done.is_terminal());
|
||||
assert!(InstallStage::Failed.is_terminal());
|
||||
assert!(!InstallStage::Downloading.is_terminal());
|
||||
assert_eq!(human_bytes(512), "512 B");
|
||||
assert_eq!(human_bytes(2048), "2.0 KB");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! 实例相关 DTO:磁贴与列表视图的唯一数据源。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 实例标识:core::game 生成的稳定 id,app 视为不透明字符串。
|
||||
pub type InstanceId = String;
|
||||
|
||||
/// 加载器种类(磁贴徽标 + 详情页版本链)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LoaderKind {
|
||||
/// 原版(无加载器)
|
||||
Vanilla,
|
||||
/// Fabric
|
||||
Fabric,
|
||||
/// Quilt
|
||||
Quilt,
|
||||
/// Forge
|
||||
Forge,
|
||||
/// NeoForge
|
||||
NeoForge,
|
||||
/// OptiFine
|
||||
OptiFine,
|
||||
/// LiteLoader
|
||||
LiteLoader,
|
||||
}
|
||||
|
||||
impl LoaderKind {
|
||||
/// 徽标显示文字。
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Vanilla => "原版",
|
||||
Self::Fabric => "Fabric",
|
||||
Self::Quilt => "Quilt",
|
||||
Self::Forge => "Forge",
|
||||
Self::NeoForge => "NeoForge",
|
||||
Self::OptiFine => "OptiFine",
|
||||
Self::LiteLoader => "LiteLoader",
|
||||
}
|
||||
}
|
||||
|
||||
/// 稳定 id(`vanilla` / `fabric` / …;GUI 与 CLI 的 `--loader` 同词表)。
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Vanilla => "vanilla",
|
||||
Self::Fabric => "fabric",
|
||||
Self::Quilt => "quilt",
|
||||
Self::Forge => "forge",
|
||||
Self::NeoForge => "neoforge",
|
||||
Self::OptiFine => "optifine",
|
||||
Self::LiteLoader => "liteloader",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从稳定 id 解析(未知给 `None`,由调用方决定报错文案)。
|
||||
pub fn parse(raw: &str) -> Option<Self> {
|
||||
Some(match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"vanilla" | "none" | "" => Self::Vanilla,
|
||||
"fabric" => Self::Fabric,
|
||||
"quilt" => Self::Quilt,
|
||||
"forge" => Self::Forge,
|
||||
"neoforge" => Self::NeoForge,
|
||||
"optifine" => Self::OptiFine,
|
||||
"liteloader" => Self::LiteLoader,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 向导里的可选加载器(顺序即展示顺序)。
|
||||
pub fn wizard_choices() -> [Self; 5] {
|
||||
[
|
||||
Self::Vanilla,
|
||||
Self::Fabric,
|
||||
Self::Quilt,
|
||||
Self::Forge,
|
||||
Self::NeoForge,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载器 + 版本(例如 Fabric 0.16.5)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LoaderInfo {
|
||||
/// 加载器种类
|
||||
pub kind: LoaderKind,
|
||||
/// 加载器版本号;原版为空串
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
impl Default for LoaderInfo {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
kind: LoaderKind::Vanilla,
|
||||
version: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LoaderInfo {
|
||||
/// 徽标文字:原版只写「原版」,装加载器时写「Fabric 0.16.5」。
|
||||
pub fn badge(&self) -> String {
|
||||
if self.kind == LoaderKind::Vanilla || self.version.is_empty() {
|
||||
self.kind.label().to_string()
|
||||
} else {
|
||||
format!("{} {}", self.kind.label(), self.version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 字节进度;`total == 0` 表示总量未知(UI 转不确定态)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct Progress {
|
||||
/// 已完成量
|
||||
pub done: u64,
|
||||
/// 总量;0 = 未知
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
impl Progress {
|
||||
/// 0.0~1.0;未知总量返回 0.0。
|
||||
pub fn ratio(self) -> f32 {
|
||||
if self.total == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(self.done as f32 / self.total as f32).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 总量是否已知。
|
||||
pub fn is_known(self) -> bool {
|
||||
self.total > 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动管线阶段(磁贴进度环的文案来源;D8 的 10 阶段在 P1 细化)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LaunchStage {
|
||||
/// 下载资源
|
||||
Download,
|
||||
/// 校验文件
|
||||
Verify,
|
||||
/// 组装命令行并拉起进程
|
||||
Launch,
|
||||
/// 进程已起、等待游戏窗口(M28 的窗口检测阶段)
|
||||
Window,
|
||||
}
|
||||
|
||||
impl LaunchStage {
|
||||
/// 磁贴上的阶段文案。
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Download => "下载资源",
|
||||
Self::Verify => "校验文件",
|
||||
Self::Launch => "组装命令行",
|
||||
Self::Window => "等待窗口",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 实例运行时状态(磁贴的四种外观)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum InstanceRuntime {
|
||||
/// 空闲
|
||||
#[default]
|
||||
Idle,
|
||||
/// 启动管线进行中
|
||||
Busy {
|
||||
/// 当前阶段
|
||||
stage: LaunchStage,
|
||||
/// 该阶段进度
|
||||
progress: Progress,
|
||||
},
|
||||
/// 游戏进程存活
|
||||
Running {
|
||||
/// 进程号(停止/诊断用)
|
||||
pid: u32,
|
||||
},
|
||||
/// 上次启动失败(磁贴显示「重试」)
|
||||
Failed {
|
||||
/// 失败原因(用户可见文案)
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// 实例摘要:磁贴/列表视图唯一数据源。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct InstanceSummary {
|
||||
/// 稳定 id
|
||||
pub id: InstanceId,
|
||||
/// 显示名
|
||||
pub name: String,
|
||||
/// 游戏版本(如 1.20.4)
|
||||
pub mc_version: String,
|
||||
/// 加载器信息
|
||||
pub loader: LoaderInfo,
|
||||
/// ISO-8601 UTC 最后游玩时间;None = 从未启动
|
||||
pub last_played: Option<String>,
|
||||
/// 运行时状态
|
||||
pub runtime: InstanceRuntime,
|
||||
/// 实例图标路径(P0 直接给路径;P1 换成 core 提供的图像句柄)
|
||||
pub icon: Option<PathBuf>,
|
||||
/// 隔离档位(`isolated` / `shared` / `custom`;未知为空)
|
||||
pub isolation: String,
|
||||
/// 该实例绑定的版本 id(详情页/启动用)
|
||||
pub version_id: String,
|
||||
/// 版本本体是否在盘上(缺载荷时磁贴亮黄条)
|
||||
pub version_present: bool,
|
||||
}
|
||||
|
||||
/// 新建实例向导提交给 host 的请求(已通过向导校验)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CreateRequest {
|
||||
/// 目标原版版本 id(`1.20.4`)
|
||||
pub version: String,
|
||||
/// 加载器
|
||||
pub loader: LoaderKind,
|
||||
/// 加载器版本(空 = 让 core 取最新)
|
||||
pub loader_version: String,
|
||||
/// 实例名(空 = 用版本 id / 加载器版本 id)
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl CreateRequest {
|
||||
/// 实际使用的实例名。
|
||||
pub fn instance_name(&self) -> String {
|
||||
if self.name.trim().is_empty() {
|
||||
self.version.clone()
|
||||
} else {
|
||||
self.name.trim().to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn loader_badges_and_ids() {
|
||||
let vanilla = LoaderInfo::default();
|
||||
assert_eq!(vanilla.badge(), "原版");
|
||||
let fabric = LoaderInfo {
|
||||
kind: LoaderKind::Fabric,
|
||||
version: "0.16.5".to_string(),
|
||||
};
|
||||
assert_eq!(fabric.badge(), "Fabric 0.16.5");
|
||||
for kind in LoaderKind::wizard_choices() {
|
||||
assert_eq!(LoaderKind::parse(kind.as_str()), Some(kind));
|
||||
}
|
||||
assert_eq!(LoaderKind::parse("fabric"), Some(LoaderKind::Fabric));
|
||||
assert_eq!(LoaderKind::parse("unknown"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_ratio_handles_unknown_total() {
|
||||
assert_eq!(Progress { done: 1, total: 0 }.ratio(), 0.0);
|
||||
assert!((Progress { done: 1, total: 4 }.ratio() - 0.25).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_request_defaults_name_to_version() {
|
||||
let request = CreateRequest {
|
||||
version: "1.20.4".to_string(),
|
||||
loader: LoaderKind::Vanilla,
|
||||
loader_version: String::new(),
|
||||
name: " ".to_string(),
|
||||
};
|
||||
assert_eq!(request.instance_name(), "1.20.4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_summary_is_serde_round_trip_safe() {
|
||||
let instance = InstanceSummary {
|
||||
id: "inst-1".to_string(),
|
||||
name: "生存".to_string(),
|
||||
mc_version: "1.20.4".to_string(),
|
||||
loader: LoaderInfo {
|
||||
kind: LoaderKind::Fabric,
|
||||
version: "0.16.5".to_string(),
|
||||
},
|
||||
last_played: Some("2026-10-31T12:00:00.000Z".to_string()),
|
||||
runtime: InstanceRuntime::Busy {
|
||||
stage: LaunchStage::Window,
|
||||
progress: Progress { done: 1, total: 2 },
|
||||
},
|
||||
icon: None,
|
||||
isolation: "isolated".to_string(),
|
||||
version_id: "1.20.4".to_string(),
|
||||
version_present: true,
|
||||
};
|
||||
let text = serde_json::to_string(&instance).expect("序列化");
|
||||
let back: InstanceSummary = serde_json::from_str(&text).expect("反序列化");
|
||||
assert_eq!(back, instance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Java 页 DTO:四家发行版、回退链可视化与本地运行时清单(D23/D13)。
|
||||
//!
|
||||
//! 回退链是「可观测性」的载体:安装失败时用户要能看见「Temurin 跳过 → Oracle 失败 →
|
||||
//! Microsoft 命中」这条链,而不是只拿到一句「下载失败」。链的每一步由 host 从
|
||||
//! `core::game::java::source::ChainLog` 翻译过来。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 一家发行版的展示行。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct JavaVendorView {
|
||||
/// 稳定 id(`temurin` / `oracle` / `microsoft` / `zulu`)
|
||||
pub id: String,
|
||||
/// 展示名
|
||||
pub display: String,
|
||||
/// 优先级序号(1 起)
|
||||
pub rank: u32,
|
||||
/// 支持的 Java 大版本摘要(`21/17/11`)
|
||||
pub majors: String,
|
||||
/// 可用源(镜像 + 吞吐备注)
|
||||
pub sources: Vec<String>,
|
||||
/// 是否已装过这家(托管目录里有现成的)
|
||||
pub installed: bool,
|
||||
}
|
||||
|
||||
/// 回退链一步的结局。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum JavaChainOutcome {
|
||||
/// 还没尝试(安装前的计划链)
|
||||
Pending,
|
||||
/// 跳过(不支持目标大版本)
|
||||
Skip,
|
||||
/// 尝试过但失败
|
||||
Failed,
|
||||
/// 命中(下载/安装成功)
|
||||
Hit,
|
||||
}
|
||||
|
||||
impl JavaChainOutcome {
|
||||
/// 徽标文字。
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "待尝试",
|
||||
Self::Skip => "跳过",
|
||||
Self::Failed => "失败",
|
||||
Self::Hit => "命中",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 core 的 `VendorAttempt` 结局字符串解析。
|
||||
pub fn parse(raw: &str) -> Self {
|
||||
match raw {
|
||||
"hit" => Self::Hit,
|
||||
"failed" => Self::Failed,
|
||||
"skip" | "skipped" => Self::Skip,
|
||||
_ => Self::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 回退链一步。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct JavaChainStep {
|
||||
/// 发行版 id
|
||||
pub vendor: String,
|
||||
/// 结局
|
||||
pub outcome: JavaChainOutcome,
|
||||
/// 细节(跳过原因 / 失败摘要 / 命中的下载源)
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// 本地已发现的 Java 运行时。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct JavaRuntimeView {
|
||||
/// 可执行文件路径
|
||||
pub binary: String,
|
||||
/// 版本串(`17.0.9`)
|
||||
pub version: String,
|
||||
/// 大版本
|
||||
pub major: u32,
|
||||
/// 发行方
|
||||
pub vendor: String,
|
||||
/// 来源分类(system / managed / instance)
|
||||
pub origin: String,
|
||||
}
|
||||
|
||||
/// Java 页快照(一次事件整体替换)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct JavaPageView {
|
||||
/// 地区判定(`domestic` / `global`)
|
||||
pub region: String,
|
||||
/// 优先级的来源(`region_default` / `java.vendorPriority`)
|
||||
pub priority_source: String,
|
||||
/// 生效顺序(`temurin → oracle → microsoft → zulu`)
|
||||
pub order_text: String,
|
||||
/// 四家展示行(按生效顺序)
|
||||
pub vendors: Vec<JavaVendorView>,
|
||||
/// 回退链(计划链或上一次安装的真实链)
|
||||
pub chain: Vec<JavaChainStep>,
|
||||
/// 本地运行时
|
||||
pub runtimes: Vec<JavaRuntimeView>,
|
||||
/// 托管目录
|
||||
pub managed_roots: Vec<String>,
|
||||
/// 是否有 Java 安装在跑
|
||||
pub busy: bool,
|
||||
/// 状态/错误文案
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl Default for JavaPageView {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl JavaPageView {
|
||||
/// 空态(托管目录还没扫到东西)。
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
region: "global".to_string(),
|
||||
priority_source: "region_default".to_string(),
|
||||
order_text: String::new(),
|
||||
vendors: Vec::new(),
|
||||
chain: Vec::new(),
|
||||
runtimes: Vec::new(),
|
||||
managed_roots: Vec::new(),
|
||||
busy: false,
|
||||
message: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 计划链:按生效顺序铺一排「待尝试」(安装前的可视化)。
|
||||
pub fn planned_chain(vendors: &[JavaVendorView]) -> Vec<JavaChainStep> {
|
||||
vendors
|
||||
.iter()
|
||||
.map(|vendor| JavaChainStep {
|
||||
vendor: vendor.id.clone(),
|
||||
outcome: JavaChainOutcome::Pending,
|
||||
detail: String::new(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 命中的那一家的展示名(`None` = 链上还没命中)。
|
||||
pub fn hit_vendor(&self) -> Option<&str> {
|
||||
self.chain
|
||||
.iter()
|
||||
.find(|step| step.outcome == JavaChainOutcome::Hit)
|
||||
.map(|step| step.vendor.as_str())
|
||||
}
|
||||
|
||||
/// 已安装的大版本列表(去重升序)。
|
||||
pub fn majors(&self) -> Vec<u32> {
|
||||
let mut majors: Vec<u32> = self.runtimes.iter().map(|runtime| runtime.major).collect();
|
||||
majors.sort_unstable();
|
||||
majors.dedup();
|
||||
majors
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn vendor(id: &str, rank: u32) -> JavaVendorView {
|
||||
JavaVendorView {
|
||||
id: id.to_string(),
|
||||
display: id.to_uppercase(),
|
||||
rank,
|
||||
majors: "21/17".to_string(),
|
||||
sources: Vec::new(),
|
||||
installed: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planned_chain_is_all_pending_in_rank_order() {
|
||||
let vendors = vec![vendor("temurin", 1), vendor("oracle", 2)];
|
||||
let chain = JavaPageView::planned_chain(&vendors);
|
||||
assert_eq!(chain.len(), 2);
|
||||
assert!(chain
|
||||
.iter()
|
||||
.all(|step| step.outcome == JavaChainOutcome::Pending));
|
||||
assert_eq!(chain[0].vendor, "temurin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_vendor_finds_the_hit_step() {
|
||||
let mut page = JavaPageView::new();
|
||||
page.chain = vec![
|
||||
JavaChainStep {
|
||||
vendor: "temurin".to_string(),
|
||||
outcome: JavaChainOutcome::Skip,
|
||||
detail: "不支持 8".to_string(),
|
||||
},
|
||||
JavaChainStep {
|
||||
vendor: "oracle".to_string(),
|
||||
outcome: JavaChainOutcome::Hit,
|
||||
detail: "BMCLAPI".to_string(),
|
||||
},
|
||||
];
|
||||
assert_eq!(page.hit_vendor(), Some("oracle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_parses_core_words() {
|
||||
assert_eq!(JavaChainOutcome::parse("hit"), JavaChainOutcome::Hit);
|
||||
assert_eq!(JavaChainOutcome::parse("failed"), JavaChainOutcome::Failed);
|
||||
assert_eq!(JavaChainOutcome::parse("skip"), JavaChainOutcome::Skip);
|
||||
assert_eq!(JavaChainOutcome::parse("?"), JavaChainOutcome::Pending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn majors_are_deduped_and_sorted() {
|
||||
let mut page = JavaPageView::new();
|
||||
page.runtimes = vec![
|
||||
JavaRuntimeView {
|
||||
binary: "a".to_string(),
|
||||
version: "17".to_string(),
|
||||
major: 17,
|
||||
vendor: "Temurin".to_string(),
|
||||
origin: "managed".to_string(),
|
||||
},
|
||||
JavaRuntimeView {
|
||||
binary: "b".to_string(),
|
||||
version: "17".to_string(),
|
||||
major: 17,
|
||||
vendor: "Oracle".to_string(),
|
||||
origin: "system".to_string(),
|
||||
},
|
||||
];
|
||||
assert_eq!(page.majors(), vec![17]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! 日志页 DTO:会话列表、日志行与 D8 诊断结论。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 一次启动会话(日志页左栏单位)标识。
|
||||
pub type SessionId = String;
|
||||
|
||||
/// 一次运行的结局(G3 左栏图标:✓ 正常 / ✗ 崩溃 / ⚠ 静默失败)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LogOutcome {
|
||||
/// 正常退出
|
||||
Ok,
|
||||
/// 崩溃(非零退出码或 hs_err_pid)
|
||||
Crashed,
|
||||
/// exit0 但游戏没起来(D8 新增检测)
|
||||
SilentFail,
|
||||
}
|
||||
|
||||
/// 会话摘要。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LogSession {
|
||||
/// 会话 id
|
||||
pub id: SessionId,
|
||||
/// 实例名(会话属于哪个实例)
|
||||
pub instance: String,
|
||||
/// 展示用时间(`10-29 19:22`)
|
||||
pub when: String,
|
||||
/// 结局
|
||||
pub outcome: LogOutcome,
|
||||
/// 时长(秒)
|
||||
pub duration_secs: u64,
|
||||
}
|
||||
|
||||
/// 日志行等级(错误行高亮)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LogLevel {
|
||||
/// debug
|
||||
Debug,
|
||||
/// info
|
||||
Info,
|
||||
/// warn
|
||||
Warn,
|
||||
/// error
|
||||
Error,
|
||||
}
|
||||
|
||||
/// 一行日志。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LogLine {
|
||||
/// 原始文本(含时间戳前缀)
|
||||
pub text: String,
|
||||
/// 等级
|
||||
pub level: LogLevel,
|
||||
}
|
||||
|
||||
/// D8 规则引擎结论(日志页崩溃诊断卡片)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Diagnosis {
|
||||
/// 命中规则 id(`gpu.driver_old`)
|
||||
pub rule: String,
|
||||
/// 结论标题
|
||||
pub title: String,
|
||||
/// 0~100 置信度
|
||||
pub confidence: u8,
|
||||
/// 建议文案
|
||||
pub hint: String,
|
||||
/// 可一键执行的修复动作(无则只给建议)
|
||||
pub fix: Option<String>,
|
||||
}
|
||||
|
||||
/// 诊断结论 + 归属会话(UI 用它决定卡片显隐)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DiagnosisReport {
|
||||
/// 归属会话
|
||||
pub session: SessionId,
|
||||
/// 诊断内容
|
||||
pub diagnosis: Diagnosis,
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! presenter ↔ core/agent 的 IPC 边界(01c §5 纪律)。
|
||||
//!
|
||||
//! 这里只允许出现「扁平 DTO / 事件 / 指令」:本 crate 的 DTO 里不得出现 `unlucky_core::*`
|
||||
//! 或 `use unlucky_agent::*` 的内部类型。core/agent 侧的 host 层负责把自身类型翻译成本模块的类型,
|
||||
//! 反向的 [`AppCommand`] 也一样。P0 阶段由 [`crate::mock`] 的假后端实现 [`Backend`] 驱动骨架,
|
||||
//! P3 起由 [`crate::host`] 的真实后端驱动全部页面。
|
||||
//!
|
||||
//! 结构:**按领域拆子模块**(instances / versions / logs / agent / accounts / java /
|
||||
//! settings / install / notices / events),全量 `pub use` 出去,调用方依旧写
|
||||
//! `crate::dto::InstanceSummary` 这类路径。
|
||||
//! 契约说明(字段口径、事件顺序、错误语义)见 `DevDocs/SubAgentsTalk/app-DTO通道约定.md`;
|
||||
//! UI 侧的唯一入口与 iced 接线用法见 crate 文档(`src/lib.rs`)。
|
||||
//!
|
||||
//! 全部跨界类型 `Serialize + Deserialize`(AGENTS §6):事件与快照是 IPC 契约,
|
||||
//! 序列化能力由 derive 钉住,`dto` 的单测做 round-trip 断言。
|
||||
|
||||
mod accounts;
|
||||
mod agent;
|
||||
mod events;
|
||||
mod install;
|
||||
mod instances;
|
||||
mod java;
|
||||
mod logs;
|
||||
mod notices;
|
||||
mod settings;
|
||||
mod versions;
|
||||
|
||||
pub use accounts::{AccountKind, AccountView, DeviceLoginPhase, DeviceLoginView};
|
||||
pub use agent::{AgentEvent, ChatMessage, ChatRole, ToolCallView};
|
||||
pub use events::{AppCommand, AppEvent, Backend, CoreEvent, LaunchFinished, LiveLogBatch};
|
||||
pub use install::{derive_speed, human_bytes, InstallProgressView, InstallStage};
|
||||
pub use instances::{
|
||||
CreateRequest, InstanceId, InstanceRuntime, InstanceSummary, LaunchStage, LoaderInfo,
|
||||
LoaderKind, Progress,
|
||||
};
|
||||
pub use java::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView};
|
||||
pub use logs::{Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSession, SessionId};
|
||||
pub use notices::{NoticeLevel, NoticeView};
|
||||
pub use settings::{SettingControl, SettingGroupView, SettingRowView};
|
||||
pub use versions::{AncientKind, VersionCategory, VersionKind, VersionSummary};
|
||||
@@ -0,0 +1,73 @@
|
||||
//! 横幅通知 DTO:实例页/下载页顶部的「需要注意」提示条。
|
||||
//!
|
||||
//! 与 Toast 的分工:Toast 是「刚才发生了什么」(3 秒消失),通知条是
|
||||
//! 「现在需要你处理」(用户点了才收)。隔离模式切换提示(M30 `ModeChangeWarning`)
|
||||
//! 与启动失败都用它。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 通知级别。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum NoticeLevel {
|
||||
/// 一般信息
|
||||
Info,
|
||||
/// 需要留意(黄条)
|
||||
Warn,
|
||||
/// 失败(红条)
|
||||
Error,
|
||||
}
|
||||
|
||||
impl NoticeLevel {
|
||||
/// 级别文案(无障碍/测试用)。
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Info => "信息",
|
||||
Self::Warn => "注意",
|
||||
Self::Error => "错误",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条通知。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NoticeView {
|
||||
/// 稳定 id(关闭时按它移除)
|
||||
pub id: String,
|
||||
/// 级别
|
||||
pub level: NoticeLevel,
|
||||
/// 标题
|
||||
pub title: String,
|
||||
/// 正文
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
impl NoticeView {
|
||||
/// 建一条。
|
||||
pub fn new(id: &str, level: NoticeLevel, title: &str, body: &str) -> Self {
|
||||
Self {
|
||||
id: id.to_string(),
|
||||
level,
|
||||
title: title.to_string(),
|
||||
body: body.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn notices_are_serde_round_trip_safe() {
|
||||
let notice = NoticeView::new(
|
||||
"isolation:demo",
|
||||
NoticeLevel::Warn,
|
||||
"隔离模式已切换",
|
||||
"旧目录里还有 mods/saves",
|
||||
);
|
||||
let text = serde_json::to_string(¬ice).expect("序列化");
|
||||
let back: NoticeView = serde_json::from_str(&text).expect("反序列化");
|
||||
assert_eq!(back, notice);
|
||||
assert_eq!(back.level.label(), "注意");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! 设置页 DTO:设置分组的通用行模型。
|
||||
//!
|
||||
//! 「行」是 UI 与 `core::setting` schema 之间的唯一形状:一行 = 一个键 + 一种控件 +
|
||||
//! 当前值。写回走 [`crate::dto::AppCommand::SetSetting`],由 host 用 `SettingStore::set`
|
||||
//! 落盘(不自己拼 JSON、不另立配置文件,`AGENTS §6`)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 控件类型(决定设置页画什么)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SettingControl {
|
||||
/// 开关
|
||||
Toggle,
|
||||
/// 分段单选(`options` + `selected`)
|
||||
Choice,
|
||||
/// 单行文本(`value_text`)
|
||||
Text,
|
||||
/// 只读展示(当前路径/当前版本这类)
|
||||
ReadOnly,
|
||||
/// 动作按钮(`action` 给回调 id)
|
||||
Action,
|
||||
}
|
||||
|
||||
/// 一行设置。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SettingRowView {
|
||||
/// schema 键(`download.concurrency`)
|
||||
pub key: String,
|
||||
/// 行标题
|
||||
pub label: String,
|
||||
/// 说明/当前值附注
|
||||
pub description: String,
|
||||
/// 控件
|
||||
pub control: SettingControl,
|
||||
/// 开关值
|
||||
pub flag: bool,
|
||||
/// 单选序号
|
||||
pub selected: i32,
|
||||
/// 单选项文案
|
||||
pub options: Vec<String>,
|
||||
/// 文本值
|
||||
pub value_text: String,
|
||||
/// 动作 id(`detect_region` 这类)
|
||||
pub action: String,
|
||||
}
|
||||
|
||||
impl SettingRowView {
|
||||
/// 开关行。
|
||||
pub fn toggle(key: &str, label: &str, description: &str, value: bool) -> Self {
|
||||
Self {
|
||||
key: key.to_string(),
|
||||
label: label.to_string(),
|
||||
description: description.to_string(),
|
||||
control: SettingControl::Toggle,
|
||||
flag: value,
|
||||
selected: 0,
|
||||
options: Vec::new(),
|
||||
value_text: String::new(),
|
||||
action: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 单选行。
|
||||
pub fn choice(
|
||||
key: &str,
|
||||
label: &str,
|
||||
description: &str,
|
||||
options: &[&str],
|
||||
selected: i32,
|
||||
) -> Self {
|
||||
Self {
|
||||
key: key.to_string(),
|
||||
label: label.to_string(),
|
||||
description: description.to_string(),
|
||||
control: SettingControl::Choice,
|
||||
flag: false,
|
||||
selected,
|
||||
options: options.iter().map(|item| (*item).to_string()).collect(),
|
||||
value_text: String::new(),
|
||||
action: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本行。
|
||||
pub fn text(key: &str, label: &str, description: &str, value: &str) -> Self {
|
||||
Self {
|
||||
key: key.to_string(),
|
||||
label: label.to_string(),
|
||||
description: description.to_string(),
|
||||
control: SettingControl::Text,
|
||||
flag: false,
|
||||
selected: 0,
|
||||
options: Vec::new(),
|
||||
value_text: value.to_string(),
|
||||
action: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 只读行。
|
||||
pub fn read_only(label: &str, value: &str) -> Self {
|
||||
Self {
|
||||
key: String::new(),
|
||||
label: label.to_string(),
|
||||
description: value.to_string(),
|
||||
control: SettingControl::ReadOnly,
|
||||
flag: false,
|
||||
selected: 0,
|
||||
options: Vec::new(),
|
||||
value_text: value.to_string(),
|
||||
action: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 动作行。
|
||||
pub fn action(key: &str, label: &str, description: &str, action: &str) -> Self {
|
||||
Self {
|
||||
key: key.to_string(),
|
||||
label: label.to_string(),
|
||||
description: description.to_string(),
|
||||
control: SettingControl::Action,
|
||||
flag: false,
|
||||
selected: 0,
|
||||
options: Vec::new(),
|
||||
value_text: String::new(),
|
||||
action: action.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个设置分组(设置页右栏的一段)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SettingGroupView {
|
||||
/// 分组 id(与设置页子导航的 `SettingSection` 同名:download / java / data / ai / mcp)
|
||||
pub id: String,
|
||||
/// 分组标题
|
||||
pub title: String,
|
||||
/// 行
|
||||
pub rows: Vec<SettingRowView>,
|
||||
}
|
||||
|
||||
impl SettingGroupView {
|
||||
/// 建组。
|
||||
pub fn new(id: &str, title: &str) -> Self {
|
||||
Self {
|
||||
id: id.to_string(),
|
||||
title: title.to_string(),
|
||||
rows: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 追加一行(链式)。
|
||||
pub fn with(mut self, row: SettingRowView) -> Self {
|
||||
self.rows.push(row);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 在分组快照里找一个键的当前值(设置页回填用)。
|
||||
pub fn find_row<'a>(groups: &'a [SettingGroupView], key: &str) -> Option<&'a SettingRowView> {
|
||||
groups
|
||||
.iter()
|
||||
.flat_map(|group| group.rows.iter())
|
||||
.find(|row| row.key == key)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rows_carry_exactly_one_control_kind() {
|
||||
let toggle = SettingRowView::toggle("agent.mcp.enabled", "MCP", "", true);
|
||||
assert_eq!(toggle.control, SettingControl::Toggle);
|
||||
assert!(toggle.flag);
|
||||
|
||||
let choice = SettingRowView::choice("download.region", "地区", "", &["auto", "cn"], 1);
|
||||
assert_eq!(choice.options, vec!["auto", "cn"]);
|
||||
assert_eq!(choice.selected, 1);
|
||||
|
||||
let action = SettingRowView::action("java.action", "检测", "", "detect_region");
|
||||
assert_eq!(action.action, "detect_region");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_row_scans_all_groups() {
|
||||
let groups = vec![
|
||||
SettingGroupView::new("download", "下载").with(SettingRowView::text(
|
||||
"download.proxy_url",
|
||||
"代理",
|
||||
"",
|
||||
"",
|
||||
)),
|
||||
SettingGroupView::new("mcp", "MCP").with(SettingRowView::toggle(
|
||||
"agent.mcp.enabled",
|
||||
"开关",
|
||||
"",
|
||||
false,
|
||||
)),
|
||||
];
|
||||
assert_eq!(
|
||||
find_row(&groups, "agent.mcp.enabled").map(|row| row.label.as_str()),
|
||||
Some("开关")
|
||||
);
|
||||
assert!(find_row(&groups, "nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_are_serde_round_trip_safe() {
|
||||
let group = SettingGroupView::new("download", "下载").with(SettingRowView::choice(
|
||||
"download.concurrency",
|
||||
"并发",
|
||||
"",
|
||||
&["4", "8", "16"],
|
||||
1,
|
||||
));
|
||||
let text = serde_json::to_string(&group).expect("序列化");
|
||||
let back: SettingGroupView = serde_json::from_str(&text).expect("反序列化");
|
||||
assert_eq!(back, group);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//! 下载页 DTO:版本列表与分类(含远古版子类,D16/Q24-4)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 版本类型(远古版按 unlisted-versions 的分类拆开)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum VersionKind {
|
||||
/// 正式版
|
||||
Release,
|
||||
/// 预览版(snapshot)
|
||||
Snapshot,
|
||||
/// 远古版(带子类)
|
||||
Ancient(AncientKind),
|
||||
}
|
||||
|
||||
/// 远古版子类。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AncientKind {
|
||||
/// classic(rd-*)
|
||||
Classic,
|
||||
/// old_alpha(a1.*)
|
||||
OldAlpha,
|
||||
/// old_beta(b1.*)
|
||||
OldBeta,
|
||||
/// 未列入官方清单的远古版汇总
|
||||
Unlisted,
|
||||
}
|
||||
|
||||
impl VersionKind {
|
||||
/// 是否属于「远古版」——列表行要加警示徽标。
|
||||
pub fn is_ancient(self) -> bool {
|
||||
matches!(self, Self::Ancient(_))
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载页版本行。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct VersionSummary {
|
||||
/// 版本 id(如 `1.20.4` / `rd-132211`)
|
||||
pub id: String,
|
||||
/// 版本类型
|
||||
pub kind: VersionKind,
|
||||
/// 发布日期(ISO-8601 日期部分)
|
||||
pub released: String,
|
||||
/// 本地是否已安装(行尾显示「已装」)
|
||||
pub installed: bool,
|
||||
}
|
||||
|
||||
/// 下载页左侧分类(页内常驻侧栏的选中项)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum VersionCategory {
|
||||
/// 正式版
|
||||
Release,
|
||||
/// 预览版
|
||||
Snapshot,
|
||||
/// 远古版某子类
|
||||
Ancient(AncientKind),
|
||||
/// 整合包(P1)
|
||||
Modpack,
|
||||
/// Mod(P1)
|
||||
Mod,
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
//! 账户:三轨统一列表(只读公开字段)+ 离线新增 + 删除/切换 + 微软登录(两条流)。
|
||||
//!
|
||||
//! 凭据红线(D9):`Credentials` / `MicrosoftSession` 只在本模块与 `launch.rs` 里出现,
|
||||
//! 出口一律是 [`AccountView`] / [`DeviceLoginView`]。
|
||||
//!
|
||||
//! 两条登录流:授权码([`start_browser_login`],GUI 默认:拉起系统浏览器 + 本地回调,
|
||||
//! `research/08` §3 方案 A)与设备码([`start_login`],无浏览器/CLI 场景)。除了「怎么拿到
|
||||
//! MS access_token」之外完全共用:同一个取消槽位、同一段账户入库、同一个刷新链。
|
||||
//!
|
||||
//! 取消语义:轮询/等回调都跑在 `core` 里、没有取消钩子,所以取消只把面板收回;线程收尾时
|
||||
//! 若发现「已取消」就不入库(避免用户点了取消却凭空多出一个账户)。
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use unlucky_core::auth::account::Credentials;
|
||||
use unlucky_core::auth::microsoft::{now_ms, MicrosoftAuth, MicrosoftSession};
|
||||
use unlucky_core::auth::offline::offline_meta;
|
||||
use unlucky_core::auth::{AccountStore, AuthResult};
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
|
||||
use crate::dto::{AccountKind, AccountView, DeviceLoginView};
|
||||
|
||||
use super::env::HostEnv;
|
||||
use super::job::{spawn, HostTask, JobSender};
|
||||
use super::opener;
|
||||
|
||||
/// 账户列表(公开字段)。
|
||||
pub fn accounts(env: &HostEnv, active: Option<&str>) -> Vec<AccountView> {
|
||||
let store = AccountStore::open(env.layout.auth_data());
|
||||
store
|
||||
.accounts()
|
||||
.iter()
|
||||
.map(|meta| AccountView {
|
||||
id: meta.id.as_str().to_string(),
|
||||
name: meta.display_name().to_string(),
|
||||
kind: AccountKind::parse(meta.kind.as_str()),
|
||||
profile_id: meta.profile_id.clone(),
|
||||
login_name: meta.login_name.clone().unwrap_or_default(),
|
||||
created: display_ms(meta.created_at_ms),
|
||||
active: active.is_some_and(|id| id == meta.id.as_str()),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 毫秒时间戳 → `2026-10-31 20:12`(UTC;账户列表只用来排序/辨认)。
|
||||
pub fn display_ms(ms: u64) -> String {
|
||||
if ms == 0 {
|
||||
return String::new();
|
||||
}
|
||||
let total_secs = ms / 1000;
|
||||
let days = total_secs / 86_400;
|
||||
let rem = total_secs % 86_400;
|
||||
let (year, month, day) = civil_from_days(i64::try_from(days).unwrap_or(0));
|
||||
format!(
|
||||
"{year:04}-{month:02}-{day:02} {:02}:{:02}",
|
||||
rem / 3_600,
|
||||
(rem % 3_600) / 60
|
||||
)
|
||||
}
|
||||
|
||||
/// Howard Hinnant 的 `civil_from_days`(1970-01-01 为 0)。
|
||||
fn civil_from_days(days: i64) -> (i64, u32, u32) {
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
(if m <= 2 { y + 1 } else { y }, m as u32, d as u32)
|
||||
}
|
||||
|
||||
/// 加一个离线账户。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 名字不合法、同名冲突、落盘失败时返回用户可读文案。
|
||||
pub fn add_offline(env: &HostEnv, name: &str) -> Result<String, String> {
|
||||
let name = name.trim();
|
||||
let meta = offline_meta(name, now_ms()).map_err(|error| error.to_string())?;
|
||||
let display = meta.display_name().to_string();
|
||||
let mut store = AccountStore::open(env.layout.auth_data());
|
||||
store
|
||||
.insert(meta, Credentials::Offline)
|
||||
.map_err(|error| format!("添加账户失败:{error}"))?;
|
||||
store
|
||||
.save()
|
||||
.map_err(|error| format!("保存账户失败:{error}"))?;
|
||||
Ok(display)
|
||||
}
|
||||
|
||||
/// 删除账户(按 id 或名字)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 找不到、落盘失败时返回用户可读文案。
|
||||
pub fn remove(env: &HostEnv, key: &str) -> Result<String, String> {
|
||||
let mut store = AccountStore::open(env.layout.auth_data());
|
||||
let meta = store
|
||||
.remove(key)
|
||||
.map_err(|error| format!("删除账户失败:{error}"))?;
|
||||
store
|
||||
.save()
|
||||
.map_err(|error| format!("保存账户失败:{error}"))?;
|
||||
Ok(meta.display_name().to_string())
|
||||
}
|
||||
|
||||
/// 微软登录任务句柄(取消标志 + 账户名回执;两条流共用)。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LoginJob {
|
||||
/// 用户点了取消(线程收尾时据此丢弃结果)
|
||||
pub cancelled: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl LoginJob {
|
||||
/// 新建一个未取消的句柄。
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
cancelled: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消。
|
||||
pub fn cancel(&self) {
|
||||
self.cancelled.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// 是否已取消。
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.cancelled.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
/// 起一次设备码登录(后台线程;`present` 时刻发 Waiting 事件)。
|
||||
pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) {
|
||||
spawn("msa-login", move || {
|
||||
let auth = match microsoft_auth(&env) {
|
||||
Ok(auth) => auth,
|
||||
Err(message) => return fail(&tx, message),
|
||||
};
|
||||
let tx_prompt = tx.clone();
|
||||
let result = block_on(auth.device_code_login(|prompt| {
|
||||
let _ = tx_prompt.send(HostTask::Login(Box::new(DeviceLoginView::waiting(
|
||||
&prompt.user_code,
|
||||
&prompt.verification_uri,
|
||||
prompt.expires_in.as_secs(),
|
||||
&format!("{}#{}", prompt.verification_uri, prompt.user_code),
|
||||
))));
|
||||
}));
|
||||
finish(&env, &tx, &job, result);
|
||||
});
|
||||
}
|
||||
|
||||
/// 起一次授权码登录(后台线程:绑本地端口 → 拉起系统默认浏览器 → 等回调 → 换链)。
|
||||
///
|
||||
/// 与设备码流共用同一个 [`LoginJob`] 取消槽位、同一段账户入库逻辑,所以两条流的
|
||||
/// 凭据/刷新语义完全一致(`core` 侧也共用 [`unlucky_core::auth::MicrosoftSession`])。
|
||||
pub fn start_browser_login(env: HostEnv, tx: JobSender, job: LoginJob) {
|
||||
spawn("msa-authcode", move || {
|
||||
let auth = match microsoft_auth(&env) {
|
||||
Ok(auth) => auth,
|
||||
Err(message) => return fail(&tx, message),
|
||||
};
|
||||
let tx_prompt = tx.clone();
|
||||
let result = block_on(auth.authcode_login(|prompt| {
|
||||
// 浏览器拉不起来不是致命错误:面板里还有可复制的授权链接。
|
||||
let opened = opener::open_url(&prompt.authorization_url);
|
||||
let _ = tx_prompt.send(HostTask::Login(Box::new(DeviceLoginView::browser_waiting(
|
||||
&prompt.authorization_url,
|
||||
prompt.timeout.as_secs(),
|
||||
opened.err().as_deref(),
|
||||
))));
|
||||
}));
|
||||
finish(&env, &tx, &job, result);
|
||||
});
|
||||
}
|
||||
|
||||
/// 生产入口的 `MicrosoftAuth`:client id 取环境/构建期,失败日志落 `<数据根>/logs/`。
|
||||
fn microsoft_auth(env: &HostEnv) -> Result<MicrosoftAuth, String> {
|
||||
MicrosoftAuth::from_env()
|
||||
.map(|auth| {
|
||||
auth.with_journal(Some(unlucky_core::auth::AuthJournal::in_logs_dir(
|
||||
env.layout.logs(),
|
||||
)))
|
||||
})
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn fail(tx: &JobSender, message: String) {
|
||||
let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::failed(message))));
|
||||
}
|
||||
|
||||
/// 收尾:取消就吞掉结果;成功入库;失败带 core 的诊断文案(M38 的 403 也走这条)。
|
||||
fn finish(env: &HostEnv, tx: &JobSender, job: &LoginJob, result: AuthResult<MicrosoftSession>) {
|
||||
if job.is_cancelled() {
|
||||
let _ = tx.send(HostTask::Login(Box::default()));
|
||||
return;
|
||||
}
|
||||
match result {
|
||||
Ok(session) => match store_session(env, session) {
|
||||
Ok(name) => {
|
||||
let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::succeeded(&name))));
|
||||
let _ = tx.send(HostTask::Accounts(accounts(env, None)));
|
||||
}
|
||||
Err(message) => fail(tx, message),
|
||||
},
|
||||
Err(error) => fail(tx, error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 微软会话入库(元数据 + 凭据分文件,见 `core::auth::AccountStore`)。
|
||||
fn store_session(env: &HostEnv, session: MicrosoftSession) -> Result<String, String> {
|
||||
let meta = unlucky_core::auth::account::AccountMeta::new(
|
||||
unlucky_core::auth::account::AccountKind::Microsoft,
|
||||
session.profile_id.clone(),
|
||||
session.profile_name.clone(),
|
||||
now_ms(),
|
||||
);
|
||||
let name = meta.display_name().to_string();
|
||||
let mut store = AccountStore::open(env.layout.auth_data());
|
||||
store
|
||||
.insert(meta, Credentials::Microsoft(session))
|
||||
.and_then(|()| store.save())
|
||||
.map_err(|error| format!("账户入库失败:{error}"))?;
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn time_display_matches_known_timestamps() {
|
||||
assert_eq!(display_ms(0), "");
|
||||
// 1_769_000_000_000 ms = 2026-01-21T12:53:20Z(取整到分钟)
|
||||
assert_eq!(display_ms(1_769_000_000_000), "2026-01-21 12:53");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_add_list_remove_round_trips() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-host-accounts-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let env = HostEnv::at_root(&dir);
|
||||
std::fs::create_dir_all(env.layout.auth_data()).expect("auth 目录");
|
||||
|
||||
let name = add_offline(&env, "Steve").expect("加账户");
|
||||
assert_eq!(name, "Steve");
|
||||
let list = accounts(&env, None);
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0].kind, AccountKind::Offline);
|
||||
assert!(!list[0].active);
|
||||
|
||||
let active = accounts(&env, Some(list[0].id.as_str()));
|
||||
assert!(active[0].active);
|
||||
|
||||
let removed = remove(&env, "Steve").expect("删账户");
|
||||
assert_eq!(removed, "Steve");
|
||||
assert!(accounts(&env, None).is_empty());
|
||||
assert!(add_offline(&env, " ").is_err());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_job_cancel_flag_is_shared() {
|
||||
let job = LoginJob::new();
|
||||
let clone = job.clone();
|
||||
assert!(!clone.is_cancelled());
|
||||
job.cancel();
|
||||
assert!(clone.is_cancelled());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! `CoreHost::dispatch` 的指令实现(从 `host/mod.rs` 拆出,守 400 行上限)。
|
||||
//!
|
||||
//! 父模块的私有字段对子模块可见,所以这里直接读写 `self.env` / `self.tx` 等。
|
||||
|
||||
use crate::dto::{AppCommand, Backend, CoreEvent, CreateRequest, LoaderKind};
|
||||
use crate::toast::ToastKind;
|
||||
|
||||
use super::accounts::{self, LoginJob};
|
||||
use super::{game, install, java, launch, logs, settings, versions, CoreHost};
|
||||
|
||||
impl CoreHost {
|
||||
/// 下发一条用户意图(`Backend::dispatch` 的实现体)。
|
||||
pub(super) fn dispatch_command(&mut self, command: AppCommand) {
|
||||
self.dispatch_inner(command);
|
||||
}
|
||||
}
|
||||
impl CoreHost {
|
||||
/// 指令分发(见 `dispatch_command`)。
|
||||
fn dispatch_inner(&mut self, command: AppCommand) {
|
||||
match command {
|
||||
AppCommand::RefreshAll => self.refresh_all(),
|
||||
AppCommand::LaunchInstance(id) => self.start_launch(&id),
|
||||
AppCommand::StopInstance(id) => match self.running_pid(&id) {
|
||||
Some(pid) => match launch::stop(pid) {
|
||||
Ok(()) => self.toast(ToastKind::Info, "已请求停止游戏进程"),
|
||||
Err(reason) => self.toast(ToastKind::Error, reason),
|
||||
},
|
||||
None => self.toast(ToastKind::Warn, "这个实例当前没有在跑"),
|
||||
},
|
||||
AppCommand::ListVersions(_) => versions::spawn_list(self.env.clone(), self.tx.clone()),
|
||||
AppCommand::InstallVersion(version) => {
|
||||
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
self.install_cancel = Some(std::sync::Arc::clone(&cancel));
|
||||
install::spawn_install(
|
||||
self.env.clone(),
|
||||
self.tx.clone(),
|
||||
CreateRequest {
|
||||
name: version.clone(),
|
||||
version,
|
||||
loader: LoaderKind::Vanilla,
|
||||
loader_version: String::new(),
|
||||
},
|
||||
cancel,
|
||||
);
|
||||
}
|
||||
AppCommand::CreateInstance(request) => {
|
||||
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
self.install_cancel = Some(std::sync::Arc::clone(&cancel));
|
||||
install::spawn_install(self.env.clone(), self.tx.clone(), request, cancel);
|
||||
}
|
||||
AppCommand::CancelInstall => {
|
||||
// 取消只在阶段边界生效(core 的下载循环没有取消钩子):这里先把面板收回,
|
||||
// 后台线程会在下一阶段开始前看到标记并收尾。
|
||||
if let Some(cancel) = &self.install_cancel {
|
||||
cancel.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
self.install = None;
|
||||
self.send(CoreEvent::InstallProgress(None));
|
||||
}
|
||||
AppCommand::SetIsolation { id, mode } => {
|
||||
match game::set_isolation(&self.env, &id, &mode) {
|
||||
Ok(()) => {
|
||||
self.toast(ToastKind::Success, "隔离档位已更新");
|
||||
self.refresh_instances();
|
||||
}
|
||||
Err(reason) => self.toast(ToastKind::Error, reason),
|
||||
}
|
||||
}
|
||||
AppCommand::RefreshLogSessions => {
|
||||
self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env)));
|
||||
}
|
||||
AppCommand::OpenLogSession(id) => self.open_session(&id),
|
||||
AppCommand::ImportCrashLog(path) => match logs::analyze_crash_file(&path) {
|
||||
Ok((label, report)) => {
|
||||
self.send(CoreEvent::DiagnosisReady(report));
|
||||
self.toast(ToastKind::Info, label);
|
||||
}
|
||||
Err(reason) => self.toast(ToastKind::Warn, reason),
|
||||
},
|
||||
AppCommand::AddOfflineAccount(name) => match accounts::add_offline(&self.env, &name) {
|
||||
Ok(display) => {
|
||||
self.toast(ToastKind::Success, format!("已添加离线账户 {display}"));
|
||||
self.refresh_accounts();
|
||||
}
|
||||
Err(reason) => self.toast(ToastKind::Error, reason),
|
||||
},
|
||||
AppCommand::RemoveAccount(key) => match accounts::remove(&self.env, &key) {
|
||||
Ok(display) => {
|
||||
if self.active_account.as_deref() == Some(key.as_str()) {
|
||||
self.active_account = None;
|
||||
}
|
||||
self.toast(ToastKind::Info, format!("已删除账户 {display}"));
|
||||
self.refresh_accounts();
|
||||
}
|
||||
Err(reason) => self.toast(ToastKind::Error, reason),
|
||||
},
|
||||
AppCommand::SwitchAccount(id) => {
|
||||
self.active_account = Some(id);
|
||||
self.refresh_accounts();
|
||||
self.toast(ToastKind::Info, "已切换启动账户");
|
||||
}
|
||||
AppCommand::StartDeviceLogin => {
|
||||
self.login = Some(LoginJob::new());
|
||||
let job = self.login.clone().unwrap_or_default();
|
||||
self.send(CoreEvent::DeviceLoginChanged(crate::dto::DeviceLoginView {
|
||||
phase: crate::dto::DeviceLoginPhase::Requesting,
|
||||
message: "正在向微软申请设备码…".to_string(),
|
||||
..crate::dto::DeviceLoginView::new()
|
||||
}));
|
||||
accounts::start_login(self.env.clone(), self.tx.clone(), job);
|
||||
}
|
||||
AppCommand::StartBrowserLogin => {
|
||||
self.login = Some(LoginJob::new());
|
||||
let job = self.login.clone().unwrap_or_default();
|
||||
self.send(CoreEvent::DeviceLoginChanged(crate::dto::DeviceLoginView {
|
||||
phase: crate::dto::DeviceLoginPhase::Requesting,
|
||||
message: "正在准备浏览器登录(本地回调端口)…".to_string(),
|
||||
..crate::dto::DeviceLoginView::new()
|
||||
}));
|
||||
accounts::start_browser_login(self.env.clone(), self.tx.clone(), job);
|
||||
}
|
||||
AppCommand::CancelDeviceLogin => {
|
||||
if let Some(job) = &self.login {
|
||||
job.cancel();
|
||||
}
|
||||
self.login = None;
|
||||
self.send(CoreEvent::DeviceLoginChanged(
|
||||
crate::dto::DeviceLoginView::new(),
|
||||
));
|
||||
}
|
||||
AppCommand::InstallJava(vendor) => {
|
||||
self.java_busy = true;
|
||||
self.java_message = format!("正在安装 Java({vendor})…");
|
||||
self.refresh_java();
|
||||
java::spawn_install(
|
||||
self.env.clone(),
|
||||
self.tx.clone(),
|
||||
unlucky_core::game::java::JdkVendor::parse(&vendor),
|
||||
);
|
||||
}
|
||||
AppCommand::SetSetting { key, value } => match settings::set(&self.env, &key, &value) {
|
||||
Ok(()) => {
|
||||
self.send(CoreEvent::SettingsChanged(settings::groups(&self.env)));
|
||||
self.java_chain.clear();
|
||||
self.refresh_java();
|
||||
}
|
||||
Err(reason) => self.toast(ToastKind::Error, reason),
|
||||
},
|
||||
AppCommand::DismissNotice(id) => {
|
||||
self.notices.retain(|notice| notice.id != id);
|
||||
self.send(CoreEvent::NoticesChanged(self.notices.clone()));
|
||||
}
|
||||
// AI 对话:agent 的 GUI host 未落地前用脚本回复(不接真实模型)
|
||||
other @ (AppCommand::AskAgent(_) | AppCommand::StopAgent | AppCommand::SetYolo(_)) => {
|
||||
self.chat.dispatch(other);
|
||||
for event in self.chat.drain_events() {
|
||||
self.events.push_back(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! 下载引擎与 Java 落点的装配(versions / install / java 三个任务共用)。
|
||||
//!
|
||||
//! 与 CLI `install::build_engine` 同源:内容寻址缓存落 `.ULL/cache/`,地区判定走
|
||||
//! 24h 缓存 + 环境信号(**不做网络探测**,与 D7 的「不探测时」约定一致)。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use unlucky_core::download::cache::now_ms;
|
||||
use unlucky_core::download::region::{decide_from, load_cached, store_cached};
|
||||
use unlucky_core::download::region_system::detect_signals;
|
||||
use unlucky_core::download::{
|
||||
ContentCache, DownloadEngine, EngineConfig, HealthRegistry, MirrorTable,
|
||||
};
|
||||
use unlucky_core::game::java::vendor::JdkVendor;
|
||||
use unlucky_core::game::java::{self, JavaInstallRoot};
|
||||
use unlucky_core::platform::paths::ProcessEnv;
|
||||
|
||||
use super::env::HostEnv;
|
||||
|
||||
/// 建下载引擎(缓存/健康度/地区判定都在数据根里)。
|
||||
pub fn engine(env: &HostEnv) -> DownloadEngine {
|
||||
let cache_root = env.layout.cache();
|
||||
let now = now_ms();
|
||||
let region_path = cache_root.join("region.json");
|
||||
let region = load_cached(®ion_path, now).unwrap_or_else(|| {
|
||||
let profile = decide_from(&detect_signals(), now);
|
||||
let _ = store_cached(®ion_path, &profile);
|
||||
profile
|
||||
});
|
||||
DownloadEngine::new(
|
||||
EngineConfig::default(),
|
||||
Arc::new(MirrorTable::builtin()),
|
||||
Arc::new(region),
|
||||
Arc::new(HealthRegistry::open_in(&cache_root, now)),
|
||||
Arc::new(ContentCache::open(&cache_root)),
|
||||
)
|
||||
}
|
||||
|
||||
/// 是否国内网络(决定默认发行版顺序与镜像优先级)。
|
||||
pub fn domestic(env: &HostEnv) -> bool {
|
||||
engine(env).region().is_domestic()
|
||||
}
|
||||
|
||||
/// 设置里的 `java.vendorPriority`(空 = 按地区默认序)。
|
||||
pub fn java_priority(env: &HostEnv) -> Vec<JdkVendor> {
|
||||
env.store()
|
||||
.effective("java.vendorPriority")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.map(|items| {
|
||||
let texts: Vec<String> = items
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
unlucky_core::game::java::vendor::parse_priority(&texts)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 设置里的 `java.installRoot`(空 = 按数据目录模式派生)。
|
||||
pub fn java_install_override(env: &HostEnv) -> Option<PathBuf> {
|
||||
env.store()
|
||||
.doc()
|
||||
.effective_str("java.installRoot")
|
||||
.map(str::trim)
|
||||
.filter(|text| !text.is_empty())
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
/// 托管 JDK 落点(解析失败 → 用户可读文案)。
|
||||
pub fn java_install_root(env: &HostEnv) -> Result<JavaInstallRoot, String> {
|
||||
let override_root = java_install_override(env);
|
||||
JavaInstallRoot::resolve(
|
||||
env.mode(),
|
||||
&env.layout,
|
||||
override_root.as_deref(),
|
||||
env.platform,
|
||||
&ProcessEnv,
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// 托管 JDK 可能存在的全部安装根(扫描运行时用)。
|
||||
pub fn java_scan_roots(env: &HostEnv) -> Vec<PathBuf> {
|
||||
java::location::scan_roots(
|
||||
env.mode(),
|
||||
&env.layout,
|
||||
java_install_override(env).as_deref(),
|
||||
env.platform,
|
||||
&ProcessEnv,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn engine_uses_the_data_root_cache() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-host-engine-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let env = HostEnv::at_root(&dir);
|
||||
std::fs::create_dir_all(env.layout.cache()).expect("cache");
|
||||
let engine = engine(&env);
|
||||
assert_eq!(engine.config().max_concurrency, 64);
|
||||
// 地区判定不联网:只读缓存/信号
|
||||
let _ = domestic(&env);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_merge_priority_reads_settings() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-host-engine-p-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let env = HostEnv::at_root(&dir);
|
||||
std::fs::create_dir_all(env.layout.config()).expect("config");
|
||||
std::fs::write(
|
||||
env.launcher_config(),
|
||||
r#"{"java":{"vendorPriority":["zulu"]}}"#,
|
||||
)
|
||||
.expect("写配置");
|
||||
assert_eq!(java_priority(&env), vec![JdkVendor::Zulu]);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! host 的环境快照:一份 `ResolvedData` + 由其派生的布局视图。
|
||||
//!
|
||||
//! app 自己不解析三模式数据根(那是 `core::platform::data_dir` 的活);这里只做两件事:
|
||||
//! ①按「exe 同级便携 `launcher.json` 的 `core.*` 四键」拼请求;②把解析结果与
|
||||
//! `Platform` / `DataMode` 打成一个可克隆的结构,交给后台线程。
|
||||
//!
|
||||
//! 截图/演示通道用 [`HostEnv::at_root`] 直接钉一个根(`<root>/.ULL` + `<root>/.minecraft`)。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use unlucky_core::game::GameLayout;
|
||||
use unlucky_core::platform::data_dir::{DataDirRequest, DataMode, ResolvedData, Resolver};
|
||||
use unlucky_core::platform::layout::LauncherLayout;
|
||||
use unlucky_core::platform::paths::Platform;
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::store::SettingStore;
|
||||
|
||||
/// 数据根决定键(与 CLI 的 `DATA_ROOT_KEYS` 同源同义)。
|
||||
pub(crate) const DATA_ROOT_KEYS: [&str; 4] = [
|
||||
"core.data_mode",
|
||||
"core.data_custom_root",
|
||||
"core.launcher_home",
|
||||
"core.game_home",
|
||||
];
|
||||
|
||||
/// host 运行期需要的一切路径/平台信息(可克隆进线程)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HostEnv {
|
||||
/// 数据根解析结果
|
||||
pub data: ResolvedData,
|
||||
/// `.ULL/` 布局
|
||||
pub layout: LauncherLayout,
|
||||
/// `.minecraft/` 布局
|
||||
pub game: GameLayout,
|
||||
/// 平台
|
||||
pub platform: Platform,
|
||||
}
|
||||
|
||||
impl HostEnv {
|
||||
/// 按便携目录的 `core.*` 解析真实数据根;解析不出来返回 `None`(host 退回 mock)。
|
||||
pub fn resolve() -> Option<Self> {
|
||||
let exe_dir = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|path| path.parent().map(Path::to_path_buf))?;
|
||||
let request = request_from_portable(&exe_dir);
|
||||
let resolver = Resolver::new(Platform::current(), Some(exe_dir));
|
||||
let data = resolver.resolve_default(&request).ok()?.clone();
|
||||
Some(Self::from_data(data))
|
||||
}
|
||||
|
||||
/// 直接钉一个根:`<root>/.ULL` + `<root>/.minecraft`(截图/演示通道)。
|
||||
///
|
||||
/// 根下已有 `config/launcher.json` 时**尊重其中的 `core.game_home` / `core.launcher_home`**:
|
||||
/// 演示根常用它把游戏目录指到既有真数据上(避免复制几 GB 的 assets/libraries)。
|
||||
pub fn at_root(root: impl AsRef<Path>) -> Self {
|
||||
let root = root.as_ref();
|
||||
let request = request_from_portable(root);
|
||||
let absolute = |path: Option<PathBuf>| path.filter(|path| path.is_absolute());
|
||||
Self::from_data(ResolvedData {
|
||||
mode: DataMode::Portable,
|
||||
launcher_home: absolute(request.launcher_home).unwrap_or_else(|| root.join(".ULL")),
|
||||
game_home: absolute(request.game_home).unwrap_or_else(|| root.join(".minecraft")),
|
||||
redirected_from: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 由解析结果建环境。
|
||||
pub fn from_data(data: ResolvedData) -> Self {
|
||||
Self {
|
||||
layout: data.layout(),
|
||||
game: GameLayout::new(data.game_home.clone()),
|
||||
platform: Platform::current(),
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
/// `config/launcher.json` 路径。
|
||||
pub fn launcher_config(&self) -> PathBuf {
|
||||
self.layout.launcher_config()
|
||||
}
|
||||
|
||||
/// 设置存储(每次现读一份;写操作自己 save)。
|
||||
pub fn store(&self) -> SettingStore {
|
||||
SettingStore::load(self.launcher_config(), launcher_schema())
|
||||
}
|
||||
|
||||
/// 启动会话根(`.ULL/data/agent/sessions`)。
|
||||
pub fn sessions_root(&self) -> PathBuf {
|
||||
self.layout.agent_sessions()
|
||||
}
|
||||
|
||||
/// 数据目录模式。
|
||||
pub fn mode(&self) -> DataMode {
|
||||
self.data.mode
|
||||
}
|
||||
|
||||
/// 人读一行(设置页「数据」分组展示)。
|
||||
pub fn data_line(&self) -> String {
|
||||
format!(
|
||||
"{} · 启动器 {} · 游戏 {}",
|
||||
self.data.mode,
|
||||
self.data.launcher_home.display(),
|
||||
self.data.game_home.display()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 便携目录那份 `launcher.json` → 数据根请求(只读 `core.*` 四键)。
|
||||
pub(crate) fn request_from_portable(exe_dir: &Path) -> DataDirRequest {
|
||||
let mut request = DataDirRequest::default();
|
||||
let path = portable_config_path(exe_dir);
|
||||
if !path.is_file() {
|
||||
return request;
|
||||
}
|
||||
let store = SettingStore::load(&path, launcher_schema());
|
||||
let text = |key: &str| store.doc().effective_str(key).map(str::to_string);
|
||||
if let Some(mode) = text(DATA_ROOT_KEYS[0]).as_deref().and_then(DataMode::parse) {
|
||||
request.mode = mode;
|
||||
}
|
||||
if let Some(root) = text(DATA_ROOT_KEYS[1]) {
|
||||
request.custom_root = Some(PathBuf::from(root));
|
||||
}
|
||||
if let Some(home) = text(DATA_ROOT_KEYS[2]) {
|
||||
request.launcher_home = Some(PathBuf::from(home));
|
||||
}
|
||||
if let Some(home) = text(DATA_ROOT_KEYS[3]) {
|
||||
request.game_home = Some(PathBuf::from(home));
|
||||
}
|
||||
request
|
||||
}
|
||||
|
||||
/// `<exe 目录>/.ULL/config/launcher.json`。
|
||||
pub(crate) fn portable_config_path(exe_dir: &Path) -> PathBuf {
|
||||
LauncherLayout::new(exe_dir.join(".ULL")).launcher_config()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn at_root_splits_launcher_and_game_homes() {
|
||||
let env = HostEnv::at_root("D:/tmp/ull-demo");
|
||||
assert_eq!(env.layout.home(), Path::new("D:/tmp/ull-demo/.ULL"));
|
||||
assert_eq!(env.game.root(), Path::new("D:/tmp/ull-demo/.minecraft"));
|
||||
assert_eq!(env.mode(), DataMode::Portable);
|
||||
assert!(env.data_line().contains("portable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_request_reads_core_keys_only_when_file_exists() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-host-env-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join(".ULL/config")).expect("建目录");
|
||||
let request = request_from_portable(&dir);
|
||||
assert_eq!(request, DataDirRequest::default());
|
||||
|
||||
std::fs::write(
|
||||
portable_config_path(&dir),
|
||||
r#"{"core":{"data_mode":"custom","data_custom_root":"D:/data/ull"}}"#,
|
||||
)
|
||||
.expect("写配置");
|
||||
let request = request_from_portable(&dir);
|
||||
assert_eq!(request.mode, DataMode::Custom);
|
||||
assert_eq!(
|
||||
request.custom_root.as_deref(),
|
||||
Some(Path::new("D:/data/ull"))
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
//! 实例快照:`profiles.json` + 版本 JSON + 会话索引 + 实例设置 → [`InstanceSummary`]。
|
||||
//!
|
||||
//! 「磁贴上的每个字」都在这里翻译完:版本号取自继承链根、加载器取自版本 id 前缀、
|
||||
//! 最后游玩时间取自会话索引、隔离档位取自实例设置(缺省跟全局 `instances.mode`)。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use unlucky_core::game::install::instance::{
|
||||
read_instance_config, read_profiles, write_instance_config,
|
||||
};
|
||||
use unlucky_core::game::isolation::IsolationMode;
|
||||
use unlucky_core::game::{ProfileList, VersionJson};
|
||||
use unlucky_core::launch::session_index;
|
||||
|
||||
use crate::dto::{
|
||||
InstanceRuntime, InstanceSummary, LoaderInfo, LoaderKind, NoticeLevel, NoticeView,
|
||||
};
|
||||
|
||||
use super::env::HostEnv;
|
||||
|
||||
/// 全局默认隔离档位(`instances.mode`;缺键/坏值按 D20 默认 `isolated`)。
|
||||
pub fn global_mode(env: &HostEnv) -> IsolationMode {
|
||||
env.store()
|
||||
.doc()
|
||||
.effective_str("instances.mode")
|
||||
.and_then(IsolationMode::parse)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 读实例清单(文件缺失 = 空列表,不是错误)。
|
||||
pub fn profiles(env: &HostEnv) -> ProfileList {
|
||||
read_profiles(&env.layout).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 全会话索引里「每个实例最近一次启动」的时间串。
|
||||
pub fn last_played(env: &HostEnv) -> BTreeMap<String, String> {
|
||||
let index = session_index::index_path(&env.sessions_root());
|
||||
let entries = session_index::read_all(&index).unwrap_or_default();
|
||||
let mut map: BTreeMap<String, String> = BTreeMap::new();
|
||||
for entry in entries {
|
||||
if entry.instance_id.is_empty() || entry.started_at.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let current = map.get(&entry.instance_id);
|
||||
if current.is_none_or(|existing| existing.as_str() < entry.started_at.as_str()) {
|
||||
map.insert(entry.instance_id.clone(), entry.started_at);
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// 版本 JSON 的继承链根 id + 加载器(读不到返回 `(version_id, 原版)`)。
|
||||
pub fn describe_version(env: &HostEnv, version_id: &str) -> (String, LoaderInfo) {
|
||||
let loader = loader_from_id(version_id);
|
||||
let mut current = version_id.to_string();
|
||||
let mut seen = 0;
|
||||
while seen < 16 {
|
||||
seen += 1;
|
||||
let path = env.game.version_json(¤t);
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
break;
|
||||
};
|
||||
let Ok(json) = VersionJson::from_json_str(&text) else {
|
||||
break;
|
||||
};
|
||||
match json.inherits_from {
|
||||
Some(parent) if !parent.is_empty() => current = parent,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
(current, loader)
|
||||
}
|
||||
|
||||
/// 版本 id → 加载器(HMCL 同款命名口径:`1.20.1-forge-47.2.0` / `fabric-loader-0.16.5-1.20.1`)。
|
||||
pub fn loader_from_id(version_id: &str) -> LoaderInfo {
|
||||
let lower = version_id.to_ascii_lowercase();
|
||||
let grab = |marker: &str| -> String {
|
||||
lower
|
||||
.split_once(marker)
|
||||
.map(|(_, rest)| rest.trim_start_matches('-').to_string())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let (kind, version) = if lower.contains("neoforge") {
|
||||
(LoaderKind::NeoForge, grab("neoforge-"))
|
||||
} else if lower.contains("fabric-loader") {
|
||||
(LoaderKind::Fabric, grab("fabric-loader-"))
|
||||
} else if lower.contains("quilt-loader") {
|
||||
(LoaderKind::Quilt, grab("quilt-loader-"))
|
||||
} else if lower.contains("forge") {
|
||||
(LoaderKind::Forge, grab("forge-"))
|
||||
} else if lower.contains("optifine") {
|
||||
(LoaderKind::OptiFine, grab("optifine"))
|
||||
} else if lower.contains("liteloader") {
|
||||
(LoaderKind::LiteLoader, grab("liteloader-"))
|
||||
} else {
|
||||
(LoaderKind::Vanilla, String::new())
|
||||
};
|
||||
LoaderInfo { kind, version }
|
||||
}
|
||||
|
||||
/// 实例的隔离档位(实例文件优先,缺省显示全局档位)。
|
||||
pub fn isolation_of(env: &HostEnv, id: &str) -> String {
|
||||
let global = global_mode(env);
|
||||
match read_instance_config(&env.layout, id) {
|
||||
Ok(Some(config)) => config.effective_isolation(global).as_str().to_string(),
|
||||
_ => global.as_str().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 实例快照。
|
||||
pub fn instances(env: &HostEnv) -> Vec<InstanceSummary> {
|
||||
let profiles = profiles(env);
|
||||
let played = last_played(env);
|
||||
profiles
|
||||
.profiles
|
||||
.iter()
|
||||
.map(|profile| {
|
||||
let version_id = profile.version_id.clone();
|
||||
let (mc_version, loader) = describe_version(env, &version_id);
|
||||
InstanceSummary {
|
||||
id: profile.id.as_str().to_string(),
|
||||
name: profile.name.clone(),
|
||||
mc_version,
|
||||
loader,
|
||||
last_played: played.get(profile.id.as_str()).cloned(),
|
||||
runtime: InstanceRuntime::Idle,
|
||||
icon: None,
|
||||
isolation: isolation_of(env, profile.id.as_str()),
|
||||
version_present: env.game.is_version_installed(&version_id),
|
||||
version_id,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 改实例隔离档位(写实例设置文件;不动任何数据)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 实例不存在、文件读不了/写不了时返回用户可读文案。
|
||||
pub fn set_isolation(env: &HostEnv, id: &str, mode: &str) -> Result<(), String> {
|
||||
let parsed = IsolationMode::parse(mode)
|
||||
.ok_or_else(|| format!("未知隔离档位 `{mode}`(isolated / shared)"))?;
|
||||
let profiles = profiles(env);
|
||||
let profile = profiles
|
||||
.profiles
|
||||
.iter()
|
||||
.find(|profile| profile.id.as_str() == id)
|
||||
.ok_or_else(|| format!("找不到实例 `{id}`"))?;
|
||||
let mut config = read_instance_config(&env.layout, id)
|
||||
.map_err(|error| format!("读实例设置失败:{error}"))?
|
||||
.unwrap_or_else(|| {
|
||||
unlucky_core::game::install::instance::InstanceConfig::new(
|
||||
profile.id.as_str(),
|
||||
&profile.version_id,
|
||||
)
|
||||
});
|
||||
config.isolation = Some(match parsed {
|
||||
IsolationMode::Isolated => unlucky_core::game::install::instance::Isolation::Isolated,
|
||||
IsolationMode::Shared => unlucky_core::game::install::instance::Isolation::Shared,
|
||||
});
|
||||
write_instance_config(&env.layout, &config)
|
||||
.map(|_| ())
|
||||
.map_err(|error| format!("写实例设置失败:{error}"))
|
||||
}
|
||||
|
||||
/// 启动前的切换检测:把 M30 的 `ModeChangeWarning` 翻成横幅通知。
|
||||
///
|
||||
/// 只在真启动时调用(会写 `lastModeChange.warned`,保证同一次切换只提示一次)。
|
||||
pub fn mode_change_notice(env: &HostEnv, id: &str) -> Option<NoticeView> {
|
||||
let mut config = read_instance_config(&env.layout, id).ok()??;
|
||||
let warning = config.take_mode_change_warning()?;
|
||||
let _ = write_instance_config(&env.layout, &config);
|
||||
Some(NoticeView::new(
|
||||
&format!("isolation:{id}"),
|
||||
NoticeLevel::Warn,
|
||||
"隔离档位已切换",
|
||||
&warning.message(),
|
||||
))
|
||||
}
|
||||
|
||||
/// 建目录(数据根首次运行时)。
|
||||
pub fn ensure_root(env: &HostEnv) {
|
||||
let _ = env.layout.ensure();
|
||||
let _ = std::fs::create_dir_all(env.game.root());
|
||||
}
|
||||
|
||||
/// 版本本体目录是否存在(启动前快速判断)。
|
||||
pub fn version_present(env: &HostEnv, version_id: &str) -> bool {
|
||||
env.game.is_version_installed(version_id)
|
||||
}
|
||||
|
||||
/// 路径显示成短串(通知/错误文案用)。
|
||||
pub fn short_path(path: &Path) -> String {
|
||||
path.display().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn loader_parsing_covers_all_shapes() {
|
||||
let cases = [
|
||||
("1.20.4", LoaderKind::Vanilla, ""),
|
||||
(
|
||||
"fabric-loader-0.16.5-1.20.1",
|
||||
LoaderKind::Fabric,
|
||||
"0.16.5-1.20.1",
|
||||
),
|
||||
(
|
||||
"quilt-loader-0.24.0-1.20.1",
|
||||
LoaderKind::Quilt,
|
||||
"0.24.0-1.20.1",
|
||||
),
|
||||
("1.20.1-forge-47.2.0", LoaderKind::Forge, "47.2.0"),
|
||||
("1.21.1-neoforge-21.1.80", LoaderKind::NeoForge, "21.1.80"),
|
||||
];
|
||||
for (id, kind, version) in cases {
|
||||
let info = loader_from_id(id);
|
||||
assert_eq!(info.kind, kind, "{id}");
|
||||
assert_eq!(info.version, version, "{id}");
|
||||
assert!(!info.badge().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolation_write_then_read_round_trips() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-host-game-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let env = HostEnv::at_root(&dir);
|
||||
std::fs::create_dir_all(env.layout.config()).expect("config 目录");
|
||||
|
||||
// 直接建一份 profiles.json(register 需要版本本体,这里只测隔离写读)
|
||||
let profiles = ProfileList {
|
||||
profiles: vec![unlucky_core::game::InstanceProfile {
|
||||
id: unlucky_core::game::ProfileId::generate(),
|
||||
name: "demo".to_string(),
|
||||
version_id: "1.20.1".to_string(),
|
||||
game_dir: None,
|
||||
running_dir: None,
|
||||
java_path: None,
|
||||
created_at: None,
|
||||
last_played_at: None,
|
||||
extra: BTreeMap::new(),
|
||||
}],
|
||||
..ProfileList::default()
|
||||
};
|
||||
unlucky_core::game::install::instance::write_profiles(&env.layout, &profiles)
|
||||
.expect("写清单");
|
||||
let id = profiles.profiles[0].id.as_str().to_string();
|
||||
|
||||
assert_eq!(isolation_of(&env, &id), "isolated");
|
||||
set_isolation(&env, &id, "shared").expect("切档位");
|
||||
assert_eq!(isolation_of(&env, &id), "shared");
|
||||
assert!(set_isolation(&env, &id, "nope").is_err());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! `HostTask` → `CoreEvent` 的翻译(从 `host/mod.rs` 拆出,守 400 行硬限)。
|
||||
//!
|
||||
//! 父模块的私有字段对子模块可见,所以这里直接读写 `self.tx` / `self.launches` 等。
|
||||
|
||||
use crate::dto::{AppEvent, CoreEvent, InstanceRuntime, LogOutcome};
|
||||
use crate::toast::ToastKind;
|
||||
|
||||
use super::{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::Install(progress) => {
|
||||
self.install = Some(*progress.clone());
|
||||
self.send(CoreEvent::InstallProgress(Some(*progress)));
|
||||
}
|
||||
HostTask::InstallDone { progress, result } => {
|
||||
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)));
|
||||
}
|
||||
Err(reason) => self.toast(ToastKind::Error, reason),
|
||||
}
|
||||
}
|
||||
HostTask::Accounts(accounts) => {
|
||||
self.accounts = accounts.clone();
|
||||
self.send(CoreEvent::AccountsChanged(accounts));
|
||||
self.refresh_instances();
|
||||
}
|
||||
HostTask::Login(view) => self.send(CoreEvent::DeviceLoginChanged(*view)),
|
||||
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 盖掉。
|
||||
self.failed
|
||||
.insert(finished.id.clone(), finished.summary.clone());
|
||||
InstanceRuntime::Failed {
|
||||
reason: finished.summary.clone(),
|
||||
}
|
||||
};
|
||||
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),
|
||||
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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! 启动身份:选中账户 → `AuthPlaceholders`(**凭据只在这里读,读完即丢**)+ 收尾结局映射。
|
||||
//!
|
||||
//! 与 CLI `commands/identity.rs` 同源:微软轨过期就静默续期并回写凭据(refresh_token 轮换,
|
||||
//! 旧值作废);零账户回落离线占位,保证没有账户时照样能启动。
|
||||
|
||||
use unlucky_core::auth::account::{AccountKind, Credentials};
|
||||
use unlucky_core::auth::microsoft::MicrosoftAuth;
|
||||
use unlucky_core::auth::offline::offline_login;
|
||||
use unlucky_core::auth::AccountStore;
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
use unlucky_core::game::ProfileId;
|
||||
use unlucky_core::launch::request::AuthPlaceholders;
|
||||
|
||||
use crate::dto::LogOutcome;
|
||||
|
||||
use super::env::HostEnv;
|
||||
|
||||
/// 把选中账户翻成启动占位符(**凭据只在这里读,读完即丢**)。
|
||||
///
|
||||
/// 微软轨:过期就静默续期并回写凭据(refresh_token 轮换,旧值作废)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 账户不存在/凭据类型不符/令牌过期且刷新失败时返回用户可读文案。
|
||||
pub fn resolve(env: &HostEnv, account: Option<&str>) -> Result<(AuthPlaceholders, String), String> {
|
||||
let store = AccountStore::open(env.layout.auth_data());
|
||||
let chosen = match account {
|
||||
Some(key) => store
|
||||
.find(key)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("找不到账户 `{key}`"))?,
|
||||
None => match store.accounts() {
|
||||
[] => {
|
||||
let uuid = ProfileId::generate().as_str().replace('-', "");
|
||||
return Ok((
|
||||
AuthPlaceholders {
|
||||
player_name: "Player".to_string(),
|
||||
uuid,
|
||||
user_type: "legacy".to_string(),
|
||||
user_properties: "{}".to_string(),
|
||||
..AuthPlaceholders::default()
|
||||
},
|
||||
"未登录账户,按离线占位启动".to_string(),
|
||||
));
|
||||
}
|
||||
[only] => only.clone(),
|
||||
many => return Err(format!("有 {} 个账户,请在账户页选一个再启动", many.len())),
|
||||
},
|
||||
};
|
||||
let line = format!("账户 {}({})", chosen.display_name(), chosen.kind.label());
|
||||
match chosen.kind {
|
||||
AccountKind::Offline => {
|
||||
let placeholders =
|
||||
offline_login(&chosen).map_err(|error| format!("离线账户解析失败:{error}"))?;
|
||||
Ok((placeholders, line))
|
||||
}
|
||||
AccountKind::Microsoft => {
|
||||
let Some(Credentials::Microsoft(session)) = store.credentials(&chosen.id) else {
|
||||
return Err(format!(
|
||||
"账户 {} 的微软凭据缺失或类型不符,请重新登录",
|
||||
chosen.display_name()
|
||||
));
|
||||
};
|
||||
let auth =
|
||||
MicrosoftAuth::from_env().map_err(|error| format!("微软登录不可用:{error}"))?;
|
||||
let (fresh, _) = block_on(auth.ensure_fresh(session))
|
||||
.map_err(|error| format!("微软令牌刷新失败:{error}"))?;
|
||||
if &fresh != session {
|
||||
// refresh_token 已轮换:立刻回写,旧值作废。
|
||||
let mut store = AccountStore::open(env.layout.auth_data());
|
||||
let _ = store.update_credentials(&chosen.id, Credentials::Microsoft(fresh.clone()));
|
||||
let _ = store.save();
|
||||
}
|
||||
let placeholders = fresh
|
||||
.to_placeholders()
|
||||
.map_err(|error| format!("微软身份转换失败:{error}"))?;
|
||||
Ok((placeholders, line))
|
||||
}
|
||||
AccountKind::AuthlibInjector => {
|
||||
let Some(Credentials::AuthlibInjector(session)) = store.credentials(&chosen.id) else {
|
||||
return Err(format!(
|
||||
"账户 {} 的第三方凭据缺失或类型不符",
|
||||
chosen.display_name()
|
||||
));
|
||||
};
|
||||
let placeholders = session
|
||||
.to_placeholders()
|
||||
.map_err(|error| format!("第三方身份转换失败:{error}"))?;
|
||||
Ok((placeholders, line))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `SessionHandle` → 日志页的三种结局。
|
||||
pub fn outcome_of(handle: &unlucky_core::launch::SessionHandle) -> LogOutcome {
|
||||
if handle.exit.silent_failure.is_some() {
|
||||
LogOutcome::SilentFail
|
||||
} else if handle.succeeded() {
|
||||
LogOutcome::Ok
|
||||
} else {
|
||||
LogOutcome::Crashed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
//! 安装:新建实例向导 / 下载页「安装」→ core 安装管线 → 登记实例。
|
||||
//!
|
||||
//! 与 CLI `ull install` 同序:`RemoteSource::resolve → InstallPlan::build → execute → register`。
|
||||
//! Forge/NeoForge 多一段 installer processors([`super::install_processors`]):
|
||||
//! `ProcessorPlan::build → 依赖库下载 → 选 Java → 串行 java -cp`,**跑完才写版本 JSON**。
|
||||
//! 差异只在线程模型:进度经 [`super::job::HostTask::Install`] 回流(文件计数 + 字节 + 速度/ETA),
|
||||
//! 且整段跑在后台线程里(`block_on` 不占 UI 线程)。
|
||||
//!
|
||||
//! 加载器范围:原版 + Fabric/Quilt/Forge/NeoForge(四家,与 CLI 对齐)。OptiFine/LiteLoader
|
||||
//! 两家 core 侧都还没有安装管线,仍在 GUI 里显式拒装并说清去哪装。
|
||||
//! 失败一律落 `.ULL/logs/install.log`([`super::install_journal`]),文案里带日志路径。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use unlucky_core::addon::loader::{
|
||||
LoaderInstallPlan, LoaderKind as CoreLoaderKind, LoaderPlanInput, LoaderSource,
|
||||
};
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
use unlucky_core::download::cache::now_ms;
|
||||
use unlucky_core::download::DownloadEngine;
|
||||
use unlucky_core::game::arguments::Environment;
|
||||
use unlucky_core::game::install::{
|
||||
execute, InstallError, InstallPlan, InstallReport, PlanInput, RemoteSource,
|
||||
};
|
||||
|
||||
use crate::dto::{CreateRequest, InstallStage, LoaderKind};
|
||||
use crate::toast::{Toast, ToastKind};
|
||||
|
||||
use super::env::HostEnv;
|
||||
use super::install_journal::Journal;
|
||||
use super::install_processors::{self, StageInput};
|
||||
use super::job::{spawn, HostTask, JobSender};
|
||||
use super::meter::{sink_for, Meter};
|
||||
|
||||
/// 文件级并发上限(与 CLI 同值:16 路压满带宽又不至于打爆镜像)。
|
||||
pub const CONCURRENCY: usize = 16;
|
||||
|
||||
/// 用户取消的稳定文案(阶段边界生效)。
|
||||
pub const CANCELLED: &str = "已取消(阶段边界停下;已下载的文件保留,重装会续用)";
|
||||
|
||||
/// 一次安装任务的运行上下文(后台线程内一路传下去,免得每个函数排七八个参数)。
|
||||
struct Job<'a> {
|
||||
env: &'a HostEnv,
|
||||
tx: &'a JobSender,
|
||||
meter: &'a Arc<std::sync::Mutex<Meter>>,
|
||||
cancel: &'a Arc<AtomicBool>,
|
||||
journal: &'a Journal,
|
||||
}
|
||||
|
||||
impl Job<'_> {
|
||||
/// processor 段的进度出口(同一套 channel/meter/journal)。
|
||||
fn reporter(&self) -> install_processors::Reporter<'_> {
|
||||
install_processors::Reporter {
|
||||
tx: self.tx,
|
||||
meter: self.meter,
|
||||
journal: self.journal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载器徽标(`原版` / `Fabric 0.16.5`)。
|
||||
pub fn loader_label(kind: LoaderKind, version: &str) -> String {
|
||||
if kind == LoaderKind::Vanilla || version.trim().is_empty() {
|
||||
kind.label().to_string()
|
||||
} else {
|
||||
format!("{} {}", kind.label(), version)
|
||||
}
|
||||
}
|
||||
|
||||
/// GUI 侧支持的加载器(五选:原版 + 四家加载器,与 CLI 的 `--loader` 词表一致)。
|
||||
///
|
||||
/// 写成穷举 `matches!` 而不是 `true`:将来 core 加了第六种加载器,这里默认拒绝,
|
||||
/// 由 [`unsupported_reason`] 给出明确说法,而不是让它走进不属于它的安装分支。
|
||||
pub fn supported(kind: LoaderKind) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
LoaderKind::Vanilla
|
||||
| LoaderKind::Fabric
|
||||
| LoaderKind::Quilt
|
||||
| LoaderKind::Forge
|
||||
| LoaderKind::NeoForge
|
||||
)
|
||||
}
|
||||
|
||||
/// 不支持的加载器 → 给用户的可执行建议。
|
||||
pub fn unsupported_reason(kind: LoaderKind) -> String {
|
||||
format!(
|
||||
"{} 还没有接入安装管线(目前支持原版 + Fabric/Quilt/Forge/NeoForge);请先在实例页导入已有版本,或换成受支持的加载器。",
|
||||
kind.label()
|
||||
)
|
||||
}
|
||||
|
||||
/// 后台安装(向导提交 / 下载页行内安装共用)。
|
||||
pub fn spawn_install(env: HostEnv, tx: JobSender, request: CreateRequest, cancel: Arc<AtomicBool>) {
|
||||
spawn("install", move || {
|
||||
let meter = Arc::new(std::sync::Mutex::new(Meter::new(&request)));
|
||||
let journal = Journal::open(&env);
|
||||
journal.line(&format!(
|
||||
"—— 开始安装 {}({} {})——",
|
||||
request.instance_name(),
|
||||
loader_label(request.loader, ""),
|
||||
request.version
|
||||
));
|
||||
let job = Job {
|
||||
env: &env,
|
||||
tx: &tx,
|
||||
meter: &meter,
|
||||
cancel: &cancel,
|
||||
journal: &journal,
|
||||
};
|
||||
let result = run(&job, &request);
|
||||
let mut progress = meter
|
||||
.lock()
|
||||
.map(|guard| guard.progress.clone())
|
||||
.unwrap_or_else(|_| Meter::new(&request).progress);
|
||||
match result {
|
||||
Ok(name) => {
|
||||
journal.line(&format!("完成:实例 {name} 已登记"));
|
||||
progress.stage = InstallStage::Done;
|
||||
progress.files_done = progress.files_total;
|
||||
progress.message = format!("已安装 {name}({})", progress.loader);
|
||||
let _ = tx.send(HostTask::InstallDone {
|
||||
progress: Box::new(progress),
|
||||
result: Ok(name),
|
||||
});
|
||||
let _ = tx.send(HostTask::Toast(Box::new(Toast {
|
||||
id: 0,
|
||||
kind: ToastKind::Success,
|
||||
text: "安装完成,实例已登记".to_string(),
|
||||
remaining_ms: 0,
|
||||
})));
|
||||
}
|
||||
Err(reason) => {
|
||||
let cancelled = reason == CANCELLED;
|
||||
progress.stage = if cancelled {
|
||||
InstallStage::Cancelled
|
||||
} else {
|
||||
InstallStage::Failed
|
||||
};
|
||||
// 失败文案带上日志路径:面板只有一行、Toast 4 秒就消失,
|
||||
// 装日志是用户事后能自己回看的唯一渠道(进度板之外)。
|
||||
let text = if cancelled {
|
||||
reason
|
||||
} else {
|
||||
journal.line(&format!("安装未完成:{reason}"));
|
||||
format!("{reason}(日志:{})", journal.path().display())
|
||||
};
|
||||
progress.message.clone_from(&text);
|
||||
let _ = tx.send(HostTask::InstallDone {
|
||||
progress: Box::new(progress),
|
||||
result: Err(text),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 真安装(同步跑完,调用方在后台线程里)。
|
||||
fn run(job: &Job<'_>, request: &CreateRequest) -> Result<String, String> {
|
||||
if request.version.trim().is_empty() {
|
||||
return Err("没有选择版本".to_string());
|
||||
}
|
||||
if !supported(request.loader) {
|
||||
return Err(unsupported_reason(request.loader));
|
||||
}
|
||||
let name = request.instance_name();
|
||||
let env = job.env;
|
||||
let engine = super::engine::engine(env);
|
||||
{
|
||||
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
||||
guard.stage(InstallStage::Planning, "正在解析版本清单…");
|
||||
let _ = job
|
||||
.tx
|
||||
.send(HostTask::Install(Box::new(guard.progress.clone())));
|
||||
}
|
||||
|
||||
if job.cancel.load(Ordering::SeqCst) {
|
||||
return Err(CANCELLED.to_string());
|
||||
}
|
||||
let version_id = if request.loader == LoaderKind::Vanilla {
|
||||
install_vanilla(job, &engine, request)?
|
||||
} else {
|
||||
install_loader(job, &engine, request)?
|
||||
};
|
||||
|
||||
{
|
||||
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
||||
guard.stage(InstallStage::Registering, "正在登记实例…");
|
||||
let _ = job
|
||||
.tx
|
||||
.send(HostTask::Install(Box::new(guard.progress.clone())));
|
||||
}
|
||||
let created_at =
|
||||
unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now());
|
||||
unlucky_core::game::install::instance::register(
|
||||
&env.layout,
|
||||
&name,
|
||||
&version_id,
|
||||
Some(&created_at),
|
||||
)
|
||||
.map_err(|error| format!("登记实例失败:{error}"))?;
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// 原版安装(`resolve → plan → [prefetch 资源索引] → execute`)。
|
||||
fn install_vanilla(
|
||||
job: &Job<'_>,
|
||||
engine: &DownloadEngine,
|
||||
request: &CreateRequest,
|
||||
) -> Result<String, String> {
|
||||
let env = job.env;
|
||||
let version = request.version.trim();
|
||||
let meta_cache = env.layout.meta_cache();
|
||||
let now = now_ms();
|
||||
let sink = sink_for(job.tx.clone(), Arc::clone(job.meter));
|
||||
block_on(async {
|
||||
let source = RemoteSource::new(engine, &env.game, &meta_cache, now);
|
||||
let resolved = source.resolve(version).await.map_err(install_error)?;
|
||||
let fetched = match resolved
|
||||
.asset_index
|
||||
.as_ref()
|
||||
.filter(|index| index.url.is_some())
|
||||
{
|
||||
Some(reference) => {
|
||||
let (index, bytes) = source.asset_index(reference).await.map_err(install_error)?;
|
||||
Some((index, env.game.asset_index(&reference.id), bytes))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let mut plan = InstallPlan::build(PlanInput {
|
||||
layout: &env.game,
|
||||
version_id: version,
|
||||
resolved: &resolved,
|
||||
asset_index: fetched.as_ref().map(|(index, _, _)| index),
|
||||
environment: &Environment::host(),
|
||||
})
|
||||
.map_err(|error| error.to_string())?;
|
||||
if let Some((_, dest, bytes)) = fetched {
|
||||
plan.prefetch(dest, bytes);
|
||||
}
|
||||
if !plan.is_installable() {
|
||||
return Err(format!(
|
||||
"版本 {version} 装不全:{}",
|
||||
plan.warnings.join(";")
|
||||
));
|
||||
}
|
||||
let (bytes_total, _) = plan.total_bytes();
|
||||
{
|
||||
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
||||
guard.progress.files_total = u64::try_from(plan.file_count()).unwrap_or(u64::MAX);
|
||||
guard.progress.bytes_total = bytes_total;
|
||||
guard.stage(
|
||||
InstallStage::Downloading,
|
||||
&format!("开始下载 {} 个文件…", plan.file_count()),
|
||||
);
|
||||
}
|
||||
if job.cancel.load(Ordering::SeqCst) {
|
||||
return Err(CANCELLED.to_string());
|
||||
}
|
||||
let report = execute(engine, &plan, CONCURRENCY, Some(sink))
|
||||
.await
|
||||
.map_err(install_error)?;
|
||||
ensure_complete(&report)?;
|
||||
Ok::<String, String>(version.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// 加载器安装(原版前置 → installer processors → 加载器库 → 版本 JSON 落盘)。
|
||||
fn install_loader(
|
||||
job: &Job<'_>,
|
||||
engine: &DownloadEngine,
|
||||
request: &CreateRequest,
|
||||
) -> Result<String, String> {
|
||||
let env = job.env;
|
||||
let version = request.version.trim();
|
||||
let kind = CoreLoaderKind::parse(request.loader.as_str())
|
||||
.ok_or_else(|| format!("未知加载器 `{}`", request.loader.as_str()))?;
|
||||
let meta_cache = env.layout.meta_cache();
|
||||
let loader_version = request.loader_version.trim().to_string();
|
||||
let explicit = (!loader_version.is_empty()).then_some(loader_version.as_str());
|
||||
let release = block_on(async {
|
||||
LoaderSource::new(engine, &meta_cache)
|
||||
.resolve(kind, version, explicit)
|
||||
.await
|
||||
})
|
||||
.map_err(|error| error.to_string())?;
|
||||
job.journal.line(&format!(
|
||||
"加载器解析:{} {} → {}",
|
||||
release.kind.label(),
|
||||
release.loader_version,
|
||||
release.version_id
|
||||
));
|
||||
|
||||
let plan = LoaderInstallPlan::build(LoaderPlanInput {
|
||||
layout: &env.game,
|
||||
release: &release,
|
||||
environment: &Environment::host(),
|
||||
vanilla_installed: env.game.is_version_installed(version),
|
||||
});
|
||||
if plan.vanilla_install_needed {
|
||||
let mut vanilla = request.clone();
|
||||
vanilla.loader = LoaderKind::Vanilla;
|
||||
install_vanilla(job, engine, &vanilla)?;
|
||||
}
|
||||
// 两段(加载器库 + 处理器依赖库)在动手前一次预记账:进度条只走一条单调曲线,
|
||||
// 处理器依赖库下完不会先满格、再在加载器库开始时回落到一半。
|
||||
{
|
||||
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
||||
let (bytes, _) = plan.install.total_bytes();
|
||||
guard.expect(plan.files().len(), bytes);
|
||||
}
|
||||
// Forge/NeoForge:installer processors 必须先跑完,`execute` 才允许写版本 JSON。
|
||||
if release.requires_installer() {
|
||||
let input = StageInput {
|
||||
kind: release.kind,
|
||||
mc_version: &release.mc_version,
|
||||
version_id: &release.version_id,
|
||||
profile: release.install_profile.as_ref(),
|
||||
installer: release.installer.as_deref(),
|
||||
installed: env.game.is_version_installed(&release.version_id),
|
||||
};
|
||||
// `None` = 这一版不需要跑处理器(版本 JSON 已在盘上 / profile 没有 client 侧步骤),
|
||||
// 进度与日志都由处理器段自己写全,这里只消费返回值。
|
||||
let _ = install_processors::stage(
|
||||
env,
|
||||
engine,
|
||||
&input,
|
||||
&install_processors::CORE,
|
||||
&job.reporter(),
|
||||
job.cancel,
|
||||
)?;
|
||||
}
|
||||
{
|
||||
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
||||
guard.stage(
|
||||
InstallStage::Downloading,
|
||||
&format!("开始下载加载器库({} 个文件)…", plan.files().len()),
|
||||
);
|
||||
}
|
||||
if job.cancel.load(Ordering::SeqCst) {
|
||||
return Err(CANCELLED.to_string());
|
||||
}
|
||||
let sink = sink_for(job.tx.clone(), Arc::clone(job.meter));
|
||||
let report = block_on(async {
|
||||
execute(engine, &plan.install, CONCURRENCY, Some(sink))
|
||||
.await
|
||||
.map_err(install_error)
|
||||
})?;
|
||||
ensure_complete(&report)?;
|
||||
Ok(release.version_id)
|
||||
}
|
||||
|
||||
/// 安装错误 → 用户可读文案。
|
||||
fn install_error(error: InstallError) -> String {
|
||||
match error.hint() {
|
||||
Some(hint) => format!("{}({hint})", error.message()),
|
||||
None => error.message().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 有文件失败就报错(列前三条明细)。
|
||||
fn ensure_complete(report: &InstallReport) -> Result<(), String> {
|
||||
if report.is_complete() {
|
||||
return Ok(());
|
||||
}
|
||||
let details: Vec<String> = report
|
||||
.failures(3)
|
||||
.iter()
|
||||
.filter_map(|outcome| {
|
||||
outcome
|
||||
.failure
|
||||
.as_ref()
|
||||
.map(|failure| format!("{}:{}", outcome.name, failure.message))
|
||||
})
|
||||
.collect();
|
||||
Err(format!(
|
||||
"{} 个文件没下成(共 {} 个):{}",
|
||||
report.failed(),
|
||||
report.files.len(),
|
||||
details.join(";")
|
||||
))
|
||||
}
|
||||
|
||||
/// 安装目录的短显示(诊断/Toast 用)。
|
||||
pub fn game_root(env: &HostEnv) -> PathBuf {
|
||||
env.game.root().to_path_buf()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "install_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,79 @@
|
||||
//! 安装过程的可回溯日志(`.ULL/logs/install.log`,一轮一行)。
|
||||
//!
|
||||
//! 起因(M45):Forge/NeoForge 装失败时,GUI 只有一行 Toast,processor 的 stderr 尾部
|
||||
//! 与「哪一步挂了」在面板一关就没了。这里把安装过程按行追加到 `.ULL/logs/`
|
||||
//! (与 `auth.jsonl` 同一分区),失败文案里带上日志路径,用户能自己回看。
|
||||
//!
|
||||
//! 三条纪律(与 auth 日志同源):
|
||||
//! 1. **只追加**,不轮转、不截断(体积由「一行一条、只记诊断字段」控制);
|
||||
//! 2. **只记诊断字段**:阶段、processor 坐标、退出码、已脱敏的 stderr 尾部(core 侧脱敏);
|
||||
//! 3. **写盘尽力而为**:开不了文件就静默跳过,绝不让日志失败连累安装。
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use unlucky_core::launch::time;
|
||||
|
||||
use super::env::HostEnv;
|
||||
|
||||
/// 日志文件名(在 `.ULL/logs/` 下)。
|
||||
pub const INSTALL_LOG_FILE: &str = "install.log";
|
||||
|
||||
/// 一轮安装的日志句柄(`Clone` 很便宜:只有一个路径)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Journal {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl Journal {
|
||||
/// 按数据根打开(`logs/` 不存在就补建;建不出来只是没有日志)。
|
||||
pub fn open(env: &HostEnv) -> Self {
|
||||
let dir = env.layout.logs();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
Self {
|
||||
path: dir.join(INSTALL_LOG_FILE),
|
||||
}
|
||||
}
|
||||
|
||||
/// 日志路径(失败文案里给用户看)。
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// 追加一行(自动补 RFC3339 时间戳;写失败静默)。
|
||||
pub fn line(&self, text: &str) {
|
||||
let Ok(mut file) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.path)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let _ = writeln!(
|
||||
file,
|
||||
"[{}] {text}",
|
||||
time::format_rfc3339_millis(time::now())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lines_are_appended_with_a_timestamp() {
|
||||
let root = std::env::temp_dir().join(format!("ull-install-journal-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let env = HostEnv::at_root(&root);
|
||||
let journal = Journal::open(&env);
|
||||
journal.line("第一行");
|
||||
journal.line("第二行");
|
||||
let text = std::fs::read_to_string(journal.path()).expect("日志存在");
|
||||
assert!(text.contains("第一行") && text.contains("第二行"), "{text}");
|
||||
assert_eq!(text.lines().count(), 2);
|
||||
assert!(text.starts_with("[2"), "带时间戳:{text}");
|
||||
assert_eq!(journal.path(), env.layout.logs().join(INSTALL_LOG_FILE));
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
//! Forge / NeoForge 的 installer processors 段(M45:GUI 与 CLI 行为对齐)。
|
||||
//!
|
||||
//! 顺序与 CLI `ull install --loader forge` 完全一致(`crates/cli/src/commands/install/
|
||||
//! loader_processors.rs`),只是把进度接到 GUI 的回流通道上:
|
||||
//!
|
||||
//! ```text
|
||||
//! ProcessorPlan::build(纯计算:变量表 + 每步 java -cp + 依赖库清单)
|
||||
//! → download_libraries(M27 引擎;进度回流 HostTask::Install)
|
||||
//! → select_java(M33 解析链:托管 > JAVA_HOME > PATH > 扫盘)
|
||||
//! → run(串行 java -cp;每步一条面板文案 + 一行装日志)
|
||||
//! ```
|
||||
//!
|
||||
//! 四条硬约束(与 CLI 同源,GUI 侧一条都不放宽):
|
||||
//!
|
||||
//! 1. **processors 跑完才允许写版本 JSON**:版本清单存在 = 这版装完了,抢在 processors
|
||||
//! 前写会留下启动必崩却被报成"已安装"的实例(顺序由 `install_loader` 保证);
|
||||
//! 2. **幂等**:版本 JSON 已在盘上 = 上一轮跑完了,整段跳过;
|
||||
//! 3. **取消在阶段边界生效**:处理器阶段是原子段(串行外调 java,进程内没有取消钩子),
|
||||
//! 进入前与退出后各查一次 `cancel`;
|
||||
//! 4. **失败不静默**:结构化文案(带 `processor_*` 大类码)+ 装日志一行一条,由调用方
|
||||
//! 再补 Toast(`HostTask::InstallDone` 的既有通路)。
|
||||
//!
|
||||
//! 执行后端走 [`Backend`]:真实实现 [`CoreBackend`] 直接转调 core,单测注入 fake,
|
||||
//! 因此「计划怎么组、Java 找不到怎么说、依赖库下漏怎么报」都能在没有网络/没有 JDK
|
||||
//! 的机器上断言。
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use unlucky_core::addon::loader::forge::InstallProfile;
|
||||
use unlucky_core::addon::loader::processors::{
|
||||
self, ProcessorError, ProcessorPlan, ProcessorPlanInput, ProcessorRun, StepOutcome, StepSink,
|
||||
StepStatus,
|
||||
};
|
||||
use unlucky_core::addon::loader::LoaderKind as CoreLoaderKind;
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
use unlucky_core::download::DownloadEngine;
|
||||
use unlucky_core::game::arguments::Environment;
|
||||
use unlucky_core::game::install::{InstallReport, PlanFile, ProgressSink};
|
||||
use unlucky_core::game::java::{JavaRequirement, JavaRuntime};
|
||||
|
||||
use crate::dto::InstallStage;
|
||||
|
||||
use super::env::HostEnv;
|
||||
use super::install::{CANCELLED, CONCURRENCY};
|
||||
use super::install_journal::Journal;
|
||||
use super::job::{HostTask, JobSender};
|
||||
use super::meter::{sink_for, Meter};
|
||||
|
||||
/// 真实后端(无状态,可以放静态里复用)。
|
||||
pub(super) const CORE: CoreBackend = CoreBackend;
|
||||
|
||||
/// processors 段需要的输入(从 `LoaderRelease` 摘出来,便于单测构造)。
|
||||
pub(super) struct StageInput<'a> {
|
||||
/// 加载器种类(补库时 maven 基址的兜底来源)。
|
||||
pub kind: CoreLoaderKind,
|
||||
/// 目标原版版本。
|
||||
pub mc_version: &'a str,
|
||||
/// loader 版本 id(依赖库落点 + 临时目录名)。
|
||||
pub version_id: &'a str,
|
||||
/// installer 里读出来的 install profile(`requires_installer` 为真时必须有)。
|
||||
pub profile: Option<&'a InstallProfile>,
|
||||
/// installer jar 的磁盘路径。
|
||||
pub installer: Option<&'a Path>,
|
||||
/// 版本 JSON 已在盘上 = 上一轮跑完了,整段跳过(幂等)。
|
||||
pub installed: bool,
|
||||
}
|
||||
|
||||
/// processors 段的执行后端(真实实现转调 core;单测注入 fake)。
|
||||
pub(super) trait Backend {
|
||||
/// 下载 processor 依赖库(M27 引擎:镜像/健康度/退避/校验)。
|
||||
fn download_libraries(
|
||||
&self,
|
||||
engine: &DownloadEngine,
|
||||
files: &[PlanFile],
|
||||
sink: Option<ProgressSink>,
|
||||
) -> InstallReport;
|
||||
/// 按 M33 的解析链挑一个能跑 installer 的 Java。
|
||||
fn select_java(&self, managed: &Path, requirement: &JavaRequirement) -> Option<JavaRuntime>;
|
||||
/// 串行跑 processors。
|
||||
fn run(
|
||||
&self,
|
||||
plan: &ProcessorPlan,
|
||||
java: &JavaRuntime,
|
||||
sink: Option<StepSink>,
|
||||
) -> Result<ProcessorRun, ProcessorError>;
|
||||
}
|
||||
|
||||
/// 真实后端:三件事全部转调 core 的同一个执行器(CLI 用的就是它)。
|
||||
pub(super) struct CoreBackend;
|
||||
|
||||
impl Backend for CoreBackend {
|
||||
fn download_libraries(
|
||||
&self,
|
||||
engine: &DownloadEngine,
|
||||
files: &[PlanFile],
|
||||
sink: Option<ProgressSink>,
|
||||
) -> InstallReport {
|
||||
block_on(processors::download_libraries(
|
||||
engine,
|
||||
files,
|
||||
CONCURRENCY,
|
||||
sink,
|
||||
))
|
||||
}
|
||||
|
||||
fn select_java(&self, managed: &Path, requirement: &JavaRequirement) -> Option<JavaRuntime> {
|
||||
processors::select_java(managed, requirement)
|
||||
}
|
||||
|
||||
fn run(
|
||||
&self,
|
||||
plan: &ProcessorPlan,
|
||||
java: &JavaRuntime,
|
||||
sink: Option<StepSink>,
|
||||
) -> Result<ProcessorRun, ProcessorError> {
|
||||
processors::run(plan, java, sink)
|
||||
}
|
||||
}
|
||||
|
||||
/// 段内的进度出口:进度面板(`HostTask::Install`)+ 装日志。
|
||||
pub(super) struct Reporter<'a> {
|
||||
/// 后台线程 → host 的通道。
|
||||
pub tx: &'a JobSender,
|
||||
/// 进度记账器。
|
||||
pub meter: &'a Arc<Mutex<Meter>>,
|
||||
/// 装日志。
|
||||
pub journal: &'a Journal,
|
||||
}
|
||||
|
||||
impl Reporter<'_> {
|
||||
/// 换阶段并推一条进度。
|
||||
fn stage(&self, stage: InstallStage, message: &str) {
|
||||
let Ok(mut guard) = self.meter.lock() else {
|
||||
return;
|
||||
};
|
||||
guard.stage(stage, message);
|
||||
let _ = self
|
||||
.tx
|
||||
.send(HostTask::Install(Box::new(guard.progress.clone())));
|
||||
}
|
||||
|
||||
/// 先把待下的库记账(文件数 + 已知字节),下载回调再逐个累加。
|
||||
fn expect(&self, files: usize, bytes: u64) {
|
||||
let Ok(mut guard) = self.meter.lock() else {
|
||||
return;
|
||||
};
|
||||
guard.expect(files, bytes);
|
||||
}
|
||||
|
||||
/// 失败留痕(文案由调用方决定怎么呈现;这里只保证日志里有一条)。
|
||||
fn fail(&self, reason: String) -> String {
|
||||
self.journal.line(&format!("失败:{reason}"));
|
||||
reason
|
||||
}
|
||||
}
|
||||
|
||||
/// 跑 Forge / NeoForge 的 installer processors(`Ok(None)` = 这一轮不需要跑)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 元数据缺 profile/installer、依赖库没下全、找不到可用 Java、processor 非零退出、
|
||||
/// 产物没落盘、用户取消时返回给 UI 的文案(已写进装日志)。
|
||||
pub(super) fn stage(
|
||||
env: &HostEnv,
|
||||
engine: &DownloadEngine,
|
||||
input: &StageInput<'_>,
|
||||
backend: &dyn Backend,
|
||||
reporter: &Reporter<'_>,
|
||||
cancel: &Arc<AtomicBool>,
|
||||
) -> Result<Option<ProcessorRun>, String> {
|
||||
if input.installed {
|
||||
reporter.journal.line(&format!(
|
||||
"{} 的版本 JSON 已在盘上,跳过 installer processors",
|
||||
input.version_id
|
||||
));
|
||||
return Ok(None);
|
||||
}
|
||||
let (Some(profile), Some(installer)) = (input.profile, input.installer) else {
|
||||
return Err(reporter.fail(metadata_missing(input)));
|
||||
};
|
||||
reporter.stage(InstallStage::Installing, "正在组 installer 处理器计划…");
|
||||
let scratch = env
|
||||
.layout
|
||||
.cache()
|
||||
.join("processors")
|
||||
.join(safe_name(input.version_id));
|
||||
let plan = ProcessorPlan::build(ProcessorPlanInput {
|
||||
kind: input.kind,
|
||||
mc_version: input.mc_version,
|
||||
version_id: input.version_id,
|
||||
profile,
|
||||
layout: &env.game,
|
||||
installer,
|
||||
scratch: &scratch,
|
||||
environment: &Environment::host(),
|
||||
})
|
||||
.map_err(|error| reporter.fail(diagnostic(&error)))?;
|
||||
for warning in &plan.warnings {
|
||||
reporter.journal.line(&format!("规划告警:{warning}"));
|
||||
}
|
||||
if plan.is_empty() {
|
||||
reporter
|
||||
.journal
|
||||
.line("install profile 没有 client 侧 processor,跳过这一段");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let total = plan.step_count();
|
||||
let bytes = plan.libraries.iter().filter_map(|file| file.size).sum();
|
||||
reporter.expect(plan.libraries.len(), bytes);
|
||||
reporter.stage(
|
||||
InstallStage::Downloading,
|
||||
&format!("下载 installer 依赖库({} 个文件)…", plan.libraries.len()),
|
||||
);
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
return Err(CANCELLED.to_string());
|
||||
}
|
||||
let sink = sink_for(reporter.tx.clone(), Arc::clone(reporter.meter));
|
||||
let libraries = backend.download_libraries(engine, &plan.libraries, Some(sink));
|
||||
if !libraries.is_complete() {
|
||||
return Err(reporter.fail(libraries_failed(&libraries)));
|
||||
}
|
||||
|
||||
let managed = env.layout.java_managed();
|
||||
let Some(java) = backend.select_java(&managed, &plan.java_requirement) else {
|
||||
return Err(reporter.fail(java_missing(&plan.java_requirement, &managed)));
|
||||
};
|
||||
reporter.journal.line(&format!(
|
||||
"用 {}({})跑 {total} 步 processor",
|
||||
java.binary.display(),
|
||||
java_label(&java)
|
||||
));
|
||||
reporter.stage(
|
||||
InstallStage::Installing,
|
||||
&format!("运行 installer 处理器 0/{total}({})…", java_label(&java)),
|
||||
);
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
return Err(CANCELLED.to_string());
|
||||
}
|
||||
|
||||
let steps = step_sink(
|
||||
reporter.tx.clone(),
|
||||
Arc::clone(reporter.meter),
|
||||
reporter.journal.clone(),
|
||||
total,
|
||||
);
|
||||
let run = backend
|
||||
.run(&plan, &java, Some(steps))
|
||||
.map_err(|error| reporter.fail(diagnostic(&error)))?;
|
||||
for warning in &run.warnings {
|
||||
reporter.journal.line(&format!("执行告警:{warning}"));
|
||||
}
|
||||
reporter.journal.line(&format!(
|
||||
"processors 完成:执行 {} 步、跳过 {} 步",
|
||||
run.executed(),
|
||||
run.skipped()
|
||||
));
|
||||
reporter.stage(
|
||||
InstallStage::Installing,
|
||||
&format!(
|
||||
"installer 处理器完成(执行 {} 步、跳过 {} 步),正在写入版本清单…",
|
||||
run.executed(),
|
||||
run.skipped()
|
||||
),
|
||||
);
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
return Err(CANCELLED.to_string());
|
||||
}
|
||||
Ok(Some(run))
|
||||
}
|
||||
|
||||
/// 每步完成的回调:面板一行 + 装日志一行。
|
||||
///
|
||||
/// `StepSink` 要求 `'static`,所以这里全持所有权(通道/记账器/日志都是克隆)。
|
||||
fn step_sink(tx: JobSender, meter: Arc<Mutex<Meter>>, journal: Journal, total: usize) -> StepSink {
|
||||
Arc::new(move |outcome: &StepOutcome| {
|
||||
let line = step_line(outcome, total);
|
||||
journal.line(&line);
|
||||
let Ok(mut guard) = meter.lock() else {
|
||||
return;
|
||||
};
|
||||
guard.stage(InstallStage::Installing, &line);
|
||||
let _ = tx.send(HostTask::Install(Box::new(guard.progress.clone())));
|
||||
})
|
||||
}
|
||||
|
||||
/// 一步的进度/日志文案(纯函数,单测直接断言)。
|
||||
pub(super) fn step_line(outcome: &StepOutcome, total: usize) -> String {
|
||||
let suffix = match outcome.status {
|
||||
StepStatus::Skipped => "(产物已就绪,跳过)".to_string(),
|
||||
StepStatus::Executed => format!("({} ms)", outcome.elapsed_ms),
|
||||
};
|
||||
format!(
|
||||
"installer 处理器 {}/{}:{}{suffix}",
|
||||
outcome.index, total, outcome.label
|
||||
)
|
||||
}
|
||||
|
||||
/// processor 错误 → 用户可读文案(保留 `processor_*` 大类码,便于按码搜日志)。
|
||||
pub(super) fn diagnostic(error: &ProcessorError) -> String {
|
||||
let mut text = format!("{}({})", error.message(), error.kind().as_str());
|
||||
if let Some(hint) = error.hint() {
|
||||
text.push_str(";建议:");
|
||||
text.push_str(hint);
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// 缺 Java 运行时的专用文案(比通用错误多给一条「去哪装」)。
|
||||
pub(super) fn java_missing(requirement: &JavaRequirement, managed: &Path) -> String {
|
||||
format!(
|
||||
"跑 installer 需要 {},但一个可用的 Java 运行时都没找到;请到「Java」页安装托管 JDK,或把 JAVA_HOME 指向可用的 JDK(托管目录 {})",
|
||||
requirement.describe(),
|
||||
managed.display()
|
||||
)
|
||||
}
|
||||
|
||||
/// 依赖库没下全 → 列前三条失败明细。
|
||||
pub(super) fn libraries_failed(report: &InstallReport) -> String {
|
||||
let details: Vec<String> = report
|
||||
.failures(3)
|
||||
.iter()
|
||||
.filter_map(|outcome| {
|
||||
outcome
|
||||
.failure
|
||||
.as_ref()
|
||||
.map(|failure| format!("{}:{}", outcome.name, failure.message))
|
||||
})
|
||||
.collect();
|
||||
format!(
|
||||
"installer 依赖库没下全({} 个失败,共 {} 个):{}",
|
||||
report.failed(),
|
||||
report.files.len(),
|
||||
details.join(";")
|
||||
)
|
||||
}
|
||||
|
||||
/// 需要 installer 但元数据不全(缓存半截 / 换线路重解析过)。
|
||||
fn metadata_missing(input: &StageInput<'_>) -> String {
|
||||
format!(
|
||||
"{} {} 需要跑 installer processors,但没拿到 install profile / installer 路径;重跑一次即可(元数据缓存可能不完整)",
|
||||
input.kind.label(),
|
||||
input.version_id
|
||||
)
|
||||
}
|
||||
|
||||
/// Java 的一行短标识(`Java 21 · managed`)。
|
||||
fn java_label(java: &JavaRuntime) -> String {
|
||||
match java.major {
|
||||
Some(major) => format!("Java {major} · {}", java.origin.as_str()),
|
||||
None => format!("Java 主版本未知 · {}", java.origin.as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本 id → 可做目录名的片段(`+` 与路径分隔符都换掉,与 CLI 同规则)。
|
||||
fn safe_name(raw: &str) -> String {
|
||||
raw.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
|
||||
ch
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim_end_matches('.')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "install_processors_fixture.rs"]
|
||||
mod fixture;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "install_processors_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,272 @@
|
||||
//! installer processors 段的单测夹具(拆文件守 400 行上限)。
|
||||
//!
|
||||
//! 不需要网络、不需要 JDK、不真的起 java:`Harness` 给一个临时数据根 + 进度通道,
|
||||
//! `Fake` 回放预置的下载/Java/执行结果并记录被调顺序与组出来的命令行。
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use unlucky_core::addon::loader::forge::{InstallProfile, Processor};
|
||||
use unlucky_core::addon::loader::processors::{
|
||||
ProcessorError, ProcessorPlan, ProcessorRun, StepOutcome, StepSink, StepStatus,
|
||||
};
|
||||
use unlucky_core::addon::loader::LoaderKind as CoreLoaderKind;
|
||||
use unlucky_core::download::{DownloadEngine, DownloadErrorKind};
|
||||
use unlucky_core::game::install::{
|
||||
FileFailure, FileOutcome, FileStatus, InstallReport, PlanFile, ProgressSink,
|
||||
};
|
||||
use unlucky_core::game::java::{JavaOrigin, JavaRequirement, JavaRuntime};
|
||||
|
||||
use super::{Backend, Reporter, StageInput};
|
||||
use crate::dto::{CreateRequest, LoaderKind};
|
||||
use crate::host::env::HostEnv;
|
||||
use crate::host::install_journal::Journal;
|
||||
use crate::host::job::{channel, HostTask, JobSender};
|
||||
use crate::host::meter::Meter;
|
||||
|
||||
static SEQ: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// 一步的计划视图(序号 / 坐标 / 参数 / classpath 条数)。
|
||||
pub(super) type StepView = (usize, String, Vec<String>, usize);
|
||||
|
||||
/// 一段 cases 的公共环境:临时数据根 + 引擎 + 进度通道。
|
||||
pub(super) struct Harness {
|
||||
dir: PathBuf,
|
||||
pub(super) env: HostEnv,
|
||||
pub(super) engine: DownloadEngine,
|
||||
tx: JobSender,
|
||||
rx: Receiver<HostTask>,
|
||||
pub(super) meter: Arc<Mutex<Meter>>,
|
||||
pub(super) journal: Journal,
|
||||
pub(super) cancel: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
pub(super) fn new() -> Self {
|
||||
let seq = SEQ.fetch_add(1, Ordering::SeqCst);
|
||||
let dir = std::env::temp_dir().join(format!("ull-proc-{}-{seq}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let env = HostEnv::at_root(&dir);
|
||||
let engine = crate::host::engine::engine(&env);
|
||||
let request = CreateRequest {
|
||||
version: "1.20.1".to_string(),
|
||||
loader: LoaderKind::Forge,
|
||||
loader_version: "47.2.0".to_string(),
|
||||
name: "proc".to_string(),
|
||||
};
|
||||
let (tx, rx) = channel();
|
||||
Self {
|
||||
journal: Journal::open(&env),
|
||||
meter: Arc::new(Mutex::new(Meter::new(&request))),
|
||||
cancel: Arc::new(AtomicBool::new(false)),
|
||||
dir,
|
||||
env,
|
||||
engine,
|
||||
tx,
|
||||
rx,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reporter(&self) -> Reporter<'_> {
|
||||
Reporter {
|
||||
tx: &self.tx,
|
||||
meter: &self.meter,
|
||||
journal: &self.journal,
|
||||
}
|
||||
}
|
||||
|
||||
/// 进度面板收到的所有文案。
|
||||
pub(super) fn messages(&self) -> Vec<String> {
|
||||
self.rx
|
||||
.try_iter()
|
||||
.filter_map(|task| match task {
|
||||
HostTask::Install(progress) => Some(progress.message.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 装日志全文。
|
||||
pub(super) fn log(&self) -> String {
|
||||
std::fs::read_to_string(self.journal.path()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Harness {
|
||||
fn drop(&mut self) {
|
||||
std::fs::remove_dir_all(&self.dir).ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// 注入用的假后端:回放预置结果 + 记录调用顺序与被组出来的命令行。
|
||||
pub(super) struct Fake {
|
||||
pub(super) libraries_ok: bool,
|
||||
pub(super) java: Option<JavaRuntime>,
|
||||
pub(super) run_error: Option<ProcessorError>,
|
||||
calls: RefCell<Vec<&'static str>>,
|
||||
steps: RefCell<Vec<StepView>>,
|
||||
}
|
||||
|
||||
impl Fake {
|
||||
pub(super) fn new() -> Self {
|
||||
Self {
|
||||
libraries_ok: true,
|
||||
java: Some(java()),
|
||||
run_error: None,
|
||||
calls: RefCell::new(Vec::new()),
|
||||
steps: RefCell::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn calls(&self) -> Vec<&'static str> {
|
||||
self.calls.borrow().clone()
|
||||
}
|
||||
|
||||
/// 被组出来的每步(序号 / 坐标 / 参数 / classpath 条数)。
|
||||
pub(super) fn steps(&self) -> Vec<StepView> {
|
||||
self.steps.borrow().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for Fake {
|
||||
fn download_libraries(
|
||||
&self,
|
||||
_engine: &DownloadEngine,
|
||||
files: &[PlanFile],
|
||||
_sink: Option<ProgressSink>,
|
||||
) -> InstallReport {
|
||||
self.calls.borrow_mut().push("libraries");
|
||||
let ok = self.libraries_ok;
|
||||
let outcomes = files
|
||||
.iter()
|
||||
.map(|file| FileOutcome {
|
||||
role: file.role,
|
||||
name: file.name.clone(),
|
||||
dest: file.dest.clone(),
|
||||
status: if ok {
|
||||
FileStatus::Skipped
|
||||
} else {
|
||||
FileStatus::Failed
|
||||
},
|
||||
bytes: 0,
|
||||
source: None,
|
||||
failure: (!ok).then(|| FileFailure {
|
||||
kind: DownloadErrorKind::Network,
|
||||
message: "连接超时".to_string(),
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
InstallReport {
|
||||
files: outcomes,
|
||||
version_json: None,
|
||||
warnings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn select_java(&self, _managed: &Path, _requirement: &JavaRequirement) -> Option<JavaRuntime> {
|
||||
self.calls.borrow_mut().push("java");
|
||||
self.java.clone()
|
||||
}
|
||||
|
||||
fn run(
|
||||
&self,
|
||||
plan: &ProcessorPlan,
|
||||
java: &JavaRuntime,
|
||||
sink: Option<StepSink>,
|
||||
) -> Result<ProcessorRun, ProcessorError> {
|
||||
self.calls.borrow_mut().push("run");
|
||||
*self.steps.borrow_mut() = plan
|
||||
.steps
|
||||
.iter()
|
||||
.map(|step| {
|
||||
(
|
||||
step.index,
|
||||
step.label.clone(),
|
||||
step.args.clone(),
|
||||
step.classpath.len(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if let Some(error) = &self.run_error {
|
||||
return Err(error.clone());
|
||||
}
|
||||
let outcomes: Vec<StepOutcome> = plan
|
||||
.steps
|
||||
.iter()
|
||||
.map(|step| StepOutcome {
|
||||
index: step.index,
|
||||
label: step.label.clone(),
|
||||
status: StepStatus::Executed,
|
||||
outputs: step.outputs.len(),
|
||||
elapsed_ms: 12,
|
||||
stdout_tail: None,
|
||||
stderr_tail: None,
|
||||
})
|
||||
.collect();
|
||||
if let Some(sink) = &sink {
|
||||
for outcome in &outcomes {
|
||||
sink(outcome);
|
||||
}
|
||||
}
|
||||
Ok(ProcessorRun {
|
||||
java: java.clone(),
|
||||
steps: outcomes,
|
||||
warnings: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个主版本已知的假 Java。
|
||||
pub(super) fn java() -> JavaRuntime {
|
||||
JavaRuntime {
|
||||
binary: PathBuf::from("java"),
|
||||
origin: JavaOrigin::Managed,
|
||||
version: Some("21.0.4".to_string()),
|
||||
major: Some(21),
|
||||
bits: 64,
|
||||
vendor: Some("temurin".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个最小可用的 install profile:一个 client 侧 processor、两个字面量参数。
|
||||
pub(super) fn profile() -> InstallProfile {
|
||||
let mut profile = InstallProfile::default();
|
||||
profile.processors.push(Processor {
|
||||
sides: vec!["client".to_string()],
|
||||
jar: Some("net.minecraftforge:installertools:1.3.0".to_string()),
|
||||
classpath: vec!["net.minecraftforge:jarsplitter:1.1.4".to_string()],
|
||||
args: vec!["--task".to_string(), "'MCP_DATA'".to_string()],
|
||||
..Processor::default()
|
||||
});
|
||||
profile
|
||||
}
|
||||
|
||||
pub(super) fn input<'a>(profile: &'a InstallProfile, installer: &'a Path) -> StageInput<'a> {
|
||||
StageInput {
|
||||
kind: CoreLoaderKind::Forge,
|
||||
mc_version: "1.20.1",
|
||||
version_id: "1.20.1-forge-47.2.0",
|
||||
profile: Some(profile),
|
||||
installer: Some(installer),
|
||||
installed: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn installer() -> PathBuf {
|
||||
PathBuf::from("cache/meta/forge-installer.jar")
|
||||
}
|
||||
|
||||
pub(super) fn outcome(status: StepStatus) -> StepOutcome {
|
||||
StepOutcome {
|
||||
index: 3,
|
||||
label: "net.minecraftforge:installertools:1.3.0".to_string(),
|
||||
status,
|
||||
outputs: 2,
|
||||
elapsed_ms: 1234,
|
||||
stdout_tail: None,
|
||||
stderr_tail: None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
//! host/install_processors.rs 的单测(拆文件守 400 行上限)。
|
||||
//!
|
||||
//! 夹具在 [`super::fixture`]:这里只断言「计划怎么组」「找不到 Java 怎么说」
|
||||
//! 「哪一步挂了怎么报」「进度文案长什么样」。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::addon::loader::forge::InstallProfile;
|
||||
use unlucky_core::addon::loader::processors::{ProcessorError, StepStatus};
|
||||
use unlucky_core::addon::loader::LoaderKind as CoreLoaderKind;
|
||||
use unlucky_core::download::DownloadErrorKind;
|
||||
use unlucky_core::game::install::{FileFailure, FileOutcome, FileRole, FileStatus, InstallReport};
|
||||
use unlucky_core::game::java::JavaRequirement;
|
||||
|
||||
use super::fixture::{input, installer, outcome, profile, Fake, Harness};
|
||||
use super::{diagnostic, java_missing, libraries_failed, stage, step_line, StageInput};
|
||||
|
||||
#[test]
|
||||
fn step_line_names_the_step_and_marks_the_outcome() {
|
||||
let executed = step_line(&outcome(StepStatus::Executed), 9);
|
||||
assert_eq!(
|
||||
executed,
|
||||
"installer 处理器 3/9:net.minecraftforge:installertools:1.3.0(1234 ms)"
|
||||
);
|
||||
let skipped = step_line(&outcome(StepStatus::Skipped), 9);
|
||||
assert!(skipped.contains("产物已就绪"), "{skipped}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostic_keeps_the_stable_code_and_the_hint() {
|
||||
let failed = ProcessorError::failed("net.minecraftforge:installertools:1.3.0", "1", "boom")
|
||||
.with_hint("确认 Java 版本与磁盘权限");
|
||||
let text = diagnostic(&failed);
|
||||
assert!(text.contains("processor_failed"), "{text}");
|
||||
assert!(text.contains("退出码 1"), "{text}");
|
||||
assert!(text.contains("boom"), "stderr 尾部要留:{text}");
|
||||
assert!(text.contains("建议:确认 Java 版本"), "{text}");
|
||||
|
||||
let plan = diagnostic(&ProcessorError::plan("install profile 缺 `jar`"));
|
||||
assert!(plan.contains("processor_plan"), "{plan}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_missing_points_at_the_java_page() {
|
||||
let managed = PathBuf::from("/tmp/.ULL/java");
|
||||
let text = java_missing(&JavaRequirement::table(21, None), &managed);
|
||||
assert!(text.contains("Java 21+"), "{text}");
|
||||
assert!(text.contains("Java」页"), "{text}");
|
||||
assert!(text.contains("JAVA_HOME"), "{text}");
|
||||
assert!(text.contains(&managed.display().to_string()), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn libraries_failed_lists_at_most_three() {
|
||||
let files: Vec<FileOutcome> = (0..4)
|
||||
.map(|index| FileOutcome {
|
||||
role: FileRole::Library,
|
||||
name: format!("lib{index}.jar"),
|
||||
dest: PathBuf::from(format!("lib{index}.jar")),
|
||||
status: FileStatus::Failed,
|
||||
bytes: 0,
|
||||
source: None,
|
||||
failure: Some(FileFailure {
|
||||
kind: DownloadErrorKind::Network,
|
||||
message: "连接超时".to_string(),
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
let report = InstallReport {
|
||||
files,
|
||||
version_json: None,
|
||||
warnings: Vec::new(),
|
||||
};
|
||||
let text = libraries_failed(&report);
|
||||
assert!(text.contains("4 个失败,共 4 个"), "{text}");
|
||||
assert!(text.contains("lib0.jar"), "{text}");
|
||||
assert!(!text.contains("lib3.jar"), "最多列三条:{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_skips_when_the_version_json_is_already_there() {
|
||||
let h = Harness::new();
|
||||
let installer = installer();
|
||||
let profile = profile();
|
||||
let mut input = input(&profile, &installer);
|
||||
input.installed = true;
|
||||
let fake = Fake::new();
|
||||
let result = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel);
|
||||
assert!(matches!(result, Ok(None)));
|
||||
assert!(fake.calls().is_empty(), "已装完就不该再动网络/Java");
|
||||
assert!(h.log().contains("跳过 installer processors"), "{}", h.log());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_skips_an_empty_processor_list() {
|
||||
let h = Harness::new();
|
||||
let installer = installer();
|
||||
let profile = InstallProfile::default();
|
||||
let input = input(&profile, &installer);
|
||||
let fake = Fake::new();
|
||||
let result = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel);
|
||||
assert!(matches!(result, Ok(None)), "没有 processor 就没这段");
|
||||
assert!(fake.calls().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_reports_missing_installer_metadata() {
|
||||
let h = Harness::new();
|
||||
let input = StageInput {
|
||||
kind: CoreLoaderKind::Forge,
|
||||
mc_version: "1.20.1",
|
||||
version_id: "1.20.1-forge-47.2.0",
|
||||
profile: None,
|
||||
installer: None,
|
||||
installed: false,
|
||||
};
|
||||
let fake = Fake::new();
|
||||
let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err();
|
||||
assert!(err.contains("install profile"), "{err}");
|
||||
assert!(h.log().contains("失败:"), "{}", h.log());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_builds_the_command_line_from_the_install_profile() {
|
||||
let h = Harness::new();
|
||||
let installer = installer();
|
||||
let profile = profile();
|
||||
let input = input(&profile, &installer);
|
||||
let fake = Fake::new();
|
||||
let run = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel)
|
||||
.expect("stage 成功")
|
||||
.expect("应当真的跑了 processors");
|
||||
assert_eq!(run.executed(), 1);
|
||||
let steps = fake.steps();
|
||||
assert_eq!(steps.len(), 1);
|
||||
let (index, label, args, classpath) = &steps[0];
|
||||
assert_eq!(*index, 1);
|
||||
assert_eq!(label, "net.minecraftforge:installertools:1.3.0");
|
||||
assert_eq!(args, &vec!["--task".to_string(), "MCP_DATA".to_string()]);
|
||||
assert_eq!(*classpath, 1, "jar 自己不在 -cp 里(拼命令行时才补)");
|
||||
assert_eq!(fake.calls(), vec!["libraries", "java", "run"]);
|
||||
let messages = h.messages();
|
||||
assert!(
|
||||
messages.iter().any(|m| m.contains("installer 处理器 1/1")),
|
||||
"面板要看到当前处理器:{messages:?}"
|
||||
);
|
||||
assert!(
|
||||
messages
|
||||
.last()
|
||||
.is_some_and(|m| m.contains("正在写入版本清单")),
|
||||
"{messages:?}"
|
||||
);
|
||||
assert!(
|
||||
h.log().contains("processors 完成:执行 1 步"),
|
||||
"{}",
|
||||
h.log()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_fails_loudly_without_a_usable_java() {
|
||||
let h = Harness::new();
|
||||
let installer = installer();
|
||||
let profile = profile();
|
||||
let input = input(&profile, &installer);
|
||||
let mut fake = Fake::new();
|
||||
fake.java = None;
|
||||
let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err();
|
||||
assert!(err.contains("Java"), "{err}");
|
||||
assert!(err.contains("托管目录"), "{err}");
|
||||
assert_eq!(
|
||||
fake.calls(),
|
||||
vec!["libraries", "java"],
|
||||
"没 Java 就不该起进程"
|
||||
);
|
||||
assert!(h.log().contains("失败:"), "{}", h.log());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_fails_loudly_when_a_processor_exits_nonzero() {
|
||||
let h = Harness::new();
|
||||
let installer = installer();
|
||||
let profile = profile();
|
||||
let input = input(&profile, &installer);
|
||||
let mut fake = Fake::new();
|
||||
fake.run_error = Some(ProcessorError::failed(
|
||||
"net.minecraftforge:installertools:1.3.0",
|
||||
"1",
|
||||
"java.lang.NoSuchMethodError",
|
||||
));
|
||||
let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err();
|
||||
assert!(err.contains("processor_failed"), "{err}");
|
||||
assert!(err.contains("NoSuchMethodError"), "{err}");
|
||||
let log = h.log();
|
||||
assert!(log.contains("失败:"), "{log}");
|
||||
assert!(log.contains("NoSuchMethodError"), "日志要能回溯:{log}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_reports_incomplete_library_downloads() {
|
||||
let h = Harness::new();
|
||||
let installer = installer();
|
||||
let profile = profile();
|
||||
let input = input(&profile, &installer);
|
||||
let mut fake = Fake::new();
|
||||
fake.libraries_ok = false;
|
||||
let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err();
|
||||
assert!(err.contains("依赖库没下全"), "{err}");
|
||||
assert!(err.contains("连接超时"), "{err}");
|
||||
assert_eq!(fake.calls(), vec!["libraries"], "库没下全就别去选 Java");
|
||||
let progress = h.meter.lock().expect("进度锁").progress.clone();
|
||||
assert!(progress.files_total >= 2, "依赖库要计入文件总数");
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//! host/install.rs 的单测(拆文件守 400 行上限)。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use unlucky_core::game::install::{FileOutcome, FileRole, FileStatus};
|
||||
|
||||
use super::{loader_label, run, supported, unsupported_reason, Job};
|
||||
use crate::dto::{CreateRequest, LoaderKind};
|
||||
use crate::host::env::HostEnv;
|
||||
use crate::host::install_journal::Journal;
|
||||
use crate::host::job::{channel, JobSender};
|
||||
use crate::host::meter::Meter;
|
||||
|
||||
static SEQ: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// 一个装在临时根里的任务上下文(只走「联网前就返回」的用例)。
|
||||
struct Fixture {
|
||||
dir: PathBuf,
|
||||
env: HostEnv,
|
||||
tx: JobSender,
|
||||
meter: Arc<Mutex<Meter>>,
|
||||
cancel: Arc<AtomicBool>,
|
||||
journal: Journal,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new(request: &CreateRequest) -> Self {
|
||||
let seq = SEQ.fetch_add(1, Ordering::SeqCst);
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("ull-host-install-{}-{seq}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let env = HostEnv::at_root(&dir);
|
||||
let journal = Journal::open(&env);
|
||||
Self {
|
||||
dir,
|
||||
env,
|
||||
tx: channel().0,
|
||||
meter: Arc::new(Mutex::new(Meter::new(request))),
|
||||
cancel: Arc::new(AtomicBool::new(false)),
|
||||
journal,
|
||||
}
|
||||
}
|
||||
|
||||
fn job(&self) -> Job<'_> {
|
||||
Job {
|
||||
env: &self.env,
|
||||
tx: &self.tx,
|
||||
meter: &self.meter,
|
||||
cancel: &self.cancel,
|
||||
journal: &self.journal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
std::fs::remove_dir_all(&self.dir).ok();
|
||||
}
|
||||
}
|
||||
|
||||
fn request(loader: LoaderKind, version: &str) -> CreateRequest {
|
||||
CreateRequest {
|
||||
version: version.to_string(),
|
||||
loader,
|
||||
loader_version: String::new(),
|
||||
name: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个「刚下完」的文件结局(记账用)。
|
||||
fn downloaded(bytes: u64) -> FileOutcome {
|
||||
FileOutcome {
|
||||
role: FileRole::Library,
|
||||
name: "lib.jar".to_string(),
|
||||
dest: PathBuf::from("lib.jar"),
|
||||
status: FileStatus::Downloaded,
|
||||
bytes,
|
||||
source: None,
|
||||
failure: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_is_pre_accounted_across_segments_so_the_bar_never_snaps_to_full() {
|
||||
// M45 r1 P2-2 的回归:处理器依赖库下完那一刻,面板曾显示 36/36 满格 + 3.4MB/10MB,
|
||||
// 之后加载器库的 29 个文件才被计入 → 进度条从 100% 回落到 55% 再涨回去。
|
||||
let request = request(LoaderKind::Forge, "1.20.1");
|
||||
let mut meter = Meter::new(&request);
|
||||
|
||||
// 原版段:30 个文件全下完(install_vanilla 对总量是赋值,不是累加)
|
||||
meter.progress.files_total = 30;
|
||||
meter.progress.bytes_total = 3_000_000;
|
||||
for _ in 0..30 {
|
||||
meter.note(&downloaded(100_000));
|
||||
}
|
||||
assert!((meter.progress.ratio() - 1.0).abs() < f32::EPSILON);
|
||||
|
||||
// install_loader 的顺序:加载器库与处理器依赖库都在动手前一次预记账
|
||||
meter.expect(29, 10_000_000);
|
||||
meter.expect(36, 4_000_000);
|
||||
assert_eq!(meter.progress.files_total, 95, "两段之和一次到位");
|
||||
assert_eq!(
|
||||
meter.progress.bytes_total, 17_000_000,
|
||||
"字节是相加不是取最大"
|
||||
);
|
||||
assert!(
|
||||
meter.progress.ratio() < 0.7,
|
||||
"预记账后不能还停在满格:{}",
|
||||
meter.progress.ratio()
|
||||
);
|
||||
|
||||
// 处理器依赖库 36 个下完 → 仍没满格(加载器库还在后面)
|
||||
for _ in 0..36 {
|
||||
meter.note(&downloaded(110_000));
|
||||
}
|
||||
let ratio = meter.progress.ratio();
|
||||
assert!(
|
||||
(0.6..1.0).contains(&ratio),
|
||||
"回落到中间态而不是满格:{ratio}"
|
||||
);
|
||||
assert!(
|
||||
meter.progress.bytes_done < meter.progress.bytes_total,
|
||||
"字节总量要覆盖已下载量:{}/{}",
|
||||
meter.progress.bytes_done,
|
||||
meter.progress.bytes_total
|
||||
);
|
||||
|
||||
// 加载器库也下完 → 这时候才该是满格
|
||||
for _ in 0..29 {
|
||||
meter.note(&downloaded(340_000));
|
||||
}
|
||||
assert!((meter.progress.ratio() - 1.0).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loader_support_matrix_is_explicit() {
|
||||
// M45 语义变更:Forge/NeoForge 从「GUI 显式拒装」扩进受支持集合(走 installer processors)。
|
||||
for kind in [
|
||||
LoaderKind::Vanilla,
|
||||
LoaderKind::Fabric,
|
||||
LoaderKind::Quilt,
|
||||
LoaderKind::Forge,
|
||||
LoaderKind::NeoForge,
|
||||
] {
|
||||
assert!(supported(kind), "{} 应当受支持", kind.label());
|
||||
}
|
||||
// 这两家 core 侧还没有安装管线,仍然显式拒装并说清去哪装。
|
||||
for kind in [LoaderKind::OptiFine, LoaderKind::LiteLoader] {
|
||||
assert!(!supported(kind), "{} 不该被当成可装", kind.label());
|
||||
let reason = unsupported_reason(kind);
|
||||
assert!(reason.contains(kind.label()), "{reason}");
|
||||
assert!(reason.contains("还没有接入"), "{reason}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forge_install_is_no_longer_refused_at_the_door() {
|
||||
// 旧文案是把用户赶去终端跑 `ull install`;M45 之后 GUI 与 CLI 同一条路径。
|
||||
assert!(supported(LoaderKind::Forge));
|
||||
assert!(supported(LoaderKind::NeoForge));
|
||||
assert_eq!(loader_label(LoaderKind::Forge, "47.2.0"), "Forge 47.2.0");
|
||||
assert_eq!(
|
||||
loader_label(LoaderKind::NeoForge, "21.1.80"),
|
||||
"NeoForge 21.1.80"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meter_counts_files_and_skips_skipped_bytes() {
|
||||
let request = CreateRequest {
|
||||
version: "1.20.4".to_string(),
|
||||
loader: LoaderKind::Fabric,
|
||||
loader_version: "0.16.5".to_string(),
|
||||
name: "生存".to_string(),
|
||||
};
|
||||
let mut meter = Meter::new(&request);
|
||||
assert_eq!(meter.progress.loader, "Fabric 0.16.5");
|
||||
assert_eq!(meter.progress.instance, "生存");
|
||||
meter.progress.files_total = 2;
|
||||
|
||||
let mut outcome = FileOutcome {
|
||||
role: FileRole::Library,
|
||||
name: "a.jar".to_string(),
|
||||
dest: PathBuf::from("a.jar"),
|
||||
status: FileStatus::Downloaded,
|
||||
bytes: 100,
|
||||
source: Some("bmclapi".to_string()),
|
||||
failure: None,
|
||||
};
|
||||
meter.note(&outcome);
|
||||
assert_eq!(meter.progress.files_done, 1);
|
||||
assert_eq!(meter.progress.bytes_done, 100);
|
||||
|
||||
outcome.status = FileStatus::Skipped;
|
||||
outcome.bytes = 500;
|
||||
meter.note(&outcome);
|
||||
assert_eq!(meter.progress.files_done, 2);
|
||||
assert_eq!(meter.progress.bytes_done, 100, "跳过的文件不计入已下载字节");
|
||||
assert!((meter.progress.ratio() - 1.0).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_version_is_rejected_before_network() {
|
||||
let request = request(LoaderKind::Vanilla, "");
|
||||
let fixture = Fixture::new(&request);
|
||||
let err = run(&fixture.job(), &request).unwrap_err();
|
||||
assert!(err.contains("没有选择版本"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_loader_is_rejected_before_network() {
|
||||
let request = request(LoaderKind::OptiFine, "1.20.1");
|
||||
let fixture = Fixture::new(&request);
|
||||
let err = run(&fixture.job(), &request).unwrap_err();
|
||||
assert!(err.contains("还没有接入"), "{err}");
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
//! Java 页:四家发行版 + 回退链可视化 + 本地运行时 + 托管 JDK 安装。
|
||||
//!
|
||||
//! 回退链的两态:
|
||||
//! - **计划链**(还没装):按生效顺序铺一排「待尝试」,让用户先看清会按什么顺序找;
|
||||
//! - **真实链**(装过一次/装失败):`core::game::java::ChainLog` 的每跳结局与原因。
|
||||
//!
|
||||
//! 安装走 `java::install`(core 自己跑回退链),host 只负责把 `ChainLog` 翻成 DTO。
|
||||
|
||||
use unlucky_core::game::java::probe::JavaOrigin;
|
||||
use unlucky_core::game::java::source::ChainLog;
|
||||
use unlucky_core::game::java::{self, JdkVendor};
|
||||
|
||||
use crate::dto::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView};
|
||||
|
||||
use super::env::HostEnv;
|
||||
use super::job::{spawn, HostTask, JobSender};
|
||||
|
||||
/// 托管 JDK 的默认目标大版本(D13 基准 JDK17)。
|
||||
pub const DEFAULT_JDK_MAJOR: u32 = 17;
|
||||
|
||||
/// 该装哪个大版本:取第一个实例版本的要求,没有实例就给 17。
|
||||
pub fn target_major(env: &HostEnv) -> u32 {
|
||||
let profiles = super::game::profiles(env);
|
||||
profiles
|
||||
.profiles
|
||||
.first()
|
||||
.map(|profile| {
|
||||
java::requirement_for_version(&env.game, &profile.version_id)
|
||||
.minimum
|
||||
.max(8)
|
||||
})
|
||||
.unwrap_or(DEFAULT_JDK_MAJOR)
|
||||
}
|
||||
|
||||
/// 四家发行版的展示行(镜像与吞吐口径与 CLI `ull java vendors` 一致)。
|
||||
pub fn vendors(env: &HostEnv, installed_vendors: &[String]) -> Vec<JavaVendorView> {
|
||||
let domestic = super::engine::domestic(env);
|
||||
let order = java::vendor::order(&super::engine::java_priority(env), domestic);
|
||||
let table = unlucky_core::download::MirrorTable::builtin();
|
||||
order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, vendor)| {
|
||||
let id = vendor.id();
|
||||
JavaVendorView {
|
||||
id: id.to_string(),
|
||||
display: vendor.display().to_string(),
|
||||
rank: u32::try_from(index + 1).unwrap_or(u32::MAX),
|
||||
majors: vendor
|
||||
.hint_majors()
|
||||
.iter()
|
||||
.map(u32::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("/"),
|
||||
sources: sources_of(&table, *vendor),
|
||||
installed: installed_vendors.iter().any(|known| known == id),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 一家的源清单(与 CLI 展示同形)。
|
||||
fn sources_of(table: &unlucky_core::download::MirrorTable, vendor: JdkVendor) -> Vec<String> {
|
||||
match vendor {
|
||||
JdkVendor::Temurin => table
|
||||
.adoptium
|
||||
.iter()
|
||||
.filter(|mirror| mirror.enabled)
|
||||
.map(|mirror| mirror.name.clone())
|
||||
.chain(std::iter::once(
|
||||
"GitHub 官方(国内套 gh-proxy)".to_string(),
|
||||
))
|
||||
.collect(),
|
||||
JdkVendor::Oracle => table
|
||||
.oracle_mirrors()
|
||||
.map(|mirror| format!("{} {}", mirror.name, mirror.base()))
|
||||
.collect(),
|
||||
JdkVendor::Microsoft => vec!["aka.ms → VS CDN(无国内镜像)".to_string()],
|
||||
JdkVendor::Zulu => table
|
||||
.zulu()
|
||||
.map(|zulu| vec![format!("{}({})", zulu.api, zulu.measured_speed)])
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地运行时(托管 / JAVA_HOME / PATH / 系统安装位置,同一路径只留优先级高者)。
|
||||
pub fn runtimes(env: &HostEnv) -> Vec<JavaRuntimeView> {
|
||||
let roots = super::engine::java_scan_roots(env);
|
||||
let candidates = java::discover::dedupe(java::candidates_in_roots(&roots));
|
||||
candidates
|
||||
.iter()
|
||||
.map(|runtime| JavaRuntimeView {
|
||||
binary: runtime.binary.display().to_string(),
|
||||
version: runtime
|
||||
.version
|
||||
.clone()
|
||||
.unwrap_or_else(|| "版本未知".to_string()),
|
||||
major: runtime.major.unwrap_or(java::DEFAULT_MAJOR),
|
||||
vendor: runtime.vendor.clone().unwrap_or_else(|| "—".to_string()),
|
||||
origin: runtime.origin.category().to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 托管目录(展示「装到哪」)。
|
||||
pub fn managed_roots(env: &HostEnv) -> Vec<String> {
|
||||
super::engine::java_scan_roots(env)
|
||||
.iter()
|
||||
.map(|root| root.display().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 计划链(按生效顺序铺「待尝试」)。
|
||||
pub fn planned_chain(env: &HostEnv) -> Vec<JavaChainStep> {
|
||||
let domestic = super::engine::domestic(env);
|
||||
let order = java::vendor::order(&super::engine::java_priority(env), domestic);
|
||||
order
|
||||
.iter()
|
||||
.map(|vendor| JavaChainStep {
|
||||
vendor: vendor.id().to_string(),
|
||||
outcome: JavaChainOutcome::Pending,
|
||||
detail: String::new(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 真实链(`ChainLog` → DTO)。
|
||||
pub fn chain_steps(log: &ChainLog) -> Vec<JavaChainStep> {
|
||||
log.attempts()
|
||||
.iter()
|
||||
.map(|attempt| JavaChainStep {
|
||||
vendor: attempt.vendor.id().to_string(),
|
||||
outcome: if attempt.ok {
|
||||
JavaChainOutcome::Hit
|
||||
} else if attempt.detail.contains("不支持") {
|
||||
JavaChainOutcome::Skip
|
||||
} else {
|
||||
JavaChainOutcome::Failed
|
||||
},
|
||||
detail: attempt.detail.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 整页快照。
|
||||
pub fn page(env: &HostEnv, chain: Vec<JavaChainStep>, busy: bool, message: &str) -> JavaPageView {
|
||||
let runtimes = runtimes(env);
|
||||
let installed_vendors: Vec<String> = runtimes
|
||||
.iter()
|
||||
.filter(|runtime| runtime.origin == JavaOrigin::Managed.category())
|
||||
.map(|runtime| runtime.vendor.clone())
|
||||
.collect();
|
||||
let vendor_rows = vendors(env, &installed_vendors);
|
||||
let domestic = super::engine::domestic(env);
|
||||
let priority_set = !super::engine::java_priority(env).is_empty();
|
||||
let chain = if chain.is_empty() {
|
||||
planned_chain(env)
|
||||
} else {
|
||||
chain
|
||||
};
|
||||
JavaPageView {
|
||||
region: if domestic { "domestic" } else { "global" }.to_string(),
|
||||
priority_source: if priority_set {
|
||||
"java.vendorPriority"
|
||||
} else {
|
||||
"region_default"
|
||||
}
|
||||
.to_string(),
|
||||
order_text: vendor_rows
|
||||
.iter()
|
||||
.map(|vendor| vendor.display.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" → "),
|
||||
vendors: vendor_rows,
|
||||
chain,
|
||||
runtimes,
|
||||
managed_roots: managed_roots(env),
|
||||
busy,
|
||||
message: message.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 后台装一个托管 JDK(`vendor` 为空 = 按回退链自己选)。
|
||||
pub fn spawn_install(env: HostEnv, tx: JobSender, vendor: Option<JdkVendor>) {
|
||||
spawn("java-install", move || {
|
||||
let major = target_major(&env);
|
||||
let mut busy = page(&env, Vec::new(), true, &format!("正在安装 Java {major}…"));
|
||||
busy.busy = true;
|
||||
let _ = tx.send(HostTask::Java(Box::new(busy)));
|
||||
|
||||
let engine = super::engine::engine(&env);
|
||||
let root = match super::engine::java_install_root(&env) {
|
||||
Ok(root) => root,
|
||||
Err(reason) => {
|
||||
let _ = tx.send(HostTask::Java(Box::new(page(
|
||||
&env,
|
||||
Vec::new(),
|
||||
false,
|
||||
&format!("解析 Java 安装位置失败:{reason}"),
|
||||
))));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let request = match vendor {
|
||||
Some(vendor) => java::InstallRequest::with_vendor(major, vendor)
|
||||
.with_priority(super::engine::java_priority(&env)),
|
||||
None => {
|
||||
java::InstallRequest::new(major).with_priority(super::engine::java_priority(&env))
|
||||
}
|
||||
};
|
||||
let result =
|
||||
unlucky_core::download::blocking::block_on(java::install(&engine, &root, request));
|
||||
match result {
|
||||
Ok(installed) => {
|
||||
let message = if installed.reused() {
|
||||
format!(
|
||||
"Java {}({})已就绪:{}",
|
||||
installed.major,
|
||||
installed.vendor.id(),
|
||||
installed.root.display()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"已安装 Java {}({})→ {} · {} 个文件",
|
||||
installed.major,
|
||||
installed.vendor.display(),
|
||||
installed.root.display(),
|
||||
installed.files
|
||||
)
|
||||
};
|
||||
let chain = chain_steps(&installed.chain);
|
||||
let _ = tx.send(HostTask::Java(Box::new(page(&env, chain, false, &message))));
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = tx.send(HostTask::Java(Box::new(page(
|
||||
&env,
|
||||
Vec::new(),
|
||||
false,
|
||||
&format!("Java 安装失败:{error}"),
|
||||
))));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 「安装一个 Java」的 UI 文案(下载页 Java 分组的按钮说明)。
|
||||
pub fn install_hint(env: &HostEnv) -> String {
|
||||
let major = target_major(env);
|
||||
format!("按回退链取一个 Java {major}(托管目录,装完自动被启动链选中)")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn planned_chain_covers_four_vendors() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-host-java-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let env = HostEnv::at_root(&dir);
|
||||
std::fs::create_dir_all(env.layout.config()).expect("config");
|
||||
let chain = planned_chain(&env);
|
||||
assert_eq!(chain.len(), 4);
|
||||
assert!(chain
|
||||
.iter()
|
||||
.all(|step| step.outcome == JavaChainOutcome::Pending));
|
||||
assert_eq!(chain[0].vendor, "temurin", "国内/国外默认都是 Temurin 第一");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chain_log_maps_to_steps() {
|
||||
let mut log = ChainLog::new();
|
||||
log.skip(
|
||||
JdkVendor::Microsoft,
|
||||
"不支持 Java 8(microsoft 只发 11/17/21/25)",
|
||||
);
|
||||
log.hit(JdkVendor::Temurin, "命中 Adoptium 17");
|
||||
let steps = chain_steps(&log);
|
||||
assert_eq!(steps.len(), 2);
|
||||
assert_eq!(steps[0].outcome, JavaChainOutcome::Skip);
|
||||
assert_eq!(steps[1].outcome, JavaChainOutcome::Hit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_reports_region_and_priority_source() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-host-java-page-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let env = HostEnv::at_root(&dir);
|
||||
std::fs::create_dir_all(env.layout.config()).expect("config");
|
||||
std::fs::write(
|
||||
env.launcher_config(),
|
||||
r#"{"java":{"vendorPriority":["zulu"]}}"#,
|
||||
)
|
||||
.expect("写配置");
|
||||
let page = page(&env, Vec::new(), false, "");
|
||||
assert_eq!(page.priority_source, "java.vendorPriority");
|
||||
assert_eq!(page.vendors[0].id, "zulu");
|
||||
assert_eq!(page.vendors[0].rank, 1);
|
||||
assert!(page.order_text.contains("Azul Zulu"));
|
||||
assert_eq!(page.vendors.len(), 4);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//! 后台任务的回流消息:`HostTask` 是唯一「线程 → host」的载荷。
|
||||
//!
|
||||
//! 线程只做 core 调用(网络/磁盘),产出的都是 `crate::dto` 的 DTO;
|
||||
//! host 的 `tick` 把它们翻成 [`crate::dto::CoreEvent`]。
|
||||
//! 这样「跨线程传的是什么」在一处看全,也不会出现半成品领域类型漏回 UI。
|
||||
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
use crate::dto::{
|
||||
AccountView, DeviceLoginView, DiagnosisReport, InstallProgressView, InstanceId,
|
||||
InstanceRuntime, JavaPageView, LaunchFinished, LiveLogBatch, LogLine, LogOutcome, LogSession,
|
||||
NoticeView, SessionId, VersionSummary,
|
||||
};
|
||||
use crate::toast::Toast;
|
||||
|
||||
/// 后台任务 → host 的消息。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum HostTask {
|
||||
/// 版本清单(下载页)
|
||||
Versions(Result<Vec<VersionSummary>, String>),
|
||||
/// 安装进度(高频,host 只做聚合与转发)
|
||||
Install(Box<InstallProgressView>),
|
||||
/// 安装收尾(成功带实例名,失败带文案)
|
||||
InstallDone {
|
||||
/// 最终进度快照
|
||||
progress: Box<InstallProgressView>,
|
||||
/// 成功时为 `Ok(实例名)`
|
||||
result: Result<String, String>,
|
||||
},
|
||||
/// 账户列表
|
||||
Accounts(Vec<AccountView>),
|
||||
/// 设备码登录状态
|
||||
Login(Box<DeviceLoginView>),
|
||||
/// Java 页(发行版/回退链/运行时)
|
||||
Java(Box<JavaPageView>),
|
||||
/// 日志页快照(会话列表 + 选中会话的行 + 诊断)
|
||||
Logs {
|
||||
/// 会话列表
|
||||
sessions: Vec<LogSession>,
|
||||
/// 选中会话的行
|
||||
lines: Vec<LogLine>,
|
||||
/// 诊断结论
|
||||
diagnosis: Option<DiagnosisReport>,
|
||||
},
|
||||
/// 实时日志增量
|
||||
Tail(Box<LiveLogBatch>),
|
||||
/// 启动开始(pid + 会话 id)
|
||||
LaunchStarted {
|
||||
/// 实例 id
|
||||
id: InstanceId,
|
||||
/// 会话 id
|
||||
session_id: SessionId,
|
||||
/// 进程号
|
||||
pid: u32,
|
||||
},
|
||||
/// 启动阶段推进(磁贴进度环)
|
||||
Runtime {
|
||||
/// 实例 id
|
||||
id: InstanceId,
|
||||
/// 阶段状态
|
||||
runtime: InstanceRuntime,
|
||||
},
|
||||
/// 启动收尾
|
||||
LaunchEnd(Box<LaunchFinished>),
|
||||
/// 崩溃日志导入的分析结论
|
||||
Crash(Box<DiagnosisReport>),
|
||||
/// 横幅通知(隔离切换提示等)
|
||||
Notice(Box<NoticeView>),
|
||||
/// Toast
|
||||
Toast(Box<Toast>),
|
||||
/// 会话列表刷新(只换左栏)
|
||||
Sessions(Vec<LogSession>),
|
||||
}
|
||||
|
||||
/// 后台线程用的发送端(`Sender` 克隆即可)。
|
||||
pub type JobSender = Sender<HostTask>;
|
||||
|
||||
/// host 侧的接收端。
|
||||
pub type JobReceiver = Receiver<HostTask>;
|
||||
|
||||
/// 建一对通道。
|
||||
pub fn channel() -> (JobSender, JobReceiver) {
|
||||
std::sync::mpsc::channel()
|
||||
}
|
||||
|
||||
/// 起一个带名字的后台线程(名字只进 panic 信息,方便定位)。
|
||||
pub fn spawn(name: &str, job: impl FnOnce() + Send + 'static) -> std::thread::JoinHandle<()> {
|
||||
std::thread::Builder::new()
|
||||
.name(format!("ull-host-{name}"))
|
||||
.spawn(job)
|
||||
.unwrap_or_else(|error| panic!("起不来后台线程 {name}: {error}"))
|
||||
}
|
||||
|
||||
/// 人读一行「启动结束」。
|
||||
pub fn launch_summary(outcome: LogOutcome, exit_code: Option<i32>) -> String {
|
||||
match outcome {
|
||||
LogOutcome::Ok => "正常退出".to_string(),
|
||||
LogOutcome::Crashed => match exit_code {
|
||||
Some(code) => format!("崩溃(exit {code})"),
|
||||
None => "崩溃".to_string(),
|
||||
},
|
||||
LogOutcome::SilentFail => "静默失败(exit 0 但游戏没起来)".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn channel_carries_tasks_across_threads() {
|
||||
let (tx, rx) = channel();
|
||||
let handle = spawn("test", move || {
|
||||
tx.send(HostTask::Toast(Box::new(Toast {
|
||||
id: 1,
|
||||
kind: crate::toast::ToastKind::Info,
|
||||
text: "hi".to_string(),
|
||||
remaining_ms: 0,
|
||||
})))
|
||||
.expect("发送");
|
||||
});
|
||||
handle.join().expect("线程结束");
|
||||
let task = rx.recv().expect("收到");
|
||||
assert!(matches!(task, HostTask::Toast(toast) if toast.text == "hi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_summary_names_the_outcome() {
|
||||
assert_eq!(launch_summary(LogOutcome::Ok, Some(0)), "正常退出");
|
||||
assert_eq!(
|
||||
launch_summary(LogOutcome::Crashed, Some(1)),
|
||||
"崩溃(exit 1)"
|
||||
);
|
||||
assert!(launch_summary(LogOutcome::SilentFail, Some(0)).contains("静默"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
//! 启动全链:实例 → `LaunchRequest` → 子进程 → JSONL 事件流(GUI 版)。
|
||||
//!
|
||||
//! 与 CLI `ull launch` 同源同序(`core` 的 `game::resolve` / `arguments.expand` /
|
||||
//! `launch::adapter::build_request` / `Launcher::spawn`),差异只有两处:
|
||||
//! - **不阻塞 UI**:装配与等待都在后台线程里,进度/日志经 [`super::job::HostTask`] 回流;
|
||||
//! - **身份来自 GUI 选中的账户**(`AccountStore` → `AuthPlaceholders`),凭据只在
|
||||
//! 本模块内被读一次,绝不进 DTO。
|
||||
//!
|
||||
//! 停止:`kill_tree(pid)`(core 的三段式进程树回收),`finish()` 会照常收尾写索引。
|
||||
|
||||
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, IsolationMode};
|
||||
use unlucky_core::game::{self, VersionJson};
|
||||
use unlucky_core::launch::adapter::{
|
||||
self, AdapterOptions, AdapterWarning, LibraryInput, SnapshotInput,
|
||||
};
|
||||
use unlucky_core::launch::request::{JavaSelection, LaunchRequest, MemoryOptions};
|
||||
use unlucky_core::launch::time::{format_rfc3339_millis, now};
|
||||
use unlucky_core::launch::tree_kill;
|
||||
use unlucky_core::launch::{Launcher, LauncherConfig};
|
||||
use unlucky_core::platform::paths::ProcessEnv;
|
||||
|
||||
use crate::dto::{
|
||||
InstanceId, InstanceRuntime, LaunchFinished, LaunchStage, LoaderKind, LogOutcome, Progress,
|
||||
};
|
||||
|
||||
use super::env::HostEnv;
|
||||
use super::job::{launch_summary, HostTask, JobSender};
|
||||
|
||||
/// 启动器名(与 CLI `meta::LAUNCHER_NAME` 同值)。
|
||||
pub const LAUNCHER_NAME: &str = "Unlucky Launcher";
|
||||
|
||||
/// 一次启动需要的实例信息。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LaunchTarget {
|
||||
/// 实例 id
|
||||
pub id: InstanceId,
|
||||
/// 实例名
|
||||
pub name: String,
|
||||
/// 绑定的版本 id
|
||||
pub version_id: String,
|
||||
/// 账户 id(`None` = 离线占位)
|
||||
pub account: Option<String>,
|
||||
}
|
||||
|
||||
/// 选中并用起来的 Java(`summary` 给 UI 一行提示)。
|
||||
pub struct JavaPick {
|
||||
/// 交给 adapter 的选择
|
||||
pub selection: JavaSelection,
|
||||
/// 人读一行
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
/// 选一个能用的 Java:托管 > JAVA_HOME > PATH(`core::game::java` 的探测与优先级)。
|
||||
pub fn pick_java(env: &HostEnv, version_id: &str) -> JavaPick {
|
||||
let store = env.store();
|
||||
let install_root = store
|
||||
.doc()
|
||||
.effective_str("java.installRoot")
|
||||
.map(str::trim)
|
||||
.filter(|text| !text.is_empty())
|
||||
.map(PathBuf::from);
|
||||
let roots = unlucky_core::game::java::location::scan_roots(
|
||||
env.mode(),
|
||||
&env.layout,
|
||||
install_root.as_deref(),
|
||||
env.platform,
|
||||
&ProcessEnv,
|
||||
);
|
||||
let candidates = unlucky_core::game::java::candidates_in_roots(&roots);
|
||||
let required = unlucky_core::game::java::requirement_for_version(&env.game, version_id);
|
||||
let picked = unlucky_core::game::java::pick(&candidates, &required).cloned();
|
||||
match picked {
|
||||
Some(runtime) => {
|
||||
let major = runtime
|
||||
.major
|
||||
.unwrap_or(unlucky_core::game::java::DEFAULT_MAJOR);
|
||||
let summary = format!(
|
||||
"Java {}({}{})",
|
||||
runtime.version.clone().unwrap_or_else(|| major.to_string()),
|
||||
runtime.origin.category(),
|
||||
runtime
|
||||
.vendor
|
||||
.as_ref()
|
||||
.map(|vendor| format!(" · {vendor}"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
JavaPick {
|
||||
selection: JavaSelection::new(runtime.binary, major, runtime.bits),
|
||||
summary,
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let binary = PathBuf::from(unlucky_core::game::java::java_binary_name());
|
||||
let major = required.minimum;
|
||||
JavaPick {
|
||||
selection: JavaSelection::new(binary, major, 64),
|
||||
summary: format!("PATH 上的 Java(未探测到版本,按 Java {major} 启动)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 装配 [`LaunchRequest`](与 CLI `snapshot::prepare` 同序)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 版本 JSON 缺失/非法、继承链断裂、隔离派生失败时返回用户可读文案。
|
||||
pub fn build_request(
|
||||
env: &HostEnv,
|
||||
target: &LaunchTarget,
|
||||
) -> Result<(LaunchRequest, Vec<String>), String> {
|
||||
let version_id = target.version_id.clone();
|
||||
let manifest_path = env.game.version_json(&version_id);
|
||||
let text = std::fs::read_to_string(&manifest_path)
|
||||
.map_err(|error| format!("读版本 JSON 失败 {}:{error}", manifest_path.display()))?;
|
||||
let manifest =
|
||||
VersionJson::from_json_str(&text).map_err(|error| format!("版本 JSON 非法:{error}"))?;
|
||||
let source = |id: &str| -> Option<VersionJson> {
|
||||
std::fs::read_to_string(env.game.version_json(id))
|
||||
.ok()
|
||||
.and_then(|text| VersionJson::from_json_str(&text).ok())
|
||||
};
|
||||
let resolved =
|
||||
game::resolve(&source, &manifest).map_err(|error| format!("解析继承链失败:{error}"))?;
|
||||
|
||||
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) || library.native_classifier(&environment).is_some() {
|
||||
continue;
|
||||
}
|
||||
let Ok(coordinate) = library.coordinate() else {
|
||||
continue;
|
||||
};
|
||||
let path = if library.is_local() {
|
||||
env.game.version_library(&version_id, &coordinate)
|
||||
} else {
|
||||
env.game.library(&coordinate)
|
||||
};
|
||||
libraries.push(LibraryInput::new(path, library.name.clone()));
|
||||
}
|
||||
let natives = unlucky_core::launch::natives::collect(
|
||||
&env.game,
|
||||
&version_id,
|
||||
resolved.libraries.as_deref().unwrap_or_default(),
|
||||
&environment,
|
||||
);
|
||||
|
||||
let game_dir = run_dir(env, target)?;
|
||||
let jar_id = resolved.jar.clone().unwrap_or_else(|| version_id.clone());
|
||||
let assets_root = env.game.assets_dir();
|
||||
let assets_index = resolved
|
||||
.assets
|
||||
.clone()
|
||||
.or_else(|| resolved.asset_index.as_ref().map(|index| index.id.clone()));
|
||||
let snapshot = SnapshotInput {
|
||||
version_id: &version_id,
|
||||
version_name: Some(&target.name),
|
||||
version_type: None,
|
||||
game_version: None,
|
||||
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: env.game.version_jar(&jar_id),
|
||||
game_dir,
|
||||
assets_index: assets_index.as_deref(),
|
||||
natives_dir: env.game.natives_dir(&version_id),
|
||||
library_dir: env.game.libraries_dir(),
|
||||
assets_root: assets_root.clone(),
|
||||
game_assets: assets_root,
|
||||
};
|
||||
|
||||
let java = pick_java(env, &version_id);
|
||||
let (auth, identity_line) = super::identity::resolve(env, target.account.as_deref())?;
|
||||
let memory = env
|
||||
.store()
|
||||
.effective("java.default_memory_mb")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.and_then(|value| u32::try_from(value).ok());
|
||||
let options = AdapterOptions {
|
||||
java: java.selection,
|
||||
memory: MemoryOptions {
|
||||
min_mb: None,
|
||||
max_mb: memory,
|
||||
},
|
||||
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();
|
||||
Ok((request, warnings))
|
||||
}
|
||||
|
||||
/// 派生运行目录(D20 覆盖链:实例 `isolation` > 全局 `instances.mode`)。
|
||||
fn run_dir(env: &HostEnv, target: &LaunchTarget) -> Result<PathBuf, String> {
|
||||
let global = super::game::global_mode(env);
|
||||
let config = read_instance_config(&env.layout, &target.id)
|
||||
.map_err(|error| format!("读实例设置失败:{error}"))?;
|
||||
let derivation = derive_game_dir(&env.game, &target.version_id, global, config.as_ref(), None)
|
||||
.map_err(|error| format!("隔离目录派生失败:{error}"))?;
|
||||
if let Some(mut config) = config {
|
||||
let at = format_rfc3339_millis(now());
|
||||
if observe_mode(&mut config, &env.game, &at, derivation.isolation).unwrap_or(false) {
|
||||
let _ =
|
||||
unlucky_core::game::install::instance::write_instance_config(&env.layout, &config);
|
||||
}
|
||||
}
|
||||
Ok(derivation.game_dir)
|
||||
}
|
||||
|
||||
/// 起一次启动(后台线程;进度/日志/收尾全部走 `HostTask`)。
|
||||
pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
||||
let id = target.id.clone();
|
||||
let tx_progress = tx.clone();
|
||||
super::job::spawn("launch", move || {
|
||||
let _ = tx_progress.send(HostTask::Runtime {
|
||||
id: id.clone(),
|
||||
runtime: InstanceRuntime::Busy {
|
||||
stage: LaunchStage::Launch,
|
||||
progress: Progress { done: 0, total: 1 },
|
||||
},
|
||||
});
|
||||
let (request, warnings) = match build_request(&env, &target) {
|
||||
Ok(ok) => ok,
|
||||
Err(reason) => {
|
||||
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
|
||||
id,
|
||||
session: String::new(),
|
||||
outcome: LogOutcome::Crashed,
|
||||
summary: format!("启动失败:{reason}"),
|
||||
})));
|
||||
return;
|
||||
}
|
||||
};
|
||||
for warning in &warnings {
|
||||
let _ = tx.send(HostTask::Notice(Box::new(crate::dto::NoticeView::new(
|
||||
&format!("launch-warn:{}", target.id),
|
||||
crate::dto::NoticeLevel::Info,
|
||||
"启动提示",
|
||||
warning,
|
||||
))));
|
||||
}
|
||||
let config = LauncherConfig::new(env.sessions_root())
|
||||
.with_launcher(LAUNCHER_NAME, env!("CARGO_PKG_VERSION"));
|
||||
let session = match Launcher::new(config).spawn(&request) {
|
||||
Ok(session) => session,
|
||||
Err(error) => {
|
||||
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
|
||||
id,
|
||||
session: String::new(),
|
||||
outcome: LogOutcome::Crashed,
|
||||
summary: format!("启动失败:{error}"),
|
||||
})));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let session_id = session.session_id().to_string();
|
||||
let pid = session.pid();
|
||||
let _ = tx.send(HostTask::LaunchStarted {
|
||||
id: id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
pid,
|
||||
});
|
||||
let _ = tx.send(HostTask::Runtime {
|
||||
id: id.clone(),
|
||||
runtime: InstanceRuntime::Running { pid },
|
||||
});
|
||||
match session.finish() {
|
||||
Ok(handle) => {
|
||||
let outcome = super::identity::outcome_of(&handle);
|
||||
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
|
||||
id,
|
||||
session: session_id,
|
||||
outcome,
|
||||
summary: format!(
|
||||
"{}({},{:.1}s)",
|
||||
launch_summary(outcome, Some(handle.exit.code)),
|
||||
handle.exit.exit_type.as_str(),
|
||||
handle.uptime_ms as f64 / 1000.0
|
||||
),
|
||||
})));
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
|
||||
id,
|
||||
session: session_id,
|
||||
outcome: LogOutcome::SilentFail,
|
||||
summary: format!("启动收尾失败:{error}"),
|
||||
})));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 停止一次启动(按 pid 回收进程树;`finish()` 会在后台线程里照常收尾)。
|
||||
pub fn stop(pid: u32) -> Result<(), String> {
|
||||
tree_kill::kill_tree(pid, std::time::Duration::from_secs(3))
|
||||
.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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载器 → 适配层的模组加载器标记(GUI 只用于显示,不改命令行)。
|
||||
pub fn loader_label(kind: LoaderKind) -> &'static str {
|
||||
match kind {
|
||||
LoaderKind::Vanilla => "原版",
|
||||
LoaderKind::Fabric => "Fabric",
|
||||
LoaderKind::Quilt => "Quilt",
|
||||
LoaderKind::Forge => "Forge",
|
||||
LoaderKind::NeoForge => "NeoForge",
|
||||
LoaderKind::OptiFine => "OptiFine",
|
||||
LoaderKind::LiteLoader => "LiteLoader",
|
||||
}
|
||||
}
|
||||
|
||||
/// 隔离档位的展示串(`isolated` / `shared`)。
|
||||
pub fn isolation_label(mode: IsolationMode) -> &'static str {
|
||||
mode.as_str()
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
//! host 启动状态机单测(P2-1/P2-2 的回归网)。
|
||||
//!
|
||||
//! 覆盖三件事:
|
||||
//! 1. `running_pid` 按实例 id 取 pid(并发启动不会把 A 的停止键指到 B);
|
||||
//! 2. 重复启动守卫(`launches` 已落盘 + `pending` 窗口两态都要挡);
|
||||
//! 3. `refresh_instances` 重建快照后仍保留真实运行时(Running / Busy / Failed)。
|
||||
|
||||
use unlucky_core::game::install::instance::write_profiles;
|
||||
use unlucky_core::game::{InstanceProfile, ProfileId, ProfileList};
|
||||
|
||||
use super::*;
|
||||
use crate::dto::{LaunchFinished, LogOutcome};
|
||||
use crate::toast::ToastKind;
|
||||
|
||||
/// 临时数据根守卫:测试结束(含断言 panic)时把目录删掉,不留空壳。
|
||||
struct TempRoot(std::path::PathBuf);
|
||||
|
||||
impl Drop for TempRoot {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// 建一个只有一条实例档案的数据根,返回 `(host, instance_id, 守卫)`。
|
||||
fn host_with_instance(label: &str) -> (CoreHost, String, TempRoot) {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ull-host-launch-{label}-{}-{}",
|
||||
std::process::id(),
|
||||
line!()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let env = HostEnv::at_root(&dir);
|
||||
std::fs::create_dir_all(env.layout.config()).expect("config 目录");
|
||||
|
||||
let profile = InstanceProfile {
|
||||
id: ProfileId::generate(),
|
||||
name: "demo".to_string(),
|
||||
version_id: "1.20.1".to_string(),
|
||||
game_dir: None,
|
||||
running_dir: None,
|
||||
java_path: None,
|
||||
created_at: None,
|
||||
last_played_at: None,
|
||||
extra: std::collections::BTreeMap::new(),
|
||||
};
|
||||
let id = profile.id.as_str().to_string();
|
||||
let profiles = ProfileList {
|
||||
profiles: vec![profile],
|
||||
..ProfileList::default()
|
||||
};
|
||||
write_profiles(&env.layout, &profiles).expect("写 profiles");
|
||||
(CoreHost::headless(env), id, TempRoot(dir))
|
||||
}
|
||||
|
||||
fn live(instance: &str, session: &str, pid: u32) -> LiveLaunch {
|
||||
LiveLaunch {
|
||||
instance: instance.to_string(),
|
||||
session: session.to_string(),
|
||||
pid,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_pid_is_per_instance_not_a_single_slot() {
|
||||
let (mut host, id, _root) = host_with_instance("pid");
|
||||
host.launches.push(live("a", "s-a", 111));
|
||||
host.launches.push(live("b", "s-b", 222));
|
||||
|
||||
assert_eq!(host.running_pid("a"), Some(111));
|
||||
assert_eq!(host.running_pid("b"), Some(222));
|
||||
assert_eq!(
|
||||
host.running_pid("c"),
|
||||
None,
|
||||
"没启动过的实例不该拿到别人的 pid"
|
||||
);
|
||||
|
||||
// A 收尾后只影响 A:这正是 review r1 P2-1 的场景(旧实现共用一个槽位)。
|
||||
host.launches.retain(|entry| entry.instance != "a");
|
||||
assert_eq!(host.running_pid("a"), None);
|
||||
assert_eq!(host.running_pid("b"), Some(222));
|
||||
|
||||
// 落盘实例默认是 Idle,兜底分支不误报。
|
||||
assert_eq!(host.running_pid(&id), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_launch_is_rejected_before_spawning() {
|
||||
let (mut host, id, _root) = host_with_instance("dup");
|
||||
let _ = host.drain_events();
|
||||
host.launches.push(live(&id, "s", 111));
|
||||
|
||||
host.start_launch(&id);
|
||||
|
||||
let events = host.drain_events();
|
||||
assert!(
|
||||
!events.iter().any(|event| matches!(
|
||||
event,
|
||||
AppEvent::Core(CoreEvent::InstanceRuntimeChanged { .. })
|
||||
)),
|
||||
"已在运行/启动中的实例不该再发 Busy(那会起第二个进程)"
|
||||
);
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.any(|event| matches!(event, AppEvent::Toast(toast) if toast.kind == ToastKind::Warn)),
|
||||
"至少要给用户一条「已经在启动/运行中」的提示"
|
||||
);
|
||||
assert_eq!(host.launches.len(), 1, "启动表不该被重复启动污染");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_window_also_blocks_a_second_launch() {
|
||||
let (mut host, id, _root) = host_with_instance("pending");
|
||||
// 走 start_launch 用的同一个入口,模拟「已受理、后台线程还没回报 LaunchStarted」。
|
||||
host.mark_pending(&id);
|
||||
assert!(host.in_flight(&id));
|
||||
|
||||
let _ = host.drain_events();
|
||||
host.start_launch(&id);
|
||||
let events = host.drain_events();
|
||||
assert!(
|
||||
!events.iter().any(|event| matches!(
|
||||
event,
|
||||
AppEvent::Core(CoreEvent::InstanceRuntimeChanged { .. })
|
||||
)),
|
||||
"pending 窗口内重复点启动必须被挡住"
|
||||
);
|
||||
assert_eq!(host.pending.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_started_registers_and_launch_end_clears() {
|
||||
let (mut host, id, _root) = host_with_instance("cycle");
|
||||
host.mark_pending(&id);
|
||||
|
||||
host.handle(HostTask::LaunchStarted {
|
||||
id: id.clone(),
|
||||
session_id: "20261031T120000-abcd12".to_string(),
|
||||
pid: 4_242,
|
||||
});
|
||||
assert!(!host.pending.contains(&id), "回报后 pending 要清掉");
|
||||
assert_eq!(host.running_pid(&id), Some(4_242));
|
||||
|
||||
host.handle(HostTask::LaunchEnd(Box::new(LaunchFinished {
|
||||
id: id.clone(),
|
||||
session: "20261031T120000-abcd12".to_string(),
|
||||
outcome: LogOutcome::Ok,
|
||||
summary: "正常退出".to_string(),
|
||||
})));
|
||||
assert!(host.launches.is_empty(), "收尾后启动表要清空");
|
||||
assert!(!host.failed.contains_key(&id));
|
||||
assert_eq!(host.running_pid(&id), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_keeps_running_instances_running() {
|
||||
let (mut host, id, _root) = host_with_instance("refresh-run");
|
||||
host.launches.push(live(&id, "s", 4_242));
|
||||
let _ = host.drain_events();
|
||||
|
||||
host.refresh_instances();
|
||||
|
||||
let instance = instances_from_events(host.drain_events())
|
||||
.into_iter()
|
||||
.find(|instance| instance.id == id)
|
||||
.expect("刷新后应有该实例");
|
||||
assert_eq!(
|
||||
instance.runtime,
|
||||
InstanceRuntime::Running { pid: 4_242 },
|
||||
"刷新(装版本/账户变更/别的实例收尾)不能把在跑实例打回 Idle"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_keeps_pending_as_busy_and_failed_as_failed() {
|
||||
let (mut host, id, _root) = host_with_instance("refresh-busy");
|
||||
|
||||
// Busy:走 start_launch 用的同一个入口(mark_pending),而不是直接改字段。
|
||||
host.mark_pending(&id);
|
||||
let pending = instances_from_events({
|
||||
host.refresh_instances();
|
||||
host.drain_events()
|
||||
})
|
||||
.into_iter()
|
||||
.find(|instance| instance.id == id)
|
||||
.expect("实例存在");
|
||||
assert!(matches!(
|
||||
pending.runtime,
|
||||
InstanceRuntime::Busy {
|
||||
stage: crate::dto::LaunchStage::Launch,
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
// Failed:走唯一真实写入路径 LaunchEnd(不是手动塞 failed 表)。
|
||||
host.handle(HostTask::LaunchEnd(Box::new(LaunchFinished {
|
||||
id: id.clone(),
|
||||
session: "20261031T120000-dead01".to_string(),
|
||||
outcome: LogOutcome::Crashed,
|
||||
summary: "崩溃(exit 1)".to_string(),
|
||||
})));
|
||||
assert!(!host.pending.contains(&id), "LaunchEnd 必须清 pending");
|
||||
assert!(!host.in_flight(&id), "收尾后该实例要能被重新启动");
|
||||
|
||||
let failed = instances_from_events({
|
||||
host.refresh_instances();
|
||||
host.drain_events()
|
||||
})
|
||||
.into_iter()
|
||||
.find(|instance| instance.id == id)
|
||||
.expect("实例存在");
|
||||
assert_eq!(
|
||||
failed.runtime,
|
||||
InstanceRuntime::Failed {
|
||||
reason: "崩溃(exit 1)".to_string()
|
||||
},
|
||||
"失败原因要经得住一次刷新(否则磁贴的「重试」外观会被 Idle 盖掉)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_end_before_launch_started_still_releases_the_instance() {
|
||||
let (mut host, id, _root) = host_with_instance("fail-early");
|
||||
// build_request / spawn 失败的现场:start_launch 已标 pending、后台线程只发 LaunchEnd。
|
||||
host.mark_pending(&id);
|
||||
assert!(host.in_flight(&id));
|
||||
|
||||
host.handle(HostTask::LaunchEnd(Box::new(LaunchFinished {
|
||||
id: id.clone(),
|
||||
session: String::new(),
|
||||
outcome: LogOutcome::Crashed,
|
||||
summary: "启动失败:找不到 Java".to_string(),
|
||||
})));
|
||||
|
||||
assert!(
|
||||
!host.pending.contains(&id),
|
||||
"LaunchEnd 早于 LaunchStarted 时也必须清 pending(否则该实例本会话再也起不来)"
|
||||
);
|
||||
assert!(!host.in_flight(&id), "失败后要能重试");
|
||||
|
||||
let failed = instances_from_events({
|
||||
host.refresh_instances();
|
||||
host.drain_events()
|
||||
})
|
||||
.into_iter()
|
||||
.find(|instance| instance.id == id)
|
||||
.expect("实例存在");
|
||||
assert_eq!(
|
||||
failed.runtime,
|
||||
InstanceRuntime::Failed {
|
||||
reason: "启动失败:找不到 Java".to_string()
|
||||
},
|
||||
"早失败也要在磁贴上留下「失败/重试」,不能被 Idle 盖掉"
|
||||
);
|
||||
}
|
||||
|
||||
/// 从事件队列里取最后一次 `InstancesChanged`。
|
||||
fn instances_from_events(events: Vec<AppEvent>) -> Vec<crate::dto::InstanceSummary> {
|
||||
events
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find_map(|event| match event {
|
||||
AppEvent::Core(CoreEvent::InstancesChanged(instances)) => Some(instances),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
//! 日志 / 诊断:会话索引 → 会话列表,事件 JSONL → 日志行,规则引擎 → 诊断卡片。
|
||||
//!
|
||||
//! 与 CLI `ull logs` / `ull diagnose` 同源(`launch::session_index` + `diagnose` 规则引擎):
|
||||
//! 「同一份 jsonl 的两种读法」只有这一处实现(M39 ports/events.rs 的设计)。
|
||||
//! 实时滚动 = host 每拍尾随启动会话的 JSONL(只读新增字节),不额外开第二条事件通道。
|
||||
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::Value;
|
||||
use unlucky_core::diagnose::collectors::{ExitSignal, SignalOrigin};
|
||||
use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput};
|
||||
use unlucky_core::diagnose::loader::RuleLoader;
|
||||
use unlucky_core::launch::session_index::{self, SessionIndexEntry};
|
||||
|
||||
use crate::dto::{
|
||||
Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSession, SessionId,
|
||||
};
|
||||
|
||||
use super::env::HostEnv;
|
||||
|
||||
/// 送进规则引擎的默认尾巴行数(与 CLI 同值,避免两条路看到不同证据)。
|
||||
pub const DEFAULT_TAIL: usize = 2000;
|
||||
|
||||
/// 日志页一次最多渲染的行数(超出的截断,避免大文件把 UI 拖死)。
|
||||
pub const MAX_RENDERED_LINES: usize = 2_000;
|
||||
|
||||
/// 会话索引目录(`.ULL/data/agent/sessions`)。
|
||||
pub fn root(env: &HostEnv) -> PathBuf {
|
||||
env.sessions_root()
|
||||
}
|
||||
|
||||
/// 会话列表(新→旧)。
|
||||
pub fn sessions(env: &HostEnv) -> Vec<LogSession> {
|
||||
let index = session_index::index_path(&root(env));
|
||||
let mut entries = session_index::read_all(&index).unwrap_or_default();
|
||||
entries.sort_by(|a, b| b.started_at.cmp(&a.started_at));
|
||||
entries.iter().map(session_of).collect()
|
||||
}
|
||||
|
||||
/// 索引条目 → 左栏会话行。
|
||||
pub fn session_of(entry: &SessionIndexEntry) -> LogSession {
|
||||
LogSession {
|
||||
id: entry.session_id.clone(),
|
||||
instance: if entry.instance_name.is_empty() {
|
||||
entry.instance_id.clone()
|
||||
} else {
|
||||
entry.instance_name.clone()
|
||||
},
|
||||
when: display_time(&entry.started_at),
|
||||
outcome: outcome_of(entry),
|
||||
duration_secs: entry.duration_ms / 1000,
|
||||
}
|
||||
}
|
||||
|
||||
/// 结局:静默失败 > 崩溃(非 0 / crash_found) > 正常。
|
||||
pub fn outcome_of(entry: &SessionIndexEntry) -> LogOutcome {
|
||||
if entry.silent_failure {
|
||||
LogOutcome::SilentFail
|
||||
} else if entry.crash_found
|
||||
|| entry.exit_code.is_some_and(|code| code != 0)
|
||||
|| (entry.exit_code.is_none() && !entry.ended_at.is_empty())
|
||||
{
|
||||
LogOutcome::Crashed
|
||||
} else {
|
||||
LogOutcome::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-29T19:22:45.123Z` → `10-29 19:22`。
|
||||
pub fn display_time(started_at: &str) -> String {
|
||||
let body = started_at.trim_end_matches('Z');
|
||||
let Some((date, time)) = body.split_once('T') else {
|
||||
return started_at.to_string();
|
||||
};
|
||||
let date = date.get(5..).unwrap_or(date);
|
||||
let time = time.get(..5).unwrap_or(time);
|
||||
format!("{date} {time}")
|
||||
}
|
||||
|
||||
/// 事件流文件路径(`<sessions>/game/<log_path>`)。
|
||||
pub fn event_path(env: &HostEnv, entry: &SessionIndexEntry) -> PathBuf {
|
||||
root(env).join("game").join(&entry.log_path)
|
||||
}
|
||||
|
||||
/// 会话 id 或唯一前缀 → 索引条目。
|
||||
fn find(env: &HostEnv, key: &str) -> Option<SessionIndexEntry> {
|
||||
let index = session_index::index_path(&root(env));
|
||||
let entries = session_index::read_all(&index).ok()?;
|
||||
if let Some(exact) = entries.iter().find(|entry| entry.session_id == key) {
|
||||
return Some(exact.clone());
|
||||
}
|
||||
entries
|
||||
.iter()
|
||||
.find(|entry| entry.session_id.starts_with(key))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// 事件 JSON → 一行日志(`stdout`/`stderr` 的 `payload.line`)。
|
||||
pub fn line_of(event: &Value) -> Option<LogLine> {
|
||||
let text = event.pointer("/payload/line").and_then(Value::as_str)?;
|
||||
let kind = event
|
||||
.get("kind")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let level = match event
|
||||
.pointer("/payload/level")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"error" => LogLevel::Error,
|
||||
"warn" | "warning" => LogLevel::Warn,
|
||||
"debug" | "trace" => LogLevel::Debug,
|
||||
_ => {
|
||||
if kind == "stderr" {
|
||||
LogLevel::Warn
|
||||
} else {
|
||||
LogLevel::Info
|
||||
}
|
||||
}
|
||||
};
|
||||
Some(LogLine {
|
||||
text: text.to_string(),
|
||||
level,
|
||||
})
|
||||
}
|
||||
|
||||
/// 整个事件流 → 日志行(解析失败的行跳过,容忍被 kill 时截断的尾行)。
|
||||
pub fn read_lines(path: &Path) -> Vec<LogLine> {
|
||||
let Ok(text) = std::fs::read_to_string(path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
text.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
||||
.filter_map(|event| line_of(&event))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 打开一个会话:日志行 + 规则引擎结论。
|
||||
pub fn open(env: &HostEnv, key: &str) -> (Vec<LogLine>, Option<DiagnosisReport>) {
|
||||
let Some(entry) = find(env, key) else {
|
||||
return (Vec::new(), None);
|
||||
};
|
||||
let path = event_path(env, &entry);
|
||||
let lines = read_lines(&path);
|
||||
let diagnosis = diagnose_lines(&entry, &lines);
|
||||
(lines, diagnosis)
|
||||
}
|
||||
|
||||
/// 规则引擎跑一遍(**规则先跑,模型补位**:这里只出规则结论)。
|
||||
pub fn diagnose_lines(entry: &SessionIndexEntry, lines: &[LogLine]) -> Option<DiagnosisReport> {
|
||||
let stderr_text: String = lines
|
||||
.iter()
|
||||
.filter(|line| line.level == LogLevel::Error || line.level == LogLevel::Warn)
|
||||
.map(|line| line.text.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let body: String = lines
|
||||
.iter()
|
||||
.rev()
|
||||
.take(DEFAULT_TAIL)
|
||||
.map(|line| line.text.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if body.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let error_lines = u32::try_from(
|
||||
lines
|
||||
.iter()
|
||||
.filter(|line| line.level == LogLevel::Error)
|
||||
.count(),
|
||||
)
|
||||
.unwrap_or(u32::MAX);
|
||||
let exit = ExitSignal::classify(
|
||||
entry.exit_code,
|
||||
Some(entry.duration_ms),
|
||||
error_lines,
|
||||
&stderr_text,
|
||||
);
|
||||
let mut input = DiagnosisInput::new().with_exit(exit);
|
||||
input.push_text(SignalOrigin::Stderr, body);
|
||||
let loader = RuleLoader::embedded();
|
||||
let report = Diagnoser::new(loader.rule_set().rules().to_vec()).analyze(&input);
|
||||
let finding = report
|
||||
.findings
|
||||
.first()
|
||||
.or_else(|| report.secondary.first())?;
|
||||
Some(DiagnosisReport {
|
||||
session: entry.session_id.clone(),
|
||||
diagnosis: Diagnosis {
|
||||
rule: finding.rule_id.clone(),
|
||||
title: finding.message.clone(),
|
||||
confidence: confidence_percent(finding.confidence),
|
||||
hint: finding.detail.clone().unwrap_or_else(|| {
|
||||
report
|
||||
.exit
|
||||
.as_ref()
|
||||
.map(|exit| exit.exit_type.as_str().to_string())
|
||||
.unwrap_or_else(|| "未知退出".to_string())
|
||||
}),
|
||||
fix: finding
|
||||
.materialized_actions()
|
||||
.first()
|
||||
.map(|action| action.tool.to_string()),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// 0.0~1.0 → 0~100(四舍五入)。
|
||||
pub fn confidence_percent(confidence: f32) -> u8 {
|
||||
let percent = (confidence.clamp(0.0, 1.0) * 100.0).round();
|
||||
u8::try_from(percent as u32).unwrap_or(100)
|
||||
}
|
||||
|
||||
/// 导入崩溃日志(`hs_err_pid*.log` / `crash-*.txt` / `latest.log`)做规则分析。
|
||||
///
|
||||
/// 文件名含 `hs_err` 时先走 `hs_err::parse`(结构化字段进规则引擎),否则整份文本当信号喂进去。
|
||||
pub fn analyze_crash_file(path: &str) -> Result<(String, DiagnosisReport), String> {
|
||||
let path = Path::new(path);
|
||||
if !path.is_file() {
|
||||
return Err(format!("找不到崩溃日志:{}", path.display()));
|
||||
}
|
||||
let text = std::fs::read_to_string(path).map_err(|error| format!("读崩溃日志失败:{error}"))?;
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "crash.log".to_string());
|
||||
let is_hs_err = name.contains("hs_err");
|
||||
let mut input = DiagnosisInput::new();
|
||||
if is_hs_err {
|
||||
input.push_hs_err(unlucky_core::diagnose::hs_err::parse(&text));
|
||||
}
|
||||
input.push_text(
|
||||
if is_hs_err {
|
||||
SignalOrigin::HsErr
|
||||
} else if name.contains("crash") {
|
||||
SignalOrigin::CrashReport
|
||||
} else {
|
||||
SignalOrigin::LatestLog
|
||||
},
|
||||
text,
|
||||
);
|
||||
let loader = RuleLoader::embedded();
|
||||
let report = Diagnoser::new(loader.rule_set().rules().to_vec()).analyze(&input);
|
||||
let finding = report
|
||||
.findings
|
||||
.first()
|
||||
.or_else(|| report.secondary.first())
|
||||
.ok_or_else(|| "规则引擎没有从这份日志里认出已知问题".to_string())?;
|
||||
Ok((
|
||||
format!("导入的日志:{name}"),
|
||||
DiagnosisReport {
|
||||
session: format!("file:{name}"),
|
||||
diagnosis: Diagnosis {
|
||||
rule: finding.rule_id.clone(),
|
||||
title: finding.message.clone(),
|
||||
confidence: confidence_percent(finding.confidence),
|
||||
hint: finding
|
||||
.detail
|
||||
.clone()
|
||||
.unwrap_or_else(|| "规则引擎命中(详见证据行)".to_string()),
|
||||
fix: finding
|
||||
.materialized_actions()
|
||||
.first()
|
||||
.map(|action| action.tool.to_string()),
|
||||
},
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// 实时尾随器:只读文件新增的字节,解析出完整的 JSON 行。
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Tail {
|
||||
path: Option<PathBuf>,
|
||||
session: Option<SessionId>,
|
||||
offset: u64,
|
||||
}
|
||||
|
||||
impl Tail {
|
||||
/// 开始尾随一个会话。
|
||||
pub fn attach(&mut self, path: PathBuf, session: &str) {
|
||||
self.path = Some(path);
|
||||
self.session = Some(session.to_string());
|
||||
self.offset = 0;
|
||||
}
|
||||
|
||||
/// 停止尾随。
|
||||
pub fn detach(&mut self) {
|
||||
self.path = None;
|
||||
self.session = None;
|
||||
self.offset = 0;
|
||||
}
|
||||
|
||||
/// 当前尾随的会话。
|
||||
pub fn session(&self) -> Option<&str> {
|
||||
self.session.as_deref()
|
||||
}
|
||||
|
||||
/// 读新行(返回 `(会话 id, 新行)`;没有新内容返回 `None`)。
|
||||
pub fn poll(&mut self) -> Option<(String, Vec<LogLine>)> {
|
||||
let path = self.path.clone()?;
|
||||
let session = self.session.clone()?;
|
||||
let file = std::fs::File::open(&path).ok()?;
|
||||
let len = file.metadata().ok()?.len();
|
||||
if len <= self.offset {
|
||||
return None;
|
||||
}
|
||||
let mut file = file;
|
||||
file.seek(SeekFrom::Start(self.offset)).ok()?;
|
||||
let mut buffer = String::new();
|
||||
file.read_to_string(&mut buffer).ok()?;
|
||||
// 只吃到最后一个换行:半行留到下一拍(避免把 JSON 截断解析成脏行)
|
||||
let last_newline = buffer.rfind('\n')?;
|
||||
let complete = &buffer[..=last_newline];
|
||||
self.offset += u64::try_from(complete.len()).unwrap_or(u64::MAX);
|
||||
let lines: Vec<LogLine> = complete
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
||||
.filter_map(|event| line_of(&event))
|
||||
.collect();
|
||||
Some((session, lines))
|
||||
}
|
||||
}
|
||||
|
||||
/// 会话是否已收尾(索引里出现 `ended_at` 或文件停止增长超过一拍)。
|
||||
pub fn session_finished(env: &HostEnv, session: &str) -> bool {
|
||||
let index = session_index::index_path(&root(env));
|
||||
session_index::read_all(&index)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.any(|entry| entry.session_id == session && !entry.ended_at.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "logs_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,86 @@
|
||||
//! host/logs.rs 的单测(拆文件守 400 行上限)。
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn time_display_and_outcomes() {
|
||||
assert_eq!(display_time("2026-10-29T19:22:45.123Z"), "10-29 19:22");
|
||||
assert_eq!(display_time("nope"), "nope");
|
||||
let mut entry = SessionIndexEntry {
|
||||
v: 1,
|
||||
session_id: "s".to_string(),
|
||||
instance_id: String::new(),
|
||||
instance_name: String::new(),
|
||||
game_version: String::new(),
|
||||
started_at: String::new(),
|
||||
ended_at: "2026-10-29T19:22:45Z".to_string(),
|
||||
duration_ms: 1000,
|
||||
exit_code: Some(0),
|
||||
exit_type: Some("normal".to_string()),
|
||||
silent_failure: false,
|
||||
window_detected: true,
|
||||
event_count: 1,
|
||||
log_path: "s.jsonl".to_string(),
|
||||
crash_found: false,
|
||||
diagnosis_top: None,
|
||||
};
|
||||
assert_eq!(outcome_of(&entry), LogOutcome::Ok);
|
||||
entry.exit_code = Some(1);
|
||||
assert_eq!(outcome_of(&entry), LogOutcome::Crashed);
|
||||
entry.silent_failure = true;
|
||||
assert_eq!(outcome_of(&entry), LogOutcome::SilentFail);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_map_levels_and_skip_non_line_events() {
|
||||
let stdout = serde_json::json!({"kind":"stdout","payload":{"line":"hi","level":"info"}});
|
||||
assert_eq!(line_of(&stdout).unwrap().level, LogLevel::Info);
|
||||
let stderr = serde_json::json!({"kind":"stderr","payload":{"line":"boom","level":"error"}});
|
||||
assert_eq!(line_of(&stderr).unwrap().level, LogLevel::Error);
|
||||
let other = serde_json::json!({"kind":"cmd_built","payload":{"argv":[]}});
|
||||
assert!(line_of(&other).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tail_only_consumes_complete_lines() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-host-tail-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("目录");
|
||||
let path = dir.join("s.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"{\"kind\":\"stdout\",\"payload\":{\"line\":\"one\"}}\n",
|
||||
)
|
||||
.expect("写");
|
||||
let mut tail = Tail::default();
|
||||
tail.attach(path.clone(), "s");
|
||||
let (session, lines) = tail.poll().expect("首拍有新行");
|
||||
assert_eq!(session, "s");
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert!(tail.poll().is_none(), "没有新增就不该再产出");
|
||||
|
||||
// 半行不消费
|
||||
let mut text = std::fs::read_to_string(&path).expect("读");
|
||||
text.push_str("{\"kind\":\"stdout\",\"payload\":{\"line\":\"tw");
|
||||
std::fs::write(&path, text).expect("写");
|
||||
assert!(tail.poll().is_none(), "半个 JSON 行不能解析");
|
||||
let mut text = std::fs::read_to_string(&path).expect("读");
|
||||
text.push_str("o\"}}\n");
|
||||
std::fs::write(&path, text).expect("写");
|
||||
let (_, lines) = tail.poll().expect("补全后应产出");
|
||||
assert_eq!(lines[0].text, "two");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crash_import_reports_missing_file() {
|
||||
let err = analyze_crash_file("D:/definitely/missing/hs_err_pid1.log").unwrap_err();
|
||||
assert!(err.contains("找不到崩溃日志"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confidence_percent_is_rounded_and_clamped() {
|
||||
assert_eq!(confidence_percent(0.923), 92);
|
||||
assert_eq!(confidence_percent(-1.0), 0);
|
||||
assert_eq!(confidence_percent(2.0), 100);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! 安装进度的记账器(文件计数 / 字节 / 速度 / ETA)。
|
||||
//!
|
||||
//! `execute` 的文件级回调已经把每个文件的结局给了我们,这里只做聚合;
|
||||
//! 速度用相邻两次采样的字节差算(core 的事件只给绝对值)。
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use unlucky_core::game::install::{FileOutcome, FileStatus, ProgressSink};
|
||||
|
||||
use crate::dto::{derive_speed, CreateRequest, InstallProgressView, InstallStage};
|
||||
|
||||
use super::install::loader_label;
|
||||
use super::job::{HostTask, JobSender};
|
||||
/// 一次安装的上下文(进度回调共享)。
|
||||
pub(super) struct Meter {
|
||||
pub(super) progress: InstallProgressView,
|
||||
previous: Option<(u64, Instant)>,
|
||||
}
|
||||
|
||||
impl Meter {
|
||||
pub(super) fn new(request: &CreateRequest) -> Self {
|
||||
Self {
|
||||
progress: InstallProgressView::start(
|
||||
&request.version,
|
||||
&request.instance_name(),
|
||||
&loader_label(request.loader, &request.loader_version),
|
||||
),
|
||||
previous: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 换阶段(重置速度采样)。
|
||||
pub(super) fn stage(&mut self, stage: InstallStage, message: &str) {
|
||||
self.progress.stage = stage;
|
||||
self.progress.message = message.to_string();
|
||||
self.previous = None;
|
||||
}
|
||||
|
||||
/// 预记账一段待下载的文件(文件数 + 字节)。
|
||||
///
|
||||
/// 一次安装可能分几段(原版 / 处理器依赖库 / 加载器库):每段开工前都先记账,
|
||||
/// 整条进度曲线才是单调的——某一段下完不会先到 100%,再因为下一段的文件被计入而回落。
|
||||
pub(super) fn expect(&mut self, files: usize, bytes: u64) {
|
||||
let files = u64::try_from(files).unwrap_or(u64::MAX);
|
||||
self.progress.files_total = self.progress.files_total.saturating_add(files);
|
||||
self.progress.bytes_total = self.progress.bytes_total.saturating_add(bytes);
|
||||
}
|
||||
|
||||
/// 记账一个文件的结局。
|
||||
pub(super) fn note(&mut self, outcome: &FileOutcome) {
|
||||
self.progress.files_done += 1;
|
||||
if outcome.status != FileStatus::Skipped {
|
||||
self.progress.bytes_done = self.progress.bytes_done.saturating_add(outcome.bytes);
|
||||
}
|
||||
let now = Instant::now();
|
||||
if let Some(previous) = self.previous {
|
||||
if let Some((speed, eta)) = derive_speed(
|
||||
previous,
|
||||
(self.progress.bytes_done, now),
|
||||
self.progress.bytes_total,
|
||||
) {
|
||||
self.progress.speed_bps = speed;
|
||||
self.progress.eta_secs = eta;
|
||||
}
|
||||
}
|
||||
self.previous = Some((self.progress.bytes_done, now));
|
||||
}
|
||||
}
|
||||
|
||||
/// 进度 sink(`execute` 的文件级回调)。
|
||||
pub(super) fn sink_for(tx: JobSender, meter: Arc<std::sync::Mutex<Meter>>) -> ProgressSink {
|
||||
Arc::new(move |outcome: &FileOutcome| {
|
||||
let Ok(mut meter) = meter.lock() else {
|
||||
return;
|
||||
};
|
||||
meter.note(outcome);
|
||||
let _ = tx.send(HostTask::Install(Box::new(meter.progress.clone())));
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
//! 真实后端:把 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 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 logs;
|
||||
mod meter;
|
||||
mod opener;
|
||||
mod settings;
|
||||
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,
|
||||
};
|
||||
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>,
|
||||
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,
|
||||
/// 当前安装任务的取消标记(阶段边界检查)
|
||||
install_cancel: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
}
|
||||
|
||||
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);
|
||||
let mut host = Self {
|
||||
env,
|
||||
events: VecDeque::new(),
|
||||
tx,
|
||||
rx,
|
||||
ticks: 0,
|
||||
active_account: None,
|
||||
accounts: Vec::new(),
|
||||
login: None,
|
||||
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,
|
||||
};
|
||||
if refresh {
|
||||
host.refresh_all();
|
||||
}
|
||||
host
|
||||
}
|
||||
|
||||
/// 数据根(截图/诊断展示用)。
|
||||
pub fn env(&self) -> &HostEnv {
|
||||
&self.env
|
||||
}
|
||||
|
||||
fn send(&mut self, event: CoreEvent) {
|
||||
self.events.push_back(AppEvent::Core(event));
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
/// 重建实例快照,并把**真实运行时**盖回去。
|
||||
///
|
||||
/// `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_accounts(&mut self) {
|
||||
self.accounts = accounts::accounts(&self.env, self.active_account.as_deref());
|
||||
if self.active_account.is_none() {
|
||||
// 默认选中第一个账户(没有账户时保持 None = 离线占位)
|
||||
self.active_account = self.accounts.first().map(|account| account.id.clone());
|
||||
self.accounts = accounts::accounts(&self.env, self.active_account.as_deref());
|
||||
}
|
||||
self.send(CoreEvent::AccountsChanged(self.accounts.clone()));
|
||||
}
|
||||
|
||||
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 = "launch_state_tests.rs"]
|
||||
mod launch_state_tests;
|
||||
@@ -0,0 +1,72 @@
|
||||
//! 用系统默认浏览器打开授权 URL(授权码流唯一需要宿主做的事)。
|
||||
//!
|
||||
//! core 只负责拼授权 URL 与等本地回调(`core::auth::microsoft::authcode`);
|
||||
//! 「谁来打开浏览器」是宿主的决定:GUI 走这里,CLI/无浏览器场景仍用设备码流。
|
||||
//!
|
||||
//! 平台差异收在 [`launcher`] 一处,不往业务代码里散写 `cfg` 补丁(`AGENTS.md` §7)。
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
/// 拉起系统默认浏览器打开 `url`(只接受 http/https)。
|
||||
///
|
||||
/// 不等待浏览器退出:用户可能在浏览器里停留几分钟,宿主只关心「有没有拉起来」。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// URL 不是 http/https,或进程拉不起来时返回用户可读文案。
|
||||
pub fn open_url(url: &str) -> Result<(), String> {
|
||||
if !url.starts_with("https://") && !url.starts_with("http://") {
|
||||
return Err(format!("不是可打开的网址:{url}"));
|
||||
}
|
||||
let (program, args) = launcher(url);
|
||||
Command::new(program)
|
||||
.args(args)
|
||||
.spawn()
|
||||
.map(|_child| ())
|
||||
.map_err(|error| format!("拉起浏览器失败({program}):{error}"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn launcher(url: &str) -> (&'static str, Vec<String>) {
|
||||
// `rundll32` 不用 shell,URL 里的 `&` 不会被当成命令分隔符(`cmd /C start` 会)。
|
||||
(
|
||||
"rundll32",
|
||||
vec!["url.dll,FileProtocolHandler".to_string(), url.to_string()],
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launcher(url: &str) -> (&'static str, Vec<String>) {
|
||||
("open", vec![url.to_string()])
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
fn launcher(url: &str) -> (&'static str, Vec<String>) {
|
||||
("xdg-open", vec![url.to_string()])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{launcher, open_url};
|
||||
|
||||
#[test]
|
||||
fn rejects_non_http_urls() {
|
||||
assert!(open_url("javascript:alert(1)").is_err());
|
||||
assert!(open_url("").is_err());
|
||||
// 合法 URL 只验命令形态,不在单测里真去拉浏览器。
|
||||
let (program, args) = launcher("http://localhost:1234/auth?code=a&state=b");
|
||||
assert!(!program.is_empty());
|
||||
assert!(args
|
||||
.iter()
|
||||
.any(|arg| arg.contains("http://localhost:1234/auth")));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_uses_the_file_protocol_handler() {
|
||||
let (program, args) = launcher("https://example.invalid/");
|
||||
assert_eq!(program, "rundll32");
|
||||
assert_eq!(args[0], "url.dll,FileProtocolHandler");
|
||||
assert_eq!(args[1], "https://example.invalid/");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
//! 设置页分组:按 `core::setting` schema 的键生成行,写回走 `SettingStore::set`。
|
||||
//!
|
||||
//! 三件纪律(AGENTS §6):
|
||||
//! 1. 读写同一份 `<数据根>/config/launcher.json`(与 `ull config set` / AI `set_config` 同源);
|
||||
//! 2. 不自己拼 JSON,也不绕 schema(非法键/越界值由 `store.set` 拒绝);
|
||||
//! 3. 这里只认「键名 + 字符串值」,领域语义留在 core。
|
||||
//!
|
||||
//! 分组 id 与设置页子导航同名:`download` / `java` / `data` / `ai` / `mcp`。
|
||||
|
||||
use serde_json::Value;
|
||||
use unlucky_core::game::java::JdkVendor;
|
||||
|
||||
use crate::dto::{SettingGroupView, SettingRowView};
|
||||
|
||||
use super::env::HostEnv;
|
||||
|
||||
/// 全部设置分组(设置页右栏一次拿全,切分组不再请求)。
|
||||
pub fn groups(env: &HostEnv) -> Vec<SettingGroupView> {
|
||||
vec![
|
||||
download_group(env),
|
||||
java_group(env),
|
||||
data_group(env),
|
||||
ai_group(env),
|
||||
mcp_group(env),
|
||||
]
|
||||
}
|
||||
|
||||
/// 读一个键的字符串值(缺省给空串)。
|
||||
fn text(env: &HostEnv, key: &str) -> String {
|
||||
env.store()
|
||||
.doc()
|
||||
.effective_str(key)
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 读一个键的整数(缺省给 `fallback`)。
|
||||
fn int(env: &HostEnv, key: &str, fallback: i64) -> i64 {
|
||||
env.store()
|
||||
.effective(key)
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(fallback)
|
||||
}
|
||||
|
||||
/// 读一个键的布尔(缺省给 `fallback`)。
|
||||
fn flag(env: &HostEnv, key: &str, fallback: bool) -> bool {
|
||||
env.store()
|
||||
.effective(key)
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(fallback)
|
||||
}
|
||||
|
||||
/// 枚举值在选项表里的序号(找不到给 0)。
|
||||
fn selected(options: &[&str], value: &str) -> i32 {
|
||||
options
|
||||
.iter()
|
||||
.position(|option| *option == value)
|
||||
.map(|index| i32::try_from(index).unwrap_or(0))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 下载(D7):并发/重试/地区/镜像偏好/代理。
|
||||
fn download_group(env: &HostEnv) -> SettingGroupView {
|
||||
let concurrency = int(env, "download.concurrency", 8).to_string();
|
||||
let retry = int(env, "download.retry_limit", 3).to_string();
|
||||
let concurrency_options = ["4", "8", "16"];
|
||||
let retry_options = ["0", "1", "3", "5"];
|
||||
let regions = ["auto", "cn", "global"];
|
||||
let mirrors = ["auto", "official", "mirror"];
|
||||
let proxies = ["none", "system", "manual"];
|
||||
let region_value = text(env, "download.region");
|
||||
let mirror_value = text(env, "download.mirror_preference");
|
||||
let proxy_value = text(env, "download.proxy_mode");
|
||||
SettingGroupView::new("download", "下载与镜像(D7)")
|
||||
.with(SettingRowView::choice(
|
||||
"download.concurrency",
|
||||
"并发数",
|
||||
"同时下载的文件数(海量小文件,16 路足够压满带宽)",
|
||||
&concurrency_options,
|
||||
selected(&concurrency_options, &concurrency),
|
||||
))
|
||||
.with(SettingRowView::choice(
|
||||
"download.retry_limit",
|
||||
"单文件重试次数",
|
||||
"失败后换源重试的上限",
|
||||
&retry_options,
|
||||
selected(&retry_options, &retry),
|
||||
))
|
||||
.with(SettingRowView::choice(
|
||||
"download.region",
|
||||
"地区判定",
|
||||
"auto = 时区+语言+探测;决定是否优先国内镜像",
|
||||
®ions,
|
||||
selected(
|
||||
®ions,
|
||||
if region_value.is_empty() {
|
||||
"auto"
|
||||
} else {
|
||||
®ion_value
|
||||
},
|
||||
),
|
||||
))
|
||||
.with(SettingRowView::choice(
|
||||
"download.mirror_preference",
|
||||
"镜像偏好",
|
||||
"auto = 按健康度评分;official = 官方源优先",
|
||||
&mirrors,
|
||||
selected(
|
||||
&mirrors,
|
||||
if mirror_value.is_empty() {
|
||||
"auto"
|
||||
} else {
|
||||
&mirror_value
|
||||
},
|
||||
),
|
||||
))
|
||||
.with(SettingRowView::choice(
|
||||
"download.proxy_mode",
|
||||
"代理模式",
|
||||
"none / system(跟随系统)/ manual(下面填地址)",
|
||||
&proxies,
|
||||
selected(
|
||||
&proxies,
|
||||
if proxy_value.is_empty() {
|
||||
"none"
|
||||
} else {
|
||||
&proxy_value
|
||||
},
|
||||
),
|
||||
))
|
||||
.with(SettingRowView::text(
|
||||
"download.proxy_url",
|
||||
"HTTP 代理",
|
||||
"manual 模式生效;留空则用系统代理",
|
||||
&text(env, "download.proxy_url"),
|
||||
))
|
||||
.with(SettingRowView::action(
|
||||
"download.action.detect_region",
|
||||
"重新检测地区",
|
||||
"清掉 24h 地区缓存,下次下载重新判定",
|
||||
"detect_region",
|
||||
))
|
||||
}
|
||||
|
||||
/// Java(D13/D23):自动安装/内存/选路/发行版优先级。
|
||||
fn java_group(env: &HostEnv) -> SettingGroupView {
|
||||
let memory = int(env, "java.default_memory_mb", 2048).to_string();
|
||||
let memory_options = ["1024", "2048", "4096", "8192"];
|
||||
let priority: Vec<String> = env
|
||||
.store()
|
||||
.effective("java.vendorPriority")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let priority_text = if priority.is_empty() {
|
||||
"地区默认序(Temurin → …)".to_string()
|
||||
} else {
|
||||
priority.join(" → ")
|
||||
};
|
||||
let roots = super::engine::java_scan_roots(env);
|
||||
SettingGroupView::new("java", "Java(D13)")
|
||||
.with(SettingRowView::toggle(
|
||||
"java.auto_install",
|
||||
"缺 Java 时自动下载",
|
||||
"按回退链装一个托管 JDK(基准 JDK17)",
|
||||
flag(env, "java.auto_install", true),
|
||||
))
|
||||
.with(SettingRowView::choice(
|
||||
"java.default_memory_mb",
|
||||
"默认最大堆内存",
|
||||
"实例没单独指定时用这个值(MB)",
|
||||
&memory_options,
|
||||
selected(&memory_options, &memory),
|
||||
))
|
||||
.with(SettingRowView::text(
|
||||
"java.selected_path",
|
||||
"指定 Java 可执行文件",
|
||||
"留空 = 自动挑(托管 > JAVA_HOME > PATH)",
|
||||
&text(env, "java.selected_path"),
|
||||
))
|
||||
.with(SettingRowView::text(
|
||||
"java.installRoot",
|
||||
"托管 JDK 安装根",
|
||||
"留空 = 按数据目录模式派生(portable 进数据根 / standard 进系统目录)",
|
||||
&text(env, "java.installRoot"),
|
||||
))
|
||||
.with(SettingRowView::text(
|
||||
"java.extra_args",
|
||||
"附加 JVM 参数",
|
||||
"原样拼到命令行尾部",
|
||||
&text(env, "java.extra_args"),
|
||||
))
|
||||
.with(SettingRowView::read_only(
|
||||
"发行版优先级(java.vendorPriority)",
|
||||
&priority_text,
|
||||
))
|
||||
.with(SettingRowView::read_only(
|
||||
"托管根",
|
||||
&roots
|
||||
.iter()
|
||||
.map(|root| root.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · "),
|
||||
))
|
||||
}
|
||||
|
||||
/// 数据目录(D6 三模式)。
|
||||
fn data_group(env: &HostEnv) -> SettingGroupView {
|
||||
let modes = ["portable", "standard", "custom"];
|
||||
let mode = env.mode().as_str().to_string();
|
||||
SettingGroupView::new("data", "数据目录(D6 三模式)")
|
||||
.with(SettingRowView::choice(
|
||||
"core.data_mode",
|
||||
"存储模式",
|
||||
"便携(运行目录)/ 标准(系统应用数据目录)/ 自定义",
|
||||
&modes,
|
||||
selected(&modes, &mode),
|
||||
))
|
||||
.with(SettingRowView::text(
|
||||
"core.data_custom_root",
|
||||
"自定义根目录",
|
||||
"custom 模式生效(绝对路径)",
|
||||
&text(env, "core.data_custom_root"),
|
||||
))
|
||||
.with(SettingRowView::read_only(
|
||||
"启动器数据目录",
|
||||
&env.data.launcher_home.display().to_string(),
|
||||
))
|
||||
.with(SettingRowView::read_only(
|
||||
"游戏目录",
|
||||
&env.data.game_home.display().to_string(),
|
||||
))
|
||||
.with(SettingRowView::read_only(
|
||||
"生效模式",
|
||||
&format!("{}(改数据根需重启启动器)", env.mode()),
|
||||
))
|
||||
}
|
||||
|
||||
/// AI(D4):开关/端点/模型/YOLO。
|
||||
fn ai_group(env: &HostEnv) -> SettingGroupView {
|
||||
let providers = ["openai_compatible", "anthropic", "ollama"];
|
||||
let provider = text(env, "ai.provider");
|
||||
SettingGroupView::new("ai", "AI 助手(D4)")
|
||||
.with(SettingRowView::toggle(
|
||||
"ai.enabled",
|
||||
"启用 AI 助手",
|
||||
"关闭后规则引擎诊断仍可用",
|
||||
flag(env, "ai.enabled", true),
|
||||
))
|
||||
.with(SettingRowView::choice(
|
||||
"ai.provider",
|
||||
"模型端点",
|
||||
"OpenAI 兼容(含本地 llama.cpp)/ Anthropic / Ollama",
|
||||
&providers,
|
||||
selected(
|
||||
&providers,
|
||||
if provider.is_empty() {
|
||||
"openai_compatible"
|
||||
} else {
|
||||
&provider
|
||||
},
|
||||
),
|
||||
))
|
||||
.with(SettingRowView::text(
|
||||
"ai.base_url",
|
||||
"端点 base_url",
|
||||
"如 http://127.0.0.1:8080/v1",
|
||||
&text(env, "ai.base_url"),
|
||||
))
|
||||
.with(SettingRowView::text(
|
||||
"ai.model",
|
||||
"模型名",
|
||||
"如 deepseek-chat",
|
||||
&text(env, "ai.model"),
|
||||
))
|
||||
.with(SettingRowView::toggle(
|
||||
"ai.yolo",
|
||||
"YOLO 模式",
|
||||
"跳过确认弹窗(审计照记;账户/凭据操作永不 YOLO)",
|
||||
flag(env, "ai.yolo", false),
|
||||
))
|
||||
}
|
||||
|
||||
/// M39 的对外 MCP server(默认关、默认只读)。
|
||||
fn mcp_group(env: &HostEnv) -> SettingGroupView {
|
||||
let port = int(env, "agent.mcp.port", 47_823);
|
||||
SettingGroupView::new("mcp", "MCP server(M39)")
|
||||
.with(SettingRowView::toggle(
|
||||
"agent.mcp.enabled",
|
||||
"对外提供 MCP server",
|
||||
"127.0.0.1 上的 Streamable HTTP(stdio 需显式启动)",
|
||||
flag(env, "agent.mcp.enabled", false),
|
||||
))
|
||||
.with(SettingRowView::text(
|
||||
"agent.mcp.port",
|
||||
"端口",
|
||||
"1024~65535,只绑 127.0.0.1",
|
||||
&port.to_string(),
|
||||
))
|
||||
.with(SettingRowView::toggle(
|
||||
"agent.mcp.allow_write",
|
||||
"允许外部客户端写配置",
|
||||
"默认只读;写仍受 schema 白名单与分级确认约束",
|
||||
flag(env, "agent.mcp.allow_write", false),
|
||||
))
|
||||
.with(SettingRowView::read_only(
|
||||
"客户端地址",
|
||||
&format!("http://127.0.0.1:{port}/mcp(令牌见 agent.mcp.token,AI 不可写)"),
|
||||
))
|
||||
}
|
||||
|
||||
/// 写一条设置。`value` 是字符串形态,按 schema 键的类型转换后交 `store.set`。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 未知键、值类型不符、越界、落盘失败时返回用户可读文案。
|
||||
pub fn set(env: &HostEnv, key: &str, value: &str) -> Result<(), String> {
|
||||
if key == "download.action.detect_region" {
|
||||
return detect_region(env);
|
||||
}
|
||||
let parsed = parse_value(key, value)?;
|
||||
let mut store = env.store();
|
||||
store
|
||||
.set(key, parsed)
|
||||
.map_err(|error| format!("设置 {key} 失败:{error}"))?;
|
||||
store
|
||||
.save()
|
||||
.map_err(|error| format!("保存设置失败:{error}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 键 → 正确类型的 JSON 值(schema 会再校验范围/枚举)。
|
||||
fn parse_value(key: &str, value: &str) -> Result<Value, String> {
|
||||
if matches!(key, "download.concurrency" | "download.retry_limit") {
|
||||
let number: u32 = value
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| format!("{key} 需要整数:{value}"))?;
|
||||
return Ok(Value::from(number));
|
||||
}
|
||||
if matches!(key, "java.default_memory_mb" | "agent.mcp.port") {
|
||||
let number: u32 = value
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| format!("{key} 需要整数:{value}"))?;
|
||||
return Ok(Value::from(number));
|
||||
}
|
||||
if matches!(
|
||||
key,
|
||||
"java.auto_install"
|
||||
| "ai.enabled"
|
||||
| "ai.yolo"
|
||||
| "agent.mcp.enabled"
|
||||
| "agent.mcp.allow_write"
|
||||
) {
|
||||
let flag = match value.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "on" | "yes" => true,
|
||||
"0" | "false" | "off" | "no" => false,
|
||||
_ => return Err(format!("{key} 需要布尔值:{value}")),
|
||||
};
|
||||
return Ok(Value::Bool(flag));
|
||||
}
|
||||
Ok(Value::String(value.to_string()))
|
||||
}
|
||||
|
||||
/// 清掉 24h 地区缓存(下次建引擎重新判定)。
|
||||
fn detect_region(env: &HostEnv) -> Result<(), String> {
|
||||
let path = env.layout.cache().join("region.json");
|
||||
if path.is_file() {
|
||||
std::fs::remove_file(&path).map_err(|error| format!("清地区缓存失败:{error}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 设置页「重置发行版优先级」用(清空 enum_list)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 落盘失败时返回用户可读文案。
|
||||
pub fn clear_vendor_priority(env: &HostEnv) -> Result<(), String> {
|
||||
let mut store = env.store();
|
||||
store
|
||||
.set("java.vendorPriority", Value::Array(Vec::new()))
|
||||
.and_then(|()| store.save())
|
||||
.map_err(|error| format!("清空发行版优先级失败:{error}"))?;
|
||||
let _ = JdkVendor::ALL;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "settings_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,77 @@
|
||||
//! host/settings.rs 的单测(拆文件守 400 行上限)。
|
||||
|
||||
use super::*;
|
||||
|
||||
fn env_in(dir: &std::path::Path) -> HostEnv {
|
||||
std::fs::create_dir_all(dir.join(".ULL/config")).expect("config");
|
||||
HostEnv::at_root(dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_cover_the_six_required_areas() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-host-settings-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let env = env_in(&dir);
|
||||
let groups = groups(&env);
|
||||
let ids: Vec<&str> = groups.iter().map(|group| group.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["download", "java", "data", "ai", "mcp"]);
|
||||
let keys: Vec<&str> = groups
|
||||
.iter()
|
||||
.flat_map(|group| group.rows.iter())
|
||||
.map(|row| row.key.as_str())
|
||||
.collect();
|
||||
for key in [
|
||||
"download.region",
|
||||
"download.mirror_preference",
|
||||
"java.default_memory_mb",
|
||||
"core.data_mode",
|
||||
"agent.mcp.enabled",
|
||||
"agent.mcp.port",
|
||||
"ai.base_url",
|
||||
] {
|
||||
assert!(keys.contains(&key), "缺少 {key}");
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_parses_types_and_persists() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-host-settings-w-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let env = env_in(&dir);
|
||||
|
||||
set(&env, "download.concurrency", "16").expect("写并发");
|
||||
set(&env, "agent.mcp.enabled", "true").expect("写开关");
|
||||
set(&env, "agent.mcp.port", "48000").expect("写端口");
|
||||
set(&env, "download.region", "cn").expect("写地区");
|
||||
|
||||
let groups = groups(&env);
|
||||
let row = |key: &str| {
|
||||
groups
|
||||
.iter()
|
||||
.flat_map(|g| g.rows.iter())
|
||||
.find(|r| r.key == key)
|
||||
.cloned()
|
||||
};
|
||||
assert_eq!(row("download.concurrency").unwrap().selected, 2);
|
||||
assert!(row("agent.mcp.enabled").unwrap().flag);
|
||||
assert_eq!(row("agent.mcp.port").unwrap().value_text, "48000");
|
||||
assert_eq!(row("download.region").unwrap().selected, 1);
|
||||
|
||||
assert!(set(&env, "download.concurrency", "abc").is_err());
|
||||
assert!(set(&env, "agent.mcp.enabled", "maybe").is_err());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_region_action_removes_cache() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-host-settings-r-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let env = env_in(&dir);
|
||||
let path = env.layout.cache().join("region.json");
|
||||
std::fs::create_dir_all(env.layout.cache()).expect("cache");
|
||||
std::fs::write(&path, "{}").expect("写缓存");
|
||||
set(&env, "download.action.detect_region", "").expect("检测");
|
||||
assert!(!path.exists());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! 版本清单(下载页):远端 manifest(12h 缓存)+ 内嵌 unlisted 清单 → 分类好的行。
|
||||
//!
|
||||
//! 分类口径抄 analyse/03 §2.4:官方 `type` 决定 release/snapshot/远古;远古再按 id
|
||||
//! 前缀拆 classic(`rd-*`)/ old_alpha(`a*`)/ old_beta(`b*`),其余归 unlisted 汇总。
|
||||
//! 拉取失败但有旧缓存时用旧缓存(离线可用),全无才报错。
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
use unlucky_core::download::cache::now_ms;
|
||||
use unlucky_core::game::install::RemoteSource;
|
||||
use unlucky_core::game::unlisted;
|
||||
use unlucky_core::game::version::{ReleaseType, RemoteVersion};
|
||||
|
||||
use crate::dto::{AncientKind, VersionKind, VersionSummary};
|
||||
|
||||
use super::env::HostEnv;
|
||||
use super::job::{spawn, HostTask, JobSender};
|
||||
|
||||
/// 版本 id / 官方类型 → 下载页的四种归类。
|
||||
pub fn kind_of(id: &str, release_type: ReleaseType) -> VersionKind {
|
||||
match release_type {
|
||||
ReleaseType::Release => VersionKind::Release,
|
||||
ReleaseType::Snapshot | ReleaseType::Pending | ReleaseType::Unobfuscated => {
|
||||
VersionKind::Snapshot
|
||||
}
|
||||
ReleaseType::OldAlpha | ReleaseType::OldBeta | ReleaseType::Modified => {
|
||||
VersionKind::Ancient(ancient_kind(id))
|
||||
}
|
||||
ReleaseType::Unknown => {
|
||||
if id.starts_with("rd-") || id.starts_with("a") || id.starts_with("b") {
|
||||
VersionKind::Ancient(ancient_kind(id))
|
||||
} else {
|
||||
VersionKind::Release
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 远古版子类(`rd-*` → classic;`a1.*` → old_alpha;`b1.*` → old_beta;其余 unlisted)。
|
||||
pub fn ancient_kind(id: &str) -> AncientKind {
|
||||
if id.starts_with("rd-") || id.starts_with("inf-") {
|
||||
AncientKind::Classic
|
||||
} else if id.starts_with("a1.") || id.starts_with("a0.") || id.starts_with('a') {
|
||||
AncientKind::OldAlpha
|
||||
} else if id.starts_with("b1.") || id.starts_with('b') {
|
||||
AncientKind::OldBeta
|
||||
} else {
|
||||
AncientKind::Unlisted
|
||||
}
|
||||
}
|
||||
|
||||
/// 发布日期取 `releaseTime` 的日期部分(缺失时退回 `time`,再退回空串)。
|
||||
fn released_of(version: &RemoteVersion) -> String {
|
||||
version
|
||||
.release_time
|
||||
.as_deref()
|
||||
.or(version.time.as_deref())
|
||||
.map(|text| text.get(..10).unwrap_or(text).to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 拉清单并翻译成 DTO(安装状态按盘上版本判定)。
|
||||
fn fetch(env: &HostEnv) -> Result<Vec<VersionSummary>, String> {
|
||||
let engine = super::engine::engine(env);
|
||||
let meta_cache = env.layout.meta_cache();
|
||||
let now = now_ms();
|
||||
let (mut list, _freshness) = block_on(async {
|
||||
RemoteSource::new(&engine, &env.game, &meta_cache, now)
|
||||
.manifest(false)
|
||||
.await
|
||||
})
|
||||
.map_err(|error| format!("拉取版本清单失败:{error}"))?;
|
||||
// 内嵌 unlisted 清单先合(同 id 先插入者胜),保证远古版显式可达(09 §G2)。
|
||||
unlisted::merge_into(&mut list);
|
||||
list.sort_newest_first();
|
||||
|
||||
let installed: BTreeSet<String> = env
|
||||
.game
|
||||
.installed_versions()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.collect();
|
||||
Ok(list
|
||||
.versions
|
||||
.iter()
|
||||
.map(|version| VersionSummary {
|
||||
id: version.id.clone(),
|
||||
kind: kind_of(&version.id, version.version_type),
|
||||
released: released_of(version),
|
||||
installed: installed.contains(&version.id),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// 后台拉清单(网络在后台线程里,UI 不卡)。
|
||||
pub fn spawn_list(env: HostEnv, tx: JobSender) {
|
||||
spawn("versions", move || {
|
||||
let result = fetch(&env);
|
||||
let _ = tx.send(HostTask::Versions(result));
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn kinds_follow_official_types() {
|
||||
assert_eq!(
|
||||
kind_of("1.21.9", ReleaseType::Release),
|
||||
VersionKind::Release
|
||||
);
|
||||
assert_eq!(
|
||||
kind_of("1.21.9-pre3", ReleaseType::Snapshot),
|
||||
VersionKind::Snapshot
|
||||
);
|
||||
assert_eq!(
|
||||
kind_of("rd-132211", ReleaseType::OldAlpha),
|
||||
VersionKind::Ancient(AncientKind::Classic)
|
||||
);
|
||||
assert_eq!(
|
||||
kind_of("a1.1.1", ReleaseType::OldAlpha),
|
||||
VersionKind::Ancient(AncientKind::OldAlpha)
|
||||
);
|
||||
assert_eq!(
|
||||
kind_of("b1.7.3", ReleaseType::OldBeta),
|
||||
VersionKind::Ancient(AncientKind::OldBeta)
|
||||
);
|
||||
assert_eq!(
|
||||
kind_of("x-modified", ReleaseType::Modified),
|
||||
VersionKind::Ancient(AncientKind::Unlisted)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn released_uses_date_part() {
|
||||
let version = RemoteVersion {
|
||||
id: "1.20.4".to_string(),
|
||||
version_type: ReleaseType::Release,
|
||||
url: String::new(),
|
||||
time: None,
|
||||
release_time: Some("2023-12-07T12:00:00+00:00".to_string()),
|
||||
sha1: None,
|
||||
size: None,
|
||||
};
|
||||
assert_eq!(released_of(&version), "2023-12-07");
|
||||
let empty = RemoteVersion {
|
||||
id: "x".to_string(),
|
||||
version_type: ReleaseType::Unknown,
|
||||
url: String::new(),
|
||||
time: None,
|
||||
release_time: None,
|
||||
sha1: None,
|
||||
size: None,
|
||||
};
|
||||
assert_eq!(released_of(&empty), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlisted_merge_keeps_ancient_versions_reachable() {
|
||||
// core 的内嵌清单里一定有远古版(`rd-*` / `a1.*` / `b1.*`)
|
||||
let mut list = unlucky_core::game::version::VersionList::default();
|
||||
let added = unlisted::merge_into(&mut list);
|
||||
assert!(added > 0, "内嵌 unlisted 清单不该为空");
|
||||
assert!(
|
||||
list.versions.iter().any(|v| v.id.starts_with("a1.")
|
||||
|| v.id.starts_with("b1.")
|
||||
|| v.id.starts_with("rd-")),
|
||||
"远古版必须显式可达"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Unlucky-Launcher 的 **UI 框架无关宿主层**(presenter)。
|
||||
//!
|
||||
//! # 1. 这层为什么存在
|
||||
//!
|
||||
//! D26 的技术路线是「用 iced 重写 UI」。重写只应该换掉**渲染**,不该重写业务装配——
|
||||
//! 本 crate 就是被两个 UI 版共用的那一半:Slint 版(`crates/app`)与 iced 版
|
||||
//! (`crates/app-iced`)都从这里取 DTO、状态 reducer 与 host。Slint 退役后只剩 iced
|
||||
//! 依赖它,本 crate 不受影响。
|
||||
//!
|
||||
//! 判据(M85 已核):这套代码原本住在 `crates/app/src`,但 `host/`(25 个文件)、
|
||||
//! `state.rs`、`settings_bridge.rs`、`dto/` 里**没有一处** UI 框架符号;它们依赖的只有
|
||||
//! 标准库、serde 与 `unlucky-core`。把它们留在 UI crate 里等于让 iced 重写跟着重写一遍
|
||||
//! 约 5400 行,白担行为漂移的风险。
|
||||
//!
|
||||
//! # 2. 对外表面(UI 侧的唯一入口)
|
||||
//!
|
||||
//! ```text
|
||||
//! host::CoreHost ──impl Backend──► dto::AppEvent ──► state::AppState::apply ──► dto 快照
|
||||
//! ▲ │
|
||||
//! └──────────── dto::AppCommand ◄────────────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! | 模块 | 入口 | UI 侧怎么用 |
|
||||
//! | ---- | ---- | ----------- |
|
||||
//! | [`dto`] | [`dto::Backend`] / [`dto::AppCommand`] / [`dto::AppEvent`] / 各 `*View` 快照 | 派发指令、消费事件、把快照字段映射成视图模型 |
|
||||
//! | [`host`] | [`host::CoreHost`](实现 [`dto::Backend`])、[`host::HostEnv`] | 进程启动时构造一次,之后只当 `Box<dyn Backend>` 用 |
|
||||
//! | [`state`] | [`state::AppState`](`apply` / `navigate` / 各页面字段) | **唯一**状态入口:事件进来先 `apply`,再从字段取值画 |
|
||||
//! | [`prefs`] | `UiPrefs` / `ThemeMode` / `TileSize` / `AppearancePrefs` / `keys` | 读字段做展示;不自己写盘 |
|
||||
//! | [`settings_bridge`] | `SettingsBridge`(`open` / `load` / `persist` / `reload_if_changed`) | 启动读一次、变更时写回、每拍问一次「变了吗」 |
|
||||
//! | [`theme`] | `CONFIG_POLL_TICKS` / `BACKGROUND_KEY` 一类纯判据、`tile_viewport` / `system_theme_value` | 「这一拍该不该重读/重建」由它回答,UI 只执行 |
|
||||
//! | [`router`] | `Route` / `Router` / `AiPanelMode` | 导航状态 |
|
||||
//! | [`dialogs`] | `DialogQueue` / `DialogRequest` | 模态队列 |
|
||||
//! | [`toast`] | `ToastCenter` / `Toast` | 非阻塞提示 |
|
||||
//! | [`wizard`] | `WizardState` | 新建实例向导的纯状态机 |
|
||||
//! | [`mock`] / [`mock_data`] | `MockBackend` / 样例数据 | 无数据根时的演示后端、UI 回归 |
|
||||
//! | [`backend`] | `AppOptions` / `build_backend` / `config_path` | 启动时选真实 host 还是 mock |
|
||||
//!
|
||||
//! # 3. UI 侧允许做什么、禁止做什么
|
||||
//!
|
||||
//! 允许(**只做展示与转发**):
|
||||
//!
|
||||
//! - 把本 crate 的值翻译成框架类型(视图模型、颜色、图标、字符串)并灌进属性/`Element`;
|
||||
//! - 回调里把用户动作翻成 [`dto::AppCommand`] 交给 [`dto::Backend::dispatch`],再 `drain_events()` 喂
|
||||
//! [`state::AppState::apply`]——**不要**在本地做乐观回显(会话气泡/磁贴状态都以事件为准);
|
||||
//! - 框架能力:窗口、定时器/订阅、图片解码与模糊层、截图落盘、字体加载。
|
||||
//!
|
||||
//! 禁止:
|
||||
//!
|
||||
//! - 在本 crate 里出现 Slint / iced / winit 类型(守门命令见 `crates/app/Cargo.toml` 的注释:
|
||||
//! 自指问题让那条命令不能写在本 crate 里,否则它自己就被抓出来了);
|
||||
//! - UI 侧绕过 [`state::AppState::apply`] 直接改状态字段(会与事件流打架,磁贴/日志会漂);
|
||||
//! - UI 侧直接 `use unlucky_core::…` 组装业务(读盘、起进程、拼路径、写设置一律走 host/bridge);
|
||||
//! - UI 侧自己造 `<data root>` 路径:路径只从 [`host::HostEnv`] / [`settings_bridge`] 拿。
|
||||
//!
|
||||
//! # 4. iced 接线的推荐用法(骨架,伪代码)
|
||||
//!
|
||||
//! ```ignore
|
||||
//! // 1) 启动:选后端(数据根解析不出来自动退回 mock)
|
||||
//! let backend: Box<dyn Backend> = build_backend(&AppOptions::default());
|
||||
//! let bridge = SettingsBridge::open(); // 读盘一次,主题/外观看这里
|
||||
//! let state = AppState::new();
|
||||
//!
|
||||
//! // 2) 订阅:iced 的 30fps 订阅里推进一拍
|
||||
//! fn subscription() -> Subscription<Message> { iced::time::every(TICK).map(|_| Message::Tick) }
|
||||
//! fn update(&mut self, Message::Tick) {
|
||||
//! backend.tick(TICK); // 后台线程的消息在这里汇聚
|
||||
//! for event in backend.drain_events() { self.state.apply(event); } // 唯一的写状态入口
|
||||
//! if config_poll_due(self.ticks) { // presenter 的纯判据
|
||||
//! if let Some((appearance, theme)) = self.bridge.reload_if_changed() { … }
|
||||
//! }
|
||||
//! self.view = ViewModel::from(&self.state); // 展示转换在 iced 侧做
|
||||
//! }
|
||||
//!
|
||||
//! // 3) 回调:用户动作 → AppCommand → 事件回流(不本地回显)
|
||||
//! fn update(&mut self, Message::Launch(id)) { self.dispatch(AppCommand::Launch(id)); }
|
||||
//! ```
|
||||
//!
|
||||
//! 要点:`state` / `bridge` / `backend` 三件由 UI 侧持有并驱动,`presenter` 不持有定时器也不
|
||||
//! 知道帧率;事件顺序以 `drain_events()` 的顺序为准(host 侧保证配对:`Install` 之后必有
|
||||
//! `InstallDone`、`LaunchStarted` 之后必有 `LaunchEnd`)。
|
||||
//!
|
||||
//! # 5. 留在 `crates/app`(Slint 侧)的东西,以及为什么
|
||||
//!
|
||||
//! | 留在 app | 为什么不能进 presenter |
|
||||
//! | -------- | ---------------------- |
|
||||
//! | `conv/` | 把 DTO 转成 **Slint 结构体**(UI 侧生成的视图模型类型),是 Slint 专属转换层 |
|
||||
//! | `ui_bind.rs` / `page_sync.rs` / `callbacks*.rs` | 持有 `MainWindow`、`ModelRc`、回调闭包,全是框架绑定 |
|
||||
//! | `theme_runtime.rs` / `appearance_bind.rs` | `impl Inner` 的方法,读写窗口属性与图片属性;其中的**纯判据**已抽到 [`theme`] |
|
||||
//! | `bg_image.rs` / `png.rs` | 解码/模糊/编码产出框架图片类型与像素缓冲,属渲染侧 |
|
||||
//! | `screenshot.rs` | Slint 快照通道(`Window::take_snapshot`) |
|
||||
//! | `main.rs` | `include_modules!()` 与窗口拉起 |
|
||||
//!
|
||||
//! # 6. 零 UI 依赖的证据命令
|
||||
//!
|
||||
//! 两条命令(`grep` 与 `cargo tree`)的原文与实测输出见 `crates/app/Cargo.toml` 的注释与 M85
|
||||
//! 的完成报告:本 crate 的依赖树里只有 serde / serde_json / unlucky-core(及其自身依赖)。
|
||||
|
||||
// 关于 dead_code:host 里有一批「已定义、由单测覆盖、等 iced 页面接线才调用」的辅助函数
|
||||
// (`host::logs::session_finished`、`host::settings::clear_vendor_priority`、`loader_label`
|
||||
// 等)。它们原本住在 `crates/app` 里,由那边的 crate 级 `#![allow(dead_code)]` 一并放开;
|
||||
// 搬进 lib 之后「pub 但模块私有」会被判成死码。逐条 `#[allow]` 会把噪音藏进实现细节,
|
||||
// 这里沿用同一条策略统一放开,接线完成后再删掉本行暴露真死码。
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod backend;
|
||||
pub mod dialogs;
|
||||
pub mod dto;
|
||||
pub mod host;
|
||||
pub mod mock;
|
||||
pub mod mock_data;
|
||||
pub mod prefs;
|
||||
pub mod router;
|
||||
pub mod settings_bridge;
|
||||
pub mod state;
|
||||
pub mod theme;
|
||||
pub mod toast;
|
||||
pub mod wizard;
|
||||
@@ -0,0 +1,366 @@
|
||||
//! P0 假后端:用脚本化事件驱动骨架,验证 app ↔ core/agent 通道语义。
|
||||
//!
|
||||
//! 覆盖四条通道:实例运行状态(磁贴进度环)、AI 流式会话(含工具调用)、
|
||||
//! 对话框队列(进度对话框)、Toast。阶段 B 里由真正的 core/agent host 替换
|
||||
//! [`MockBackend`],[`crate::dto::Backend`] 之上的 state/reducer 不动。
|
||||
//! 假数据本体在 [`crate::mock_data`]。
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::dialogs::{DialogKind, DialogRequest, DialogSource};
|
||||
use crate::dto::{
|
||||
AgentEvent, AppCommand, AppEvent, Backend, ChatMessage, ChatRole, CoreEvent, InstanceId,
|
||||
InstanceRuntime, LaunchStage, Progress,
|
||||
};
|
||||
use crate::mock_data::{mock_instances, mock_log_lines, mock_log_sessions, mock_versions};
|
||||
use crate::toast::{Toast, ToastKind};
|
||||
|
||||
/// 启动脚本:按时间推进 download → verify → launch → running。
|
||||
#[derive(Debug)]
|
||||
struct LaunchScript {
|
||||
id: InstanceId,
|
||||
elapsed_ms: u64,
|
||||
}
|
||||
|
||||
/// AI 流式脚本:每条提问回一段可切分的话 + 一次工具调用。
|
||||
#[derive(Debug)]
|
||||
struct ChatScript {
|
||||
elapsed_ms: u64,
|
||||
chunks: VecDeque<String>,
|
||||
/// 流式助手消息在 app 会话里的序号
|
||||
message_index: usize,
|
||||
}
|
||||
|
||||
/// 假后端。
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MockBackend {
|
||||
events: VecDeque<AppEvent>,
|
||||
launch: Option<LaunchScript>,
|
||||
chat: Option<ChatScript>,
|
||||
/// 已投递给 app 的会话消息条数(用来给 MessageDelta 算序号)
|
||||
emitted_messages: usize,
|
||||
next_dialog: u64,
|
||||
}
|
||||
|
||||
impl MockBackend {
|
||||
/// 预置一批假数据事件(实例/版本/日志会话)。
|
||||
pub fn new() -> Self {
|
||||
let mut backend = Self::default();
|
||||
backend.seed();
|
||||
backend
|
||||
}
|
||||
|
||||
fn seed(&mut self) {
|
||||
self.events.push_back(AppEvent::Core(
|
||||
CoreEvent::InstancesChanged(mock_instances()),
|
||||
));
|
||||
self.events
|
||||
.push_back(AppEvent::Core(CoreEvent::VersionListChanged(
|
||||
mock_versions(),
|
||||
)));
|
||||
self.events
|
||||
.push_back(AppEvent::Core(CoreEvent::LogSessionsChanged(
|
||||
mock_log_sessions(),
|
||||
)));
|
||||
self.events
|
||||
.push_back(AppEvent::Core(CoreEvent::LogLinesChanged(mock_log_lines())));
|
||||
}
|
||||
|
||||
fn emit(&mut self, event: AppEvent) {
|
||||
self.events.push_back(event);
|
||||
}
|
||||
|
||||
fn next_dialog_id(&mut self) -> u64 {
|
||||
self.next_dialog += 1;
|
||||
self.next_dialog
|
||||
}
|
||||
|
||||
fn script_launch(&mut self, id: InstanceId) {
|
||||
self.emit(AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
|
||||
id: id.clone(),
|
||||
runtime: InstanceRuntime::Busy {
|
||||
stage: LaunchStage::Download,
|
||||
progress: Progress {
|
||||
done: 0,
|
||||
total: 1_500,
|
||||
},
|
||||
},
|
||||
}));
|
||||
self.launch = Some(LaunchScript { id, elapsed_ms: 0 });
|
||||
}
|
||||
|
||||
fn step_launch(&mut self, elapsed: Duration) {
|
||||
let Some((id, elapsed_ms)) = self.launch.as_mut().map(|script| {
|
||||
script.elapsed_ms += u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
|
||||
(script.id.clone(), script.elapsed_ms)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// 0~1.5s 下载、1.5~2.5s 校验、2.5~3s 启动、3s 后转为运行中
|
||||
let runtime = if elapsed_ms < 1_500 {
|
||||
InstanceRuntime::Busy {
|
||||
stage: LaunchStage::Download,
|
||||
progress: Progress {
|
||||
done: elapsed_ms,
|
||||
total: 1_500,
|
||||
},
|
||||
}
|
||||
} else if elapsed_ms < 2_500 {
|
||||
InstanceRuntime::Busy {
|
||||
stage: LaunchStage::Verify,
|
||||
progress: Progress {
|
||||
done: elapsed_ms - 1_500,
|
||||
total: 1_000,
|
||||
},
|
||||
}
|
||||
} else if elapsed_ms < 3_000 {
|
||||
InstanceRuntime::Busy {
|
||||
stage: LaunchStage::Launch,
|
||||
progress: Progress {
|
||||
done: elapsed_ms - 2_500,
|
||||
total: 500,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
self.launch = None;
|
||||
self.emit(AppEvent::Toast(Toast {
|
||||
id: 0,
|
||||
kind: ToastKind::Success,
|
||||
text: "游戏已启动".to_string(),
|
||||
remaining_ms: 0,
|
||||
}));
|
||||
InstanceRuntime::Running { pid: 12_345 }
|
||||
};
|
||||
|
||||
self.emit(AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
|
||||
id,
|
||||
runtime,
|
||||
}));
|
||||
}
|
||||
|
||||
fn script_chat(&mut self, text: String) {
|
||||
let user_index = self.emitted_messages;
|
||||
self.emit(AppEvent::Agent(AgentEvent::MessageAppended(
|
||||
ChatMessage::user(text),
|
||||
)));
|
||||
self.emit(AppEvent::Agent(AgentEvent::MessageAppended(
|
||||
ChatMessage::assistant_stream(),
|
||||
)));
|
||||
self.emitted_messages += 2;
|
||||
self.chat = Some(ChatScript {
|
||||
elapsed_ms: 0,
|
||||
chunks: VecDeque::from(vec![
|
||||
"先确认版本:".to_string(),
|
||||
"官方 1.20.4 是正式版,".to_string(),
|
||||
"装完再用 Fabric 加加载器。".to_string(),
|
||||
]),
|
||||
message_index: user_index + 1,
|
||||
});
|
||||
}
|
||||
|
||||
fn step_chat(&mut self, elapsed: Duration) {
|
||||
let Some(chat) = self.chat.as_mut() else {
|
||||
return;
|
||||
};
|
||||
chat.elapsed_ms += u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
|
||||
if chat.elapsed_ms < 300 {
|
||||
return;
|
||||
}
|
||||
chat.elapsed_ms = 0;
|
||||
let index = chat.message_index;
|
||||
let chunk = chat.chunks.pop_front();
|
||||
|
||||
if let Some(delta) = chunk {
|
||||
self.emit(AppEvent::Agent(AgentEvent::MessageDelta { index, delta }));
|
||||
return;
|
||||
}
|
||||
|
||||
// 流结束后补一次工具调用(工具执行在会话里可见)+ 收尾消息
|
||||
self.emit(AppEvent::Agent(AgentEvent::ToolCallStarted {
|
||||
name: "read_game_log".to_string(),
|
||||
summary: "读取最近一次崩溃日志".to_string(),
|
||||
}));
|
||||
self.emit(AppEvent::Agent(AgentEvent::ToolCallFinished {
|
||||
name: "read_game_log".to_string(),
|
||||
ok: true,
|
||||
summary: "读到 128 行,无 ERROR".to_string(),
|
||||
}));
|
||||
self.emit(AppEvent::Agent(AgentEvent::MessageFinished { index }));
|
||||
self.emit(AppEvent::Agent(AgentEvent::MessageAppended(ChatMessage {
|
||||
role: ChatRole::Assistant,
|
||||
text: "日志没有 ERROR,问题更可能在 Java 版本。".to_string(),
|
||||
tool: None,
|
||||
streaming: false,
|
||||
})));
|
||||
self.emitted_messages += 2;
|
||||
self.chat = None;
|
||||
}
|
||||
|
||||
fn script_install(&mut self, version: String) {
|
||||
let id = self.next_dialog_id();
|
||||
self.emit(AppEvent::DialogRequested(DialogRequest {
|
||||
id,
|
||||
source: DialogSource::BackgroundTask,
|
||||
title: format!("正在安装 {version}"),
|
||||
body: String::new(),
|
||||
kind: DialogKind::Progress {
|
||||
label: "下载资源".to_string(),
|
||||
progress: 0.0,
|
||||
},
|
||||
}));
|
||||
self.emit(AppEvent::DialogClosed(id));
|
||||
self.emit(AppEvent::Toast(Toast {
|
||||
id: 0,
|
||||
kind: ToastKind::Success,
|
||||
text: format!("{version} 安装完成"),
|
||||
remaining_ms: 0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for MockBackend {
|
||||
fn drain_events(&mut self) -> Vec<AppEvent> {
|
||||
self.events.drain(..).collect()
|
||||
}
|
||||
|
||||
fn dispatch(&mut self, command: AppCommand) {
|
||||
match command {
|
||||
AppCommand::LaunchInstance(id) => self.script_launch(id),
|
||||
AppCommand::StopInstance(id) => {
|
||||
self.launch = None;
|
||||
self.emit(AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
|
||||
id,
|
||||
runtime: InstanceRuntime::Idle,
|
||||
}));
|
||||
}
|
||||
AppCommand::AskAgent(text) => self.script_chat(text),
|
||||
AppCommand::InstallVersion(version) => self.script_install(version),
|
||||
AppCommand::SetYolo(enabled) => self.emit(AppEvent::Toast(Toast {
|
||||
id: 0,
|
||||
kind: ToastKind::Info,
|
||||
text: if enabled {
|
||||
"YOLO 已开启(审计照记)"
|
||||
} else {
|
||||
"YOLO 已关闭"
|
||||
}
|
||||
.to_string(),
|
||||
remaining_ms: 0,
|
||||
})),
|
||||
AppCommand::ListVersions(category) => {
|
||||
self.emit(AppEvent::Core(CoreEvent::VersionListChanged(
|
||||
crate::mock_data::mock_versions_for(category),
|
||||
)));
|
||||
}
|
||||
AppCommand::OpenLogSession(id) => {
|
||||
// P0:换会话 = 换一批假日志行 + 一条诊断结论(日志页诊断卡片可见)
|
||||
self.emit(AppEvent::Core(CoreEvent::LogLinesChanged(mock_log_lines())));
|
||||
self.emit(AppEvent::Core(CoreEvent::DiagnosisReady(
|
||||
crate::mock_data::mock_diagnosis(id),
|
||||
)));
|
||||
}
|
||||
AppCommand::RefreshAll | AppCommand::RefreshLogSessions | AppCommand::StopAgent => {}
|
||||
// 其余指令(安装向导/账户/Java/设置/通知)由真实 host 承接:mock 不模拟
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn tick(&mut self, elapsed: Duration) {
|
||||
self.step_launch(elapsed);
|
||||
self.step_chat(elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::MockBackend;
|
||||
use crate::dto::{AgentEvent, AppCommand, AppEvent, Backend, CoreEvent, InstanceRuntime};
|
||||
|
||||
#[test]
|
||||
fn seeds_instances_and_versions() {
|
||||
let mut backend = MockBackend::new();
|
||||
let events = backend.drain_events();
|
||||
assert_eq!(events.len(), 4);
|
||||
assert!(events
|
||||
.iter()
|
||||
.all(|event| matches!(event, AppEvent::Core(_))));
|
||||
assert!(backend.drain_events().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launching_emits_progress_then_running() {
|
||||
let mut backend = MockBackend::new();
|
||||
backend.drain_events();
|
||||
backend.dispatch(AppCommand::LaunchInstance("survival".to_string()));
|
||||
|
||||
let first = backend.drain_events();
|
||||
assert!(matches!(
|
||||
first[0],
|
||||
AppEvent::Core(CoreEvent::InstanceRuntimeChanged { .. })
|
||||
));
|
||||
|
||||
backend.tick(std::time::Duration::from_millis(1_600));
|
||||
let verify = backend.drain_events();
|
||||
match &verify[0] {
|
||||
AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
|
||||
runtime: InstanceRuntime::Busy { stage, .. },
|
||||
..
|
||||
}) => assert_eq!(*stage, crate::dto::LaunchStage::Verify),
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
|
||||
backend.tick(std::time::Duration::from_millis(2_000));
|
||||
let done = backend.drain_events();
|
||||
assert!(done.iter().any(|event| matches!(
|
||||
event,
|
||||
AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
|
||||
runtime: InstanceRuntime::Running { .. },
|
||||
..
|
||||
})
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopping_cancels_the_launch_script() {
|
||||
let mut backend = MockBackend::new();
|
||||
backend.drain_events();
|
||||
backend.dispatch(AppCommand::LaunchInstance("a".to_string()));
|
||||
backend.drain_events();
|
||||
|
||||
backend.dispatch(AppCommand::StopInstance("a".to_string()));
|
||||
let events = backend.drain_events();
|
||||
assert!(matches!(
|
||||
events[0],
|
||||
AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
|
||||
runtime: InstanceRuntime::Idle,
|
||||
..
|
||||
})
|
||||
));
|
||||
|
||||
// 脚本已取消:再推进时间也不会再产出事件
|
||||
backend.tick(std::time::Duration::from_millis(5_000));
|
||||
assert!(backend.drain_events().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asking_the_agent_streams_and_finishes() {
|
||||
let mut backend = MockBackend::new();
|
||||
backend.drain_events();
|
||||
backend.dispatch(AppCommand::AskAgent("装哪个版本".to_string()));
|
||||
|
||||
let start = backend.drain_events();
|
||||
assert_eq!(start.len(), 2);
|
||||
|
||||
let mut deltas = 0;
|
||||
for _ in 0..6 {
|
||||
backend.tick(std::time::Duration::from_millis(400));
|
||||
let events = backend.drain_events();
|
||||
deltas += events
|
||||
.iter()
|
||||
.filter(|event| matches!(event, AppEvent::Agent(AgentEvent::MessageDelta { .. })))
|
||||
.count();
|
||||
}
|
||||
assert_eq!(deltas, 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
//! P0 假数据:实例 / 版本 / 日志 / 诊断 / 工具调用样例。
|
||||
//!
|
||||
//! 只被 [`crate::mock`] 与阶段 B 的 main.rs 使用;真实后端接入后整文件可删。
|
||||
|
||||
use crate::dto::{
|
||||
AncientKind, ChatMessage, ChatRole, Diagnosis, DiagnosisReport, InstanceRuntime,
|
||||
InstanceSummary, LoaderInfo, LoaderKind, LogLevel, LogLine, LogOutcome, LogSession, SessionId,
|
||||
ToolCallView, VersionCategory, VersionKind, VersionSummary,
|
||||
};
|
||||
|
||||
/// P0 演示实例:覆盖空闲 / 失败两种磁贴状态(运行中由启动脚本产生)。
|
||||
pub fn mock_instances() -> Vec<InstanceSummary> {
|
||||
vec![
|
||||
InstanceSummary {
|
||||
id: "survival".to_string(),
|
||||
name: "生存整合包".to_string(),
|
||||
mc_version: "1.20.4".to_string(),
|
||||
loader: LoaderInfo {
|
||||
kind: LoaderKind::Fabric,
|
||||
version: "0.16.5".to_string(),
|
||||
},
|
||||
last_played: Some("2026-10-29T19:22:00Z".to_string()),
|
||||
runtime: InstanceRuntime::Idle,
|
||||
icon: None,
|
||||
isolation: "isolated".to_string(),
|
||||
version_id: "1.20.4".to_string(),
|
||||
version_present: true,
|
||||
},
|
||||
InstanceSummary {
|
||||
id: "vanilla-latest".to_string(),
|
||||
name: "原版最新".to_string(),
|
||||
mc_version: "1.21.9".to_string(),
|
||||
loader: LoaderInfo {
|
||||
kind: LoaderKind::Vanilla,
|
||||
version: String::new(),
|
||||
},
|
||||
last_played: Some("2026-10-28T21:03:00Z".to_string()),
|
||||
runtime: InstanceRuntime::Idle,
|
||||
icon: None,
|
||||
isolation: "isolated".to_string(),
|
||||
version_id: "1.21.9".to_string(),
|
||||
version_present: true,
|
||||
},
|
||||
InstanceSummary {
|
||||
id: "ancient-1.7.10".to_string(),
|
||||
name: "远古 1.7.10".to_string(),
|
||||
mc_version: "1.7.10".to_string(),
|
||||
loader: LoaderInfo {
|
||||
kind: LoaderKind::Forge,
|
||||
version: "10.13.4.1614".to_string(),
|
||||
},
|
||||
last_played: None,
|
||||
runtime: InstanceRuntime::Idle,
|
||||
icon: None,
|
||||
isolation: "shared".to_string(),
|
||||
version_id: "1.7.10".to_string(),
|
||||
version_present: false,
|
||||
},
|
||||
InstanceSummary {
|
||||
id: "modded-dev".to_string(),
|
||||
name: "开发测试".to_string(),
|
||||
mc_version: "1.21.1".to_string(),
|
||||
loader: LoaderInfo {
|
||||
kind: LoaderKind::NeoForge,
|
||||
version: "21.1.72".to_string(),
|
||||
},
|
||||
last_played: Some("2026-10-27T12:40:00Z".to_string()),
|
||||
runtime: InstanceRuntime::Failed {
|
||||
reason: "exit0 静默失败".to_string(),
|
||||
},
|
||||
icon: None,
|
||||
isolation: "isolated".to_string(),
|
||||
version_id: "1.21.1-neoforge-21.1.72".to_string(),
|
||||
version_present: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// P0 演示版本列表(含远古版三子类,验证分类侧栏与警示徽标)。
|
||||
pub fn mock_versions() -> Vec<VersionSummary> {
|
||||
fn row(id: &str, kind: VersionKind, released: &str, installed: bool) -> VersionSummary {
|
||||
VersionSummary {
|
||||
id: id.to_string(),
|
||||
kind,
|
||||
released: released.to_string(),
|
||||
installed,
|
||||
}
|
||||
}
|
||||
vec![
|
||||
row("1.21.9", VersionKind::Release, "2026-09-20", false),
|
||||
row("1.21.8", VersionKind::Release, "2026-07-11", true),
|
||||
row("1.20.4", VersionKind::Release, "2023-12-07", true),
|
||||
row("1.21.9-pre3", VersionKind::Snapshot, "2026-09-01", false),
|
||||
row(
|
||||
"rd-132211",
|
||||
VersionKind::Ancient(AncientKind::Classic),
|
||||
"2009-05-13",
|
||||
false,
|
||||
),
|
||||
row(
|
||||
"a1.2.6",
|
||||
VersionKind::Ancient(AncientKind::OldAlpha),
|
||||
"2010-08-11",
|
||||
false,
|
||||
),
|
||||
row(
|
||||
"b1.7.3",
|
||||
VersionKind::Ancient(AncientKind::OldBeta),
|
||||
"2011-07-08",
|
||||
false,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// 日志会话样例(✓ / ✗ / ⚠ 三种结局各一条)。
|
||||
pub fn mock_log_sessions() -> Vec<LogSession> {
|
||||
vec![
|
||||
LogSession {
|
||||
id: "s-1029-1922".to_string(),
|
||||
instance: "生存整合包".to_string(),
|
||||
when: "10-29 19:22".to_string(),
|
||||
outcome: LogOutcome::Ok,
|
||||
duration_secs: 3_780,
|
||||
},
|
||||
LogSession {
|
||||
id: "s-1029-1840".to_string(),
|
||||
instance: "开发测试".to_string(),
|
||||
when: "10-29 18:40".to_string(),
|
||||
outcome: LogOutcome::Crashed,
|
||||
duration_secs: 62,
|
||||
},
|
||||
LogSession {
|
||||
id: "s-1029-1755".to_string(),
|
||||
instance: "原版最新".to_string(),
|
||||
when: "10-29 17:55".to_string(),
|
||||
outcome: LogOutcome::SilentFail,
|
||||
duration_secs: 8,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// 日志行样例(含一条 ERROR 供高亮验证)。
|
||||
pub fn mock_log_lines() -> Vec<LogLine> {
|
||||
fn line(text: &str, level: LogLevel) -> LogLine {
|
||||
LogLine {
|
||||
text: text.to_string(),
|
||||
level,
|
||||
}
|
||||
}
|
||||
vec![
|
||||
line(
|
||||
"[19:22:01] [main/INFO] Launching Minecraft 1.20.4",
|
||||
LogLevel::Info,
|
||||
),
|
||||
line("[19:22:02] [main/INFO] Loading 42 mods", LogLevel::Info),
|
||||
line(
|
||||
"[19:22:03] [Render thread/WARN] GL vendor: unknown",
|
||||
LogLevel::Warn,
|
||||
),
|
||||
line(
|
||||
"[19:22:05] [Render thread/ERROR] OpenGL 2.1 not supported",
|
||||
LogLevel::Error,
|
||||
),
|
||||
line("[19:22:05] [main/INFO] Stopping!", LogLevel::Info),
|
||||
]
|
||||
}
|
||||
|
||||
/// 崩溃诊断样例(验证日志页诊断卡片 + 「让 AI 详解」入口)。
|
||||
pub fn mock_diagnosis(session: SessionId) -> DiagnosisReport {
|
||||
DiagnosisReport {
|
||||
session,
|
||||
diagnosis: Diagnosis {
|
||||
rule: "gpu.driver_old".to_string(),
|
||||
title: "显卡驱动过旧(OpenGL 版本不足)".to_string(),
|
||||
confidence: 92,
|
||||
hint: "升级显卡驱动,或在启动参数里改用软件渲染".to_string(),
|
||||
fix: Some("switch_software_renderer".to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 工具调用条目样例(AI 面板静态填充用)。
|
||||
pub fn mock_tool_message() -> ChatMessage {
|
||||
ChatMessage {
|
||||
role: ChatRole::Tool,
|
||||
text: "读到 128 行,无 ERROR".to_string(),
|
||||
tool: Some(ToolCallView {
|
||||
name: "read_game_log".to_string(),
|
||||
ok: true,
|
||||
summary: "读到 128 行,无 ERROR".to_string(),
|
||||
}),
|
||||
streaming: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 分类 → 该分类下的假版本(下载页切换分类时用)。
|
||||
pub fn mock_versions_for(category: VersionCategory) -> Vec<VersionSummary> {
|
||||
mock_versions()
|
||||
.into_iter()
|
||||
.filter(|version| match category {
|
||||
VersionCategory::Release => version.kind == VersionKind::Release,
|
||||
VersionCategory::Snapshot => version.kind == VersionKind::Snapshot,
|
||||
VersionCategory::Ancient(kind) => version.kind == VersionKind::Ancient(kind),
|
||||
VersionCategory::Modpack | VersionCategory::Mod => false,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::dto::{AncientKind, VersionCategory, VersionKind};
|
||||
use crate::mock_data::{mock_instances, mock_versions_for};
|
||||
|
||||
#[test]
|
||||
fn mock_instances_cover_the_tile_states() {
|
||||
let instances = mock_instances();
|
||||
assert_eq!(instances.len(), 4);
|
||||
assert!(instances.iter().any(|instance| matches!(
|
||||
instance.runtime,
|
||||
crate::dto::InstanceRuntime::Failed { .. }
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_are_filtered_by_category() {
|
||||
let release = mock_versions_for(VersionCategory::Release);
|
||||
assert!(release
|
||||
.iter()
|
||||
.all(|version| version.kind == VersionKind::Release));
|
||||
assert_eq!(release.len(), 3);
|
||||
|
||||
let classic = mock_versions_for(VersionCategory::Ancient(AncientKind::Classic));
|
||||
assert_eq!(classic.len(), 1);
|
||||
assert_eq!(classic[0].id, "rd-132211");
|
||||
|
||||
assert!(mock_versions_for(VersionCategory::Mod).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
//! UI 偏好项与持久化键登记(导航折叠、AI 栏折叠、磁贴尺寸、视图、主题三态与外观四件、下载分类)。
|
||||
//!
|
||||
//! 偏好不自己写盘:core::setting 的 schema 按 [`keys::ALL`] 登记这些键,
|
||||
//! app 启动时读一次、变更时写回(跨模块约定见 `DevDocs/SubAgentsTalk/app-DTO通道约定.md`)。
|
||||
//! 落盘实现是 [`crate::settings_bridge`](同一份 `config/launcher.json`,与 `ull config set` 同源),
|
||||
//! 主题三态 + `appearance.*` 高级键已接(M41);其余 UI 偏好键仍只在内存里(后续 mission 接)。
|
||||
|
||||
/// 背景图填充模式(09 §101)。直接复用 core 的枚举:取值与落盘词表只有一处真相。
|
||||
pub use unlucky_core::setting::appearance::BgFill;
|
||||
|
||||
use crate::dto::VersionCategory;
|
||||
|
||||
/// 预设强调色板(09 §104)。**必须**与 UI 侧设置页「外观」分组的色板控件同序同值:
|
||||
/// UI 侧只回传索引,十六进制串由这张表给出(一处真相)。
|
||||
pub const ACCENT_PRESETS: [&str; 8] = [
|
||||
"#0078D4", "#0F6CBD", "#107C10", "#9D5D00", "#C42B1C", "#8764B8", "#C239B3", "#00B7C3",
|
||||
];
|
||||
|
||||
/// 磁贴尺寸(Q24-3:小 6 列 / 中 4 列 / 大 3 列)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum TileSize {
|
||||
/// 小:6 列,只留图标/名字/启动键
|
||||
Small,
|
||||
/// 中:4 列(D16 默认)
|
||||
#[default]
|
||||
Medium,
|
||||
/// 大:3 列,显示完整徽标与最后游玩时间
|
||||
Large,
|
||||
}
|
||||
|
||||
impl TileSize {
|
||||
/// 该尺寸对应的网格列数。
|
||||
pub fn columns(self) -> usize {
|
||||
match self {
|
||||
Self::Small => 6,
|
||||
Self::Medium => 4,
|
||||
Self::Large => 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置页/实例页分段控件的文案。
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Small => "小",
|
||||
Self::Medium => "中",
|
||||
Self::Large => "大",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 实例页视图模式。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum InstanceView {
|
||||
/// 磁贴网格(默认)
|
||||
#[default]
|
||||
Tiles,
|
||||
/// 紧凑列表
|
||||
List,
|
||||
}
|
||||
|
||||
/// 主题三态(D17,P0 交付):`appearance.theme_mode ∈ {light, dark, auto}`,出厂 auto。
|
||||
///
|
||||
/// `auto` = 跟随系统(由 UI 侧的令牌层读系统调色板,随 OS 热更;探测口径见
|
||||
/// [`crate::theme::system_theme_value`]);`light` / `dark` 钉死**自绘体系**的取值。
|
||||
/// 三个取值怎么变成框架颜色是 UI 侧的事,本 crate 只给语义。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ThemeMode {
|
||||
/// 跟随系统(默认)
|
||||
#[default]
|
||||
Auto,
|
||||
/// 强制浅色
|
||||
Light,
|
||||
/// 强制深色
|
||||
Dark,
|
||||
}
|
||||
|
||||
impl ThemeMode {
|
||||
/// 落盘取值(`appearance.theme_mode`):`auto` / `light` / `dark`。
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Auto => "auto",
|
||||
Self::Light => "light",
|
||||
Self::Dark => "dark",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从落盘取值解析;未知值按 `auto` 兜底(配置被手改坏也不崩)。
|
||||
pub fn from_key_value(value: &str) -> Self {
|
||||
match value {
|
||||
"light" => Self::Light,
|
||||
"dark" => Self::Dark,
|
||||
_ => Self::Auto,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 09 §101-104「外观(高级)」四件的取值快照(出厂全部关闭)。
|
||||
///
|
||||
/// 与 `unlucky_core::setting::appearance::Appearance` 一一对应,但字段类型是 UI 侧好用的
|
||||
/// `f32`/`String`;落盘由 [`crate::settings_bridge`] 转成 schema 键。
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct AppearancePrefs {
|
||||
/// 背景图总开关
|
||||
pub bg_image_enabled: bool,
|
||||
/// 背景图路径(PNG;空 = 未设置)
|
||||
pub bg_image: String,
|
||||
/// 填充模式
|
||||
pub bg_fill: BgFill,
|
||||
/// 暗化层强度 0.0~0.9
|
||||
pub bg_dim: f32,
|
||||
/// 面板半透明度 0.4~1.0
|
||||
pub panel_alpha: f32,
|
||||
/// 玻璃拟态卡片
|
||||
pub glass: bool,
|
||||
/// 自定义强调色 `#RRGGBB`;空 = 出厂 [#0078D4]
|
||||
pub accent_color: String,
|
||||
}
|
||||
|
||||
impl AppearancePrefs {
|
||||
/// 出厂态:Metro 纯色,四个高级特性全关。
|
||||
pub fn factory() -> Self {
|
||||
Self {
|
||||
bg_image_enabled: false,
|
||||
bg_image: String::new(),
|
||||
bg_fill: BgFill::Cover,
|
||||
bg_dim: 0.35,
|
||||
panel_alpha: 1.0,
|
||||
glass: false,
|
||||
accent_color: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 背景图是否真的生效(开关打开且路径非空)。
|
||||
pub fn bg_active(&self) -> bool {
|
||||
self.bg_image_enabled && !self.bg_image.trim().is_empty()
|
||||
}
|
||||
|
||||
/// 规范化写入强调色:`#RRGGBB` 才接受(空串 = 恢复出厂)。
|
||||
///
|
||||
/// 返回 `false` 表示输入不合法(调用方据此亮设置页的 warn 行,不改现状)。
|
||||
pub fn set_accent(&mut self, text: &str) -> bool {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
self.accent_color.clear();
|
||||
return true;
|
||||
}
|
||||
match unlucky_core::setting::appearance::normalize_hex(trimmed) {
|
||||
Some(hex) => {
|
||||
self.accent_color = hex;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 用预设色板第 `index` 个颜色(越界忽略)。
|
||||
pub fn set_accent_preset(&mut self, index: usize) -> bool {
|
||||
match ACCENT_PRESETS.get(index) {
|
||||
Some(hex) => self.set_accent(hex),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 需要跨会话记忆的 UI 偏好。
|
||||
///
|
||||
/// 注意:`Eq` 不成立([`AppearancePrefs`] 带 `f32` 滑条值),用到的地方按 `PartialEq` 比。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UiPrefs {
|
||||
/// 左侧导航栏折叠成图标条
|
||||
pub nav_collapsed: bool,
|
||||
/// 右侧 AI 栏折叠成 icon 轨
|
||||
pub ai_collapsed: bool,
|
||||
/// 磁贴尺寸
|
||||
pub tile_size: TileSize,
|
||||
/// 实例页视图
|
||||
pub instance_view: InstanceView,
|
||||
/// 主题模式
|
||||
pub theme: ThemeMode,
|
||||
/// 外观(高级)四件(09 §101-104)
|
||||
pub appearance: AppearancePrefs,
|
||||
/// 下载页选中的分类
|
||||
pub download_category: VersionCategory,
|
||||
}
|
||||
|
||||
impl Default for UiPrefs {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
nav_collapsed: false,
|
||||
ai_collapsed: false,
|
||||
// D16 默认中磁贴(4 列)
|
||||
tile_size: TileSize::Medium,
|
||||
instance_view: InstanceView::Tiles,
|
||||
theme: ThemeMode::Auto,
|
||||
appearance: AppearancePrefs::factory(),
|
||||
download_category: VersionCategory::Release,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 持久化键名登记(core::setting schema 按此注册;改名等于迁移,慎动)。
|
||||
pub mod keys {
|
||||
/// 导航栏折叠
|
||||
pub const NAV_COLLAPSED: &str = "ui.nav.collapsed";
|
||||
/// AI 栏折叠
|
||||
pub const AI_COLLAPSED: &str = "ui.ai.collapsed";
|
||||
/// 磁贴尺寸
|
||||
pub const TILE_SIZE: &str = "ui.instances.tile_size";
|
||||
/// 实例页视图(磁贴/列表)
|
||||
pub const INSTANCE_VIEW: &str = "ui.instances.view";
|
||||
/// 主题三态(D17;与设置页「外观」分组同名,取值见 [`super::ThemeMode::as_str`])
|
||||
pub const THEME_MODE: &str = "appearance.theme_mode";
|
||||
/// 背景图开关(09 §101)
|
||||
pub const BG_IMAGE_ENABLED: &str = "appearance.bg_image_enabled";
|
||||
/// 背景图路径
|
||||
pub const BG_IMAGE: &str = "appearance.bg_image";
|
||||
/// 背景图填充模式
|
||||
pub const BG_FILL: &str = "appearance.bg_fill";
|
||||
/// 暗化层强度
|
||||
pub const BG_DIM: &str = "appearance.bg_dim";
|
||||
/// 面板半透明度(09 §102)
|
||||
pub const PANEL_ALPHA: &str = "appearance.panel_alpha";
|
||||
/// 玻璃拟态卡片(09 §103)
|
||||
pub const GLASS: &str = "appearance.glass";
|
||||
/// 自定义强调色(09 §104)
|
||||
pub const ACCENT_COLOR: &str = "appearance.accent_color";
|
||||
/// 下载页分类
|
||||
pub const DOWNLOAD_CATEGORY: &str = "ui.download.category";
|
||||
|
||||
/// 本模块登记的全部键(自检/迁移用)。
|
||||
pub const ALL: [&str; 13] = [
|
||||
NAV_COLLAPSED,
|
||||
AI_COLLAPSED,
|
||||
TILE_SIZE,
|
||||
INSTANCE_VIEW,
|
||||
THEME_MODE,
|
||||
BG_IMAGE_ENABLED,
|
||||
BG_IMAGE,
|
||||
BG_FILL,
|
||||
BG_DIM,
|
||||
PANEL_ALPHA,
|
||||
GLASS,
|
||||
ACCENT_COLOR,
|
||||
DOWNLOAD_CATEGORY,
|
||||
];
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{keys, InstanceView, ThemeMode, TileSize, UiPrefs};
|
||||
|
||||
#[test]
|
||||
fn defaults_match_d16() {
|
||||
let prefs = UiPrefs::default();
|
||||
assert_eq!(prefs.tile_size, TileSize::Medium);
|
||||
assert_eq!(prefs.tile_size.columns(), 4);
|
||||
assert_eq!(prefs.instance_view, InstanceView::Tiles);
|
||||
assert_eq!(prefs.theme, ThemeMode::Auto);
|
||||
assert_eq!(prefs.theme.as_str(), "auto");
|
||||
assert!(!prefs.nav_collapsed);
|
||||
assert!(!prefs.ai_collapsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tile_size_columns_follow_q24() {
|
||||
assert_eq!(TileSize::Small.columns(), 6);
|
||||
assert_eq!(TileSize::Medium.columns(), 4);
|
||||
assert_eq!(TileSize::Large.columns(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_mode_round_trips_through_key_values() {
|
||||
for mode in [ThemeMode::Auto, ThemeMode::Light, ThemeMode::Dark] {
|
||||
assert_eq!(ThemeMode::from_key_value(mode.as_str()), mode);
|
||||
}
|
||||
// 坏值兜底
|
||||
assert_eq!(ThemeMode::from_key_value("solarized"), ThemeMode::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pref_keys_are_unique() {
|
||||
let mut keys = keys::ALL.to_vec();
|
||||
let total = keys.len();
|
||||
keys.sort_unstable();
|
||||
keys.dedup();
|
||||
assert_eq!(keys.len(), total);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! 页面路由与 AI 助手栏形态(09 §1 / D16)。
|
||||
//!
|
||||
//! 栈语义抄 HMCL 的导航栈:**顶层页面互相替换**(实例/下载/日志/设置),
|
||||
//! 子页面(实例详情、AI 独立页)压栈,`back()` 逐层回退。
|
||||
//! 本模块刻意不依赖 Slint 类型,便于单元测试;UI 侧枚举映射在 main.rs 的转换层做。
|
||||
|
||||
use crate::dto::InstanceId;
|
||||
|
||||
/// 一级导航项(左侧导航栏高亮单位)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NavSection {
|
||||
/// 实例
|
||||
Instances,
|
||||
/// 下载
|
||||
Download,
|
||||
/// 日志
|
||||
Logs,
|
||||
/// 设置
|
||||
Settings,
|
||||
}
|
||||
|
||||
/// 页面路由。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Route {
|
||||
/// G1 实例页(主页)
|
||||
Instances,
|
||||
/// G2 下载页
|
||||
Download,
|
||||
/// G3 日志页
|
||||
Logs,
|
||||
/// G6 设置页
|
||||
Settings,
|
||||
/// 实例详情(G4):从磁贴点进来,返回时回到实例页
|
||||
Detail(InstanceId),
|
||||
/// AI 助手独立页(D16 双形态之一)
|
||||
Agent,
|
||||
}
|
||||
|
||||
impl Route {
|
||||
/// 是否是一级导航页(互相替换,不进栈)。
|
||||
pub fn is_top_level(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Instances | Self::Download | Self::Logs | Self::Settings
|
||||
)
|
||||
}
|
||||
|
||||
/// 该路由高亮哪个导航项(None = 没有导航项高亮,如 AI 独立页)。
|
||||
pub fn nav(&self) -> Option<NavSection> {
|
||||
match self {
|
||||
Self::Instances | Self::Detail(_) => Some(NavSection::Instances),
|
||||
Self::Download => Some(NavSection::Download),
|
||||
Self::Logs => Some(NavSection::Logs),
|
||||
Self::Settings => Some(NavSection::Settings),
|
||||
Self::Agent => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 栏形态:右侧栏 / 独立全宽页(两形态共享同一份会话状态)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AiPanelMode {
|
||||
/// 右侧常驻栏
|
||||
Docked,
|
||||
/// 拆出的独立全宽页
|
||||
Page,
|
||||
}
|
||||
|
||||
/// 导航栈。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Router {
|
||||
stack: Vec<Route>,
|
||||
}
|
||||
|
||||
impl Default for Router {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Router {
|
||||
/// 启动时停在实例页。
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
stack: vec![Route::Instances],
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前页面。
|
||||
pub fn route(&self) -> &Route {
|
||||
// 栈不变量:构造后永远非空(navigate/back 都不允许清空)
|
||||
self.stack.last().expect("router stack is never empty")
|
||||
}
|
||||
|
||||
/// 栈深度(1 表示没有可回退的子页面)。
|
||||
pub fn depth(&self) -> usize {
|
||||
self.stack.len()
|
||||
}
|
||||
|
||||
/// 是否能回退。
|
||||
pub fn can_go_back(&self) -> bool {
|
||||
self.stack.len() > 1
|
||||
}
|
||||
|
||||
/// 跳转到某路由:一级页替换整栈,子页面压栈(重复目标不重复压栈)。
|
||||
pub fn navigate(&mut self, route: Route) {
|
||||
if route.is_top_level() {
|
||||
self.stack.clear();
|
||||
self.stack.push(route);
|
||||
return;
|
||||
}
|
||||
if self.stack.last() == Some(&route) {
|
||||
return;
|
||||
}
|
||||
self.stack.push(route);
|
||||
}
|
||||
|
||||
/// 回退一层;已在根页面时返回 `false`。
|
||||
pub fn back(&mut self) -> bool {
|
||||
if self.stack.len() <= 1 {
|
||||
return false;
|
||||
}
|
||||
self.stack.pop();
|
||||
true
|
||||
}
|
||||
|
||||
/// 当前 AI 栏形态(栈顶是 Agent 页即为独立页形态)。
|
||||
pub fn ai_mode(&self) -> AiPanelMode {
|
||||
if self.route() == &Route::Agent {
|
||||
AiPanelMode::Page
|
||||
} else {
|
||||
AiPanelMode::Docked
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{AiPanelMode, NavSection, Route, Router};
|
||||
|
||||
#[test]
|
||||
fn top_level_navigation_replaces_the_stack() {
|
||||
let mut router = Router::new();
|
||||
router.navigate(Route::Detail("a".into()));
|
||||
assert_eq!(router.depth(), 2);
|
||||
|
||||
router.navigate(Route::Download);
|
||||
assert_eq!(router.depth(), 1);
|
||||
assert_eq!(router.route(), &Route::Download);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_pushes_and_back_pops_to_instances() {
|
||||
let mut router = Router::new();
|
||||
router.navigate(Route::Detail("survival".into()));
|
||||
assert!(router.can_go_back());
|
||||
assert_eq!(router.route().nav(), Some(NavSection::Instances));
|
||||
|
||||
assert!(router.back());
|
||||
assert_eq!(router.route(), &Route::Instances);
|
||||
assert!(!router.back());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_page_switches_ai_panel_mode() {
|
||||
let mut router = Router::new();
|
||||
assert_eq!(router.ai_mode(), AiPanelMode::Docked);
|
||||
|
||||
router.navigate(Route::Agent);
|
||||
assert_eq!(router.ai_mode(), AiPanelMode::Page);
|
||||
|
||||
router.back();
|
||||
assert_eq!(router.ai_mode(), AiPanelMode::Docked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigating_to_the_same_sub_route_does_not_grow_the_stack() {
|
||||
let mut router = Router::new();
|
||||
router.navigate(Route::Agent);
|
||||
router.navigate(Route::Agent);
|
||||
assert_eq!(router.depth(), 2);
|
||||
assert_eq!(router.route().nav(), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
//! 设置持久化桥:把 UI 偏好落到 `core::setting` 的 schema 文件里,并支持**外部热改热切**。
|
||||
//!
|
||||
//! 三条纪律(`DevDocs/SubAgentsTalk/app-DTO通道约定.md` §6 / AGENTS §6):
|
||||
//!
|
||||
//! 1. **不另立配置文件**:读写的就是 `ull config set/get` 与 AI `set_config` 用的那一份
|
||||
//! `<data root>/config/launcher.json`(`core::platform::data_dir` 解析数据根、`layout` 给路径);
|
||||
//! 2. **不自己拼 JSON**:一律过 `core::setting::store::SettingStore`(schema 校验 + 原子写);
|
||||
//! 3. **不 import 领域类型**:跨边界只看「键名 + 取值」,领域模型留在 core。
|
||||
//!
|
||||
//! 热切:文件 `mtime` 变了就整份重读(AI / CLI 改完下一拍即生效),见 [`SettingsBridge::reload_if_changed`]。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use unlucky_core::platform::data_dir::{DataDirRequest, DataMode, Resolver};
|
||||
use unlucky_core::platform::layout::LauncherLayout;
|
||||
use unlucky_core::platform::paths::Platform;
|
||||
use unlucky_core::setting::appearance::Appearance;
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::store::SettingStore;
|
||||
|
||||
use crate::prefs::{AppearancePrefs, ThemeMode};
|
||||
|
||||
/// 数据根配置键(决定 `launcher.json` 落在哪个数据目录;与 CLI 的 `DATA_ROOT_KEYS` 同源同义)。
|
||||
const DATA_ROOT_KEYS: [&str; 4] = [
|
||||
"core.data_mode",
|
||||
"core.data_custom_root",
|
||||
"core.launcher_home",
|
||||
"core.game_home",
|
||||
];
|
||||
|
||||
/// 设置文件桥(一个进程一份)。
|
||||
#[derive(Debug)]
|
||||
pub struct SettingsBridge {
|
||||
/// `config/launcher.json`;`None` = 数据根解析不出来(内存态运行,不落盘)
|
||||
path: Option<PathBuf>,
|
||||
store: Option<SettingStore>,
|
||||
/// 上次读/写之后的文件 `mtime`(热切探测用)
|
||||
last_mtime: Option<SystemTime>,
|
||||
}
|
||||
|
||||
impl SettingsBridge {
|
||||
/// 按三模式数据根打开真实配置文件(启动时调用一次)。
|
||||
#[must_use]
|
||||
pub fn open() -> Self {
|
||||
match resolve_config_path() {
|
||||
Some(path) => Self::with_path(path),
|
||||
None => Self::memory_only(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 指定路径打开(测试与特殊数据根用)。
|
||||
#[must_use]
|
||||
pub fn with_path(path: impl Into<PathBuf>) -> Self {
|
||||
let path = path.into();
|
||||
let store = SettingStore::load(&path, launcher_schema());
|
||||
let last_mtime = mtime(&path);
|
||||
Self {
|
||||
path: Some(path),
|
||||
store: Some(store),
|
||||
last_mtime,
|
||||
}
|
||||
}
|
||||
|
||||
/// 不落盘(数据根解析失败 / 单测)。
|
||||
#[must_use]
|
||||
pub fn memory_only() -> Self {
|
||||
Self {
|
||||
path: None,
|
||||
store: None,
|
||||
last_mtime: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 配置文件路径(诊断/日志用)。
|
||||
#[must_use]
|
||||
pub fn path(&self) -> Option<&Path> {
|
||||
self.path.as_deref()
|
||||
}
|
||||
|
||||
/// 当前生效的外观配置与主题模式。
|
||||
#[must_use]
|
||||
pub fn load(&self) -> (AppearancePrefs, ThemeMode) {
|
||||
let Some(store) = &self.store else {
|
||||
return (AppearancePrefs::factory(), ThemeMode::Auto);
|
||||
};
|
||||
let appearance = Appearance::from_store(store);
|
||||
let theme = ThemeMode::from_key_value(&appearance.theme_mode);
|
||||
(
|
||||
AppearancePrefs {
|
||||
bg_image_enabled: appearance.bg_image_enabled,
|
||||
bg_image: appearance.bg_image,
|
||||
bg_fill: appearance.bg_fill,
|
||||
bg_dim: appearance.bg_dim as f32,
|
||||
panel_alpha: appearance.panel_alpha as f32,
|
||||
glass: appearance.glass,
|
||||
accent_color: appearance.accent_color,
|
||||
},
|
||||
theme,
|
||||
)
|
||||
}
|
||||
|
||||
/// 落盘(只写出现过的键;失败静默——配置写不进去不该把 UI 卡住,诊断走日志)。
|
||||
pub fn persist(&mut self, appearance: &AppearancePrefs, theme: ThemeMode) {
|
||||
let Some(store) = self.store.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let mut snapshot = Appearance::from_store(store);
|
||||
snapshot.theme_mode = theme.as_str().to_owned();
|
||||
snapshot.bg_image_enabled = appearance.bg_image_enabled;
|
||||
snapshot.bg_image = appearance.bg_image.clone();
|
||||
snapshot.bg_fill = appearance.bg_fill;
|
||||
snapshot.bg_dim = f64::from(appearance.bg_dim);
|
||||
snapshot.panel_alpha = f64::from(appearance.panel_alpha);
|
||||
snapshot.glass = appearance.glass;
|
||||
snapshot.accent_color = appearance.accent_color.clone();
|
||||
if snapshot.write(store).is_err() {
|
||||
return;
|
||||
}
|
||||
let _ = store.save();
|
||||
if let Some(path) = self.path() {
|
||||
self.last_mtime = mtime(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// 文件被外部(AI / `ull config set`)改过就重读;返回新的生效值。
|
||||
///
|
||||
/// `mtime` 采样不到(某些文件系统精度/权限)时返回 `None`,退化为「只在启动时读一次」。
|
||||
pub fn reload_if_changed(&mut self) -> Option<(AppearancePrefs, ThemeMode)> {
|
||||
let path = self.path.clone()?;
|
||||
let current = mtime(&path);
|
||||
if current.is_none() || current == self.last_mtime {
|
||||
return None;
|
||||
}
|
||||
self.last_mtime = current;
|
||||
let store = self.store.as_mut()?;
|
||||
store.reload();
|
||||
Some(self.load())
|
||||
}
|
||||
}
|
||||
|
||||
/// 数据根解析 → `config/launcher.json`。
|
||||
///
|
||||
/// 与 CLI 同一套语义:先读便携目录那份 `launcher.json` 的 `core.*`(数据根决定键),
|
||||
/// 再交 `Resolver` 解析(含 `redirect.json` 跟随)。任何一步失败都退回内存态。
|
||||
fn resolve_config_path() -> Option<PathBuf> {
|
||||
let exe_dir = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|path| path.parent().map(Path::to_path_buf));
|
||||
let exe_dir = exe_dir?;
|
||||
let request = request_from_portable(&exe_dir);
|
||||
let resolver = Resolver::new(Platform::current(), Some(exe_dir.clone()));
|
||||
match resolver.resolve_default(&request) {
|
||||
Ok(resolved) => Some(resolved.layout().launcher_config()),
|
||||
// 连 exe 目录都解析不了(极端情况):仍旧用便携落点,至少设置能存下来
|
||||
Err(_) => Some(LauncherLayout::new(exe_dir.join(".ULL")).launcher_config()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 便携目录那份 `launcher.json` → 三模式请求(只读 `core.*` 四个键)。
|
||||
fn request_from_portable(exe_dir: &Path) -> DataDirRequest {
|
||||
let mut request = DataDirRequest::default();
|
||||
let path = portable_config_path(exe_dir);
|
||||
if !path.is_file() {
|
||||
return request;
|
||||
}
|
||||
let store = SettingStore::load(&path, launcher_schema());
|
||||
let text = |key: &str| store.doc().effective_str(key).map(str::to_string);
|
||||
if let Some(mode) = text(DATA_ROOT_KEYS[0]).as_deref().and_then(DataMode::parse) {
|
||||
request.mode = mode;
|
||||
}
|
||||
if let Some(root) = text(DATA_ROOT_KEYS[1]) {
|
||||
request.custom_root = Some(PathBuf::from(root));
|
||||
}
|
||||
if let Some(home) = text(DATA_ROOT_KEYS[2]) {
|
||||
request.launcher_home = Some(PathBuf::from(home));
|
||||
}
|
||||
if let Some(home) = text(DATA_ROOT_KEYS[3]) {
|
||||
request.game_home = Some(PathBuf::from(home));
|
||||
}
|
||||
request
|
||||
}
|
||||
|
||||
/// `<exe 目录>/.ULL/config/launcher.json`。
|
||||
fn portable_config_path(exe_dir: &Path) -> PathBuf {
|
||||
LauncherLayout::new(exe_dir.join(".ULL")).launcher_config()
|
||||
}
|
||||
|
||||
fn mtime(path: &Path) -> Option<SystemTime> {
|
||||
std::fs::metadata(path).ok()?.modified().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use unlucky_core::setting::appearance::BgFill;
|
||||
|
||||
fn temp_config(label: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ull-bridge-{label}-{}-{}",
|
||||
std::process::id(),
|
||||
line!()
|
||||
));
|
||||
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
|
||||
let path = dir.join("config/launcher.json");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_defaults_when_file_missing() {
|
||||
let path = temp_config("factory");
|
||||
let bridge = SettingsBridge::with_path(&path);
|
||||
let (appearance, theme) = bridge.load();
|
||||
assert_eq!(appearance, AppearancePrefs::factory());
|
||||
assert_eq!(theme, ThemeMode::Auto);
|
||||
assert!(!path.exists(), "只读不写:缺文件不该被创建");
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_then_reload_round_trips() {
|
||||
let path = temp_config("roundtrip");
|
||||
let mut bridge = SettingsBridge::with_path(&path);
|
||||
let mut appearance = AppearancePrefs::factory();
|
||||
appearance.bg_image_enabled = true;
|
||||
appearance.bg_image = "D:/wall/p.png".to_owned();
|
||||
appearance.bg_fill = BgFill::Contain;
|
||||
appearance.bg_dim = 0.5;
|
||||
appearance.panel_alpha = 0.8;
|
||||
appearance.glass = true;
|
||||
assert!(appearance.set_accent("#0f6cbd"));
|
||||
bridge.persist(&appearance, ThemeMode::Dark);
|
||||
|
||||
let reloaded = SettingsBridge::with_path(&path);
|
||||
let (value, theme) = reloaded.load();
|
||||
assert_eq!(value, appearance);
|
||||
assert_eq!(theme, ThemeMode::Dark);
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_edit_is_picked_up_by_reload() {
|
||||
let path = temp_config("hotreload");
|
||||
let mut bridge = SettingsBridge::with_path(&path);
|
||||
// 先落一份(建立 mtime 基线)
|
||||
bridge.persist(&AppearancePrefs::factory(), ThemeMode::Light);
|
||||
assert!(bridge.reload_if_changed().is_none(), "自己写完不该报告变更");
|
||||
|
||||
// 模拟外部写入(AI / `ull config set`):改主题 + 打开背景图
|
||||
let mut external = SettingsBridge::with_path(&path);
|
||||
let mut appearance = AppearancePrefs::factory();
|
||||
appearance.bg_image_enabled = true;
|
||||
appearance.bg_image = "D:/wall/x.png".to_owned();
|
||||
external.persist(&appearance, ThemeMode::Dark);
|
||||
|
||||
// mtime 精度:有的文件系统 1s 粒度,这里显式等到时间戳变化
|
||||
for _ in 0..50 {
|
||||
if let Some((value, theme)) = bridge.reload_if_changed() {
|
||||
assert_eq!(theme, ThemeMode::Dark);
|
||||
assert!(value.bg_image_enabled);
|
||||
assert_eq!(value.bg_image, "D:/wall/x.png");
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
panic!("外部修改没有被 mtime 热切捕获");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accent_preset_and_validation() {
|
||||
let mut appearance = AppearancePrefs::factory();
|
||||
assert_eq!(appearance.accent_color, "");
|
||||
assert!(appearance.set_accent_preset(1));
|
||||
assert_eq!(appearance.accent_color, "#0F6CBD");
|
||||
assert!(!appearance.set_accent("not-a-color"), "非法输入不改现状");
|
||||
assert_eq!(appearance.accent_color, "#0F6CBD");
|
||||
assert!(appearance.set_accent(""), "空串恢复出厂");
|
||||
assert_eq!(appearance.accent_color, "");
|
||||
assert!(!appearance.set_accent_preset(99));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bg_active_needs_both_switch_and_path() {
|
||||
let mut appearance = AppearancePrefs::factory();
|
||||
appearance.bg_image = "D:/wall/p.png".to_owned();
|
||||
assert!(!appearance.bg_active(), "开关没开不算生效");
|
||||
appearance.bg_image_enabled = true;
|
||||
assert!(appearance.bg_active());
|
||||
appearance.bg_image.clear();
|
||||
assert!(!appearance.bg_active(), "路径空也不算生效");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
//! UI 状态与 reducer:UI 框架只负责渲染,所有状态变更都汇聚到 [`AppState::apply`]。
|
||||
//!
|
||||
//! 数据流向:mock/host 后端 → [`crate::dto::AppEvent`] 队列 → `apply` → 状态字段 → UI 属性。
|
||||
//! 偏好项在 [`crate::prefs`] 里登记了持久化键名,core::setting 的 schema 落地后按这些键读写。
|
||||
|
||||
use crate::dialogs::DialogQueue;
|
||||
use crate::dto::{
|
||||
AccountView, AgentEvent, AppEvent, ChatMessage, ChatRole, CoreEvent, DeviceLoginView,
|
||||
DiagnosisReport, InstallProgressView, InstanceRuntime, InstanceSummary, JavaPageView, LogLine,
|
||||
LogSession, NoticeView, Progress, SettingGroupView, ToolCallView, VersionSummary,
|
||||
};
|
||||
use crate::prefs::UiPrefs;
|
||||
use crate::router::{AiPanelMode, Route, Router};
|
||||
use crate::toast::{ToastCenter, ToastKind};
|
||||
use crate::wizard::WizardState;
|
||||
|
||||
/// AI 会话状态(右侧栏与独立页共享同一份,切换不丢)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AiSession {
|
||||
/// 会话消息(user / assistant / tool / system)
|
||||
pub messages: Vec<ChatMessage>,
|
||||
/// 是否有流式消息在跑
|
||||
pub streaming: bool,
|
||||
/// YOLO 开关(跳过确认,审计照记)
|
||||
pub yolo: bool,
|
||||
/// 输入框草稿(切形态不丢)
|
||||
pub draft: String,
|
||||
/// 端点标签(显示在栏顶)
|
||||
pub endpoint_label: String,
|
||||
}
|
||||
|
||||
impl Default for AiSession {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AiSession {
|
||||
/// 默认端点标签(P0 是本地 llama.cpp;P1 由设置页决定)。
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
messages: Vec::new(),
|
||||
streaming: false,
|
||||
yolo: false,
|
||||
draft: String::new(),
|
||||
endpoint_label: "llama.cpp · 本地".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 流式收敛:清掉全部流式标记。
|
||||
pub fn finish_stream(&mut self) {
|
||||
self.streaming = false;
|
||||
for message in &mut self.messages {
|
||||
message.streaming = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 磁贴网格单元:位置由 [`grid_positions`] 算好,UI 侧只消费 row/col。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct TileCell<'a> {
|
||||
/// 实例数据
|
||||
pub instance: &'a InstanceSummary,
|
||||
/// 网格行号(0 起)
|
||||
pub row: usize,
|
||||
/// 网格列号(0 起)
|
||||
pub col: usize,
|
||||
}
|
||||
|
||||
/// 纯函数:把 n 个磁贴摊到 `columns` 列的网格上,返回 `(row, col)`。
|
||||
///
|
||||
/// 特意放在 Rust 侧:声明式 UI 的表达式里做整数除法容易引出隐式浮点,且这段逻辑可以单测。
|
||||
pub fn grid_positions(count: usize, columns: usize) -> Vec<(usize, usize)> {
|
||||
let columns = columns.max(1);
|
||||
(0..count)
|
||||
.map(|index| (index / columns, index % columns))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 实时日志最多保留的行数(滚动窗口;更多行由日志页的筛选/历史会话承担)。
|
||||
pub const MAX_LIVE_LINES: usize = 2_000;
|
||||
|
||||
/// 应用全局状态。
|
||||
#[derive(Debug)]
|
||||
pub struct AppState {
|
||||
/// UI 偏好(记忆项)
|
||||
pub prefs: UiPrefs,
|
||||
/// 导航栈
|
||||
pub router: Router,
|
||||
/// 实例列表快照
|
||||
pub instances: Vec<InstanceSummary>,
|
||||
/// 当前分类的版本列表
|
||||
pub versions: Vec<VersionSummary>,
|
||||
/// 日志会话列表
|
||||
pub log_sessions: Vec<LogSession>,
|
||||
/// 当前会话日志行
|
||||
pub log_lines: Vec<LogLine>,
|
||||
/// 最近一次诊断结论
|
||||
pub diagnosis: Option<DiagnosisReport>,
|
||||
/// 选中的日志会话
|
||||
pub selected_session: Option<String>,
|
||||
/// AI 会话状态
|
||||
pub ai: AiSession,
|
||||
/// 对话框队列(一次一条)
|
||||
pub dialogs: DialogQueue,
|
||||
/// Toast 中心
|
||||
pub toasts: ToastCenter,
|
||||
/// 实例页搜索词
|
||||
pub instance_query: String,
|
||||
/// 下载页「仅已装」过滤
|
||||
pub installed_only: bool,
|
||||
/// 下载页版本号筛选词
|
||||
pub download_query: String,
|
||||
/// 账户列表(三轨统一视图;凭据不在其中)
|
||||
pub accounts: Vec<AccountView>,
|
||||
/// 微软登录状态
|
||||
pub device_login: DeviceLoginView,
|
||||
/// Java 页快照(发行版 / 回退链 / 运行时)
|
||||
pub java: JavaPageView,
|
||||
/// 安装进度(None = 没有安装任务)
|
||||
pub install: Option<InstallProgressView>,
|
||||
/// 设置分组(设置页右栏)
|
||||
pub settings_groups: Vec<SettingGroupView>,
|
||||
/// 当前选中的设置分组(download / java / data / ai / mcp)
|
||||
pub settings_tab: String,
|
||||
/// 横幅通知
|
||||
pub notices: Vec<NoticeView>,
|
||||
/// 新建实例向导
|
||||
pub wizard: WizardState,
|
||||
/// 正在实时尾随的会话
|
||||
pub live_session: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// 空状态(数据由后端事件填充,P0 是 mock)。
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
prefs: UiPrefs::default(),
|
||||
router: Router::new(),
|
||||
instances: Vec::new(),
|
||||
versions: Vec::new(),
|
||||
log_sessions: Vec::new(),
|
||||
log_lines: Vec::new(),
|
||||
diagnosis: None,
|
||||
selected_session: None,
|
||||
ai: AiSession::new(),
|
||||
dialogs: DialogQueue::new(),
|
||||
toasts: ToastCenter::new(),
|
||||
instance_query: String::new(),
|
||||
installed_only: false,
|
||||
download_query: String::new(),
|
||||
accounts: Vec::new(),
|
||||
device_login: DeviceLoginView::new(),
|
||||
java: JavaPageView::new(),
|
||||
install: None,
|
||||
settings_groups: Vec::new(),
|
||||
settings_tab: "download".to_string(),
|
||||
notices: Vec::new(),
|
||||
wizard: WizardState::new(),
|
||||
live_session: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前页面。
|
||||
pub fn route(&self) -> &Route {
|
||||
self.router.route()
|
||||
}
|
||||
|
||||
/// 跳转(一级页替换、子页面压栈;去 AI 独立页时自动展开 AI 栏偏好)。
|
||||
pub fn navigate(&mut self, route: Route) {
|
||||
if route == Route::Agent {
|
||||
self.prefs.ai_collapsed = false;
|
||||
}
|
||||
self.router.navigate(route);
|
||||
}
|
||||
|
||||
/// 回退一层。
|
||||
pub fn back(&mut self) -> bool {
|
||||
self.router.back()
|
||||
}
|
||||
|
||||
/// AI 栏当前形态(跟着路由走)。
|
||||
pub fn ai_mode(&self) -> AiPanelMode {
|
||||
self.router.ai_mode()
|
||||
}
|
||||
|
||||
/// 应用一条事件(唯一状态变更入口)。
|
||||
pub fn apply(&mut self, event: AppEvent) {
|
||||
match event {
|
||||
AppEvent::Core(core) => self.apply_core(core),
|
||||
AppEvent::Agent(agent) => self.apply_agent(agent),
|
||||
AppEvent::DialogRequested(request) => {
|
||||
self.dialogs.push_request(request);
|
||||
}
|
||||
AppEvent::DialogClosed(id) => {
|
||||
self.dialogs.close(id);
|
||||
}
|
||||
AppEvent::Toast(toast) => {
|
||||
self.toasts.push_request(toast);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// core 事件。
|
||||
pub fn apply_core(&mut self, event: CoreEvent) {
|
||||
match event {
|
||||
CoreEvent::InstancesChanged(instances) => self.instances = instances,
|
||||
CoreEvent::InstanceRuntimeChanged { id, runtime } => {
|
||||
if let Some(instance) = self.instances.iter_mut().find(|item| item.id == id) {
|
||||
instance.runtime = runtime;
|
||||
}
|
||||
}
|
||||
CoreEvent::VersionListChanged(versions) => self.versions = versions,
|
||||
CoreEvent::LogSessionsChanged(sessions) => self.log_sessions = sessions,
|
||||
CoreEvent::LogLinesChanged(lines) => {
|
||||
self.log_lines = lines;
|
||||
self.live_session = None;
|
||||
}
|
||||
CoreEvent::LogAppended(batch) => self.append_live_lines(&batch),
|
||||
CoreEvent::LaunchFinished(finished) => {
|
||||
self.live_session = None;
|
||||
self.selected_session = Some(finished.session.clone());
|
||||
let kind = if finished.outcome == crate::dto::LogOutcome::Ok {
|
||||
ToastKind::Success
|
||||
} else {
|
||||
ToastKind::Error
|
||||
};
|
||||
self.toasts.push(kind, finished.summary);
|
||||
}
|
||||
CoreEvent::DiagnosisReady(report) => self.diagnosis = Some(report),
|
||||
CoreEvent::AccountsChanged(accounts) => self.accounts = accounts,
|
||||
CoreEvent::DeviceLoginChanged(view) => {
|
||||
if view.phase == crate::dto::DeviceLoginPhase::Succeeded {
|
||||
self.toasts
|
||||
.push(ToastKind::Success, format!("已登录:{}", view.account_name));
|
||||
}
|
||||
if view.phase == crate::dto::DeviceLoginPhase::Failed {
|
||||
self.toasts.push(ToastKind::Error, view.message.clone());
|
||||
}
|
||||
self.device_login = view;
|
||||
}
|
||||
CoreEvent::JavaChanged(page) => self.java = page,
|
||||
CoreEvent::InstallProgress(progress) => match progress {
|
||||
Some(progress) => {
|
||||
let terminal = progress.stage.is_terminal();
|
||||
self.install = Some(progress);
|
||||
if terminal {
|
||||
self.wizard.open = false;
|
||||
self.wizard.submitting = false;
|
||||
}
|
||||
}
|
||||
None => self.install = None,
|
||||
},
|
||||
CoreEvent::SettingsChanged(groups) => self.settings_groups = groups,
|
||||
CoreEvent::NoticesChanged(notices) => self.notices = notices,
|
||||
}
|
||||
}
|
||||
|
||||
/// agent 事件(AI 会话的流式消息与工具调用)。
|
||||
pub fn apply_agent(&mut self, event: AgentEvent) {
|
||||
match event {
|
||||
AgentEvent::MessageAppended(message) => {
|
||||
// 流式占位到达即视为「一轮回复进行中」:UI 的发送键/停止键据此切换
|
||||
if message.streaming {
|
||||
self.ai.streaming = true;
|
||||
}
|
||||
self.ai.messages.push(message);
|
||||
}
|
||||
AgentEvent::MessageDelta { index, delta } => {
|
||||
if let Some(message) = self.ai.messages.get_mut(index) {
|
||||
message.text.push_str(&delta);
|
||||
}
|
||||
}
|
||||
AgentEvent::MessageFinished { index } => {
|
||||
if let Some(message) = self.ai.messages.get_mut(index) {
|
||||
message.streaming = false;
|
||||
}
|
||||
self.ai.streaming = false;
|
||||
}
|
||||
AgentEvent::ToolCallStarted { name, summary } => {
|
||||
self.ai.streaming = true;
|
||||
self.ai.messages.push(ChatMessage {
|
||||
role: ChatRole::Tool,
|
||||
text: summary.clone(),
|
||||
tool: Some(ToolCallView {
|
||||
name,
|
||||
ok: false,
|
||||
summary,
|
||||
}),
|
||||
streaming: true,
|
||||
});
|
||||
}
|
||||
AgentEvent::ToolCallFinished { name, ok, summary } => {
|
||||
let target =
|
||||
self.ai.messages.iter_mut().rev().find(|message| {
|
||||
message.tool.as_ref().is_some_and(|tool| tool.name == name)
|
||||
});
|
||||
if let Some(message) = target {
|
||||
message.text.clone_from(&summary);
|
||||
message.streaming = false;
|
||||
if let Some(tool) = &mut message.tool {
|
||||
tool.ok = ok;
|
||||
tool.summary = summary;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 实时日志增量:只追加,封顶 [`MAX_LIVE_LINES`] 行(滚动窗口)。
|
||||
fn append_live_lines(&mut self, batch: &crate::dto::LiveLogBatch) {
|
||||
if self
|
||||
.live_session
|
||||
.as_deref()
|
||||
.is_some_and(|session| session != batch.session)
|
||||
{
|
||||
// 换了一个会话在跑:清掉上一场的行,避免两场日志混在一起
|
||||
self.log_lines.clear();
|
||||
}
|
||||
self.live_session = batch.running.then(|| batch.session.clone());
|
||||
self.selected_session = Some(batch.session.clone());
|
||||
self.log_lines.extend(batch.lines.iter().cloned());
|
||||
if self.log_lines.len() > MAX_LIVE_LINES {
|
||||
let drop = self.log_lines.len() - MAX_LIVE_LINES;
|
||||
self.log_lines.drain(..drop);
|
||||
}
|
||||
}
|
||||
|
||||
/// 按搜索词过滤后的磁贴网格(顺序稳定 = 实例列表顺序)。
|
||||
pub fn tiles(&self) -> Vec<TileCell<'_>> {
|
||||
let query = self.instance_query.trim().to_lowercase();
|
||||
let filtered: Vec<&InstanceSummary> = self
|
||||
.instances
|
||||
.iter()
|
||||
.filter(|instance| query.is_empty() || instance.name.to_lowercase().contains(&query))
|
||||
.collect();
|
||||
let positions = grid_positions(filtered.len(), self.prefs.tile_size.columns());
|
||||
filtered
|
||||
.into_iter()
|
||||
.zip(positions)
|
||||
.map(|(instance, (row, col))| TileCell { instance, row, col })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 运行中的实例(实例页顶部迷你状态条)。
|
||||
pub fn running(&self) -> Vec<&InstanceSummary> {
|
||||
self.instances
|
||||
.iter()
|
||||
.filter(|instance| {
|
||||
matches!(
|
||||
instance.runtime,
|
||||
InstanceRuntime::Running { .. } | InstanceRuntime::Busy { .. }
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 覆盖某实例的启动进度(mock / 真实后端共用同一入口)。
|
||||
pub fn set_progress(&mut self, id: &str, progress: Progress) {
|
||||
if let Some(instance) = self.instances.iter_mut().find(|item| item.id == id) {
|
||||
if let InstanceRuntime::Busy {
|
||||
progress: current, ..
|
||||
} = &mut instance.runtime
|
||||
{
|
||||
*current = progress;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 折叠/展开导航栏。
|
||||
pub fn toggle_nav(&mut self) {
|
||||
self.prefs.nav_collapsed = !self.prefs.nav_collapsed;
|
||||
}
|
||||
|
||||
/// 折叠/展开 AI 栏;若正处在独立页形态则先收回侧栏。
|
||||
pub fn toggle_ai(&mut self) {
|
||||
if self.route() == &Route::Agent {
|
||||
self.router.back();
|
||||
self.prefs.ai_collapsed = false;
|
||||
return;
|
||||
}
|
||||
self.prefs.ai_collapsed = !self.prefs.ai_collapsed;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "state_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,265 @@
|
||||
//! state.rs 的单元测试(拆文件保持单文件 ≤400 行)。
|
||||
|
||||
use super::{grid_positions, AiSession, AppState};
|
||||
use crate::dialogs::{DialogKind, DialogRequest, DialogSource};
|
||||
use crate::dto::{
|
||||
AgentEvent, AppEvent, ChatMessage, ChatRole, CoreEvent, Diagnosis, DiagnosisReport,
|
||||
InstanceRuntime, InstanceSummary, LoaderInfo, LoaderKind,
|
||||
};
|
||||
use crate::prefs::TileSize;
|
||||
use crate::router::Route;
|
||||
use crate::toast::Toast;
|
||||
use crate::toast::ToastKind;
|
||||
|
||||
fn instance(id: &str, name: &str) -> InstanceSummary {
|
||||
InstanceSummary {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
mc_version: "1.20.4".to_string(),
|
||||
loader: LoaderInfo {
|
||||
kind: LoaderKind::Fabric,
|
||||
version: "0.16.5".to_string(),
|
||||
},
|
||||
last_played: None,
|
||||
runtime: InstanceRuntime::Idle,
|
||||
icon: None,
|
||||
isolation: "isolated".to_string(),
|
||||
version_id: "1.20.4".to_string(),
|
||||
version_present: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_positions_wrap_by_column_count() {
|
||||
assert_eq!(grid_positions(0, 4), vec![]);
|
||||
assert_eq!(grid_positions(3, 2), vec![(0, 0), (0, 1), (1, 0)]);
|
||||
// columns == 0 时按 1 列兜底,不允许除零
|
||||
assert_eq!(grid_positions(2, 0), vec![(0, 0), (1, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiles_follow_tile_size_and_search() {
|
||||
let mut state = AppState::new();
|
||||
state.instances = vec![
|
||||
instance("a", "生存"),
|
||||
instance("b", "创造"),
|
||||
instance("c", "Demo"),
|
||||
];
|
||||
|
||||
assert_eq!(state.tiles().len(), 3);
|
||||
assert_eq!((state.tiles()[2].row, state.tiles()[2].col), (0, 2));
|
||||
|
||||
state.prefs.tile_size = TileSize::Large;
|
||||
assert_eq!((state.tiles()[2].row, state.tiles()[2].col), (0, 2));
|
||||
|
||||
state.prefs.tile_size = TileSize::Small;
|
||||
assert_eq!(state.tiles().len(), 3);
|
||||
|
||||
state.instance_query = "demo".to_string();
|
||||
let tiles = state.tiles();
|
||||
assert_eq!(tiles.len(), 1);
|
||||
assert_eq!(tiles[0].instance.id, "c");
|
||||
assert_eq!((tiles[0].row, tiles[0].col), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiles_reflow_when_tile_size_shrinks() {
|
||||
let mut state = AppState::new();
|
||||
state.instances = (0..8)
|
||||
.map(|i| instance(&format!("i{i}"), &format!("实例{i}")))
|
||||
.collect();
|
||||
|
||||
state.prefs.tile_size = TileSize::Medium;
|
||||
let medium_rows = state.tiles().iter().map(|cell| cell.row).max();
|
||||
|
||||
state.prefs.tile_size = TileSize::Small;
|
||||
let small_rows = state.tiles().iter().map(|cell| cell.row).max();
|
||||
|
||||
assert_eq!(medium_rows, Some(1)); // 8 / 4 列 = 2 行
|
||||
assert_eq!(small_rows, Some(1)); // 8 / 6 列 = 2 行
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_events_update_the_matching_instance() {
|
||||
let mut state = AppState::new();
|
||||
state.instances = vec![instance("a", "生存"), instance("b", "创造")];
|
||||
|
||||
state.apply(AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
|
||||
id: "b".to_string(),
|
||||
runtime: InstanceRuntime::Running { pid: 42 },
|
||||
}));
|
||||
|
||||
assert_eq!(state.instances[0].runtime, InstanceRuntime::Idle);
|
||||
assert_eq!(
|
||||
state.instances[1].runtime,
|
||||
InstanceRuntime::Running { pid: 42 }
|
||||
);
|
||||
assert_eq!(state.running().len(), 1);
|
||||
assert_eq!(state.running()[0].id, "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_events_only_touch_busy_instances() {
|
||||
let mut state = AppState::new();
|
||||
state.instances = vec![instance("a", "生存")];
|
||||
state.instances[0].runtime = InstanceRuntime::Busy {
|
||||
stage: crate::dto::LaunchStage::Download,
|
||||
progress: crate::dto::Progress {
|
||||
done: 0,
|
||||
total: 100,
|
||||
},
|
||||
};
|
||||
|
||||
state.set_progress(
|
||||
"a",
|
||||
crate::dto::Progress {
|
||||
done: 50,
|
||||
total: 100,
|
||||
},
|
||||
);
|
||||
match &state.instances[0].runtime {
|
||||
InstanceRuntime::Busy { progress, .. } => {
|
||||
assert!((progress.ratio() - 0.5).abs() < f32::EPSILON);
|
||||
}
|
||||
other => panic!("unexpected runtime: {other:?}"),
|
||||
}
|
||||
|
||||
// 不存在的 id / 非 Busy 状态不改变任何东西
|
||||
state.set_progress("missing", crate::dto::Progress { done: 1, total: 1 });
|
||||
assert_eq!(state.instances.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_stream_deltas_accumulate_and_finish() {
|
||||
let mut state = AppState::new();
|
||||
state.apply(AppEvent::Agent(AgentEvent::MessageAppended(
|
||||
ChatMessage::user("怎么装 1.20.4"),
|
||||
)));
|
||||
state.apply(AppEvent::Agent(AgentEvent::MessageAppended(
|
||||
ChatMessage::assistant_stream(),
|
||||
)));
|
||||
assert_eq!(state.ai.messages.len(), 2);
|
||||
assert!(state.ai.streaming);
|
||||
assert_eq!(state.ai.messages[1].role, ChatRole::Assistant);
|
||||
|
||||
state.apply(AppEvent::Agent(AgentEvent::MessageDelta {
|
||||
index: 1,
|
||||
delta: "先".to_string(),
|
||||
}));
|
||||
state.apply(AppEvent::Agent(AgentEvent::MessageDelta {
|
||||
index: 1,
|
||||
delta: "下载".to_string(),
|
||||
}));
|
||||
state.apply(AppEvent::Agent(AgentEvent::MessageFinished { index: 1 }));
|
||||
|
||||
assert_eq!(state.ai.messages[1].text, "先下载");
|
||||
assert!(!state.ai.messages[1].streaming);
|
||||
assert!(!state.ai.streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_session_finish_stream_clears_every_flag() {
|
||||
let mut session = AiSession::new();
|
||||
session.messages.push(ChatMessage::user("hi"));
|
||||
session.messages.push(ChatMessage::assistant_stream());
|
||||
session.finish_stream();
|
||||
assert!(session.messages.iter().all(|message| !message.streaming));
|
||||
assert!(!session.streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_finish_updates_the_last_matching_message() {
|
||||
let mut state = AppState::new();
|
||||
state.apply(AppEvent::Agent(AgentEvent::ToolCallStarted {
|
||||
name: "get_config".to_string(),
|
||||
summary: "读取设置".to_string(),
|
||||
}));
|
||||
state.apply(AppEvent::Agent(AgentEvent::ToolCallFinished {
|
||||
name: "get_config".to_string(),
|
||||
ok: true,
|
||||
summary: "读取到 3 项".to_string(),
|
||||
}));
|
||||
|
||||
let tool = &state.ai.messages[0];
|
||||
assert_eq!(tool.role, ChatRole::Tool);
|
||||
assert_eq!(tool.text, "读取到 3 项");
|
||||
assert!(!tool.streaming);
|
||||
assert!(tool.tool.as_ref().is_some_and(|call| call.ok));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialogs_and_toasts_flow_through_apply() {
|
||||
let mut state = AppState::new();
|
||||
state.apply(AppEvent::DialogRequested(DialogRequest {
|
||||
id: 42,
|
||||
source: DialogSource::UserAction,
|
||||
title: "AI 想修改内存".to_string(),
|
||||
body: "改为 8G".to_string(),
|
||||
kind: DialogKind::Confirm {
|
||||
primary: "允许".to_string(),
|
||||
secondary: "拒绝".to_string(),
|
||||
},
|
||||
}));
|
||||
state.apply(AppEvent::Toast(Toast {
|
||||
id: 0,
|
||||
kind: ToastKind::Success,
|
||||
text: "下载完成".to_string(),
|
||||
remaining_ms: 0,
|
||||
}));
|
||||
|
||||
assert_eq!(state.dialogs.pending_len(), 0);
|
||||
// id 归生产者:队列原样保留,DialogClosed(42) 才能命中同一条
|
||||
assert_eq!(state.dialogs.current().map(|dialog| dialog.id), Some(42));
|
||||
assert_eq!(state.toasts.len(), 1);
|
||||
|
||||
state.apply(AppEvent::DialogClosed(42));
|
||||
assert!(state.dialogs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigate_to_agent_page_expands_the_ai_panel() {
|
||||
let mut state = AppState::new();
|
||||
state.prefs.ai_collapsed = true;
|
||||
state.navigate(Route::Agent);
|
||||
assert_eq!(state.route(), &Route::Agent);
|
||||
assert_eq!(state.ai_mode(), crate::router::AiPanelMode::Page);
|
||||
assert!(!state.prefs.ai_collapsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggling_ai_from_the_page_form_docks_it_again() {
|
||||
let mut state = AppState::new();
|
||||
state.navigate(Route::Agent);
|
||||
state.toggle_ai();
|
||||
assert_eq!(state.route(), &Route::Instances);
|
||||
assert_eq!(state.ai_mode(), crate::router::AiPanelMode::Docked);
|
||||
assert!(!state.prefs.ai_collapsed);
|
||||
|
||||
state.toggle_ai();
|
||||
assert!(state.prefs.ai_collapsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_nav_flips_the_pref() {
|
||||
let mut state = AppState::new();
|
||||
state.toggle_nav();
|
||||
assert!(state.prefs.nav_collapsed);
|
||||
state.toggle_nav();
|
||||
assert!(!state.prefs.nav_collapsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnosis_report_lands_on_state() {
|
||||
let mut state = AppState::new();
|
||||
state.apply(AppEvent::Core(CoreEvent::DiagnosisReady(DiagnosisReport {
|
||||
session: "s1".to_string(),
|
||||
diagnosis: Diagnosis {
|
||||
rule: "gpu.driver_old".to_string(),
|
||||
title: "显卡驱动过旧".to_string(),
|
||||
confidence: 92,
|
||||
hint: "升级驱动".to_string(),
|
||||
fix: Some("open_driver_page".to_string()),
|
||||
},
|
||||
})));
|
||||
assert!(state.diagnosis.is_some());
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! 主题与外观的**纯**判据:轮询节拍、背景图缓存键与量化、系统主题取值。
|
||||
//!
|
||||
//! 这一层原本混在 `crates/app` 的 `ui_bind.rs` / `theme_runtime.rs` 里,与「把值灌进窗口属性」
|
||||
//! 的绑定代码缠在一起,于是两个 UI 版都得各写一遍。抽到这里的只有**判据**:什么时候该重读配置、
|
||||
//! 什么时候该重建背景图、系统主题该报哪个值。灌属性仍然由 UI 侧做,本模块不碰任何框架类型。
|
||||
|
||||
use crate::prefs::BgFill;
|
||||
|
||||
/// 配置热切轮询间隔(拍数,33ms/拍 → 约 1s 一次 `mtime` 比对,AI/CLI 改完下一拍即生效)。
|
||||
pub const CONFIG_POLL_TICKS: u64 = 30;
|
||||
|
||||
/// 系统主题兜底轮询间隔(拍数):只在 `auto` 且后端不认识系统主题时启动(约 3s)。
|
||||
pub const SYSTEM_THEME_POLL_TICKS: u64 = 90;
|
||||
|
||||
/// tile 模式重建画布的窗口尺寸粒度(像素):避免拖动窗口时反复重算平铺画布。
|
||||
pub const TILE_REGENERATE_STEP: u32 = 128;
|
||||
|
||||
/// 系统主题取值(UI 侧根组件属性就用这三个值:0 未知 / 1 浅色 / 2 深色)。
|
||||
pub const SYSTEM_THEME_UNKNOWN: i32 = 0;
|
||||
/// 浅色。
|
||||
pub const SYSTEM_THEME_LIGHT: i32 = 1;
|
||||
/// 深色。
|
||||
pub const SYSTEM_THEME_DARK: i32 = 2;
|
||||
|
||||
/// 背景图加工输入(值相同就不重新解码)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackgroundKey {
|
||||
/// 背景图路径
|
||||
pub path: String,
|
||||
/// 填充模式
|
||||
pub fill: BgFill,
|
||||
/// tile 用的窗口尺寸(按 [`TILE_REGENERATE_STEP`] 量化;cover/contain 恒为 (0, 0))
|
||||
pub viewport: (u32, u32),
|
||||
}
|
||||
|
||||
/// 本拍是否该比对一次配置文件 `mtime`(热切的采样点)。
|
||||
pub fn config_poll_due(ticks: u64) -> bool {
|
||||
ticks.is_multiple_of(CONFIG_POLL_TICKS)
|
||||
}
|
||||
|
||||
/// 本拍是否该兜底探测系统主题。
|
||||
///
|
||||
/// 启动时先探一次(`probed == false`),之后只在「主题是 auto + 后端不认识系统主题」时
|
||||
/// 按 [`SYSTEM_THEME_POLL_TICKS`] 兜底轮询。
|
||||
pub fn system_theme_poll_due(probed: bool, auto: bool, backend_known: bool, ticks: u64) -> bool {
|
||||
!probed || (auto && !backend_known && ticks.is_multiple_of(SYSTEM_THEME_POLL_TICKS))
|
||||
}
|
||||
|
||||
/// 系统主题取值(纯映射,便于单测)。
|
||||
pub fn system_theme_code(theme: unlucky_core::platform::theme_probe::SystemTheme) -> i32 {
|
||||
use unlucky_core::platform::theme_probe::SystemTheme;
|
||||
match theme {
|
||||
SystemTheme::Light => SYSTEM_THEME_LIGHT,
|
||||
SystemTheme::Dark => SYSTEM_THEME_DARK,
|
||||
SystemTheme::Unknown => SYSTEM_THEME_UNKNOWN,
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测本机系统主题并给出 UI 侧属性值。
|
||||
pub fn system_theme_value() -> i32 {
|
||||
system_theme_code(unlucky_core::platform::theme_probe::probe())
|
||||
}
|
||||
|
||||
/// tile 画布的窗口尺寸(量化到 [`TILE_REGENERATE_STEP`],避免拖动窗口时反复重算)。
|
||||
///
|
||||
/// 非 tile 模式恒为 (0, 0):cover/contain 不依赖窗口尺寸,缩放只让渲染器拉伸。
|
||||
pub fn tile_viewport(fill: BgFill, size: (u32, u32)) -> (u32, u32) {
|
||||
if fill != BgFill::Tile {
|
||||
return (0, 0);
|
||||
}
|
||||
(
|
||||
size.0 / TILE_REGENERATE_STEP * TILE_REGENERATE_STEP,
|
||||
size.1 / TILE_REGENERATE_STEP * TILE_REGENERATE_STEP,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
config_poll_due, system_theme_code, system_theme_poll_due, tile_viewport, BackgroundKey,
|
||||
CONFIG_POLL_TICKS, SYSTEM_THEME_DARK, SYSTEM_THEME_LIGHT, SYSTEM_THEME_POLL_TICKS,
|
||||
SYSTEM_THEME_UNKNOWN, TILE_REGENERATE_STEP,
|
||||
};
|
||||
use crate::prefs::BgFill;
|
||||
use unlucky_core::platform::theme_probe::SystemTheme;
|
||||
|
||||
#[test]
|
||||
fn config_poll_fires_every_interval() {
|
||||
assert!(config_poll_due(0));
|
||||
assert!(!config_poll_due(1));
|
||||
assert!(config_poll_due(CONFIG_POLL_TICKS));
|
||||
assert!(!config_poll_due(CONFIG_POLL_TICKS + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_theme_probe_backs_off_after_first_read() {
|
||||
// 启动时无条件探一次
|
||||
assert!(system_theme_poll_due(false, false, true, 1));
|
||||
// 探过、主题非 auto:不再轮询
|
||||
assert!(!system_theme_poll_due(
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
SYSTEM_THEME_POLL_TICKS
|
||||
));
|
||||
// 探过、auto、后端认识系统主题:不再轮询
|
||||
assert!(!system_theme_poll_due(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
SYSTEM_THEME_POLL_TICKS
|
||||
));
|
||||
// 探过、auto、后端不认识:按间隔兜底
|
||||
assert!(!system_theme_poll_due(
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
SYSTEM_THEME_POLL_TICKS - 1
|
||||
));
|
||||
assert!(system_theme_poll_due(
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
SYSTEM_THEME_POLL_TICKS
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_theme_codes_are_stable() {
|
||||
assert_eq!(
|
||||
system_theme_code(SystemTheme::Unknown),
|
||||
SYSTEM_THEME_UNKNOWN
|
||||
);
|
||||
assert_eq!(system_theme_code(SystemTheme::Light), SYSTEM_THEME_LIGHT);
|
||||
assert_eq!(system_theme_code(SystemTheme::Dark), SYSTEM_THEME_DARK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tile_viewport_quantizes_and_ignores_other_fills() {
|
||||
assert_eq!(tile_viewport(BgFill::Cover, (1180, 760)), (0, 0));
|
||||
assert_eq!(tile_viewport(BgFill::Contain, (1180, 760)), (0, 0));
|
||||
let (width, height) = tile_viewport(BgFill::Tile, (1180, 760));
|
||||
assert_eq!(width % TILE_REGENERATE_STEP, 0);
|
||||
assert_eq!(height % TILE_REGENERATE_STEP, 0);
|
||||
assert_eq!((width, height), (1152, 768));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_key_compares_by_value() {
|
||||
let key = BackgroundKey {
|
||||
path: "a.png".to_string(),
|
||||
fill: BgFill::Tile,
|
||||
viewport: (256, 256),
|
||||
};
|
||||
assert_eq!(key.clone(), key);
|
||||
assert_ne!(
|
||||
key,
|
||||
BackgroundKey {
|
||||
path: "a.png".to_string(),
|
||||
fill: BgFill::Tile,
|
||||
viewport: (128, 256),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Toast 中心:右下角非阻塞提示(下载完成 / 复制成功 / 后台任务结论)。
|
||||
//!
|
||||
//! 与模态对话框队列([`crate::dialogs`])分开:Toast 不阻塞、不排序、可叠加,
|
||||
//! 只做「先进先出 + 上限裁剪 + TTL 到期」。UI 侧由 Rust 定时器每帧调用 [`ToastCenter::expire`]。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Toast 标识。
|
||||
pub type ToastId = u64;
|
||||
|
||||
/// Toast 语义色。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ToastKind {
|
||||
/// 一般信息
|
||||
Info,
|
||||
/// 成功
|
||||
Success,
|
||||
/// 警告
|
||||
Warn,
|
||||
/// 错误
|
||||
Error,
|
||||
}
|
||||
|
||||
/// 一条 Toast;`remaining_ms` 由 [`ToastCenter::expire`] 递减。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Toast {
|
||||
/// 队列内唯一 id(由 [`ToastCenter::push_request`] 重新编号)
|
||||
pub id: ToastId,
|
||||
/// 语义色
|
||||
pub kind: ToastKind,
|
||||
/// 提示文案
|
||||
pub text: String,
|
||||
/// 剩余存活毫秒(0 = 用默认 TTL,入队时补)
|
||||
pub remaining_ms: u64,
|
||||
}
|
||||
|
||||
/// Toast 中心:先进先出,超出上限丢最旧的。
|
||||
#[derive(Debug)]
|
||||
pub struct ToastCenter {
|
||||
items: Vec<Toast>,
|
||||
next_id: ToastId,
|
||||
ttl_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for ToastCenter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ToastCenter {
|
||||
/// 默认 4s 后自动消失。
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
items: Vec::new(),
|
||||
next_id: 0,
|
||||
ttl_ms: 4_000,
|
||||
}
|
||||
}
|
||||
|
||||
/// 同时最多显示的条数。
|
||||
pub const MAX_VISIBLE: usize = 4;
|
||||
|
||||
/// 入队一条 Toast。
|
||||
pub fn push(&mut self, kind: ToastKind, text: impl Into<String>) -> ToastId {
|
||||
self.push_request(Toast {
|
||||
id: 0,
|
||||
kind,
|
||||
text: text.into(),
|
||||
remaining_ms: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// 入队一条已组装好的 Toast(事件通道用);id 重新编号,`remaining_ms == 0` 视为用默认 TTL。
|
||||
pub fn push_request(&mut self, mut toast: Toast) -> ToastId {
|
||||
self.next_id += 1;
|
||||
toast.id = self.next_id;
|
||||
if toast.remaining_ms == 0 {
|
||||
toast.remaining_ms = self.ttl_ms;
|
||||
}
|
||||
let id = toast.id;
|
||||
self.items.push(toast);
|
||||
while self.items.len() > Self::MAX_VISIBLE {
|
||||
self.items.remove(0);
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
/// 当前显示列表(UI 模型来源)。
|
||||
pub fn items(&self) -> &[Toast] {
|
||||
&self.items
|
||||
}
|
||||
|
||||
/// 手动关闭(点了关闭按钮)。
|
||||
pub fn dismiss(&mut self, id: ToastId) -> bool {
|
||||
if let Some(at) = self.items.iter().position(|toast| toast.id == id) {
|
||||
self.items.remove(at);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// 时间推进:移除到期 Toast。
|
||||
pub fn expire(&mut self, elapsed_ms: u64) {
|
||||
for toast in &mut self.items {
|
||||
toast.remaining_ms = toast.remaining_ms.saturating_sub(elapsed_ms);
|
||||
}
|
||||
self.items.retain(|toast| toast.remaining_ms > 0);
|
||||
}
|
||||
|
||||
/// 当前条数。
|
||||
pub fn len(&self) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
|
||||
/// 是否没有 Toast。
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ToastCenter, ToastKind};
|
||||
|
||||
#[test]
|
||||
fn toasts_expire_and_cap_the_stack() {
|
||||
let mut center = ToastCenter::new();
|
||||
center.push(ToastKind::Info, "a");
|
||||
center.push(ToastKind::Info, "b");
|
||||
assert_eq!(center.len(), 2);
|
||||
|
||||
for i in 0..10 {
|
||||
center.push(ToastKind::Success, format!("t{i}"));
|
||||
}
|
||||
assert_eq!(center.len(), ToastCenter::MAX_VISIBLE);
|
||||
|
||||
center.expire(3_000);
|
||||
assert_eq!(center.len(), ToastCenter::MAX_VISIBLE);
|
||||
center.expire(1_001);
|
||||
assert!(center.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dismiss_removes_one_toast() {
|
||||
let mut center = ToastCenter::new();
|
||||
let first = center.push(ToastKind::Info, "a");
|
||||
center.push(ToastKind::Warn, "b");
|
||||
assert!(center.dismiss(first));
|
||||
assert!(!center.dismiss(first));
|
||||
assert_eq!(center.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
//! 新建实例向导(09 §G5)的纯状态机:版本 → 加载器 → 实例名 → 确认 → 进度。
|
||||
//!
|
||||
//! 放纯 Rust 而不是 Slint 里:步骤推进、名字合法性、加载器约束都能单测,
|
||||
//! Slint 只负责画。提交时由 host 转成 [`crate::dto::CreateRequest`] 走安装管线。
|
||||
|
||||
use crate::dto::LoaderKind;
|
||||
|
||||
/// 向导步骤。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum WizardStep {
|
||||
/// 选版本
|
||||
#[default]
|
||||
Version,
|
||||
/// 选加载器
|
||||
Loader,
|
||||
/// 起名
|
||||
Name,
|
||||
/// 确认(显示将下载的项)
|
||||
Confirm,
|
||||
/// 进度(提交后由安装进度接管)
|
||||
Progress,
|
||||
}
|
||||
|
||||
impl WizardStep {
|
||||
/// 步骤序号(0 起,用于「第 N 步」文案)。
|
||||
pub fn index(self) -> usize {
|
||||
match self {
|
||||
Self::Version => 0,
|
||||
Self::Loader => 1,
|
||||
Self::Name => 2,
|
||||
Self::Confirm => 3,
|
||||
Self::Progress => 4,
|
||||
}
|
||||
}
|
||||
|
||||
/// 步骤标题。
|
||||
pub fn title(self) -> &'static str {
|
||||
match self {
|
||||
Self::Version => "选择版本",
|
||||
Self::Loader => "选择加载器",
|
||||
Self::Name => "实例名",
|
||||
Self::Confirm => "确认",
|
||||
Self::Progress => "安装中",
|
||||
}
|
||||
}
|
||||
|
||||
/// 下一步(进度是终点)。
|
||||
pub fn next(self) -> Self {
|
||||
match self {
|
||||
Self::Version | Self::Progress => {
|
||||
if self == Self::Progress {
|
||||
Self::Progress
|
||||
} else {
|
||||
Self::Loader
|
||||
}
|
||||
}
|
||||
Self::Loader => Self::Name,
|
||||
Self::Name => Self::Confirm,
|
||||
Self::Confirm => Self::Progress,
|
||||
}
|
||||
}
|
||||
|
||||
/// 上一步(版本是起点)。
|
||||
pub fn back(self) -> Self {
|
||||
match self {
|
||||
Self::Version | Self::Progress => Self::Version,
|
||||
Self::Loader => Self::Version,
|
||||
Self::Name => Self::Loader,
|
||||
Self::Confirm => Self::Name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 向导状态。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WizardState {
|
||||
/// 是否打开(全屏模态)
|
||||
pub open: bool,
|
||||
/// 当前步骤
|
||||
pub step: WizardStep,
|
||||
/// 选中的版本 id
|
||||
pub version: String,
|
||||
/// 选中的加载器
|
||||
pub loader: LoaderKind,
|
||||
/// 加载器版本(空 = 最新)
|
||||
pub loader_version: String,
|
||||
/// 实例名草稿
|
||||
pub name: String,
|
||||
/// 校验错误(显示在向导内,不弹 Toast)
|
||||
pub error: String,
|
||||
/// 已提交(防重复点)
|
||||
pub submitting: bool,
|
||||
}
|
||||
|
||||
impl Default for WizardState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl WizardState {
|
||||
/// 关闭态。
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
open: false,
|
||||
step: WizardStep::Version,
|
||||
version: String::new(),
|
||||
loader: LoaderKind::Vanilla,
|
||||
loader_version: String::new(),
|
||||
name: String::new(),
|
||||
error: String::new(),
|
||||
submitting: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 打开向导并复位。
|
||||
pub fn open() -> Self {
|
||||
Self {
|
||||
open: true,
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 选了版本:自动把实例名填成版本号(用户不改就用它)。
|
||||
pub fn select_version(&mut self, version: &str) {
|
||||
self.version = version.to_string();
|
||||
if self.name.trim().is_empty() {
|
||||
self.name = version.to_string();
|
||||
}
|
||||
self.error.clear();
|
||||
}
|
||||
|
||||
/// 选加载器(原版会清掉加载器版本)。
|
||||
pub fn select_loader(&mut self, loader: LoaderKind) {
|
||||
self.loader = loader;
|
||||
if loader == LoaderKind::Vanilla {
|
||||
self.loader_version.clear();
|
||||
}
|
||||
self.error.clear();
|
||||
}
|
||||
|
||||
/// 推进到下一步;校验不通过时留在原地并写 `error`。
|
||||
pub fn advance(&mut self) -> bool {
|
||||
match self.step {
|
||||
WizardStep::Version => {
|
||||
if self.version.trim().is_empty() {
|
||||
self.error = "请先选择一个版本".to_string();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
WizardStep::Name => {
|
||||
if let Err(error) = validate_name(&self.name) {
|
||||
self.error = error;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
WizardStep::Loader | WizardStep::Confirm | WizardStep::Progress => {}
|
||||
}
|
||||
self.error.clear();
|
||||
self.step = self.step.next();
|
||||
true
|
||||
}
|
||||
|
||||
/// 回退一步。
|
||||
pub fn step_back(&mut self) {
|
||||
self.error.clear();
|
||||
self.step = self.step.back();
|
||||
}
|
||||
|
||||
/// 提交前的最后校验(Confirm 步)。
|
||||
pub fn ready_to_submit(&self) -> Result<(), String> {
|
||||
if self.version.trim().is_empty() {
|
||||
return Err("版本为空".to_string());
|
||||
}
|
||||
validate_name(&self.name)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 提交成功后进入进度步(关掉可编辑态)。
|
||||
pub fn submitted(&mut self) {
|
||||
self.submitting = true;
|
||||
self.step = WizardStep::Progress;
|
||||
self.error.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// 实例名合法性(不能空、不能含路径分隔符/控制字符、长度上限 64)。
|
||||
pub fn validate_name(name: &str) -> Result<(), String> {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("实例名不能为空".to_string());
|
||||
}
|
||||
if trimmed.chars().count() > 64 {
|
||||
return Err("实例名最多 64 个字符".to_string());
|
||||
}
|
||||
if trimmed.contains(['/', '\\', ':', '*', '?', '"', '<', '>', '|']) {
|
||||
return Err("实例名不能包含 / \\ : * ? \" < > |".to_string());
|
||||
}
|
||||
if trimmed.chars().any(|ch| ch.is_control()) {
|
||||
return Err("实例名不能包含控制字符".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn steps_walk_forward_and_back() {
|
||||
assert_eq!(WizardStep::Version.next(), WizardStep::Loader);
|
||||
assert_eq!(WizardStep::Loader.next(), WizardStep::Name);
|
||||
assert_eq!(WizardStep::Name.next(), WizardStep::Confirm);
|
||||
assert_eq!(WizardStep::Confirm.next(), WizardStep::Progress);
|
||||
assert_eq!(WizardStep::Progress.next(), WizardStep::Progress);
|
||||
assert_eq!(WizardStep::Confirm.back(), WizardStep::Name);
|
||||
assert_eq!(WizardStep::Version.back(), WizardStep::Version);
|
||||
assert_eq!(WizardStep::Progress.index(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_selection_fills_name_but_does_not_overwrite() {
|
||||
let mut wizard = WizardState::open();
|
||||
assert!(wizard.open);
|
||||
wizard.select_version("1.20.4");
|
||||
assert_eq!(wizard.name, "1.20.4");
|
||||
wizard.name = "生存".to_string();
|
||||
wizard.select_version("1.21");
|
||||
assert_eq!(wizard.name, "生存", "用户改过的名字不该被覆盖");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_requires_a_version_and_a_valid_name() {
|
||||
let mut wizard = WizardState::open();
|
||||
assert!(!wizard.advance(), "没有版本不能进下一步");
|
||||
assert_eq!(wizard.error, "请先选择一个版本");
|
||||
|
||||
wizard.select_version("1.20.4");
|
||||
assert!(wizard.advance());
|
||||
assert_eq!(wizard.step, WizardStep::Loader);
|
||||
assert!(wizard.advance());
|
||||
assert_eq!(wizard.step, WizardStep::Name);
|
||||
|
||||
wizard.name = "a/b".to_string();
|
||||
assert!(!wizard.advance(), "非法名字不能确认");
|
||||
assert!(wizard.error.contains("不能包含"));
|
||||
|
||||
wizard.name = "生存".to_string();
|
||||
assert!(wizard.advance());
|
||||
assert_eq!(wizard.step, WizardStep::Confirm);
|
||||
assert!(wizard.ready_to_submit().is_ok());
|
||||
wizard.submitted();
|
||||
assert_eq!(wizard.step, WizardStep::Progress);
|
||||
assert!(wizard.submitting);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loader_selection_clears_loader_version_for_vanilla() {
|
||||
let mut wizard = WizardState::open();
|
||||
wizard.loader_version = "0.16.5".to_string();
|
||||
wizard.select_loader(LoaderKind::Fabric);
|
||||
assert_eq!(wizard.loader_version, "0.16.5");
|
||||
wizard.select_loader(LoaderKind::Vanilla);
|
||||
assert_eq!(wizard.loader, LoaderKind::Vanilla);
|
||||
assert!(wizard.loader_version.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_validation_rejects_long_and_control_input() {
|
||||
assert!(validate_name("生存").is_ok());
|
||||
assert!(validate_name("").is_err());
|
||||
assert!(validate_name(&"x".repeat(65)).is_err());
|
||||
assert!(validate_name("a\u{7}b").is_err());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user