Unlucky Launcher v0.2.0
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
首个 iced + Material 3 Expressive 版本的源码快照。 完整历史与日常提交在 dev 分支;main 只放阶段性快照。
This commit is contained in:
commit
8a8e5ca0c0
1079 files changed
+208035
No files matched your search
@@ -0,0 +1,395 @@
|
||||
//! 账户页 DTO:三条轨道的统一抽象 + 微软登录(设备码 / 授权码浏览器)的可见状态。
|
||||
//!
|
||||
//! **凭据红线**:本模块(以及整个 `crate::dto`)只有 [`AccountView`] 这种公开字段视图。
|
||||
//! `core::auth` 的 `Credentials` / `MicrosoftSession` / token 永远不进 app 层——
|
||||
//! host 侧在拿到账户快照的那一刻就丢掉凭据,只留「能显示什么」。
|
||||
//!
|
||||
//! | 文件 | 职责 |
|
||||
//! |---|---|
|
||||
//! | 本文件 | 账户/登录**可见状态**(全部是可上屏字段) |
|
||||
//! | [`redact`] | 形状脱敏:JWT / 不透明串 / `token=...` 一律抹掉(第二道脱敏器) |
|
||||
//! | `tests` | 契约 round-trip + 凭据红线的入口断言 |
|
||||
|
||||
mod failure;
|
||||
mod kind;
|
||||
mod name;
|
||||
mod redact;
|
||||
#[cfg(test)]
|
||||
#[path = "accounts/tests.rs"]
|
||||
mod tests;
|
||||
|
||||
pub use failure::{AccountNotice, AccountNoticeCode, LoginFailure};
|
||||
pub use kind::AccountKind;
|
||||
pub use name::valid_offline_name;
|
||||
pub use redact::{looks_like_jwt, redact, REDACTED};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 账户列表行(**只含可公开字段**)。
|
||||
#[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`)
|
||||
///
|
||||
/// 口径:core 的 `AccountMeta` 只有 `created_at_ms`,**没有 last-used**,所以列表上贴的是
|
||||
/// 「添加于 {created}」,不伪造「最近使用」(缺口登记在 M114 报告)。
|
||||
pub created: String,
|
||||
/// 是否是当前启动使用的账户
|
||||
pub active: bool,
|
||||
/// 第三方轨的认证服务器 API 根(其余轨为空串)
|
||||
#[serde(default)]
|
||||
pub server_url: String,
|
||||
/// 皮肤原图(第三方轨才可能有;`None` = 没有/没取到,UI 退回首字母块)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub skin: Option<SkinImage>,
|
||||
}
|
||||
|
||||
impl AccountView {
|
||||
/// 首字母块上的字:展示名第一个非空字符(大写化),空名字回落 `?`。
|
||||
///
|
||||
/// 放在 DTO 而不是页面里,是为了「没有皮肤时画什么」这件事只有一处判据,两个 UI 版一致。
|
||||
#[must_use]
|
||||
pub fn monogram(&self) -> String {
|
||||
self.name
|
||||
.chars()
|
||||
.find(|c| !c.is_whitespace())
|
||||
.map(|c| c.to_uppercase().to_string())
|
||||
.unwrap_or_else(|| "?".to_string())
|
||||
}
|
||||
|
||||
/// 首字母块的取色索引(0..6):由 `profile_id` 稳定推导,同一个人每次都是同一个色块。
|
||||
#[must_use]
|
||||
pub fn monogram_tone(&self) -> u8 {
|
||||
let mut hash: u32 = 2_166_136_261;
|
||||
for byte in self.profile_id.as_bytes() {
|
||||
hash ^= u32::from(*byte);
|
||||
hash = hash.wrapping_mul(16_777_619);
|
||||
}
|
||||
u8::try_from(hash % 6).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一张皮肤原图。
|
||||
///
|
||||
/// **停在「原图」这一层是刻意的**:presenter 不引入图片解码依赖(`presenter-no-ui` +
|
||||
/// 依赖极简),裁剪/缩放/取首字母块都由 UI 层自己做。离线轨永远没有它——没有皮肤就不给假皮肤。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SkinImage {
|
||||
/// PNG 字节(Minecraft 皮肤:64×64 或旧版 64×32)
|
||||
pub png: Vec<u8>,
|
||||
/// 来源 URL(**只进取证日志,不上屏**)
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
impl SkinImage {
|
||||
/// 是否有可用字节(0 字节等同没有皮肤)。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.png.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 微软登录阶段(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,
|
||||
/// 失败原因码(`None` = 不是失败态 / 未分类)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub failure: Option<LoginFailure>,
|
||||
/// 成功后入库的账户展示名
|
||||
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(),
|
||||
failure: None,
|
||||
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
|
||||
),
|
||||
failure: None,
|
||||
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!("({})", redact(warning)));
|
||||
}
|
||||
Self {
|
||||
phase: DeviceLoginPhase::Waiting,
|
||||
user_code: String::new(),
|
||||
verification_uri: authorization_url.to_string(),
|
||||
qr_link: authorization_url.to_string(),
|
||||
expires_in,
|
||||
message,
|
||||
failure: None,
|
||||
account_name: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 失败态(文案在入口就脱敏:错误原文里可能带着服务端回显的 token)。
|
||||
pub fn failed(message: impl Into<String>) -> Self {
|
||||
Self::failed_as(LoginFailure::Other, message)
|
||||
}
|
||||
|
||||
/// 带**原因码**的失败态(页面据码出 i18n;原文只进日志)。
|
||||
pub fn failed_as(failure: LoginFailure, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
phase: DeviceLoginPhase::Failed,
|
||||
message: redact(&message.into()),
|
||||
failure: Some(failure),
|
||||
..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}"),
|
||||
failure: None,
|
||||
account_name,
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 第三方轨(authlib-injector)登录请求。
|
||||
///
|
||||
/// **凭据红线**:`password` 只是「用户这一次输入的请求值」,从页面流到 host 的
|
||||
/// `AuthlibAuth::authenticate` 调用栈里,host 取走即弃——不入账户库、不进日志、
|
||||
/// 不进任何回程 DTO。所以本类型只有一个方向(UI → host),回程只有 [`AuthlibLoginView`]。
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AuthlibRequest {
|
||||
/// 认证服务器地址(用户原样输入;host 侧 `normalize_api_root` 规范化)
|
||||
pub server: String,
|
||||
/// 登录名(邮箱或用户名)
|
||||
pub login: String,
|
||||
/// 密码(只在本调用栈里出现)
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// **手写 `Debug`**:任何 `{:?}`(日志/panic 消息/测试失败输出)都不得带出明文密码。
|
||||
impl std::fmt::Debug for AuthlibRequest {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AuthlibRequest")
|
||||
.field("server", &redact(&self.server))
|
||||
.field("login", &redact(&self.login))
|
||||
.field("password", &REDACTED)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthlibRequest {
|
||||
/// 构造。
|
||||
pub fn new(
|
||||
server: impl Into<String>,
|
||||
login: impl Into<String>,
|
||||
password: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
server: server.into(),
|
||||
login: login.into(),
|
||||
password: password.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 可打印形态(日志/诊断/错误路径用):**密码恒被抹掉**。
|
||||
#[must_use]
|
||||
pub fn redacted(&self) -> String {
|
||||
format!(
|
||||
"server={} login={} password={REDACTED}",
|
||||
redact(&self.server),
|
||||
redact(&self.login)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 第三方轨登录的可见状态(阶段复用 [`DeviceLoginPhase`]:都是「申请 → 等待 → 成/败」)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AuthlibLoginView {
|
||||
/// 阶段
|
||||
pub phase: DeviceLoginPhase,
|
||||
/// 认证服务器地址(规范化后的 API 根)
|
||||
pub server: String,
|
||||
/// 服务发现里的服务器名(校验通过后才有;空 = 没校验/没名字)
|
||||
pub server_name: String,
|
||||
/// 状态/错误文案(**失败时是原始诊断,只进日志,不上屏**)
|
||||
pub message: String,
|
||||
/// 失败原因码(`None` = 不是失败态 / 未分类)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub failure: Option<LoginFailure>,
|
||||
/// 成功后入库的账户展示名
|
||||
pub account_name: String,
|
||||
}
|
||||
|
||||
impl Default for AuthlibLoginView {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthlibLoginView {
|
||||
/// 空闲态。
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
phase: DeviceLoginPhase::Idle,
|
||||
server: String::new(),
|
||||
server_name: String::new(),
|
||||
message: String::new(),
|
||||
failure: None,
|
||||
account_name: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 进行中(校验服务器地址 / 换 token)。
|
||||
pub fn running(server: &str, message: &str) -> Self {
|
||||
Self {
|
||||
phase: DeviceLoginPhase::Requesting,
|
||||
server: server.to_string(),
|
||||
server_name: String::new(),
|
||||
message: redact(message),
|
||||
failure: None,
|
||||
account_name: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 地址校验通过(服务发现拿到了服务器名)。
|
||||
pub fn reachable(server: &str, server_name: &str) -> Self {
|
||||
Self {
|
||||
phase: DeviceLoginPhase::Waiting,
|
||||
server: server.to_string(),
|
||||
server_name: redact(server_name),
|
||||
message: format!("服务器可用:{}", server_name.trim()),
|
||||
failure: None,
|
||||
account_name: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 失败态(文案在入口就脱敏)。
|
||||
pub fn failed(message: impl Into<String>) -> Self {
|
||||
Self::failed_as(LoginFailure::Other, message)
|
||||
}
|
||||
|
||||
/// 带**原因码**的失败态(页面据码出 i18n;原文只进日志)。
|
||||
pub fn failed_as(failure: LoginFailure, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
phase: DeviceLoginPhase::Failed,
|
||||
message: redact(&message.into()),
|
||||
failure: Some(failure),
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 成功态。
|
||||
pub fn succeeded(server_name: &str, account_name: impl Into<String>) -> Self {
|
||||
let account_name = account_name.into();
|
||||
Self {
|
||||
phase: DeviceLoginPhase::Succeeded,
|
||||
server_name: server_name.to_string(),
|
||||
message: format!("已登录:{account_name}"),
|
||||
account_name,
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! 失败原因码与账户页横幅(M114 r2 / 复审 P1-1、P2-2)。
|
||||
//!
|
||||
//! ## 为什么这一层必须有
|
||||
//!
|
||||
//! core/presenter 的诊断串是中文人话。把它们直接画到英文界面上,就是复审 r1 那条 p1
|
||||
//! (「全英文界面里整句中文」)。根因不是 catalog 缺条目,而是**后端诊断串没有结构**:
|
||||
//! 页面除了原样画没有别的选择。
|
||||
//!
|
||||
//! 这里补的就是那个缺环——把「失败属于哪一类」「这次操作的结果是什么」变成**枚举**。
|
||||
//! 页面据 `code()` 选 i18n key 出标题 + 正文 + 一行 `Code: …`;原始诊断留在 `message` /
|
||||
//! `detail` 里,**只进日志与详细信息,不上屏**。
|
||||
//!
|
||||
//! 约束:`code()` 一律 ascii 且**不随语言变**(日志、测试、用户报障都靠它对齐)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::redact;
|
||||
|
||||
/// 登录失败的**结构化原因码**(M114 r2 / 复审 P1-1)。
|
||||
///
|
||||
/// 为什么要有它:core/presenter 的诊断串是中文人话,**直接上屏**会让英文界面里出现整句中文
|
||||
/// (复审 r1 的 p1)。这里把「失败属于哪一类」变成**可判据的枚举**,页面据码选 i18n key 出
|
||||
/// 标题 + 正文 + 一个稳定 code;原始诊断只留在 `message` 里进日志/详细信息,**不上屏**。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LoginFailure {
|
||||
/// 服务器/微软明确拒绝(403、OAuth `error`、XErr)
|
||||
Denied,
|
||||
/// 应用注册还没获批(AADSTS7000218 一类「白名单未批」)
|
||||
NotWhitelisted,
|
||||
/// 网络不可达 / 超时
|
||||
Unreachable,
|
||||
/// 目标地址不是 authlib-injector 服务器(响应不是服务发现 JSON)
|
||||
NotAuthlibServer,
|
||||
/// 服务器拒绝了这个账号(密码错 / 账号不存在)
|
||||
BadCredentials,
|
||||
/// 服务器没有返回角色(账号还没创建角色)
|
||||
NoProfile,
|
||||
/// 落盘失败(账户库读写)
|
||||
StoreFailed,
|
||||
/// 其它(未知原因;原文只在 `message`)
|
||||
Other,
|
||||
}
|
||||
|
||||
impl LoginFailure {
|
||||
/// 稳定的 ascii code(日志 / 页面「Code: …」/ 测试断言都用它,**不随语言变**)。
|
||||
#[must_use]
|
||||
pub const fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::Denied => "denied",
|
||||
Self::NotWhitelisted => "whitelist_pending",
|
||||
Self::Unreachable => "unreachable",
|
||||
Self::NotAuthlibServer => "not_authlib_server",
|
||||
Self::BadCredentials => "bad_credentials",
|
||||
Self::NoProfile => "no_profile",
|
||||
Self::StoreFailed => "store_failed",
|
||||
Self::Other => "other",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 账户页的**一次性横幅**(添加/删除/切换的结果;页面据 `code` 选 i18n)。
|
||||
///
|
||||
/// 为什么不是 toast:app-iced 不渲染 toast(复审 r1 P2-2 实锤),发 toast 等于用户什么都看不到。
|
||||
/// 横幅是页面上的真实控件,用户点了「Create profile」失败后一定看得见。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AccountNotice {
|
||||
/// 结果码(决定标题/正文/颜色)
|
||||
pub code: AccountNoticeCode,
|
||||
/// 涉及的账户名或服务器(拼进文案;可为空)
|
||||
pub subject: String,
|
||||
/// 原始诊断(已脱敏;**只进日志/详细信息,不上屏**)
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl AccountNotice {
|
||||
/// 构造。
|
||||
pub fn new(
|
||||
code: AccountNoticeCode,
|
||||
subject: impl Into<String>,
|
||||
detail: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
code,
|
||||
subject: subject.into(),
|
||||
detail: redact(&detail.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 横幅的结果码。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AccountNoticeCode {
|
||||
/// 已添加
|
||||
Added,
|
||||
/// 已删除
|
||||
Removed,
|
||||
/// 已切换
|
||||
Switched,
|
||||
/// 名字不合法(长度/字符集)
|
||||
OfflineNameInvalid,
|
||||
/// 同名账户已存在
|
||||
AccountExists,
|
||||
/// 落盘失败
|
||||
StoreFailed,
|
||||
/// 找不到这个账户
|
||||
NotFound,
|
||||
/// 其它(原文在 `detail`)
|
||||
Other,
|
||||
}
|
||||
|
||||
impl AccountNoticeCode {
|
||||
/// 是否是一个「失败」结果(决定横幅配色)。
|
||||
#[must_use]
|
||||
pub const fn is_failure(self) -> bool {
|
||||
!matches!(self, Self::Added | Self::Removed | Self::Switched)
|
||||
}
|
||||
|
||||
/// 稳定 ascii code(日志/测试用)。
|
||||
#[must_use]
|
||||
pub const fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::Added => "added",
|
||||
Self::Removed => "removed",
|
||||
Self::Switched => "switched",
|
||||
Self::OfflineNameInvalid => "offline_name_invalid",
|
||||
Self::AccountExists => "account_exists",
|
||||
Self::StoreFailed => "store_failed",
|
||||
Self::NotFound => "not_found",
|
||||
Self::Other => "other",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! 账户轨道(三轨统一抽象,`09 §G6` / `D9`)。
|
||||
|
||||
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 {
|
||||
/// 徽标文字(CLI/终端用;GUI 走 i18n 词表,不读这里的中文)。
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//! 离线档案的名字契约(**UI 与 host 共用的同一个判据**)。
|
||||
//!
|
||||
//! 放在 `dto` 而不是 host 里:UI 用它决定「创建档案」按钮亮不亮,host 用它做写盘前校验。
|
||||
//! 两处各写一遍,迟早会漂成「按钮亮着但必然失败」——那正是复审 r1 P2-2 的一半。
|
||||
//! core 侧还有一道同名规则做兜底(`auth::offline::offline_meta`),这里只管界面这条。
|
||||
|
||||
/// 3–16 位 ascii 字母 / 数字 / 下划线。
|
||||
#[must_use]
|
||||
pub fn valid_offline_name(name: &str) -> bool {
|
||||
let len = name.chars().count();
|
||||
(3..=16).contains(&len) && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_rule_is_three_to_sixteen_ascii_word_chars() {
|
||||
assert!(valid_offline_name("Steve"));
|
||||
assert!(valid_offline_name("a_1"));
|
||||
assert!(valid_offline_name("abcdefghijklmnop"), "16 位是上限");
|
||||
assert!(!valid_offline_name("ab"), "太短");
|
||||
assert!(!valid_offline_name("abcdefghijklmnopq"), "太长");
|
||||
assert!(!valid_offline_name("has space"));
|
||||
assert!(!valid_offline_name("中文名字"), "非 ascii");
|
||||
assert!(!valid_offline_name(""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! 凭据红线的**形状脱敏**:把 JWT / 不透明 token / `token=...` 这类串从任何要上屏的文案里抹掉。
|
||||
//!
|
||||
//! ## 为什么还要第二道脱敏器
|
||||
//!
|
||||
//! `core::launch::redact` 是**按已知明文值**替换的(拿着会话里的 token 原串去抹)。这里要挡的是
|
||||
//! **没登记过的**字符串:服务端在错误响应里回显一个 token、HTTP 库把 header 拼进错误文案、
|
||||
//! 某段调试串被画到面板上。两者互补,缺一不可:
|
||||
//!
|
||||
//! | 场景 | `core::launch::redact` | 本模块 |
|
||||
//! |---|---|---|
|
||||
//! | 已知会话 token 出现在日志里 | ✅ | ❌(不知道明文) |
|
||||
//! | 服务端回包里的陌生 JWT 被画到错误面板 | ❌ | ✅ |
|
||||
//!
|
||||
//! 走形状识别(三段 base64url / 超长不透明串 / 敏感键名)就够拦住「一眼能看出是凭据」的串,
|
||||
//! 而普通文案(URL、用户码 `ABCD-EFGH`、服务器名)**一个字都不动**——脱敏器本身不能变成噪音源。
|
||||
|
||||
/// 脱敏占位符(**故意刺眼**:宁可难看,也不许静默把凭据画到屏幕上)。
|
||||
pub const REDACTED: &str = "⟪redacted⟫";
|
||||
|
||||
/// 把一段要上屏的文案里的凭据形状串抹掉(空白与其余字符**原样保留**)。
|
||||
#[must_use]
|
||||
pub fn redact(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
for piece in text.split_inclusive(char::is_whitespace) {
|
||||
let split = piece.find(char::is_whitespace).unwrap_or(piece.len());
|
||||
let (chunk, space) = piece.split_at(split);
|
||||
out.push_str(&redact_chunk(chunk));
|
||||
out.push_str(space);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 单个「词」的脱敏:① 敏感键名的值 ② 整个词本身就是凭据形状。
|
||||
fn redact_chunk(chunk: &str) -> String {
|
||||
if let Some((key, _value)) = chunk.split_once('=') {
|
||||
if is_sensitive_key(key) || looks_opaque(chunk) {
|
||||
return format!("{key}={REDACTED}");
|
||||
}
|
||||
}
|
||||
if looks_opaque(chunk) {
|
||||
return REDACTED.to_string();
|
||||
}
|
||||
chunk.to_string()
|
||||
}
|
||||
|
||||
/// 键名是否属于「它的值一定是凭据」(`accessToken` / `refresh_token` / `password` …)。
|
||||
fn is_sensitive_key(key: &str) -> bool {
|
||||
let key = key
|
||||
.trim_matches(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
|
||||
.to_lowercase();
|
||||
[
|
||||
"token",
|
||||
"secret",
|
||||
"password",
|
||||
"passwd",
|
||||
"authorization",
|
||||
"bearer",
|
||||
"session",
|
||||
]
|
||||
.iter()
|
||||
.any(|needle| key.contains(needle))
|
||||
}
|
||||
|
||||
/// 词的形状是否像凭据:① 三段 base64url(JWT)② 长度 ≥40 的不透明串。
|
||||
fn looks_opaque(chunk: &str) -> bool {
|
||||
let trimmed = chunk.trim_matches(|c: char| {
|
||||
!c.is_ascii_alphanumeric() && c != '_' && c != '-' && c != '.' && c != '+'
|
||||
});
|
||||
looks_like_jwt(trimmed) || (trimmed.len() >= 40 && trimmed.chars().all(is_base64url))
|
||||
}
|
||||
|
||||
/// 是否是 JWT 形状:`header.payload.signature`,三段都非空且够长、全是 base64url 字符。
|
||||
///
|
||||
/// 公开是为了单测能直接钉这条判据(UI 侧「注入 JWT 必须被抹掉」的测试也用它)。
|
||||
#[must_use]
|
||||
pub fn looks_like_jwt(token: &str) -> bool {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
parts.len() == 3
|
||||
&& parts
|
||||
.iter()
|
||||
.all(|part| part.len() >= 8 && part.chars().all(is_base64url))
|
||||
}
|
||||
|
||||
/// base64url 字母表(含 `=` 填充与 `+`/`/` 的宽容分支)。
|
||||
fn is_base64url(c: char) -> bool {
|
||||
c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '+' | '/' | '=' | '.')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 标准 JWT 形状(2 段 header/payload + 1 段签名)。
|
||||
const JWT: &str =
|
||||
"eyJhbGciOiJSUzI1NiIsIng1dCI6IkFCQyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVl";
|
||||
|
||||
#[test]
|
||||
fn the_jwt_shape_judgement_is_not_a_length_heuristic() {
|
||||
assert!(looks_like_jwt(JWT));
|
||||
assert!(!looks_like_jwt("not.a.jwt"), "短段不算 JWT");
|
||||
assert!(!looks_like_jwt("a.b.c.d"), "四段不算 JWT");
|
||||
assert!(!looks_like_jwt("ABCD-EFGH"), "设备码不是 JWT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_jwts_and_sensitive_pairs_are_redacted() {
|
||||
assert_eq!(redact(JWT), REDACTED);
|
||||
assert_eq!(
|
||||
redact(&format!("Bearer {JWT}")),
|
||||
format!("Bearer {REDACTED}")
|
||||
);
|
||||
assert_eq!(
|
||||
redact(&format!("accessToken={JWT}")),
|
||||
format!("accessToken={REDACTED}")
|
||||
);
|
||||
// 敏感键名 + 短值(真实 refresh token 常常很短,靠键名兜)
|
||||
assert_eq!(redact("password=hunter2"), format!("password={REDACTED}"));
|
||||
assert_eq!(
|
||||
redact("refresh_token=abc"),
|
||||
format!("refresh_token={REDACTED}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_copy_is_left_byte_for_byte() {
|
||||
for text in [
|
||||
"在浏览器打开 https://www.microsoft.com/link 并输入 ABCD-EFGH",
|
||||
"服务器不可达:dns error",
|
||||
"HTTP 403 Forbidden — 该服务器要求白名单",
|
||||
"",
|
||||
] {
|
||||
assert_eq!(redact(text), text, "普通文案被改了:{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_and_line_breaks_survive() {
|
||||
let text = format!("第一行 token={JWT}\n\n第二行 缩进");
|
||||
let out = redact(&text);
|
||||
assert!(out.contains("\n\n"), "{out}");
|
||||
assert!(out.contains(" 缩进"), "{out}");
|
||||
assert!(!out.contains(JWT), "{out}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//! 账户 DTO 的契约测试(凭据红线的主要断言都在这里)。
|
||||
|
||||
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, "网络超时");
|
||||
}
|
||||
|
||||
/// 凭据红线(审核项):JWT 形状串从**任何**入口进来都必须被抹掉。
|
||||
///
|
||||
/// 抓得住:`redact` 被摘掉、判据被放宽成「长度 ≥40 就行」、或者某个构造器绕过它。
|
||||
/// 抓不住:真实凭据的明文值(那是 `core::launch::redact` 的活儿,按已知值替换)。
|
||||
#[test]
|
||||
fn jwt_shaped_text_is_redacted_at_every_entry_point() {
|
||||
let jwt =
|
||||
"eyJhbGciOiJSUzI1NiIsIng1dCI6IkFCQyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVl";
|
||||
|
||||
let device = DeviceLoginView::failed(format!("登录失败:accessToken={jwt}"));
|
||||
assert!(!device.message.contains(jwt), "{}", device.message);
|
||||
assert!(device.message.contains(REDACTED), "{}", device.message);
|
||||
|
||||
let authlib = AuthlibLoginView::failed(format!("服务器回包异常 {jwt}"));
|
||||
assert!(!authlib.message.contains(jwt), "{}", authlib.message);
|
||||
assert!(authlib.message.contains(REDACTED), "{}", authlib.message);
|
||||
|
||||
let ready = AuthlibLoginView::reachable("https://littleskin.cn/", "LittleSkin");
|
||||
assert!(!ready.message.contains(jwt));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authlib_request_never_prints_the_password() {
|
||||
let request = AuthlibRequest::new(
|
||||
"https://littleskin.cn/api/yggdrasil/",
|
||||
"me@example.com",
|
||||
"hunter2",
|
||||
);
|
||||
let shown = request.redacted();
|
||||
assert!(!shown.contains("hunter2"), "{shown}");
|
||||
assert!(shown.contains("littleskin"), "{shown}");
|
||||
assert!(shown.contains(REDACTED), "{shown}");
|
||||
}
|
||||
|
||||
/// `Debug` 也不许带出密码(复审 r1 P3-5):日志一行 `{:?}` 就破红线。
|
||||
#[test]
|
||||
fn authlib_request_debug_never_prints_the_password() {
|
||||
let request = AuthlibRequest::new("https://littleskin.cn/api/yggdrasil/", "me", "hunter2");
|
||||
let shown = format!("{request:?}");
|
||||
assert!(!shown.contains("hunter2"), "{shown}");
|
||||
assert!(shown.contains(REDACTED), "{shown}");
|
||||
assert!(shown.contains("littleskin"), "{shown}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authlib_view_carries_the_server_name_and_no_tokens() {
|
||||
let view = AuthlibLoginView::reachable("https://littleskin.cn/api/yggdrasil/", "LittleSkin");
|
||||
assert_eq!(view.phase, DeviceLoginPhase::Waiting);
|
||||
assert_eq!(view.server_name, "LittleSkin");
|
||||
assert!(view.message.contains("LittleSkin"));
|
||||
|
||||
let ok = AuthlibLoginView::succeeded("LittleSkin", "Steve");
|
||||
assert_eq!(ok.phase, DeviceLoginPhase::Succeeded);
|
||||
assert_eq!(ok.account_name, "Steve");
|
||||
let json = serde_json::to_string(&ok).expect("序列化");
|
||||
for forbidden in ["accessToken", "clientToken", "refresh", "password"] {
|
||||
assert!(!json.contains(forbidden), "视图里不该出现 {forbidden}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_view_serializes_without_credentials() {
|
||||
let view = AccountView {
|
||||
id: "account:a".to_string(),
|
||||
name: "Steve".to_string(),
|
||||
kind: AccountKind::Authlib,
|
||||
profile_id: "0f8f".to_string(),
|
||||
login_name: "me@example.com".to_string(),
|
||||
created: "2026-10-31 20:12".to_string(),
|
||||
active: true,
|
||||
server_url: "https://littleskin.cn/api/yggdrasil/".to_string(),
|
||||
skin: Some(SkinImage {
|
||||
png: vec![1, 2, 3],
|
||||
source: "textures".to_string(),
|
||||
}),
|
||||
};
|
||||
let json = serde_json::to_string(&view).expect("序列化");
|
||||
for forbidden in ["token", "secret", "password", "refresh"] {
|
||||
assert!(
|
||||
!json.to_lowercase().contains(forbidden),
|
||||
"账户视图里不该出现 {forbidden}"
|
||||
);
|
||||
}
|
||||
assert!(json.contains("\"png\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monogram_is_stable_and_never_empty() {
|
||||
let mut view = AccountView {
|
||||
id: "account:a".to_string(),
|
||||
name: " steve".to_string(),
|
||||
kind: AccountKind::Microsoft,
|
||||
profile_id: "0f8f".to_string(),
|
||||
login_name: String::new(),
|
||||
created: String::new(),
|
||||
active: false,
|
||||
server_url: String::new(),
|
||||
skin: None,
|
||||
};
|
||||
assert_eq!(view.monogram(), "S");
|
||||
let tone = view.monogram_tone();
|
||||
assert!(tone < 6);
|
||||
view.profile_id = "0f8f".to_string();
|
||||
assert_eq!(view.monogram_tone(), tone, "同一个人每次同一个色块");
|
||||
view.name = String::new();
|
||||
assert_eq!(view.monogram(), "?", "空名字也要有字,不能画白块");
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! AI 会话 DTO:消息、工具调用可视化、确认请求与 agent 事件。
|
||||
//!
|
||||
//! 与 agent 侧的对应关系(`crates/agent/src/event.rs` / `confirm.rs`):这里是**扁平镜像**,
|
||||
//! 不 import `unlucky_agent::*`(dto 是 IPC 边界,见 `dto/mod.rs` 的纪律)。字段口径刻意逐字
|
||||
//! 对齐,翻译在接线层做:`AgentEvent::ConfirmRequested` ← `confirm::ConfirmRequest`。
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 系统提示(弱化展示;错误横幅也进会话历史,用户回翻时看得到)。
|
||||
pub fn system(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: ChatRole::System,
|
||||
text: text.into(),
|
||||
tool: None,
|
||||
streaming: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 敏感等级(与 agent 侧 `confirm::Sensitivity` 逐字一致)。
|
||||
///
|
||||
/// | 等级 | 语义 |
|
||||
/// |------|------|
|
||||
/// | `Low` | 静默生效 |
|
||||
/// | `Medium` | 静默生效,审计里能查到 |
|
||||
/// | `High` | 必须用户确认(YOLO 可跳过确认,但**仍受白名单约束**) |
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Sensitivity {
|
||||
/// 静默生效。
|
||||
#[default]
|
||||
Low,
|
||||
/// 静默生效但审计标记。
|
||||
Medium,
|
||||
/// 必须用户确认。
|
||||
High,
|
||||
}
|
||||
|
||||
impl Sensitivity {
|
||||
/// 是否会触发确认弹窗(非 YOLO 时)。
|
||||
#[must_use]
|
||||
pub fn needs_confirm(self) -> bool {
|
||||
self == Self::High
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次待确认的写操作(确认弹窗的原料;与 agent 侧 `confirm::ConfirmRequest` 对齐)。
|
||||
///
|
||||
/// 构造时**不要**放凭据:`details` 会按键名与内容双道脱敏([`crate::agent::rail`]),
|
||||
/// 但白名单之外的键本来就不该出现在这里。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ConfirmRequest {
|
||||
/// 请求 id(编排层生成,UI 回填决定时原样带回)
|
||||
pub id: String,
|
||||
/// 工具名(与 ToolSpec 注册表同名)
|
||||
pub tool: String,
|
||||
/// 一句话请求(「AI 请求把下载并发 64 改为 16」)
|
||||
pub summary: String,
|
||||
/// 结构化细节(`{"path":…, "old":…, "new":…}`),UI 画 diff 用
|
||||
pub details: serde_json::Value,
|
||||
/// 敏感等级
|
||||
pub sensitivity: Sensitivity,
|
||||
}
|
||||
|
||||
/// agent 侧事件。
|
||||
///
|
||||
/// 四类都投到 [`crate::agent::AgentRail`] 上:流式增量 → 最后一条 assistant 消息;工具调用 →
|
||||
/// 会话里可见的 tool 条目(D4);错误 → 系统条目 + 横幅;确认请求 → `rail.confirm`。
|
||||
///
|
||||
/// 注意这里**没有** `Eq`:`ConfirmRequest::details` 是 `serde_json::Value`(可含浮点)。
|
||||
#[derive(Debug, Clone, PartialEq, 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,
|
||||
},
|
||||
/// 循环级错误(provider 断流、AI 被禁用、工具内部异常)。
|
||||
///
|
||||
/// 与 `ToolCallFinished { ok: false }` 的分工:那个是**某一次工具调用**失败,对话继续;
|
||||
/// 这个是**这一轮回复**中止,rail 要收敛掉所有流式标记并立一个错误横幅。
|
||||
Failed {
|
||||
/// 人类可读原因
|
||||
message: String,
|
||||
},
|
||||
/// 需要用户确认(分级确认:写操作先问再改)。
|
||||
ConfirmRequested {
|
||||
/// 待确认请求
|
||||
request: ConfirmRequest,
|
||||
},
|
||||
/// 确认请求已收敛(重放/超时/别处已裁决):把 rail 上同 id 的待确认清掉,避免僵尸弹窗。
|
||||
ConfirmResolved {
|
||||
/// 请求 id
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
//! 内容库 DTO(M109 `CONTENTDL`):Modrinth / CurseForge 上「整合包 / Mod / 资源包 / 光影」的
|
||||
//! **检索**与**装入指定实例**。
|
||||
//!
|
||||
//! ## 为什么这些类型在 presenter 而不是 core
|
||||
//!
|
||||
//! D3 / 01c §5:UI 只经 `crate::dto` 消费后端,跨边界类型必须可序列化。而**检索本身**
|
||||
//! (HTTP + 两个站点的 JSON 方言)今天落在 `crate::host::addon`:`crates/core` 的 `addon` 模块
|
||||
//! 只有加载器安装与整合包**格式**解析,没有 CF / Modrinth 的 API 客户端(M111 才做导入管线)。
|
||||
//! 所以这是「宿主层临时落点」的契约面——长期应下沉到 `core::addon`,届时这里只留薄 DTO。
|
||||
//!
|
||||
//! 两个形状是**故意**的:
|
||||
//!
|
||||
//! - [`ContentHit::game_versions`] / [`ContentHit::loaders`] 来自检索结果的 facets ⇒ 页面在用户
|
||||
//! **点之前**就能判「装不装得上」(任务书第 3 条),不用点完才报错;
|
||||
//! - [`ContentKind::dir`] 的 `None`(整合包)是「按钮禁用」的**唯一**判据,不是一个文案开关。
|
||||
//!
|
||||
//! ## 密钥纪律(任务书第 5 条)
|
||||
//!
|
||||
//! CurseForge 的 `X-API-KEY` 从环境变量 `ULL_CF_API_KEY` 读,**不进仓库、不进 DTO、不进日志**。
|
||||
//! 这里只有 [`ContentResults::curseforge`] 一个 `bool`:源可用与否。
|
||||
//! 于是「密钥泄漏」在类型层就不可能——本 crate 没有任何字段能装下它。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dto::{InstanceId, LoaderKind};
|
||||
|
||||
/// 内容类型(内容库页左侧那四条一级侧栏)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum ContentKind {
|
||||
/// 整合包(Modpack)
|
||||
Modpack,
|
||||
/// Mod
|
||||
Mod,
|
||||
/// 资源包(Resource pack)
|
||||
Resourcepack,
|
||||
/// 光影(Shader pack)
|
||||
Shader,
|
||||
}
|
||||
|
||||
impl ContentKind {
|
||||
/// 四条侧栏的声明顺序(= 画出来的顺序)。
|
||||
pub const ALL: [ContentKind; 4] = [
|
||||
ContentKind::Modpack,
|
||||
ContentKind::Mod,
|
||||
ContentKind::Resourcepack,
|
||||
ContentKind::Shader,
|
||||
];
|
||||
|
||||
/// 稳定 slug(日志 / 文件名 / 命令行参数用,**不要改名**)。
|
||||
#[must_use]
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::Modpack => "modpack",
|
||||
Self::Mod => "mod",
|
||||
Self::Resourcepack => "resourcepack",
|
||||
Self::Shader => "shader",
|
||||
}
|
||||
}
|
||||
|
||||
/// Modrinth 的 `project_type` facet 值。
|
||||
#[must_use]
|
||||
pub const fn modrinth_type(self) -> &'static str {
|
||||
match self {
|
||||
Self::Modpack => "modpack",
|
||||
Self::Mod => "mod",
|
||||
Self::Resourcepack => "resourcepack",
|
||||
Self::Shader => "shader",
|
||||
}
|
||||
}
|
||||
|
||||
/// 装入实例时的落盘目录(相对运行目录);`None` = 不是「一个文件进一个目录」这种语义。
|
||||
///
|
||||
/// 整合包没有目录:它要走 core 的导入管线(解压 + 补全 + 登记实例),那是 M111 的活,
|
||||
/// 所以这里是 `None`——**按钮禁用由这个 `None` 驱动**,不是靠一句文案。
|
||||
#[must_use]
|
||||
pub const fn dir(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Modpack => None,
|
||||
Self::Mod => Some("mods"),
|
||||
Self::Resourcepack => Some("resourcepacks"),
|
||||
Self::Shader => Some("shaderpacks"),
|
||||
}
|
||||
}
|
||||
|
||||
/// CurseForge 的 `classId`(CF API 的类目字段;数值是官方文档里的固定值)。
|
||||
#[must_use]
|
||||
pub const fn curseforge_class(self) -> u32 {
|
||||
match self {
|
||||
Self::Modpack => 4471,
|
||||
Self::Mod => 6,
|
||||
Self::Resourcepack => 12,
|
||||
Self::Shader => 6552,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 内容来源。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum ContentSource {
|
||||
/// Modrinth(无需 API key,默认源)
|
||||
Modrinth,
|
||||
/// CurseForge(需要 `ULL_CF_API_KEY`)
|
||||
Curseforge,
|
||||
}
|
||||
|
||||
impl ContentSource {
|
||||
/// 两个来源的声明顺序(= 筛选条顺序)。
|
||||
pub const ALL: [ContentSource; 2] = [ContentSource::Modrinth, ContentSource::Curseforge];
|
||||
|
||||
/// 稳定 slug。
|
||||
#[must_use]
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::Modrinth => "modrinth",
|
||||
Self::Curseforge => "curseforge",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 排序方式(两个站点的官方排序项,口径对齐后再映射到各自参数)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum ContentSort {
|
||||
/// 相关度(Modrinth `relevance`;CF 没有对应项,退回热度)
|
||||
Relevance,
|
||||
/// 下载量
|
||||
Downloads,
|
||||
/// 最新发布
|
||||
Newest,
|
||||
/// 最近更新
|
||||
Updated,
|
||||
}
|
||||
|
||||
impl ContentSort {
|
||||
/// 四种排序的声明顺序。
|
||||
pub const ALL: [ContentSort; 4] = [
|
||||
ContentSort::Relevance,
|
||||
ContentSort::Downloads,
|
||||
ContentSort::Newest,
|
||||
ContentSort::Updated,
|
||||
];
|
||||
|
||||
/// Modrinth `/v2/search` 的 `index` 参数值。
|
||||
#[must_use]
|
||||
pub const fn modrinth_index(self) -> &'static str {
|
||||
match self {
|
||||
Self::Relevance => "relevance",
|
||||
Self::Downloads => "downloads",
|
||||
Self::Newest => "newest",
|
||||
Self::Updated => "updated",
|
||||
}
|
||||
}
|
||||
|
||||
/// CurseForge 的 `sortField`(1 = 热度,2 = 更新,3 = 发布,4 = 名称)。
|
||||
#[must_use]
|
||||
pub const fn curseforge_field(self) -> u32 {
|
||||
match self {
|
||||
Self::Relevance | Self::Downloads => 1,
|
||||
Self::Newest => 3,
|
||||
Self::Updated => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载器 → Modrinth 的 loader facet 值;`None` = 该加载器没有对应 facet(原版等)。
|
||||
#[must_use]
|
||||
pub const fn loader_facet(loader: LoaderKind) -> Option<&'static str> {
|
||||
match loader {
|
||||
LoaderKind::Fabric => Some("fabric"),
|
||||
LoaderKind::Quilt => Some("quilt"),
|
||||
LoaderKind::Forge => Some("forge"),
|
||||
LoaderKind::NeoForge => Some("neoforge"),
|
||||
LoaderKind::Vanilla | LoaderKind::OptiFine | LoaderKind::LiteLoader => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次检索请求(**页面把它整份发出去**,不做本地过滤之外的乐观处理)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ContentQuery {
|
||||
/// 内容类型
|
||||
pub kind: ContentKind,
|
||||
/// 来源
|
||||
pub source: ContentSource,
|
||||
/// 关键词(空串 = 不带 `query` 参数,即「按热度浏览」)
|
||||
pub text: String,
|
||||
/// 游戏版本过滤(`None` = 不过滤)
|
||||
pub game_version: Option<String>,
|
||||
/// 加载器过滤(`None` = 不过滤;对资源包 / 光影也照发——静默丢掉会让筛选条撒谎)
|
||||
pub loader: Option<LoaderKind>,
|
||||
/// 排序
|
||||
pub sort: ContentSort,
|
||||
/// 偏移(分页游标)
|
||||
pub offset: u32,
|
||||
/// 每页条数
|
||||
pub limit: u32,
|
||||
}
|
||||
|
||||
impl ContentQuery {
|
||||
/// 每页条数(20 = Modrinth 默认上限的一半,翻页手感与请求量折中)。
|
||||
pub const PAGE: u32 = 20;
|
||||
|
||||
/// 某个类型的首页(来源 Modrinth、无关键词、无过滤、按相关度)。
|
||||
#[must_use]
|
||||
pub fn first(kind: ContentKind) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
source: ContentSource::Modrinth,
|
||||
text: String::new(),
|
||||
game_version: None,
|
||||
loader: None,
|
||||
sort: ContentSort::Relevance,
|
||||
offset: 0,
|
||||
limit: Self::PAGE,
|
||||
}
|
||||
}
|
||||
|
||||
/// 换一批(保留全部筛选条件,只把游标往后挪一页)。
|
||||
#[must_use]
|
||||
pub fn next_page(&self) -> Self {
|
||||
Self {
|
||||
offset: self.offset.saturating_add(self.limit),
|
||||
..self.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// 筛选条件是否与另一份相同(**不含游标**:判「结果是不是同一批条件拉回来的」用它)。
|
||||
#[must_use]
|
||||
pub fn same_filters(&self, other: &Self) -> bool {
|
||||
self.kind == other.kind
|
||||
&& self.source == other.source
|
||||
&& self.text == other.text
|
||||
&& self.game_version == other.game_version
|
||||
&& self.loader == other.loader
|
||||
&& self.sort == other.sort
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条检索结果(一个项目,不是文件)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ContentHit {
|
||||
/// 项目 id(Modrinth 的 base62 id / CF 的 modId;装入指令的载荷)
|
||||
pub id: String,
|
||||
/// 人读 slug(`sodium`;链接与日志里更可读)
|
||||
pub slug: String,
|
||||
/// 标题
|
||||
pub title: String,
|
||||
/// 一句话说明(站点原文,不翻译:它是用户内容,不是界面文案)
|
||||
pub summary: String,
|
||||
/// 作者(Modrinth 只有团队 id 时给空串)
|
||||
pub author: String,
|
||||
/// 下载量
|
||||
pub downloads: u64,
|
||||
/// 最近更新时间(ISO-8601 的日期部分;未知给空串)
|
||||
pub updated: String,
|
||||
/// 来源
|
||||
pub source: ContentSource,
|
||||
/// 支持的游戏版本(升序由站点给;页面只做「包不包含」判断)
|
||||
pub game_versions: Vec<String>,
|
||||
/// 支持的加载器(`fabric` / `forge` / `neoforge` / `quilt`)
|
||||
pub loaders: Vec<String>,
|
||||
/// 图标 URL(`None` = 没有;页面不拉图,只把主机名画成占位标记)
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
impl ContentHit {
|
||||
/// 这个项目支持某个游戏版本吗(空列表 = 站点没给,按「未知」处理 ⇒ 不拦)。
|
||||
#[must_use]
|
||||
pub fn supports_version(&self, version: &str) -> bool {
|
||||
self.game_versions.is_empty() || self.game_versions.iter().any(|item| item == version)
|
||||
}
|
||||
|
||||
/// 这个项目支持某个加载器吗(空列表 = 站点没给,按「未知」处理 ⇒ 不拦)。
|
||||
#[must_use]
|
||||
pub fn supports_loader(&self, loader: LoaderKind) -> bool {
|
||||
let Some(facet) = loader_facet(loader) else {
|
||||
return true;
|
||||
};
|
||||
self.loaders.is_empty() || self.loaders.iter().any(|item| item == facet)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一批检索结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ContentResults {
|
||||
/// 这份结果对应的请求(页面据此判断「这批还算不算当前条件」)
|
||||
pub query: ContentQuery,
|
||||
/// 命中项(顺序 = 站点顺序)
|
||||
pub hits: Vec<ContentHit>,
|
||||
/// 命中总数(站点给的分页总数;`0` = 站点没给)
|
||||
pub total: u32,
|
||||
/// CurseForge 源此刻是否可用(`false` = 没有 `ULL_CF_API_KEY`)
|
||||
pub curseforge: bool,
|
||||
}
|
||||
|
||||
impl ContentResults {
|
||||
/// 还有下一页吗。
|
||||
///
|
||||
/// 判据是「本页装满了」而不是 `offset + len < total`:站点给的 `total` 有时是估算值,
|
||||
/// 用「装满」判定最多多请求一次空页,不会出现「明明还有却翻不到」。
|
||||
#[must_use]
|
||||
pub fn has_more(&self) -> bool {
|
||||
self.hits.len() as u32 >= self.query.limit
|
||||
}
|
||||
}
|
||||
|
||||
/// 「装入指定实例」的请求(UI 侧只发意图;选哪个文件由 host 按版本 + 加载器选)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ContentInstallRequest {
|
||||
/// 项目 id
|
||||
pub project: String,
|
||||
/// 项目标题(进度面板上显示的那一行;**不是** i18n 文案)
|
||||
pub title: String,
|
||||
/// 内容类型(决定落盘目录与「能不能装」)
|
||||
pub kind: ContentKind,
|
||||
/// 来源
|
||||
pub source: ContentSource,
|
||||
/// 目标实例
|
||||
pub instance: InstanceId,
|
||||
/// 目标实例的游戏版本(选文件用)
|
||||
pub game_version: String,
|
||||
/// 目标实例的加载器(选文件用)
|
||||
pub loader: LoaderKind,
|
||||
}
|
||||
|
||||
/// 内容库页的状态(M109):**检索条件 + 最近一批结果 + 请求态**。
|
||||
///
|
||||
/// 条件为什么也存在这里:页面层不持有业务状态是老纪律(同 `download_query`);换页只挪游标、
|
||||
/// 条件只有一份,才不会出现「筛选条说的是 A、列表画的是 B」。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ContentState {
|
||||
/// 当前检索条件(游标也在里面:换页就是换一份 `query`)
|
||||
pub query: ContentQuery,
|
||||
/// 最近一批结果(`None` = 还没拉过)
|
||||
pub results: Option<ContentResults>,
|
||||
/// 有请求在飞(页面画加载态的依据;命中项只由事件写进来,不做乐观回显)
|
||||
pub pending: bool,
|
||||
/// 最近一次失败的原文(`None` = 没失败)
|
||||
pub failed: Option<String>,
|
||||
/// **最近一页**是否装满(`hits.len() >= limit`)。
|
||||
///
|
||||
/// 为什么不是「累计条数够一页」:分页是**累加**到 `results.hits` 里的,累计值在第 2 页之后
|
||||
/// 必然 ≥ limit ⇒ 用累计判会得到「has_more 恒真」:加载更多永不消失、「没有更多了」在真实
|
||||
/// 翻页路径上不可达,而且每点一次白发一次网络请求(复审 P1-2)。所以这一位单独记。
|
||||
pub last_page_filled: bool,
|
||||
}
|
||||
|
||||
impl Default for ContentState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
query: ContentQuery::first(ContentKind::Mod),
|
||||
results: None,
|
||||
pending: false,
|
||||
failed: None,
|
||||
last_page_filled: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ContentState {
|
||||
/// 这批结果还算当前条件吗(`false` = 迟到的事件,丢掉,不许覆盖新的筛选结果)。
|
||||
#[must_use]
|
||||
pub fn accepts(&self, results: &ContentResults) -> bool {
|
||||
results.query.same_filters(&self.query)
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,87 @@
|
||||
//! 内容库 DTO 的守门测试(M109)。从 `dto/content.rs` 拆出:那个文件已贴 400 行硬上限。
|
||||
|
||||
use super::*;
|
||||
|
||||
fn hit(game_versions: Vec<String>, loaders: Vec<String>) -> ContentHit {
|
||||
ContentHit {
|
||||
id: "1".to_string(),
|
||||
slug: "s".to_string(),
|
||||
title: "t".to_string(),
|
||||
summary: String::new(),
|
||||
author: String::new(),
|
||||
downloads: 0,
|
||||
updated: String::new(),
|
||||
source: ContentSource::Modrinth,
|
||||
game_versions,
|
||||
loaders,
|
||||
icon: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_file_kinds_have_install_directories() {
|
||||
// 抓得住:把整合包也塞一个目录(那等于把「解压+补全+登记实例」偷偷降级成「拷一个文件进 mods」,
|
||||
// 正是任务书第 3 条禁止的事)。
|
||||
assert_eq!(ContentKind::Modpack.dir(), None);
|
||||
assert_eq!(ContentKind::Mod.dir(), Some("mods"));
|
||||
assert_eq!(ContentKind::Resourcepack.dir(), Some("resourcepacks"));
|
||||
assert_eq!(ContentKind::Shader.dir(), Some("shaderpacks"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kinds_map_to_the_official_facets_and_class_ids() {
|
||||
assert_eq!(ContentKind::Modpack.modrinth_type(), "modpack");
|
||||
assert_eq!(ContentKind::Shader.modrinth_type(), "shader");
|
||||
// CF 的四个 classId 是官方固定值,抄错就搜不到东西(而且不会报错,只会空列表)
|
||||
assert_eq!(ContentKind::Mod.curseforge_class(), 6);
|
||||
assert_eq!(ContentKind::Resourcepack.curseforge_class(), 12);
|
||||
assert_eq!(ContentKind::Modpack.curseforge_class(), 4471);
|
||||
assert_eq!(ContentKind::Shader.curseforge_class(), 6552);
|
||||
for kind in ContentKind::ALL {
|
||||
assert!(!kind.slug().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_keeps_its_filters_and_only_moves_the_cursor() {
|
||||
let mut query = ContentQuery::first(ContentKind::Mod);
|
||||
query.game_version = Some("1.20.1".to_string());
|
||||
query.text = "sodium".to_string();
|
||||
|
||||
let next = query.next_page();
|
||||
assert_eq!(next.offset, ContentQuery::PAGE, "游标按一页走");
|
||||
assert!(next.same_filters(&query), "换页不改筛选条件");
|
||||
|
||||
let mut other = next.clone();
|
||||
other.text = "lithium".to_string();
|
||||
assert!(!other.same_filters(&query), "关键词变了就不是同一批条件");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatibility_checks_treat_missing_site_data_as_unknown_not_incompatible() {
|
||||
let empty = hit(Vec::new(), Vec::new());
|
||||
// 站点没给数据时不许拦人(否则「不知道」会被画成「不兼容」)
|
||||
assert!(empty.supports_version("1.20.1"));
|
||||
assert!(empty.supports_loader(LoaderKind::Fabric));
|
||||
|
||||
let filled = hit(vec!["1.20.1".to_string()], vec!["fabric".to_string()]);
|
||||
assert!(filled.supports_version("1.20.1"));
|
||||
assert!(!filled.supports_version("1.21.9"));
|
||||
assert!(filled.supports_loader(LoaderKind::Fabric));
|
||||
assert!(!filled.supports_loader(LoaderKind::Forge));
|
||||
// 原版没有 facet:不该被加载器过滤误伤
|
||||
assert!(filled.supports_loader(LoaderKind::Vanilla));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_more_is_driven_by_a_full_page() {
|
||||
let mut results = ContentResults {
|
||||
query: ContentQuery::first(ContentKind::Mod),
|
||||
hits: Vec::new(),
|
||||
total: 999,
|
||||
curseforge: false,
|
||||
};
|
||||
assert!(!results.has_more(), "空页 = 没有更多了");
|
||||
results.hits = vec![hit(Vec::new(), Vec::new()); ContentQuery::PAGE as usize];
|
||||
assert!(results.has_more(), "装满一页 = 还有下一页");
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
//! 事件与指令: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::{
|
||||
// 主线(M109 的 Content* / M113 的 Pack*)与 M114 的 Authlib*/AccountNotice 的并集。
|
||||
AccountNotice,
|
||||
AccountView,
|
||||
AgentEvent,
|
||||
AuthlibLoginView,
|
||||
AuthlibRequest,
|
||||
ContentInstallRequest,
|
||||
ContentQuery,
|
||||
ContentResults,
|
||||
DeviceLoginView,
|
||||
DiagnosisReport,
|
||||
InstallProgressView,
|
||||
InstanceId,
|
||||
InstanceRuntime,
|
||||
InstanceSummary,
|
||||
JavaPageView,
|
||||
LoaderKind,
|
||||
LogLine,
|
||||
LogOutcome,
|
||||
LogSession,
|
||||
NoticeView,
|
||||
PackCommand,
|
||||
PackEvent,
|
||||
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),
|
||||
/// 第三方轨(authlib-injector)登录/校验状态刷新
|
||||
AuthlibLoginChanged(AuthlibLoginView),
|
||||
/// 账户页横幅(添加/删除/切换的结果;`None` = 清掉横幅)
|
||||
AccountNoticeChanged(Option<AccountNotice>),
|
||||
/// Java 页快照(发行版/回退链/运行时)
|
||||
JavaChanged(JavaPageView),
|
||||
/// 安装进度(`None` = 清掉进度面板)
|
||||
InstallProgress(Option<InstallProgressView>),
|
||||
/// 用户取消了安装(**不带人读文案**)
|
||||
///
|
||||
/// 为什么只有信号没有文本:`presenter` 不依赖 `unlucky-i18n`,任何写死的中文都会和
|
||||
/// 页面语言打架(下载页目前渲染英文,一条中文 Toast 会**同屏一中一英**,复审 P2-2)。
|
||||
/// 文案由 app 层按 `Key` 现求:见 `pages::download::cancelled_toast`。
|
||||
InstallCancelled,
|
||||
/// 某 (加载器, 原版版本) 可用的加载器版本列表(下载页「安装选项」的版本下拉)。
|
||||
///
|
||||
/// `result` 用 `Result` 而不是空列表:**取不到**与**确实没有**是两件事,
|
||||
/// 前者要给具名错误(不许让下拉显示成空还以为能装),后者是「这个组合官方就没有版本」。
|
||||
LoaderVersions {
|
||||
/// 加载器种类
|
||||
kind: LoaderKind,
|
||||
/// 目标原版版本
|
||||
mc_version: String,
|
||||
/// `Ok(最新在前)` / `Err(人读原因)`
|
||||
result: Result<Vec<String>, String>,
|
||||
},
|
||||
/// 设置分组刷新(设置页右栏)
|
||||
SettingsChanged(Vec<SettingGroupView>),
|
||||
/// 横幅通知刷新(隔离切换提示/启动失败等)
|
||||
NoticesChanged(Vec<NoticeView>),
|
||||
/// 内容库检索结果(M109):**整批替换**(同一份 `query` 的命中项,含分页游标)
|
||||
ContentResults(Box<ContentResults>),
|
||||
/// 内容库检索失败(M109):一句人读原因(没网 / 站点限速 / 缺密钥)
|
||||
ContentFailed(String),
|
||||
// M113: import/export
|
||||
/// 导入/导出(M113):进度、结果、探测诊断、导出预检(子枚举见 [`PackEvent`])
|
||||
Pack(Box<PackEvent>),
|
||||
}
|
||||
|
||||
/// 统一事件队列的元素: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),
|
||||
/// 拉取某原版版本可用的加载器版本(下载页「安装选项」的版本下拉)
|
||||
ListLoaderVersions {
|
||||
/// 加载器种类
|
||||
kind: LoaderKind,
|
||||
/// 目标原版版本(`1.20.1`)
|
||||
mc_version: String,
|
||||
},
|
||||
/// 修改实例隔离档位(写实例设置文件)
|
||||
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,
|
||||
/// 加一个第三方皮肤站(authlib-injector)账户:校验服务器 + 密码登录
|
||||
///
|
||||
/// **凭据红线**:`request.password` 只是这一次的请求值,host 用完即弃,绝不进回程 DTO。
|
||||
AddAuthlibAccount(AuthlibRequest),
|
||||
/// 只校验第三方皮肤站地址(服务发现):结果走 `AuthlibLoginChanged`
|
||||
ProbeAuthlibServer(String),
|
||||
/// 关掉账户页横幅(页面上的 × / Dismiss)
|
||||
DismissAccountNotice,
|
||||
/// 安装指定发行版的托管 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),
|
||||
/// 检索内容库(M109;结果经 [`CoreEvent::ContentResults`] 回来)
|
||||
SearchContent(ContentQuery),
|
||||
/// 把某个内容项目装进指定实例(M109;整合包不支持,host 会拒绝并给原因)
|
||||
InstallContent(Box<ContentInstallRequest>),
|
||||
// M113: import/export
|
||||
/// 整合包导入/导出(M113):导入 / 预检 / 确认写盘 / 关面板(子枚举见 [`PackCommand`])
|
||||
Pack(Box<PackCommand>),
|
||||
}
|
||||
|
||||
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,
|
||||
server_url: String::new(),
|
||||
skin: None,
|
||||
}]));
|
||||
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());
|
||||
|
||||
// 第三方轨(M114):请求进、状态出,两边都不带 token 字段
|
||||
let authlib = AppCommand::AddAuthlibAccount(AuthlibRequest::new(
|
||||
"https://littleskin.cn/api/yggdrasil/",
|
||||
"me@example.com",
|
||||
"hunter2",
|
||||
));
|
||||
let text = serde_json::to_string(&authlib).expect("序列化");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<AppCommand>(&text).expect("反序列化"),
|
||||
authlib
|
||||
);
|
||||
|
||||
let view = AppEvent::Core(CoreEvent::AuthlibLoginChanged(AuthlibLoginView::reachable(
|
||||
"https://littleskin.cn/api/yggdrasil/",
|
||||
"LittleSkin",
|
||||
)));
|
||||
let text = serde_json::to_string(&view).expect("序列化");
|
||||
let back: AppEvent = serde_json::from_str(&text).expect("反序列化");
|
||||
assert_eq!(back, view);
|
||||
assert!(
|
||||
!text.to_lowercase().contains("token"),
|
||||
"回程视图里不该有 token"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,59 @@
|
||||
//! 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 / content / 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 content;
|
||||
mod events;
|
||||
mod install;
|
||||
mod instances;
|
||||
mod java;
|
||||
mod logs;
|
||||
mod notices;
|
||||
// M113: import/export —— 整合包导入/导出的 DTO(M111/M112 管线的 IPC 面)
|
||||
mod pack;
|
||||
mod settings;
|
||||
mod versions;
|
||||
|
||||
pub use accounts::{
|
||||
looks_like_jwt, redact, valid_offline_name, AccountKind, AccountNotice, AccountNoticeCode,
|
||||
AccountView, AuthlibLoginView, AuthlibRequest, DeviceLoginPhase, DeviceLoginView, LoginFailure,
|
||||
SkinImage, REDACTED,
|
||||
};
|
||||
pub use agent::{AgentEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity, ToolCallView};
|
||||
pub use content::{
|
||||
loader_facet, ContentHit, ContentInstallRequest, ContentKind, ContentQuery, ContentResults,
|
||||
ContentSort, ContentSource, ContentState,
|
||||
};
|
||||
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};
|
||||
// M113: import/export
|
||||
pub use pack::{
|
||||
ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportDoneView,
|
||||
ExportFileView, ExportPreviewView, ExportReasonView, ExportRequestView, ExportState,
|
||||
FileActionView, ImportErrorView, ImportProgressView, ImportReportView, ImportRequestView,
|
||||
ImportStageView, ImportState, ImportStatusView, ImportedFileView, MissingFileView,
|
||||
MissingReasonView, PackCommand, PackEvent, PackFormatView, PackState, ProbeView,
|
||||
};
|
||||
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,51 @@
|
||||
//! 整合包导入/导出 DTO(M113 `PACKUI`):把 M111/M112 的管线结果搬过 IPC 边界。
|
||||
//!
|
||||
//! ## 为什么这里几乎是 core 报告的逐字段镜像
|
||||
//!
|
||||
//! `crate::dto` 的规矩(见 `dto/mod.rs`):**不得出现 `unlucky_core::*` 类型**,跨边界类型必须
|
||||
//! `Serialize + Deserialize`。所以这里一面不引 core,一面又不许「按 UI 方便重新发明一套语义」——
|
||||
//! 三态、阶段、分类、原因全部**逐条对应** `core::addon::import::report` 与
|
||||
//! `core::addon::export::report`,只把 `&'static str` 的稳定码换成人读串、把 `PathBuf` 落成
|
||||
//! 页面能显示的形态。翻译(core 类型 → 本模块)发生在 `host::addon`,页面侧不认识 core。
|
||||
//!
|
||||
//! 三条不许在翻译时丢的东西:
|
||||
//!
|
||||
//! 1. **三态**([`ImportStatusView`]):`Partial` 不是 `Success`。把「缺了 3 个文件」说成成功,
|
||||
//! 用户会在启动后才发现缺依赖;
|
||||
//! 2. **容忍 / 致命的分野**:[`MissingFileView`] 是「搞不到某个文件」(实例可用),
|
||||
//! [`ImportErrorView`] 是「已整包回滚」(实例零残留)。UI 必须让这两类**视觉可区分**;
|
||||
//! 3. **导出的三分类**([`ExportClassView`]):`Inline` 是「证明不了可被远程替代,原字节进
|
||||
//! `overrides/`」,`Skipped` 是「压根没进包」。`skipped > 0` 意味着包**不完整**。
|
||||
//!
|
||||
//! ## 文件分工(守 400 行)
|
||||
//!
|
||||
//! | 文件 | 职责 |
|
||||
//! |---|---|
|
||||
//! | [`format`] | [`PackFormatView`](三个格式 + slug 对齐) |
|
||||
//! | [`import`] | 请求 / 进度 / 三态 / 逐项原因 / 探测诊断 |
|
||||
//! | [`conflict`] | 冲突成因、处置、三方哈希 |
|
||||
//! | [`export`] | 三分类 / 逐条原因 / 预检 |
|
||||
//!
|
||||
//! ## 冲突:默认一侧永远是用户那一侧
|
||||
//!
|
||||
//! [`ImportRequestView::overwrite`] 缺省 `false` = `ConflictPolicy::KeepUser`。这是 M113 的硬线:
|
||||
//! **绝不默认覆盖用户改过的文件**。
|
||||
|
||||
mod conflict;
|
||||
mod event;
|
||||
mod export;
|
||||
mod format;
|
||||
mod import;
|
||||
|
||||
pub use conflict::{ConflictChoiceView, ConflictFileView, ConflictReasonView};
|
||||
pub use event::{PackCommand, PackEvent, PackState};
|
||||
pub use export::{
|
||||
ExportClassView, ExportDoneView, ExportFileView, ExportPreviewView, ExportReasonView,
|
||||
ExportRequestView, ExportState,
|
||||
};
|
||||
pub use format::PackFormatView;
|
||||
pub use import::{
|
||||
FileActionView, ImportErrorView, ImportProgressView, ImportReportView, ImportRequestView,
|
||||
ImportStageView, ImportState, ImportStatusView, ImportedFileView, MissingFileView,
|
||||
MissingReasonView, ProbeView,
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
//! 冲突的 DTO:成因、本次处置、以及三方哈希的那一条账。
|
||||
//!
|
||||
//! 三条判据与 core 的 `ConflictReason` 一一对应;`choice` 是**事后记录**(本次实际怎么处置的),
|
||||
//! 不是「逐文件决策通道」——core 今天没有那种通道(`ConflictPolicy` 是整次导入一个全局档)。
|
||||
//! 「默认保留用户那一侧」这条硬线在 `ImportRequestView::overwrite` 缺省 `false` 上。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 冲突成因(core 的三条判据)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ConflictReasonView {
|
||||
/// 基线里记过,磁盘内容与基线不同 ⇒ 用户改过。
|
||||
UserModified,
|
||||
/// 基线里记过,磁盘上却没了 ⇒ 用户删过。
|
||||
UserDeleted,
|
||||
/// 没有基线可判,磁盘上本来就有同名文件。
|
||||
PreExisting,
|
||||
}
|
||||
|
||||
impl ConflictReasonView {
|
||||
/// 从 core 的 id 反解。
|
||||
#[must_use]
|
||||
pub fn from_slug(slug: &str) -> Option<Self> {
|
||||
match slug {
|
||||
"user_modified" => Some(Self::UserModified),
|
||||
"user_deleted" => Some(Self::UserDeleted),
|
||||
"pre_existing" => Some(Self::PreExisting),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 冲突本次实际采用的处置。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ConflictChoiceView {
|
||||
/// 保留用户侧(**默认**)。
|
||||
KeepUser,
|
||||
/// 用包内内容覆盖(用户显式要求「整合包说了算」)。
|
||||
Overwrite,
|
||||
}
|
||||
|
||||
impl ConflictChoiceView {
|
||||
/// 从 core 的 id 反解。
|
||||
#[must_use]
|
||||
pub fn from_slug(slug: &str) -> Option<Self> {
|
||||
match slug {
|
||||
"keep_user" => Some(Self::KeepUser),
|
||||
"overwrite" => Some(Self::Overwrite),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条冲突(**事后账**:本次实际怎么处置的)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConflictFileView {
|
||||
/// 实例内相对路径。
|
||||
pub path: String,
|
||||
/// 成因。
|
||||
pub reason: ConflictReasonView,
|
||||
/// 本次实际采用的处置。
|
||||
pub choice: ConflictChoiceView,
|
||||
/// 包内声明的 SHA-1。
|
||||
pub pack_sha1: String,
|
||||
/// 上次导入写下的 SHA-1(没有基线时 `None`)。
|
||||
pub base_sha1: Option<String>,
|
||||
/// 磁盘现状的 SHA-1(文件不存在时 `None`)。
|
||||
pub disk_sha1: Option<String>,
|
||||
/// 包内声明的字节数。
|
||||
pub pack_size: u64,
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! 导入/导出的**指令、事件与面板状态**(M113):`AppCommand::Pack` / `CoreEvent::Pack` 的载荷。
|
||||
//!
|
||||
//! ## 为什么整条流程只占一个枚举变体
|
||||
//!
|
||||
//! `AppCommand` / `CoreEvent` / `HostTask` 是这条装配链上最拥挤的三个枚举(M106/M107/M108/M109/M114
|
||||
//! 都在往里加变体)。所以导入导出**不各占一个变体**,而是各自一个 [`PackCommand`] / [`PackEvent`]
|
||||
//! 子枚举、外面只多一层包装:共享枚举里只多一行,rebase 时不用替别人定序。
|
||||
//!
|
||||
//! ## 取消是**界面级**的,不是管线级的(如实写)
|
||||
//!
|
||||
//! core 的 `import_pack` 没有取消令牌(`ImportRequest` 里没有 cancel 字段,`progress` 回调是
|
||||
//! 纯通知、拿不到控制流)。所以「取消导入」在本轮的含义是:
|
||||
//!
|
||||
//! 1. 立刻把面板收回([`PackEvent::Progress`] 传 `None`);
|
||||
//! 2. **丢弃**这一次运行之后回来的事件(代次比较,与 M105 的安装取消同一套做法);
|
||||
//! 3. 后台那次导入会继续跑到结束——**它不会因为点了取消而停手**。
|
||||
//!
|
||||
//! 这条必须在报告里如实登记:把它写成「真的停下来了」就是撒谎。
|
||||
//!
|
||||
//! ## 事件 → 状态只有一条路
|
||||
//!
|
||||
//! [`PackState::apply`] 是**唯一**的写入点(与 `AppState::apply` 同一条纪律):页面读到的进度、
|
||||
//! 报告、预检都只能从事件来,页面自己不做乐观回显。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dto::pack::{
|
||||
ExportDoneView, ExportPreviewView, ExportRequestView, ExportState, ImportProgressView,
|
||||
ImportReportView, ImportRequestView, ImportState, ProbeView,
|
||||
};
|
||||
|
||||
/// app → host 的导入/导出意图。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PackCommand {
|
||||
/// 开始导入(目标实例已选好;`instance` 为 `None` = 按 `instance_name` 新建目录)
|
||||
Import(Box<ImportRequestView>),
|
||||
/// 跑**预检**(只扫描分类,**不写盘**)
|
||||
Export(Box<ExportRequestView>),
|
||||
/// 确认写盘。载荷是**预检时那一份请求**,不是页面当前字段——否则「预检 A、写 B」。
|
||||
Confirm(Box<ExportRequestView>),
|
||||
/// 关掉面板(导入结果 / 探测诊断 / 导出预检都收起来,回到入口态)
|
||||
Dismiss,
|
||||
}
|
||||
|
||||
/// host → app 的导入/导出事件。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PackEvent {
|
||||
/// 导入进度(`None` = 收回面板:取消,或流程已经给出结果)
|
||||
Progress(Option<Box<ImportProgressView>>),
|
||||
/// 导入结果(三态 + 逐项原因;致命失败也走它,`report.error` 里有原因)
|
||||
Report(Box<ImportReportView>),
|
||||
/// 探测失败:这个包看起来像什么、缺哪个标记文件
|
||||
Probe(Box<ProbeView>),
|
||||
/// 导出预检结果
|
||||
Preview(Box<ExportPreviewView>),
|
||||
/// 导出写盘完成
|
||||
Exported(Box<ExportDoneView>),
|
||||
/// 导出/预检在 host 侧失败(实例找不到、目录派生失败……一句人读原因)
|
||||
ExportFailed(String),
|
||||
/// 用户取消了导入:面板收回,但后台那次导入会跑完(core 没有取消令牌),
|
||||
/// 所以留一位给界面说清「还在跑、结果作废」。
|
||||
Cancelled,
|
||||
/// 关掉所有面板
|
||||
Cleared,
|
||||
}
|
||||
|
||||
/// 导入/导出两个面板的状态(`AppState::pack` 的全部内容)。
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct PackState {
|
||||
/// 导入侧(进度 / 结果 / 探测诊断)
|
||||
pub import: ImportState,
|
||||
/// 导出侧(预检 / 完成 / 失败)
|
||||
pub export: ExportState,
|
||||
}
|
||||
|
||||
impl PackState {
|
||||
/// 事件 → 状态(**唯一**写入点)。
|
||||
pub fn apply(&mut self, event: PackEvent) {
|
||||
match event {
|
||||
PackEvent::Progress(progress) => {
|
||||
self.import.progress = progress.map(|progress| *progress);
|
||||
}
|
||||
PackEvent::Report(report) => {
|
||||
// 结果到了:进度面板让位,探测诊断作废(这一轮已经有结论了)
|
||||
self.import.progress = None;
|
||||
self.import.probe = None;
|
||||
self.import.report = Some(*report);
|
||||
}
|
||||
PackEvent::Probe(probe) => {
|
||||
self.import.progress = None;
|
||||
self.import.report = None;
|
||||
self.import.probe = Some(*probe);
|
||||
}
|
||||
PackEvent::Preview(preview) => {
|
||||
self.export.pending = false;
|
||||
self.export.failed = None;
|
||||
self.export.done = None;
|
||||
self.export.preview = Some(*preview);
|
||||
}
|
||||
PackEvent::Exported(done) => {
|
||||
self.export.pending = false;
|
||||
self.export.failed = None;
|
||||
// 写完之后预检就过期了:留着会让用户以为「还能再点一次确认」
|
||||
self.export.preview = None;
|
||||
self.export.done = Some(*done);
|
||||
}
|
||||
PackEvent::ExportFailed(reason) => {
|
||||
self.export.pending = false;
|
||||
self.export.failed = Some(reason);
|
||||
}
|
||||
PackEvent::Cancelled => {
|
||||
self.import.progress = None;
|
||||
self.import.cancelled = true;
|
||||
}
|
||||
PackEvent::Cleared => {
|
||||
self.import = ImportState::default();
|
||||
self.export = ExportState::default();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 记下「这次预检是对着哪份请求做的」(确认写盘时原样发回)。
|
||||
pub fn set_export_request(&mut self, request: ExportRequestView) {
|
||||
self.export.request = Some(request);
|
||||
self.export.pending = true;
|
||||
self.export.failed = None;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! 导出侧 DTO:三分类 + 逐条原因 + **预检**(不写盘)。
|
||||
//!
|
||||
//! `ExportReasonView::class()` 是「原因 → 归类」的**唯一**映射处(与 core 同一条判据);
|
||||
//! 预检([`ExportPreviewView`])与写盘结果用同一套计数方法,于是「将内联 N / 无法导出 M」
|
||||
//! 与最终报告的 N/M 同源。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dto::InstanceId;
|
||||
|
||||
use super::format::PackFormatView;
|
||||
|
||||
/// 导出时一条文件的归类。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ExportClassView {
|
||||
/// 写进清单 `files[]`(靠远端引用代替本地字节)。
|
||||
Remote,
|
||||
/// 写进 `overrides/`(原字节照搬)。
|
||||
Inline,
|
||||
/// 没进包(读不了 / 符号链接 / 路径不安全)。
|
||||
Skipped,
|
||||
}
|
||||
|
||||
impl ExportClassView {
|
||||
/// 从 core 的 id 反解。
|
||||
#[must_use]
|
||||
pub fn from_slug(slug: &str) -> Option<Self> {
|
||||
match slug {
|
||||
"remote" => Some(Self::Remote),
|
||||
"inline" => Some(Self::Inline),
|
||||
"skipped" => Some(Self::Skipped),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条文件为什么归到那个类里(与 core 的 `FileReason` 一一对应)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ExportReasonView {
|
||||
/// 远程引用:Modrinth SHA-1 命中。
|
||||
ModrinthSha1,
|
||||
/// 远程引用:CurseForge 指纹命中。
|
||||
CurseForgeFingerprint,
|
||||
/// 内联:两个目录都不认识它。
|
||||
NoRemoteMatch,
|
||||
/// 内联:只有 CF 认识,目标格式用不上。
|
||||
CurseOnlyMatch,
|
||||
/// 内联:只有 Modrinth 认识,CF 清单用不上。
|
||||
ModrinthOnlyMatch,
|
||||
/// 内联:命中缺 `sha512`。
|
||||
MissingSha512,
|
||||
/// 内联:命中缺 `projectID`/`fileID`。
|
||||
MissingCurseIds,
|
||||
/// 内联:禁用文件,目标格式表达不了禁用位。
|
||||
DisabledNotExpressible,
|
||||
/// 跳过:符号链接。
|
||||
SymbolicLink,
|
||||
/// 跳过:路径过不了包内规整。
|
||||
UnsafePath,
|
||||
/// 跳过:读盘或算摘要失败。
|
||||
Unreadable,
|
||||
}
|
||||
|
||||
impl ExportReasonView {
|
||||
/// 从 core 的 id 反解。
|
||||
#[must_use]
|
||||
pub fn from_slug(slug: &str) -> Option<Self> {
|
||||
match slug {
|
||||
"modrinth_sha1" => Some(Self::ModrinthSha1),
|
||||
"curseforge_fingerprint" => Some(Self::CurseForgeFingerprint),
|
||||
"no_remote_match" => Some(Self::NoRemoteMatch),
|
||||
"curse_only_match" => Some(Self::CurseOnlyMatch),
|
||||
"modrinth_only_match" => Some(Self::ModrinthOnlyMatch),
|
||||
"missing_sha512" => Some(Self::MissingSha512),
|
||||
"missing_curse_ids" => Some(Self::MissingCurseIds),
|
||||
"disabled_not_expressible" => Some(Self::DisabledNotExpressible),
|
||||
"symbolic_link" => Some(Self::SymbolicLink),
|
||||
"unsafe_path" => Some(Self::UnsafePath),
|
||||
"unreadable" => Some(Self::Unreadable),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 这个原因属于哪一类(**唯一映射处**,与 core 的 `FileReason::class` 同一条判据)。
|
||||
#[must_use]
|
||||
pub const fn class(self) -> ExportClassView {
|
||||
match self {
|
||||
Self::ModrinthSha1 | Self::CurseForgeFingerprint => ExportClassView::Remote,
|
||||
Self::NoRemoteMatch
|
||||
| Self::CurseOnlyMatch
|
||||
| Self::ModrinthOnlyMatch
|
||||
| Self::MissingSha512
|
||||
| Self::MissingCurseIds
|
||||
| Self::DisabledNotExpressible => ExportClassView::Inline,
|
||||
Self::SymbolicLink | Self::UnsafePath | Self::Unreadable => ExportClassView::Skipped,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条文件的导出预检结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ExportFileView {
|
||||
/// 实例内相对路径(含 `.disabled` 后缀)。
|
||||
pub path: String,
|
||||
/// 归类。
|
||||
pub class: ExportClassView,
|
||||
/// 原因。
|
||||
pub reason: ExportReasonView,
|
||||
/// 字节数。
|
||||
pub bytes: u64,
|
||||
/// 是否禁用态(`.disabled`)。
|
||||
pub disabled: bool,
|
||||
}
|
||||
|
||||
/// **预检**:还没有写盘,先把「会怎样」摊开给用户看。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ExportPreviewView {
|
||||
/// 目标格式。
|
||||
pub format: PackFormatView,
|
||||
/// 源实例。
|
||||
pub instance: InstanceId,
|
||||
/// 包名。
|
||||
pub name: String,
|
||||
/// 包版本。
|
||||
pub version: String,
|
||||
/// 预定的落盘路径(确认之后写这里)。
|
||||
pub target: PathBuf,
|
||||
/// 逐文件结果(按路径排序)。
|
||||
pub files: Vec<ExportFileView>,
|
||||
/// 已知的表达不了的差异 / 取舍(core 的 `notes`)。
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
impl ExportPreviewView {
|
||||
/// 某一类的条数。
|
||||
#[must_use]
|
||||
pub fn count(&self, class: ExportClassView) -> usize {
|
||||
self.files.iter().filter(|file| file.class == class).count()
|
||||
}
|
||||
|
||||
/// 远程引用条数。
|
||||
#[must_use]
|
||||
pub fn remote_count(&self) -> usize {
|
||||
self.count(ExportClassView::Remote)
|
||||
}
|
||||
|
||||
/// 内联条数(「将内联 N 个文件」的那个 N)。
|
||||
#[must_use]
|
||||
pub fn inline_count(&self) -> usize {
|
||||
self.count(ExportClassView::Inline)
|
||||
}
|
||||
|
||||
/// 跳过条数(「无法导出 M 项」的那个 M)。
|
||||
#[must_use]
|
||||
pub fn skipped_count(&self) -> usize {
|
||||
self.count(ExportClassView::Skipped)
|
||||
}
|
||||
|
||||
/// 内联文件总字节数。
|
||||
#[must_use]
|
||||
pub fn inline_bytes(&self) -> u64 {
|
||||
self.files
|
||||
.iter()
|
||||
.filter(|file| file.class == ExportClassView::Inline)
|
||||
.map(|file| file.bytes)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// 包会完整吗(`false` = 有文件压根没进包,必须单独列给用户)。
|
||||
#[must_use]
|
||||
pub fn is_complete(&self) -> bool {
|
||||
self.skipped_count() == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 导出格式选择 + 一次导出请求。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ExportRequestView {
|
||||
/// 源实例。
|
||||
pub instance: InstanceId,
|
||||
/// 目标格式(只能是 [`PackFormatView::EXPORTABLE`] 里的两个)。
|
||||
pub format: PackFormatView,
|
||||
/// 包名(写进清单 `name`)。
|
||||
pub name: String,
|
||||
/// 包版本(写进清单 `version` / `versionId`)。
|
||||
pub version: String,
|
||||
/// 落盘路径(`None` = host 按「下载目录 + 包名 + 扩展名」给一个缺省)。
|
||||
pub target: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// 导出完成(写盘之后的账)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ExportDoneView {
|
||||
/// 产出的包路径。
|
||||
pub path: PathBuf,
|
||||
/// 包的字节数(写完量的,不是估算)。
|
||||
pub bytes: u64,
|
||||
/// 远程引用条数。
|
||||
pub remote: u32,
|
||||
/// 内联条数。
|
||||
pub inline: u32,
|
||||
/// 跳过条数。
|
||||
pub skipped: u32,
|
||||
}
|
||||
|
||||
/// 导出侧的宿主状态(`AppState::export` 的全部内容)。
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct ExportState {
|
||||
/// 预检结果(`None` = 还没预检)。
|
||||
pub preview: Option<ExportPreviewView>,
|
||||
/// **这次预检是对着哪份请求做的**(确认写盘时原样发回,防「预检 A、写 B」)。
|
||||
pub request: Option<ExportRequestView>,
|
||||
/// 预检在飞。
|
||||
pub pending: bool,
|
||||
/// 最近一次写盘的结果。
|
||||
pub done: Option<ExportDoneView>,
|
||||
/// 失败原因(预检或写盘)。
|
||||
pub failed: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! 整合包格式:探测结果与导出目标共用一个枚举。
|
||||
//!
|
||||
//! `slug()` 与 core 的格式 id **逐字一致**(`curseforge` / `modrinth` / `multimc`),
|
||||
//! `from_slug` 是反向唯一入口——翻译发生在 `host::addon`,页面侧只认这两个函数。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 整合包格式(探测结果与导出目标共用一个枚举)。
|
||||
///
|
||||
/// MultiMC / Prism 只**导入**不导出(core 的有意省略),所以导出侧两个入口是
|
||||
/// [`Self::Modrinth`] / [`Self::Curseforge`]。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum PackFormatView {
|
||||
/// CurseForge:`manifest.json` + `overrides/`。
|
||||
Curseforge,
|
||||
/// Modrinth:`.mrpack`(`modrinth.index.json` + `overrides/`)。
|
||||
Modrinth,
|
||||
/// MultiMC / Prism:`mmc-pack.json` + `.minecraft/`(**只读**)。
|
||||
Multimc,
|
||||
}
|
||||
|
||||
impl PackFormatView {
|
||||
/// 导出菜单里可选的两个(顺序 = 画出来的顺序)。
|
||||
pub const EXPORTABLE: [PackFormatView; 2] =
|
||||
[PackFormatView::Modrinth, PackFormatView::Curseforge];
|
||||
|
||||
/// 稳定 slug(与 core 的格式 id 逐字一致,**不要改名**)。
|
||||
#[must_use]
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::Curseforge => "curseforge",
|
||||
Self::Modrinth => "modrinth",
|
||||
Self::Multimc => "multimc",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 core 的格式 id 反解(`None` = 不认识的 id,调用方按「未知」处理,别猜)。
|
||||
#[must_use]
|
||||
pub fn from_slug(slug: &str) -> Option<Self> {
|
||||
match slug {
|
||||
"curseforge" => Some(Self::Curseforge),
|
||||
"modrinth" => Some(Self::Modrinth),
|
||||
"multimc" => Some(Self::Multimc),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
//! 导入侧 DTO:请求、进度、三态、逐项原因、探测诊断。
|
||||
//!
|
||||
//! 三态(`Success` / `Partial` / `Failed`)与 core 一一对应,**`Partial` 不是 `Success`**;
|
||||
//! 容忍域([`MissingFileView`])与致命域([`ImportErrorView`])分属不同类型,UI 不可能混成一个列表。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dto::InstanceId;
|
||||
|
||||
use super::conflict::ConflictFileView;
|
||||
use super::format::PackFormatView;
|
||||
|
||||
/// 导入阶段(与 core 的 `ImportStage` 一一对应)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum ImportStageView {
|
||||
/// 读取清单(开包 + 安全四检 + 探测)。
|
||||
Probe,
|
||||
/// 解压 / 三方合并覆盖内容。
|
||||
Extract,
|
||||
/// 补全并下载远端文件。
|
||||
Fetch,
|
||||
/// 落地。
|
||||
Commit,
|
||||
/// 登记实例。
|
||||
Register,
|
||||
}
|
||||
|
||||
impl ImportStageView {
|
||||
/// 五个阶段的声明顺序(= 进度条上的先后)。
|
||||
pub const ALL: [ImportStageView; 5] = [
|
||||
ImportStageView::Probe,
|
||||
ImportStageView::Extract,
|
||||
ImportStageView::Fetch,
|
||||
ImportStageView::Commit,
|
||||
ImportStageView::Register,
|
||||
];
|
||||
|
||||
/// 稳定 slug。
|
||||
#[must_use]
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::Probe => "probe",
|
||||
Self::Extract => "extract",
|
||||
Self::Fetch => "fetch",
|
||||
Self::Commit => "commit",
|
||||
Self::Register => "register",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 core 的 id 反解。
|
||||
#[must_use]
|
||||
pub fn from_slug(slug: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|stage| stage.slug() == slug)
|
||||
}
|
||||
|
||||
/// 这一阶段有没有「逐文件」的进度(`Probe` 只看清单,没条目可数)。
|
||||
#[must_use]
|
||||
pub const fn counts_files(self) -> bool {
|
||||
!matches!(self, Self::Probe)
|
||||
}
|
||||
}
|
||||
|
||||
/// 导入三态(`Partial` **不是** `Success`)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ImportStatusView {
|
||||
/// 全部落地、无冲突。
|
||||
Success,
|
||||
/// 有缺文件或有冲突(内容不完整,但实例可用)。
|
||||
Partial,
|
||||
/// 致命失败,**已整包回滚**。
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl ImportStatusView {
|
||||
/// 稳定 slug。
|
||||
#[must_use]
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::Success => "success",
|
||||
Self::Partial => "partial",
|
||||
Self::Failed => "failed",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 core 的 id 反解。
|
||||
#[must_use]
|
||||
pub fn from_slug(slug: &str) -> Option<Self> {
|
||||
match slug {
|
||||
"success" => Some(Self::Success),
|
||||
"partial" => Some(Self::Partial),
|
||||
"failed" => Some(Self::Failed),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 实例可用吗(`Success` / `Partial` 都是「装上了」)。
|
||||
#[must_use]
|
||||
pub const fn installed(self) -> bool {
|
||||
!matches!(self, Self::Failed)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条落地文件本次被怎么处理。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum FileActionView {
|
||||
/// 新写入。
|
||||
Installed,
|
||||
/// 覆盖写入(用户没改过)。
|
||||
Replaced,
|
||||
/// 与包内一致,没动。
|
||||
SkippedIdentical,
|
||||
/// 用户改过 / 删过,按策略保留了用户那一侧。
|
||||
KeptUser,
|
||||
}
|
||||
|
||||
impl FileActionView {
|
||||
/// 从 core 的 id 反解。
|
||||
#[must_use]
|
||||
pub fn from_slug(slug: &str) -> Option<Self> {
|
||||
match slug {
|
||||
"installed" => Some(Self::Installed),
|
||||
"replaced" => Some(Self::Replaced),
|
||||
"skipped_identical" => Some(Self::SkippedIdentical),
|
||||
"kept_user" => Some(Self::KeptUser),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否真的写了盘。
|
||||
#[must_use]
|
||||
pub const fn wrote(self) -> bool {
|
||||
matches!(self, Self::Installed | Self::Replaced)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次导入的请求。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ImportRequestView {
|
||||
/// 包路径:zip(`.zip` / `.mrpack`)或 MultiMC 实例目录。
|
||||
pub pack: PathBuf,
|
||||
/// 目标实例(`None` = 按包名新建一个)。
|
||||
pub instance: Option<InstanceId>,
|
||||
/// 新实例的展示名(`None` = 用包名)。
|
||||
pub instance_name: Option<String>,
|
||||
/// 冲突翻档:`false`(**默认**)= 保留用户那一侧;`true` = 以包为准覆盖。
|
||||
///
|
||||
/// 页面上把 `true` 那条写成「以包为准重新导入」,并且**只在用户显式点过之后**才发。
|
||||
pub overwrite: bool,
|
||||
}
|
||||
|
||||
/// 一条导入进度。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ImportProgressView {
|
||||
/// 阶段。
|
||||
pub stage: ImportStageView,
|
||||
/// 阶段内已完成条目数。
|
||||
pub done: u32,
|
||||
/// 阶段内总条目数(`0` = 未知)。
|
||||
pub total: u32,
|
||||
/// 当前条目的实例内路径(阶段刚开始时为 `None`)。
|
||||
pub current: Option<String>,
|
||||
/// 包名(面板标题那一行)。
|
||||
pub pack: String,
|
||||
}
|
||||
|
||||
impl ImportProgressView {
|
||||
/// 空进度(刚派发,后端还没回第一条)。
|
||||
#[must_use]
|
||||
pub fn new(pack: impl Into<String>) -> Self {
|
||||
Self {
|
||||
stage: ImportStageView::Probe,
|
||||
done: 0,
|
||||
total: 0,
|
||||
current: None,
|
||||
pack: pack.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 计数已知吗(`false` = 不画确定态进度条,避免「静止的假进度」)。
|
||||
#[must_use]
|
||||
pub const fn is_known(&self) -> bool {
|
||||
self.total > 0
|
||||
}
|
||||
|
||||
/// 阶段内完成比例 0.0~1.0(未知时 `0.0`)。
|
||||
#[must_use]
|
||||
pub fn ratio(&self) -> f32 {
|
||||
if self.total == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
(self.done as f32 / self.total as f32).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条落地文件。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ImportedFileView {
|
||||
/// 实例内相对路径。
|
||||
pub path: String,
|
||||
/// 本次处置。
|
||||
pub action: FileActionView,
|
||||
/// 字节数。
|
||||
pub bytes: u64,
|
||||
}
|
||||
|
||||
/// 一个文件「搞不到」的成因(**容忍**域)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum MissingReasonView {
|
||||
/// 调用方没给 fetcher(离线导入)。
|
||||
Offline,
|
||||
/// 条目补不出下载地址(CF 缺 API 客户端 / `downloads` 为空)。
|
||||
NoSource,
|
||||
/// 尝试过但失败(404 / 网络)。
|
||||
FetchFailed,
|
||||
}
|
||||
|
||||
impl MissingReasonView {
|
||||
/// 从 core 的 id 反解。
|
||||
#[must_use]
|
||||
pub fn from_slug(slug: &str) -> Option<Self> {
|
||||
match slug {
|
||||
"offline" => Some(Self::Offline),
|
||||
"no_source" => Some(Self::NoSource),
|
||||
"fetch_failed" => Some(Self::FetchFailed),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个「搞不到」的文件(容忍,但逐条列出)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MissingFileView {
|
||||
/// 实例内路径(CF 补全失败时没有)。
|
||||
pub path: Option<String>,
|
||||
/// 人读标识(CF 条目是 `projectID:fileID`,其余是路径)。
|
||||
pub display: String,
|
||||
/// 为什么搞不到。
|
||||
pub reason: MissingReasonView,
|
||||
/// 细节(错误原文)。
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// 一次**已整包回滚**的致命失败。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ImportErrorView {
|
||||
/// 稳定码(`pack_traversal` / `import_integrity` …,进日志与 `--json`)。
|
||||
pub code: String,
|
||||
/// 人读大类名。
|
||||
pub label: String,
|
||||
/// 具体是什么。
|
||||
pub message: String,
|
||||
/// 补救建议(core 有就给)。
|
||||
pub hint: Option<String>,
|
||||
}
|
||||
|
||||
/// 一次导入的完整结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ImportReportView {
|
||||
/// 三态。
|
||||
pub status: ImportStatusView,
|
||||
/// 命中的格式(`None` = 探测都没过)。
|
||||
pub format: Option<PackFormatView>,
|
||||
/// 包名。
|
||||
pub pack_name: Option<String>,
|
||||
/// 包版本。
|
||||
pub pack_version: Option<String>,
|
||||
/// 游戏版本。
|
||||
pub game_version: Option<String>,
|
||||
/// 加载器(人读串,如 `fabric-loader 0.15.11`)。
|
||||
pub loader: Option<String>,
|
||||
/// 实例绑定的版本 id。
|
||||
pub version_id: Option<String>,
|
||||
/// 目标实例目录。
|
||||
pub target: PathBuf,
|
||||
/// 落地的文件。
|
||||
pub files: Vec<ImportedFileView>,
|
||||
/// 冲突清单。
|
||||
pub conflicts: Vec<ConflictFileView>,
|
||||
/// 缺文件清单(容忍)。
|
||||
pub missing: Vec<MissingFileView>,
|
||||
/// MultiMC 不支持的 component(逐项)。
|
||||
pub rejected: Vec<String>,
|
||||
/// 注记。
|
||||
pub warnings: Vec<String>,
|
||||
/// 致命错误(`status == Failed` 时必有)。
|
||||
pub error: Option<ImportErrorView>,
|
||||
/// 登记的实例名。
|
||||
pub registered: Option<String>,
|
||||
/// 本次写入的字节数。
|
||||
pub bytes_written: u64,
|
||||
/// 回滚是否完整(`false` = 目标目录可能与导入前不一致,逐条在 `warnings` 里)。
|
||||
pub rolled_back: bool,
|
||||
}
|
||||
|
||||
impl ImportReportView {
|
||||
/// 真正写盘的文件条数。
|
||||
#[must_use]
|
||||
pub fn written(&self) -> usize {
|
||||
self.files.iter().filter(|file| file.action.wrote()).count()
|
||||
}
|
||||
|
||||
/// 要用户看一眼的东西条数(冲突 + 缺文件 + 被拒 component)。
|
||||
#[must_use]
|
||||
pub fn issue_count(&self) -> usize {
|
||||
self.conflicts.len() + self.missing.len() + self.rejected.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测失败的**具名**诊断:「看起来像 X,但缺 Y」。
|
||||
///
|
||||
/// 为什么不由 core 给:core 的 `detect` 只回答「谁认领」,一个都不认领时只回一句
|
||||
/// `pack_unsupported` + 试过哪些格式。而用户手里那个包往往**差一点点**就是某个已知格式
|
||||
/// (CF 导出漏了 `manifest.json`、mrpack 被浏览器改名成 `.zip` 后解压丢了 index)。
|
||||
/// 判断「像哪个」要看包内到底有哪些标记文件,这是宿主层拿着 `PackArchive` 才看得见的事。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProbeView {
|
||||
/// 最像哪个格式(`None` = 三个都不像)。
|
||||
pub looks_like: Option<PackFormatView>,
|
||||
/// 该格式**缺**的标记文件(逐条,按判据顺序)。
|
||||
pub missing: Vec<String>,
|
||||
/// 包里实际看到的标记(逐条)。
|
||||
pub saw: Vec<String>,
|
||||
/// 一句兜底说明(也是 core 的原文:试过哪些格式)。
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// 导入侧的宿主状态(`AppState::import` 的全部内容)。
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct ImportState {
|
||||
/// 正在跑的进度(`None` = 没在跑)。
|
||||
pub progress: Option<ImportProgressView>,
|
||||
/// 用户点了取消(M113 r1):**面板收回但后台还在跑**,所以这一位要留在界面上——
|
||||
/// 否则用户会以为「没建成」,回头再导一次就撞「directory already exists」。
|
||||
pub cancelled: bool,
|
||||
/// 最近一次的结果(`None` = 还没导过)。
|
||||
pub report: Option<ImportReportView>,
|
||||
/// 探测失败的具名诊断(有它就让用户看清「像 X 缺 Y」,而不是一句「无法识别」)。
|
||||
pub probe: Option<ProbeView>,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user