feat(presenter): 新建 unlucky-presenter,把 host/状态/DTO/设置桥从 app 迁出(M85 第 1 块)
D26(iced 重写)的关键一次性动作:UI 框架无关的宿主层独立成 crate, Slint 版(crates/app)与 iced 版(crates/app-iced)共用,Slint 退役后不再重写一遍。 - 新建 crates/presenter(package `unlucky-presenter` / lib `unlucky_presenter`), workspace 登记 + crate 文档头写清「这层为什么存在」、对外表面、UI 侧允许/禁止、 iced 接线骨架、以及哪些东西留在了 crates/app 及为什么。 - `git mv`(保留文件历史)迁入:host/(25 文件)、dto/(11 文件)、state.rs(+tests)、 prefs.rs、settings_bridge.rs、toast.rs、router.rs、dialogs.rs、wizard.rs、 mock.rs、mock_data.rs、backend.rs。模块路径与公开 API 名称不变(`host::install` 等), 内部实现零改动。 - 新增 `theme`:把原本混在 ui_bind/theme_runtime 里的**纯判据**(CONFIG_POLL_TICKS / SYSTEM_THEME_POLL_TICKS / TILE_REGENERATE_STEP / BackgroundKey / tile_viewport / system_theme_value / 两个 poll 判据)抽出来,附 5 个单测;app 侧改成调用它们。 - crates/app 改为消费者:删掉迁走的文件,`crate::host|state|dto|prefs|settings_bridge| toast|router|dialogs|wizard|mock|mock_data|backend` 全部改写为 `unlucky_presenter::*` (64 处),backend.rs 的 config_path/build_backend 提为 pub。 - crates/app-iced 只登记 unlucky-presenter 依赖,本 mission 不接线。 - prefs.rs 的 2 处 slint 文档引用改写;`grep -rn slint crates/presenter` 现在无输出。
This commit is contained in:
1 parent
00f132d8ce
commit
f159529936
69 files changed
+441
-129
No files matched your search
@@ -0,0 +1,270 @@
|
||||
//! 账户页 DTO:三条轨道的统一抽象 + 微软登录(设备码 / 授权码浏览器)的可见状态。
|
||||
//!
|
||||
//! **凭据红线**:本模块(以及整个 `crate::dto`)只有 [`AccountView`] 这种公开字段视图。
|
||||
//! `core::auth` 的 `Credentials` / `MicrosoftSession` / token 永远不进 app 层——
|
||||
//! host 侧在拿到账户快照的那一刻就丢掉凭据,只留「能显示什么」。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 账户轨道(三轨统一抽象,`09 §G6` / `D9`)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AccountKind {
|
||||
/// 离线账户(HMCL 兼容 UUID)
|
||||
Offline,
|
||||
/// 微软正版
|
||||
Microsoft,
|
||||
/// authlib-injector(第三方皮肤站)
|
||||
Authlib,
|
||||
}
|
||||
|
||||
impl AccountKind {
|
||||
/// 徽标文字。
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Offline => "离线",
|
||||
Self::Microsoft => "微软",
|
||||
Self::Authlib => "第三方",
|
||||
}
|
||||
}
|
||||
|
||||
/// 稳定 id(事件/持久化用;与 core 的 `kind.as_str()` 同词表)。
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Offline => "offline",
|
||||
Self::Microsoft => "microsoft",
|
||||
Self::Authlib => "authlib_injector",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 core 的稳定 id 解析(未知轨道按离线处理,不让一个坏条目毁掉整个列表)。
|
||||
pub fn parse(raw: &str) -> Self {
|
||||
match raw {
|
||||
"microsoft" => Self::Microsoft,
|
||||
"authlib_injector" => Self::Authlib,
|
||||
_ => Self::Offline,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 账户列表行(**只含可公开字段**)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AccountView {
|
||||
/// 稳定 id(`account:...`)
|
||||
pub id: String,
|
||||
/// 展示名
|
||||
pub name: String,
|
||||
/// 轨道
|
||||
pub kind: AccountKind,
|
||||
/// 档案 UUID(公开)
|
||||
pub profile_id: String,
|
||||
/// 登录名(微软轨才有;可空)
|
||||
pub login_name: String,
|
||||
/// 创建时间展示串(`2026-10-31 20:12`)
|
||||
pub created: String,
|
||||
/// 是否是当前启动使用的账户
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
/// 微软登录阶段(UI 据此切换面板;两条流共用)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DeviceLoginPhase {
|
||||
/// 没有登录在进行
|
||||
Idle,
|
||||
/// 正在向微软申请设备码(短暂)
|
||||
Requesting,
|
||||
/// 已拿到用户码,等待用户在浏览器完成
|
||||
Waiting,
|
||||
/// 成功(账户已入库)
|
||||
Succeeded,
|
||||
/// 失败(原因在 `message`,已脱敏)
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl DeviceLoginPhase {
|
||||
/// 面板是否要显示(非 Idle 都显示)。
|
||||
pub fn visible(self) -> bool {
|
||||
!matches!(self, Self::Idle)
|
||||
}
|
||||
|
||||
/// 是否还在跑(Requesting / Waiting)。
|
||||
pub fn busy(self) -> bool {
|
||||
matches!(self, Self::Requesting | Self::Waiting)
|
||||
}
|
||||
}
|
||||
|
||||
/// 设备码登录的可见状态。
|
||||
///
|
||||
/// `user_code` / `verification_uri` 来自 `core::auth::microsoft::DeviceCodePrompt`——
|
||||
/// 它们本来就是给用户看的,不是凭据。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DeviceLoginView {
|
||||
/// 阶段
|
||||
pub phase: DeviceLoginPhase,
|
||||
/// 用户码(`ABCD-EFGH`)
|
||||
pub user_code: String,
|
||||
/// 验证地址(`https://www.microsoft.com/link`)
|
||||
pub verification_uri: String,
|
||||
/// 二维码内容(= 带码的链接;GUI 用文本展示,终端可直接打开)
|
||||
pub qr_link: String,
|
||||
/// 有效期(秒;0 = 未知)
|
||||
pub expires_in: u64,
|
||||
/// 状态/错误文案
|
||||
pub message: String,
|
||||
/// 成功后入库的账户展示名
|
||||
pub account_name: String,
|
||||
}
|
||||
|
||||
impl Default for DeviceLoginView {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceLoginView {
|
||||
/// 空闲态。
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
phase: DeviceLoginPhase::Idle,
|
||||
user_code: String::new(),
|
||||
verification_uri: String::new(),
|
||||
qr_link: String::new(),
|
||||
expires_in: 0,
|
||||
message: String::new(),
|
||||
account_name: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 已拿到用户码(`present` 回调时刻)。
|
||||
pub fn waiting(
|
||||
user_code: &str,
|
||||
verification_uri: &str,
|
||||
expires_in: u64,
|
||||
qr_hint: &str,
|
||||
) -> Self {
|
||||
let qr_link = if qr_hint.is_empty() {
|
||||
format!("{verification_uri}?code={user_code}")
|
||||
} else {
|
||||
qr_hint.to_string()
|
||||
};
|
||||
Self {
|
||||
phase: DeviceLoginPhase::Waiting,
|
||||
user_code: user_code.to_string(),
|
||||
verification_uri: verification_uri.to_string(),
|
||||
qr_link,
|
||||
expires_in,
|
||||
message: format!(
|
||||
"在浏览器打开 {verification_uri} 并输入用户码 {user_code}({} 秒内有效)",
|
||||
expires_in
|
||||
),
|
||||
account_name: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 授权码流:本地回调已就绪、浏览器已拉起(`present` 回调时刻)。
|
||||
///
|
||||
/// 面板不展示用户码,改展示**可复制的授权链接**(用户可能需要在另一台设备/
|
||||
/// 另一个浏览器里手动打开);`warning` 是「浏览器没拉起来」这类非致命提示。
|
||||
pub fn browser_waiting(
|
||||
authorization_url: &str,
|
||||
expires_in: u64,
|
||||
warning: Option<&str>,
|
||||
) -> Self {
|
||||
let mut message = format!(
|
||||
"已在系统浏览器打开微软登录页,请完成授权({} 秒内有效);没弹出就把下面的链接粘到浏览器里",
|
||||
expires_in.max(1)
|
||||
);
|
||||
if let Some(warning) = warning {
|
||||
message.push_str(&format!("({warning})"));
|
||||
}
|
||||
Self {
|
||||
phase: DeviceLoginPhase::Waiting,
|
||||
user_code: String::new(),
|
||||
verification_uri: authorization_url.to_string(),
|
||||
qr_link: authorization_url.to_string(),
|
||||
expires_in,
|
||||
message,
|
||||
account_name: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 失败态。
|
||||
pub fn failed(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
phase: DeviceLoginPhase::Failed,
|
||||
message: message.into(),
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 成功态。
|
||||
pub fn succeeded(account_name: impl Into<String>) -> Self {
|
||||
let account_name = account_name.into();
|
||||
Self {
|
||||
phase: DeviceLoginPhase::Succeeded,
|
||||
message: format!("已登录:{account_name}"),
|
||||
account_name,
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn kind_round_trips_through_stable_ids() {
|
||||
for kind in [
|
||||
AccountKind::Offline,
|
||||
AccountKind::Microsoft,
|
||||
AccountKind::Authlib,
|
||||
] {
|
||||
assert_eq!(AccountKind::parse(kind.as_str()), kind);
|
||||
assert!(!kind.label().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_login_view_never_carries_tokens() {
|
||||
let view = DeviceLoginView::waiting("ABCD-EFGH", "https://www.microsoft.com/link", 900, "");
|
||||
assert_eq!(view.phase, DeviceLoginPhase::Waiting);
|
||||
assert!(view.phase.busy());
|
||||
assert!(view.qr_link.contains("ABCD-EFGH"));
|
||||
let json = serde_json::to_string(&view).expect("序列化");
|
||||
for forbidden in ["token", "secret", "password", "refresh"] {
|
||||
assert!(
|
||||
!json.to_lowercase().contains(forbidden),
|
||||
"视图里不该出现 {forbidden}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_waiting_shows_a_copyable_link_instead_of_a_user_code() {
|
||||
let view = DeviceLoginView::browser_waiting(
|
||||
"https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=x",
|
||||
300,
|
||||
Some("拉起浏览器失败"),
|
||||
);
|
||||
assert_eq!(view.phase, DeviceLoginPhase::Waiting);
|
||||
assert!(view.phase.busy());
|
||||
assert!(view.user_code.is_empty(), "授权码流没有用户码");
|
||||
assert_eq!(view.qr_link, view.verification_uri);
|
||||
assert!(view.message.contains("浏览器"));
|
||||
assert!(view.message.contains("拉起浏览器失败"), "{}", view.message);
|
||||
let json = serde_json::to_string(&view).expect("序列化");
|
||||
for forbidden in ["refresh", "mc-access"] {
|
||||
assert!(!json.contains(forbidden), "视图里不该出现 {forbidden}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn succeeded_and_failed_clear_the_code() {
|
||||
let ok = DeviceLoginView::succeeded("Steve");
|
||||
assert_eq!(ok.phase, DeviceLoginPhase::Succeeded);
|
||||
assert!(ok.user_code.is_empty());
|
||||
let bad = DeviceLoginView::failed("网络超时");
|
||||
assert_eq!(bad.phase, DeviceLoginPhase::Failed);
|
||||
assert_eq!(bad.message, "网络超时");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! AI 会话 DTO:消息、工具调用可视化与 agent 事件。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 会话消息角色。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ChatRole {
|
||||
/// 用户
|
||||
User,
|
||||
/// 助手(可流式)
|
||||
Assistant,
|
||||
/// 工具调用(会话里可见,D4 要求)
|
||||
Tool,
|
||||
/// 系统提示(弱化展示)
|
||||
System,
|
||||
}
|
||||
|
||||
/// 工具调用可视化条目。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ToolCallView {
|
||||
/// 工具名(与 ToolSpec 注册表同名)
|
||||
pub name: String,
|
||||
/// 是否成功
|
||||
pub ok: bool,
|
||||
/// 一行结果摘要
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
/// 一条会话消息。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
/// 角色
|
||||
pub role: ChatRole,
|
||||
/// 正文(流式时持续追加)
|
||||
pub text: String,
|
||||
/// role == Tool 时带上工具调用详情
|
||||
pub tool: Option<ToolCallView>,
|
||||
/// 流式未收敛
|
||||
pub streaming: bool,
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
/// 用户消息。
|
||||
pub fn user(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: ChatRole::User,
|
||||
text: text.into(),
|
||||
tool: None,
|
||||
streaming: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 助手流式消息起点(空文本 + streaming)。
|
||||
pub fn assistant_stream() -> Self {
|
||||
Self {
|
||||
role: ChatRole::Assistant,
|
||||
text: String::new(),
|
||||
tool: None,
|
||||
streaming: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// agent 侧事件。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AgentEvent {
|
||||
/// 追加一条完整消息
|
||||
MessageAppended(ChatMessage),
|
||||
/// 给指定序号的流式消息追加增量
|
||||
MessageDelta {
|
||||
/// 目标消息在会话里的序号
|
||||
index: usize,
|
||||
/// 增量文本
|
||||
delta: String,
|
||||
},
|
||||
/// 流式收敛
|
||||
MessageFinished {
|
||||
/// 目标消息序号
|
||||
index: usize,
|
||||
},
|
||||
/// 开始一次工具调用(会话里插入工具条目)
|
||||
ToolCallStarted {
|
||||
/// 工具名
|
||||
name: String,
|
||||
/// 调用参数摘要
|
||||
summary: String,
|
||||
},
|
||||
/// 工具调用结束
|
||||
ToolCallFinished {
|
||||
/// 工具名
|
||||
name: String,
|
||||
/// 是否成功
|
||||
ok: bool,
|
||||
/// 结果摘要
|
||||
summary: String,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//! 事件与指令:core/agent → app 的事件队列,app → core/agent 的用户意图。
|
||||
//!
|
||||
//! 三条通道(01c §5):①不可变快照发布(`*Changed` 整体替换)②事件流(`LogAppended` /
|
||||
//! `InstallProgress` / `DeviceLoginChanged`)③注入的显示服务(先落成事件,见 `DialogRequested`)。
|
||||
//! 全部类型 `Serialize + Deserialize`:这是 IPC 契约(AGENTS §6)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dialogs::{DialogId, DialogRequest};
|
||||
use crate::dto::CreateRequest;
|
||||
use crate::dto::{
|
||||
AccountView, AgentEvent, DeviceLoginView, DiagnosisReport, InstallProgressView, InstanceId,
|
||||
InstanceRuntime, InstanceSummary, JavaPageView, LogLine, LogOutcome, LogSession, NoticeView,
|
||||
SessionId, SettingGroupView, VersionCategory, VersionSummary,
|
||||
};
|
||||
use crate::toast::Toast;
|
||||
|
||||
/// 启动会话的一段实时日志(host 尾随 JSONL 后按批次发)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LiveLogBatch {
|
||||
/// 会话 id
|
||||
pub session: SessionId,
|
||||
/// 新增行
|
||||
pub lines: Vec<LogLine>,
|
||||
/// 会话是否还在跑(false = 收尾,可以触发诊断/刷新列表)
|
||||
pub running: bool,
|
||||
}
|
||||
|
||||
/// 一次启动收尾(磁贴状态回写 + 日志页会话列表刷新)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LaunchFinished {
|
||||
/// 实例 id
|
||||
pub id: InstanceId,
|
||||
/// 会话 id
|
||||
pub session: SessionId,
|
||||
/// 结局
|
||||
pub outcome: LogOutcome,
|
||||
/// 一句人读结论(`正常退出` / `崩溃(exit 1)` / `启动失败:…`)
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
/// core 侧事件(不可变快照发布 + 事件流,01c §5 的三条通道之二)。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum CoreEvent {
|
||||
/// 实例列表整体刷新
|
||||
InstancesChanged(Vec<InstanceSummary>),
|
||||
/// 单个实例运行时状态变化(启动进度/运行中/失败)
|
||||
InstanceRuntimeChanged {
|
||||
/// 实例 id
|
||||
id: InstanceId,
|
||||
/// 新状态
|
||||
runtime: InstanceRuntime,
|
||||
},
|
||||
/// 下载页版本列表刷新
|
||||
VersionListChanged(Vec<VersionSummary>),
|
||||
/// 日志会话列表刷新
|
||||
LogSessionsChanged(Vec<LogSession>),
|
||||
/// 当前会话日志行刷新(整批替换)
|
||||
LogLinesChanged(Vec<LogLine>),
|
||||
/// 实时日志增量(启动中尾随 JSONL;`running=false` 表示会话收尾)
|
||||
LogAppended(LiveLogBatch),
|
||||
/// 启动收尾(磁贴状态回写 + toast)
|
||||
LaunchFinished(LaunchFinished),
|
||||
/// 诊断结论产出
|
||||
DiagnosisReady(DiagnosisReport),
|
||||
/// 账户列表刷新
|
||||
AccountsChanged(Vec<AccountView>),
|
||||
/// 微软登录状态刷新
|
||||
DeviceLoginChanged(DeviceLoginView),
|
||||
/// Java 页快照(发行版/回退链/运行时)
|
||||
JavaChanged(JavaPageView),
|
||||
/// 安装进度(`None` = 清掉进度面板)
|
||||
InstallProgress(Option<InstallProgressView>),
|
||||
/// 设置分组刷新(设置页右栏)
|
||||
SettingsChanged(Vec<SettingGroupView>),
|
||||
/// 横幅通知刷新(隔离切换提示/启动失败等)
|
||||
NoticesChanged(Vec<NoticeView>),
|
||||
}
|
||||
|
||||
/// 统一事件队列的元素:core / agent / UI 自产(对话框、Toast)都走这一条路,
|
||||
/// 这样 [`crate::state::AppState::apply`] 是唯一的状态变更入口。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum AppEvent {
|
||||
/// 来自 core
|
||||
Core(CoreEvent),
|
||||
/// 来自 agent
|
||||
Agent(AgentEvent),
|
||||
/// core/agent 请求弹对话框(会进队列,一次一条)
|
||||
DialogRequested(DialogRequest),
|
||||
/// 关闭指定对话框(任务完成收起进度条)
|
||||
DialogClosed(DialogId),
|
||||
/// 右下角 Toast
|
||||
Toast(Toast),
|
||||
}
|
||||
|
||||
/// app → core/agent 的用户意图。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AppCommand {
|
||||
/// 重新扫描实例目录 + 全部页面快照(启动时与手改文件后)
|
||||
RefreshAll,
|
||||
/// 启动实例
|
||||
LaunchInstance(InstanceId),
|
||||
/// 停止实例(含取消启动中的管线)
|
||||
StopInstance(InstanceId),
|
||||
/// 拉取某分类的版本列表
|
||||
ListVersions(VersionCategory),
|
||||
/// 安装指定版本(下载页行内「安装」按钮;原版)
|
||||
InstallVersion(String),
|
||||
/// 取消安装(阶段边界生效:当前阶段跑完就停,不再进下一阶段)
|
||||
CancelInstall,
|
||||
/// 新建实例(向导提交:版本 + 加载器 + 名字)
|
||||
CreateInstance(CreateRequest),
|
||||
/// 修改实例隔离档位(写实例设置文件)
|
||||
SetIsolation {
|
||||
/// 实例 id
|
||||
id: InstanceId,
|
||||
/// `isolated` / `shared`
|
||||
mode: String,
|
||||
},
|
||||
/// 刷新日志会话列表
|
||||
RefreshLogSessions,
|
||||
/// 打开某个会话(拉取日志行 + 触发诊断)
|
||||
OpenLogSession(SessionId),
|
||||
/// 导入崩溃日志文件做规则分析(`hs_err_pid*.log` / `latest.log`)
|
||||
ImportCrashLog(String),
|
||||
/// 加一个离线账户
|
||||
AddOfflineAccount(String),
|
||||
/// 删除账户(按 id 或名字)
|
||||
RemoveAccount(String),
|
||||
/// 切换当前启动账户
|
||||
SwitchAccount(String),
|
||||
/// 开始微软设备码登录(CLI/无浏览器场景、备用通道)
|
||||
StartDeviceLogin,
|
||||
/// 开始微软授权码登录(拉起系统默认浏览器 + 本地回调,GUI 的默认入口)
|
||||
StartBrowserLogin,
|
||||
/// 取消/关掉微软登录面板(已发出的请求会在收尾时丢弃结果)
|
||||
CancelDeviceLogin,
|
||||
/// 安装指定发行版的托管 JDK(`temurin` / `oracle` / `microsoft` / `zulu`)
|
||||
InstallJava(String),
|
||||
/// 写一条设置(`key` 必须是 core schema 里的键)
|
||||
SetSetting {
|
||||
/// schema 键
|
||||
key: String,
|
||||
/// 新值(字符串形态;host 按 schema 类型转换)
|
||||
value: String,
|
||||
},
|
||||
/// 关掉一条横幅通知
|
||||
DismissNotice(String),
|
||||
/// 向 AI 提问(会话状态在 app 侧,agent 只回事件)
|
||||
AskAgent(String),
|
||||
/// 中断当前 AI 流
|
||||
StopAgent,
|
||||
/// YOLO 开关(跳过确认,审计照记)
|
||||
SetYolo(bool),
|
||||
}
|
||||
|
||||
impl AppCommand {
|
||||
/// 构造「切换隔离档位」指令(磁贴/详情页共用)。
|
||||
pub fn set_isolation(id: &str, mode: &str) -> Self {
|
||||
Self::SetIsolation {
|
||||
id: id.to_string(),
|
||||
mode: mode.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 后端通道:core/agent host 实现它;[`crate::mock::MockBackend`] 验证通道语义。
|
||||
///
|
||||
/// 轮询模型(而非回调)是为了让 Slint 的定时器成为唯一调度者,
|
||||
/// 避免跨线程回调里碰 Slint 模型。
|
||||
pub trait Backend {
|
||||
/// 取走自上次调用以来积压的事件(FIFO,不可丢序号)。
|
||||
fn drain_events(&mut self) -> Vec<AppEvent>;
|
||||
|
||||
/// 下发一条用户意图。
|
||||
fn dispatch(&mut self, command: AppCommand);
|
||||
|
||||
/// 时间推进(真实后端在这里收后台线程的结果并尾随日志)。
|
||||
fn tick(&mut self, elapsed: std::time::Duration) {
|
||||
let _ = elapsed;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dto::{AccountKind, DeviceLoginPhase, InstallStage};
|
||||
|
||||
#[test]
|
||||
fn events_are_serializable_ipc_contracts() {
|
||||
let event = AppEvent::Core(CoreEvent::InstallProgress(Some(InstallProgressView {
|
||||
stage: InstallStage::Downloading,
|
||||
files_done: 3,
|
||||
files_total: 9,
|
||||
..InstallProgressView::new()
|
||||
})));
|
||||
let text = serde_json::to_string(&event).expect("序列化");
|
||||
let back: AppEvent = serde_json::from_str(&text).expect("反序列化");
|
||||
assert_eq!(back, event);
|
||||
|
||||
let account = AppEvent::Core(CoreEvent::AccountsChanged(vec![AccountView {
|
||||
id: "account:1".to_string(),
|
||||
name: "Steve".to_string(),
|
||||
kind: AccountKind::Offline,
|
||||
profile_id: "0f8f".to_string(),
|
||||
login_name: String::new(),
|
||||
created: "2026-10-31 20:12".to_string(),
|
||||
active: true,
|
||||
}]));
|
||||
let text = serde_json::to_string(&account).expect("序列化");
|
||||
let back: AppEvent = serde_json::from_str(&text).expect("反序列化");
|
||||
assert_eq!(back, account);
|
||||
// 凭据永不跨界:序列化文本里没有 token 类字段
|
||||
assert!(!text.to_lowercase().contains("token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_round_trip() {
|
||||
let command = AppCommand::CreateInstance(CreateRequest {
|
||||
version: "1.20.4".to_string(),
|
||||
loader: crate::dto::LoaderKind::Fabric,
|
||||
loader_version: "0.16.5".to_string(),
|
||||
name: "生存".to_string(),
|
||||
});
|
||||
let text = serde_json::to_string(&command).expect("序列化");
|
||||
let back: AppCommand = serde_json::from_str(&text).expect("反序列化");
|
||||
assert_eq!(back, command);
|
||||
|
||||
let login = AppCommand::StartDeviceLogin;
|
||||
assert_eq!(
|
||||
serde_json::from_str::<AppCommand>(&serde_json::to_string(&login).expect("序列化"))
|
||||
.expect("反序列化"),
|
||||
login
|
||||
);
|
||||
assert!(DeviceLoginPhase::Waiting.busy());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
//! 安装进度 DTO:下载页与新建向导共用的「一个安装任务」视图。
|
||||
//!
|
||||
//! 只放聚合量(文件计数 / 字节 / 速度 / 剩余时间)+ 一行状态文案:
|
||||
//! 逐文件的明细留在 core 的 `InstallReport`,UI 不需要也不该持有。
|
||||
//! 速度与 ETA 由 host 依据相邻两次字节数差分算(core 的事件只给绝对值)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 安装阶段。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum InstallStage {
|
||||
/// 解析版本清单 / 规划文件(联网取 metadata)
|
||||
Planning,
|
||||
/// 下载并校验文件
|
||||
Downloading,
|
||||
/// 落版本 JSON / 跑安装器
|
||||
Installing,
|
||||
/// 登记实例
|
||||
Registering,
|
||||
/// 完成
|
||||
Done,
|
||||
/// 失败
|
||||
Failed,
|
||||
/// 用户取消
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl InstallStage {
|
||||
/// 阶段文案。
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Planning => "解析版本",
|
||||
Self::Downloading => "下载文件",
|
||||
Self::Installing => "生成版本",
|
||||
Self::Registering => "登记实例",
|
||||
Self::Done => "已完成",
|
||||
Self::Failed => "失败",
|
||||
Self::Cancelled => "已取消",
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否已经收尾(Done/Failed/Cancelled)。
|
||||
pub fn is_terminal(self) -> bool {
|
||||
matches!(self, Self::Done | Self::Failed | Self::Cancelled)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次安装的进度快照。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InstallProgressView {
|
||||
/// 目标版本 id
|
||||
pub version: String,
|
||||
/// 实例名(默认 = 版本 id)
|
||||
pub instance: String,
|
||||
/// 加载器徽标文字(`原版` / `Fabric 0.16.5`)
|
||||
pub loader: String,
|
||||
/// 阶段
|
||||
pub stage: InstallStage,
|
||||
/// 已完成文件数
|
||||
pub files_done: u64,
|
||||
/// 文件总数
|
||||
pub files_total: u64,
|
||||
/// 已下载字节
|
||||
pub bytes_done: u64,
|
||||
/// 总字节(0 = 未知)
|
||||
pub bytes_total: u64,
|
||||
/// 当前速度(字节/秒;0 = 未知)
|
||||
pub speed_bps: u64,
|
||||
/// 剩余秒数(0 = 未知)
|
||||
pub eta_secs: u64,
|
||||
/// 状态/错误文案
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl Default for InstallProgressView {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl InstallProgressView {
|
||||
/// 规划中的空快照。
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
version: String::new(),
|
||||
instance: String::new(),
|
||||
loader: String::new(),
|
||||
stage: InstallStage::Planning,
|
||||
files_done: 0,
|
||||
files_total: 0,
|
||||
bytes_done: 0,
|
||||
bytes_total: 0,
|
||||
speed_bps: 0,
|
||||
eta_secs: 0,
|
||||
message: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 起一个任务的快照。
|
||||
pub fn start(version: &str, instance: &str, loader: &str) -> Self {
|
||||
Self {
|
||||
version: version.to_string(),
|
||||
instance: instance.to_string(),
|
||||
loader: loader.to_string(),
|
||||
stage: InstallStage::Planning,
|
||||
message: "正在解析版本清单…".to_string(),
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 整体进度 0.0~1.0(优先文件计数,其次字节;都没有时 0)。
|
||||
pub fn ratio(&self) -> f32 {
|
||||
if self.files_total > 0 {
|
||||
(self.files_done as f32 / self.files_total as f32).clamp(0.0, 1.0)
|
||||
} else if self.bytes_total > 0 {
|
||||
(self.bytes_done as f32 / self.bytes_total as f32).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// 进度是否已知(未知时进度条转不确定态)。
|
||||
pub fn is_known(&self) -> bool {
|
||||
self.files_total > 0 || self.bytes_total > 0
|
||||
}
|
||||
|
||||
/// 速度文案(`1.2 MB/s`;未知给空串)。
|
||||
pub fn speed_label(&self) -> String {
|
||||
if self.speed_bps == 0 {
|
||||
return String::new();
|
||||
}
|
||||
format!("{}/s", human_bytes(self.speed_bps))
|
||||
}
|
||||
|
||||
/// 剩余时间文案(`剩 12s`;未知给空串)。
|
||||
pub fn eta_label(&self) -> String {
|
||||
if self.eta_secs == 0 {
|
||||
return String::new();
|
||||
}
|
||||
if self.eta_secs >= 60 {
|
||||
format!("剩 {}m{:02}s", self.eta_secs / 60, self.eta_secs % 60)
|
||||
} else {
|
||||
format!("剩 {}s", self.eta_secs)
|
||||
}
|
||||
}
|
||||
|
||||
/// 文件计数文案(`128/512 文件`)。
|
||||
pub fn files_label(&self) -> String {
|
||||
if self.files_total == 0 {
|
||||
return String::new();
|
||||
}
|
||||
format!("{}/{} 文件", self.files_done, self.files_total)
|
||||
}
|
||||
}
|
||||
|
||||
/// 字节数 → 人读(`1.2 MB`)。
|
||||
pub fn human_bytes(bytes: u64) -> String {
|
||||
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
|
||||
let mut value = bytes as f64;
|
||||
let mut unit = 0;
|
||||
while value >= 1024.0 && unit + 1 < UNITS.len() {
|
||||
value /= 1024.0;
|
||||
unit += 1;
|
||||
}
|
||||
if unit == 0 {
|
||||
format!("{bytes} B")
|
||||
} else {
|
||||
format!("{value:.1} {}", UNITS[unit])
|
||||
}
|
||||
}
|
||||
|
||||
/// 用相邻两次采样算速度与 ETA(`None` = 本次无法计算,保持上次值)。
|
||||
pub fn derive_speed(
|
||||
previous: (u64, std::time::Instant),
|
||||
current: (u64, std::time::Instant),
|
||||
bytes_total: u64,
|
||||
) -> Option<(u64, u64)> {
|
||||
let elapsed = current.1.saturating_duration_since(previous.1);
|
||||
if elapsed.as_millis() < 50 {
|
||||
return None;
|
||||
}
|
||||
let delta = current.0.saturating_sub(previous.0);
|
||||
if delta == 0 {
|
||||
return None;
|
||||
}
|
||||
let elapsed_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
|
||||
let speed = delta.saturating_mul(1000) / elapsed_ms.max(1);
|
||||
let eta = if bytes_total > current.0 && speed > 0 {
|
||||
(bytes_total - current.0) / speed
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Some((speed, eta))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[test]
|
||||
fn ratio_prefers_file_counts() {
|
||||
let mut view = InstallProgressView::start("1.20.4", "1.20.4", "原版");
|
||||
assert_eq!(view.ratio(), 0.0);
|
||||
assert!(!view.is_known());
|
||||
|
||||
view.files_total = 4;
|
||||
view.files_done = 1;
|
||||
view.bytes_total = 1000;
|
||||
view.bytes_done = 900;
|
||||
assert!((view.ratio() - 0.25).abs() < f32::EPSILON);
|
||||
assert!(view.is_known());
|
||||
assert_eq!(view.files_label(), "1/4 文件");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_and_eta_labels_are_human_readable() {
|
||||
let view = InstallProgressView {
|
||||
speed_bps: 1_258_291,
|
||||
eta_secs: 75,
|
||||
..InstallProgressView::new()
|
||||
};
|
||||
assert_eq!(view.speed_label(), "1.2 MB/s");
|
||||
assert_eq!(view.eta_label(), "剩 1m15s");
|
||||
assert_eq!(InstallProgressView::new().speed_label(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_speed_needs_a_measurable_delta() {
|
||||
let start = Instant::now();
|
||||
let previous = (0, start);
|
||||
// 间隔太短 → 不算
|
||||
assert!(derive_speed(previous, (500, start + Duration::from_millis(10)), 1000).is_none());
|
||||
// 正常差分:1s 下 500 字节 → 500 B/s,剩 500 字节 → 1s
|
||||
let (speed, eta) =
|
||||
derive_speed(previous, (500, start + Duration::from_secs(1)), 1000).expect("应能算");
|
||||
assert_eq!(speed, 500);
|
||||
assert_eq!(eta, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_stages() {
|
||||
assert!(InstallStage::Done.is_terminal());
|
||||
assert!(InstallStage::Failed.is_terminal());
|
||||
assert!(!InstallStage::Downloading.is_terminal());
|
||||
assert_eq!(human_bytes(512), "512 B");
|
||||
assert_eq!(human_bytes(2048), "2.0 KB");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! 实例相关 DTO:磁贴与列表视图的唯一数据源。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 实例标识:core::game 生成的稳定 id,app 视为不透明字符串。
|
||||
pub type InstanceId = String;
|
||||
|
||||
/// 加载器种类(磁贴徽标 + 详情页版本链)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LoaderKind {
|
||||
/// 原版(无加载器)
|
||||
Vanilla,
|
||||
/// Fabric
|
||||
Fabric,
|
||||
/// Quilt
|
||||
Quilt,
|
||||
/// Forge
|
||||
Forge,
|
||||
/// NeoForge
|
||||
NeoForge,
|
||||
/// OptiFine
|
||||
OptiFine,
|
||||
/// LiteLoader
|
||||
LiteLoader,
|
||||
}
|
||||
|
||||
impl LoaderKind {
|
||||
/// 徽标显示文字。
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Vanilla => "原版",
|
||||
Self::Fabric => "Fabric",
|
||||
Self::Quilt => "Quilt",
|
||||
Self::Forge => "Forge",
|
||||
Self::NeoForge => "NeoForge",
|
||||
Self::OptiFine => "OptiFine",
|
||||
Self::LiteLoader => "LiteLoader",
|
||||
}
|
||||
}
|
||||
|
||||
/// 稳定 id(`vanilla` / `fabric` / …;GUI 与 CLI 的 `--loader` 同词表)。
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Vanilla => "vanilla",
|
||||
Self::Fabric => "fabric",
|
||||
Self::Quilt => "quilt",
|
||||
Self::Forge => "forge",
|
||||
Self::NeoForge => "neoforge",
|
||||
Self::OptiFine => "optifine",
|
||||
Self::LiteLoader => "liteloader",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从稳定 id 解析(未知给 `None`,由调用方决定报错文案)。
|
||||
pub fn parse(raw: &str) -> Option<Self> {
|
||||
Some(match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"vanilla" | "none" | "" => Self::Vanilla,
|
||||
"fabric" => Self::Fabric,
|
||||
"quilt" => Self::Quilt,
|
||||
"forge" => Self::Forge,
|
||||
"neoforge" => Self::NeoForge,
|
||||
"optifine" => Self::OptiFine,
|
||||
"liteloader" => Self::LiteLoader,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 向导里的可选加载器(顺序即展示顺序)。
|
||||
pub fn wizard_choices() -> [Self; 5] {
|
||||
[
|
||||
Self::Vanilla,
|
||||
Self::Fabric,
|
||||
Self::Quilt,
|
||||
Self::Forge,
|
||||
Self::NeoForge,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载器 + 版本(例如 Fabric 0.16.5)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LoaderInfo {
|
||||
/// 加载器种类
|
||||
pub kind: LoaderKind,
|
||||
/// 加载器版本号;原版为空串
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
impl Default for LoaderInfo {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
kind: LoaderKind::Vanilla,
|
||||
version: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LoaderInfo {
|
||||
/// 徽标文字:原版只写「原版」,装加载器时写「Fabric 0.16.5」。
|
||||
pub fn badge(&self) -> String {
|
||||
if self.kind == LoaderKind::Vanilla || self.version.is_empty() {
|
||||
self.kind.label().to_string()
|
||||
} else {
|
||||
format!("{} {}", self.kind.label(), self.version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 字节进度;`total == 0` 表示总量未知(UI 转不确定态)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct Progress {
|
||||
/// 已完成量
|
||||
pub done: u64,
|
||||
/// 总量;0 = 未知
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
impl Progress {
|
||||
/// 0.0~1.0;未知总量返回 0.0。
|
||||
pub fn ratio(self) -> f32 {
|
||||
if self.total == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(self.done as f32 / self.total as f32).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 总量是否已知。
|
||||
pub fn is_known(self) -> bool {
|
||||
self.total > 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动管线阶段(磁贴进度环的文案来源;D8 的 10 阶段在 P1 细化)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LaunchStage {
|
||||
/// 下载资源
|
||||
Download,
|
||||
/// 校验文件
|
||||
Verify,
|
||||
/// 组装命令行并拉起进程
|
||||
Launch,
|
||||
/// 进程已起、等待游戏窗口(M28 的窗口检测阶段)
|
||||
Window,
|
||||
}
|
||||
|
||||
impl LaunchStage {
|
||||
/// 磁贴上的阶段文案。
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Download => "下载资源",
|
||||
Self::Verify => "校验文件",
|
||||
Self::Launch => "组装命令行",
|
||||
Self::Window => "等待窗口",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 实例运行时状态(磁贴的四种外观)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum InstanceRuntime {
|
||||
/// 空闲
|
||||
#[default]
|
||||
Idle,
|
||||
/// 启动管线进行中
|
||||
Busy {
|
||||
/// 当前阶段
|
||||
stage: LaunchStage,
|
||||
/// 该阶段进度
|
||||
progress: Progress,
|
||||
},
|
||||
/// 游戏进程存活
|
||||
Running {
|
||||
/// 进程号(停止/诊断用)
|
||||
pid: u32,
|
||||
},
|
||||
/// 上次启动失败(磁贴显示「重试」)
|
||||
Failed {
|
||||
/// 失败原因(用户可见文案)
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// 实例摘要:磁贴/列表视图唯一数据源。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct InstanceSummary {
|
||||
/// 稳定 id
|
||||
pub id: InstanceId,
|
||||
/// 显示名
|
||||
pub name: String,
|
||||
/// 游戏版本(如 1.20.4)
|
||||
pub mc_version: String,
|
||||
/// 加载器信息
|
||||
pub loader: LoaderInfo,
|
||||
/// ISO-8601 UTC 最后游玩时间;None = 从未启动
|
||||
pub last_played: Option<String>,
|
||||
/// 运行时状态
|
||||
pub runtime: InstanceRuntime,
|
||||
/// 实例图标路径(P0 直接给路径;P1 换成 core 提供的图像句柄)
|
||||
pub icon: Option<PathBuf>,
|
||||
/// 隔离档位(`isolated` / `shared` / `custom`;未知为空)
|
||||
pub isolation: String,
|
||||
/// 该实例绑定的版本 id(详情页/启动用)
|
||||
pub version_id: String,
|
||||
/// 版本本体是否在盘上(缺载荷时磁贴亮黄条)
|
||||
pub version_present: bool,
|
||||
}
|
||||
|
||||
/// 新建实例向导提交给 host 的请求(已通过向导校验)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CreateRequest {
|
||||
/// 目标原版版本 id(`1.20.4`)
|
||||
pub version: String,
|
||||
/// 加载器
|
||||
pub loader: LoaderKind,
|
||||
/// 加载器版本(空 = 让 core 取最新)
|
||||
pub loader_version: String,
|
||||
/// 实例名(空 = 用版本 id / 加载器版本 id)
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl CreateRequest {
|
||||
/// 实际使用的实例名。
|
||||
pub fn instance_name(&self) -> String {
|
||||
if self.name.trim().is_empty() {
|
||||
self.version.clone()
|
||||
} else {
|
||||
self.name.trim().to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn loader_badges_and_ids() {
|
||||
let vanilla = LoaderInfo::default();
|
||||
assert_eq!(vanilla.badge(), "原版");
|
||||
let fabric = LoaderInfo {
|
||||
kind: LoaderKind::Fabric,
|
||||
version: "0.16.5".to_string(),
|
||||
};
|
||||
assert_eq!(fabric.badge(), "Fabric 0.16.5");
|
||||
for kind in LoaderKind::wizard_choices() {
|
||||
assert_eq!(LoaderKind::parse(kind.as_str()), Some(kind));
|
||||
}
|
||||
assert_eq!(LoaderKind::parse("fabric"), Some(LoaderKind::Fabric));
|
||||
assert_eq!(LoaderKind::parse("unknown"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_ratio_handles_unknown_total() {
|
||||
assert_eq!(Progress { done: 1, total: 0 }.ratio(), 0.0);
|
||||
assert!((Progress { done: 1, total: 4 }.ratio() - 0.25).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_request_defaults_name_to_version() {
|
||||
let request = CreateRequest {
|
||||
version: "1.20.4".to_string(),
|
||||
loader: LoaderKind::Vanilla,
|
||||
loader_version: String::new(),
|
||||
name: " ".to_string(),
|
||||
};
|
||||
assert_eq!(request.instance_name(), "1.20.4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_summary_is_serde_round_trip_safe() {
|
||||
let instance = InstanceSummary {
|
||||
id: "inst-1".to_string(),
|
||||
name: "生存".to_string(),
|
||||
mc_version: "1.20.4".to_string(),
|
||||
loader: LoaderInfo {
|
||||
kind: LoaderKind::Fabric,
|
||||
version: "0.16.5".to_string(),
|
||||
},
|
||||
last_played: Some("2026-10-31T12:00:00.000Z".to_string()),
|
||||
runtime: InstanceRuntime::Busy {
|
||||
stage: LaunchStage::Window,
|
||||
progress: Progress { done: 1, total: 2 },
|
||||
},
|
||||
icon: None,
|
||||
isolation: "isolated".to_string(),
|
||||
version_id: "1.20.4".to_string(),
|
||||
version_present: true,
|
||||
};
|
||||
let text = serde_json::to_string(&instance).expect("序列化");
|
||||
let back: InstanceSummary = serde_json::from_str(&text).expect("反序列化");
|
||||
assert_eq!(back, instance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Java 页 DTO:四家发行版、回退链可视化与本地运行时清单(D23/D13)。
|
||||
//!
|
||||
//! 回退链是「可观测性」的载体:安装失败时用户要能看见「Temurin 跳过 → Oracle 失败 →
|
||||
//! Microsoft 命中」这条链,而不是只拿到一句「下载失败」。链的每一步由 host 从
|
||||
//! `core::game::java::source::ChainLog` 翻译过来。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 一家发行版的展示行。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct JavaVendorView {
|
||||
/// 稳定 id(`temurin` / `oracle` / `microsoft` / `zulu`)
|
||||
pub id: String,
|
||||
/// 展示名
|
||||
pub display: String,
|
||||
/// 优先级序号(1 起)
|
||||
pub rank: u32,
|
||||
/// 支持的 Java 大版本摘要(`21/17/11`)
|
||||
pub majors: String,
|
||||
/// 可用源(镜像 + 吞吐备注)
|
||||
pub sources: Vec<String>,
|
||||
/// 是否已装过这家(托管目录里有现成的)
|
||||
pub installed: bool,
|
||||
}
|
||||
|
||||
/// 回退链一步的结局。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum JavaChainOutcome {
|
||||
/// 还没尝试(安装前的计划链)
|
||||
Pending,
|
||||
/// 跳过(不支持目标大版本)
|
||||
Skip,
|
||||
/// 尝试过但失败
|
||||
Failed,
|
||||
/// 命中(下载/安装成功)
|
||||
Hit,
|
||||
}
|
||||
|
||||
impl JavaChainOutcome {
|
||||
/// 徽标文字。
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "待尝试",
|
||||
Self::Skip => "跳过",
|
||||
Self::Failed => "失败",
|
||||
Self::Hit => "命中",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 core 的 `VendorAttempt` 结局字符串解析。
|
||||
pub fn parse(raw: &str) -> Self {
|
||||
match raw {
|
||||
"hit" => Self::Hit,
|
||||
"failed" => Self::Failed,
|
||||
"skip" | "skipped" => Self::Skip,
|
||||
_ => Self::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 回退链一步。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct JavaChainStep {
|
||||
/// 发行版 id
|
||||
pub vendor: String,
|
||||
/// 结局
|
||||
pub outcome: JavaChainOutcome,
|
||||
/// 细节(跳过原因 / 失败摘要 / 命中的下载源)
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// 本地已发现的 Java 运行时。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct JavaRuntimeView {
|
||||
/// 可执行文件路径
|
||||
pub binary: String,
|
||||
/// 版本串(`17.0.9`)
|
||||
pub version: String,
|
||||
/// 大版本
|
||||
pub major: u32,
|
||||
/// 发行方
|
||||
pub vendor: String,
|
||||
/// 来源分类(system / managed / instance)
|
||||
pub origin: String,
|
||||
}
|
||||
|
||||
/// Java 页快照(一次事件整体替换)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct JavaPageView {
|
||||
/// 地区判定(`domestic` / `global`)
|
||||
pub region: String,
|
||||
/// 优先级的来源(`region_default` / `java.vendorPriority`)
|
||||
pub priority_source: String,
|
||||
/// 生效顺序(`temurin → oracle → microsoft → zulu`)
|
||||
pub order_text: String,
|
||||
/// 四家展示行(按生效顺序)
|
||||
pub vendors: Vec<JavaVendorView>,
|
||||
/// 回退链(计划链或上一次安装的真实链)
|
||||
pub chain: Vec<JavaChainStep>,
|
||||
/// 本地运行时
|
||||
pub runtimes: Vec<JavaRuntimeView>,
|
||||
/// 托管目录
|
||||
pub managed_roots: Vec<String>,
|
||||
/// 是否有 Java 安装在跑
|
||||
pub busy: bool,
|
||||
/// 状态/错误文案
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl Default for JavaPageView {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl JavaPageView {
|
||||
/// 空态(托管目录还没扫到东西)。
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
region: "global".to_string(),
|
||||
priority_source: "region_default".to_string(),
|
||||
order_text: String::new(),
|
||||
vendors: Vec::new(),
|
||||
chain: Vec::new(),
|
||||
runtimes: Vec::new(),
|
||||
managed_roots: Vec::new(),
|
||||
busy: false,
|
||||
message: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 计划链:按生效顺序铺一排「待尝试」(安装前的可视化)。
|
||||
pub fn planned_chain(vendors: &[JavaVendorView]) -> Vec<JavaChainStep> {
|
||||
vendors
|
||||
.iter()
|
||||
.map(|vendor| JavaChainStep {
|
||||
vendor: vendor.id.clone(),
|
||||
outcome: JavaChainOutcome::Pending,
|
||||
detail: String::new(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 命中的那一家的展示名(`None` = 链上还没命中)。
|
||||
pub fn hit_vendor(&self) -> Option<&str> {
|
||||
self.chain
|
||||
.iter()
|
||||
.find(|step| step.outcome == JavaChainOutcome::Hit)
|
||||
.map(|step| step.vendor.as_str())
|
||||
}
|
||||
|
||||
/// 已安装的大版本列表(去重升序)。
|
||||
pub fn majors(&self) -> Vec<u32> {
|
||||
let mut majors: Vec<u32> = self.runtimes.iter().map(|runtime| runtime.major).collect();
|
||||
majors.sort_unstable();
|
||||
majors.dedup();
|
||||
majors
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn vendor(id: &str, rank: u32) -> JavaVendorView {
|
||||
JavaVendorView {
|
||||
id: id.to_string(),
|
||||
display: id.to_uppercase(),
|
||||
rank,
|
||||
majors: "21/17".to_string(),
|
||||
sources: Vec::new(),
|
||||
installed: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planned_chain_is_all_pending_in_rank_order() {
|
||||
let vendors = vec![vendor("temurin", 1), vendor("oracle", 2)];
|
||||
let chain = JavaPageView::planned_chain(&vendors);
|
||||
assert_eq!(chain.len(), 2);
|
||||
assert!(chain
|
||||
.iter()
|
||||
.all(|step| step.outcome == JavaChainOutcome::Pending));
|
||||
assert_eq!(chain[0].vendor, "temurin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_vendor_finds_the_hit_step() {
|
||||
let mut page = JavaPageView::new();
|
||||
page.chain = vec![
|
||||
JavaChainStep {
|
||||
vendor: "temurin".to_string(),
|
||||
outcome: JavaChainOutcome::Skip,
|
||||
detail: "不支持 8".to_string(),
|
||||
},
|
||||
JavaChainStep {
|
||||
vendor: "oracle".to_string(),
|
||||
outcome: JavaChainOutcome::Hit,
|
||||
detail: "BMCLAPI".to_string(),
|
||||
},
|
||||
];
|
||||
assert_eq!(page.hit_vendor(), Some("oracle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_parses_core_words() {
|
||||
assert_eq!(JavaChainOutcome::parse("hit"), JavaChainOutcome::Hit);
|
||||
assert_eq!(JavaChainOutcome::parse("failed"), JavaChainOutcome::Failed);
|
||||
assert_eq!(JavaChainOutcome::parse("skip"), JavaChainOutcome::Skip);
|
||||
assert_eq!(JavaChainOutcome::parse("?"), JavaChainOutcome::Pending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn majors_are_deduped_and_sorted() {
|
||||
let mut page = JavaPageView::new();
|
||||
page.runtimes = vec![
|
||||
JavaRuntimeView {
|
||||
binary: "a".to_string(),
|
||||
version: "17".to_string(),
|
||||
major: 17,
|
||||
vendor: "Temurin".to_string(),
|
||||
origin: "managed".to_string(),
|
||||
},
|
||||
JavaRuntimeView {
|
||||
binary: "b".to_string(),
|
||||
version: "17".to_string(),
|
||||
major: 17,
|
||||
vendor: "Oracle".to_string(),
|
||||
origin: "system".to_string(),
|
||||
},
|
||||
];
|
||||
assert_eq!(page.majors(), vec![17]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! 日志页 DTO:会话列表、日志行与 D8 诊断结论。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 一次启动会话(日志页左栏单位)标识。
|
||||
pub type SessionId = String;
|
||||
|
||||
/// 一次运行的结局(G3 左栏图标:✓ 正常 / ✗ 崩溃 / ⚠ 静默失败)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LogOutcome {
|
||||
/// 正常退出
|
||||
Ok,
|
||||
/// 崩溃(非零退出码或 hs_err_pid)
|
||||
Crashed,
|
||||
/// exit0 但游戏没起来(D8 新增检测)
|
||||
SilentFail,
|
||||
}
|
||||
|
||||
/// 会话摘要。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LogSession {
|
||||
/// 会话 id
|
||||
pub id: SessionId,
|
||||
/// 实例名(会话属于哪个实例)
|
||||
pub instance: String,
|
||||
/// 展示用时间(`10-29 19:22`)
|
||||
pub when: String,
|
||||
/// 结局
|
||||
pub outcome: LogOutcome,
|
||||
/// 时长(秒)
|
||||
pub duration_secs: u64,
|
||||
}
|
||||
|
||||
/// 日志行等级(错误行高亮)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LogLevel {
|
||||
/// debug
|
||||
Debug,
|
||||
/// info
|
||||
Info,
|
||||
/// warn
|
||||
Warn,
|
||||
/// error
|
||||
Error,
|
||||
}
|
||||
|
||||
/// 一行日志。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LogLine {
|
||||
/// 原始文本(含时间戳前缀)
|
||||
pub text: String,
|
||||
/// 等级
|
||||
pub level: LogLevel,
|
||||
}
|
||||
|
||||
/// D8 规则引擎结论(日志页崩溃诊断卡片)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Diagnosis {
|
||||
/// 命中规则 id(`gpu.driver_old`)
|
||||
pub rule: String,
|
||||
/// 结论标题
|
||||
pub title: String,
|
||||
/// 0~100 置信度
|
||||
pub confidence: u8,
|
||||
/// 建议文案
|
||||
pub hint: String,
|
||||
/// 可一键执行的修复动作(无则只给建议)
|
||||
pub fix: Option<String>,
|
||||
}
|
||||
|
||||
/// 诊断结论 + 归属会话(UI 用它决定卡片显隐)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DiagnosisReport {
|
||||
/// 归属会话
|
||||
pub session: SessionId,
|
||||
/// 诊断内容
|
||||
pub diagnosis: Diagnosis,
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! presenter ↔ core/agent 的 IPC 边界(01c §5 纪律)。
|
||||
//!
|
||||
//! 这里只允许出现「扁平 DTO / 事件 / 指令」:本 crate 的 DTO 里不得出现 `unlucky_core::*`
|
||||
//! 或 `use unlucky_agent::*` 的内部类型。core/agent 侧的 host 层负责把自身类型翻译成本模块的类型,
|
||||
//! 反向的 [`AppCommand`] 也一样。P0 阶段由 [`crate::mock`] 的假后端实现 [`Backend`] 驱动骨架,
|
||||
//! P3 起由 [`crate::host`] 的真实后端驱动全部页面。
|
||||
//!
|
||||
//! 结构:**按领域拆子模块**(instances / versions / logs / agent / accounts / java /
|
||||
//! settings / install / notices / events),全量 `pub use` 出去,调用方依旧写
|
||||
//! `crate::dto::InstanceSummary` 这类路径。
|
||||
//! 契约说明(字段口径、事件顺序、错误语义)见 `DevDocs/SubAgentsTalk/app-DTO通道约定.md`;
|
||||
//! UI 侧的唯一入口与 iced 接线用法见 crate 文档(`src/lib.rs`)。
|
||||
//!
|
||||
//! 全部跨界类型 `Serialize + Deserialize`(AGENTS §6):事件与快照是 IPC 契约,
|
||||
//! 序列化能力由 derive 钉住,`dto` 的单测做 round-trip 断言。
|
||||
|
||||
mod accounts;
|
||||
mod agent;
|
||||
mod events;
|
||||
mod install;
|
||||
mod instances;
|
||||
mod java;
|
||||
mod logs;
|
||||
mod notices;
|
||||
mod settings;
|
||||
mod versions;
|
||||
|
||||
pub use accounts::{AccountKind, AccountView, DeviceLoginPhase, DeviceLoginView};
|
||||
pub use agent::{AgentEvent, ChatMessage, ChatRole, ToolCallView};
|
||||
pub use events::{AppCommand, AppEvent, Backend, CoreEvent, LaunchFinished, LiveLogBatch};
|
||||
pub use install::{derive_speed, human_bytes, InstallProgressView, InstallStage};
|
||||
pub use instances::{
|
||||
CreateRequest, InstanceId, InstanceRuntime, InstanceSummary, LaunchStage, LoaderInfo,
|
||||
LoaderKind, Progress,
|
||||
};
|
||||
pub use java::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView};
|
||||
pub use logs::{Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSession, SessionId};
|
||||
pub use notices::{NoticeLevel, NoticeView};
|
||||
pub use settings::{SettingControl, SettingGroupView, SettingRowView};
|
||||
pub use versions::{AncientKind, VersionCategory, VersionKind, VersionSummary};
|
||||
@@ -0,0 +1,73 @@
|
||||
//! 横幅通知 DTO:实例页/下载页顶部的「需要注意」提示条。
|
||||
//!
|
||||
//! 与 Toast 的分工:Toast 是「刚才发生了什么」(3 秒消失),通知条是
|
||||
//! 「现在需要你处理」(用户点了才收)。隔离模式切换提示(M30 `ModeChangeWarning`)
|
||||
//! 与启动失败都用它。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 通知级别。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum NoticeLevel {
|
||||
/// 一般信息
|
||||
Info,
|
||||
/// 需要留意(黄条)
|
||||
Warn,
|
||||
/// 失败(红条)
|
||||
Error,
|
||||
}
|
||||
|
||||
impl NoticeLevel {
|
||||
/// 级别文案(无障碍/测试用)。
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Info => "信息",
|
||||
Self::Warn => "注意",
|
||||
Self::Error => "错误",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条通知。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NoticeView {
|
||||
/// 稳定 id(关闭时按它移除)
|
||||
pub id: String,
|
||||
/// 级别
|
||||
pub level: NoticeLevel,
|
||||
/// 标题
|
||||
pub title: String,
|
||||
/// 正文
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
impl NoticeView {
|
||||
/// 建一条。
|
||||
pub fn new(id: &str, level: NoticeLevel, title: &str, body: &str) -> Self {
|
||||
Self {
|
||||
id: id.to_string(),
|
||||
level,
|
||||
title: title.to_string(),
|
||||
body: body.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn notices_are_serde_round_trip_safe() {
|
||||
let notice = NoticeView::new(
|
||||
"isolation:demo",
|
||||
NoticeLevel::Warn,
|
||||
"隔离模式已切换",
|
||||
"旧目录里还有 mods/saves",
|
||||
);
|
||||
let text = serde_json::to_string(¬ice).expect("序列化");
|
||||
let back: NoticeView = serde_json::from_str(&text).expect("反序列化");
|
||||
assert_eq!(back, notice);
|
||||
assert_eq!(back.level.label(), "注意");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! 设置页 DTO:设置分组的通用行模型。
|
||||
//!
|
||||
//! 「行」是 UI 与 `core::setting` schema 之间的唯一形状:一行 = 一个键 + 一种控件 +
|
||||
//! 当前值。写回走 [`crate::dto::AppCommand::SetSetting`],由 host 用 `SettingStore::set`
|
||||
//! 落盘(不自己拼 JSON、不另立配置文件,`AGENTS §6`)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 控件类型(决定设置页画什么)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SettingControl {
|
||||
/// 开关
|
||||
Toggle,
|
||||
/// 分段单选(`options` + `selected`)
|
||||
Choice,
|
||||
/// 单行文本(`value_text`)
|
||||
Text,
|
||||
/// 只读展示(当前路径/当前版本这类)
|
||||
ReadOnly,
|
||||
/// 动作按钮(`action` 给回调 id)
|
||||
Action,
|
||||
}
|
||||
|
||||
/// 一行设置。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SettingRowView {
|
||||
/// schema 键(`download.concurrency`)
|
||||
pub key: String,
|
||||
/// 行标题
|
||||
pub label: String,
|
||||
/// 说明/当前值附注
|
||||
pub description: String,
|
||||
/// 控件
|
||||
pub control: SettingControl,
|
||||
/// 开关值
|
||||
pub flag: bool,
|
||||
/// 单选序号
|
||||
pub selected: i32,
|
||||
/// 单选项文案
|
||||
pub options: Vec<String>,
|
||||
/// 文本值
|
||||
pub value_text: String,
|
||||
/// 动作 id(`detect_region` 这类)
|
||||
pub action: String,
|
||||
}
|
||||
|
||||
impl SettingRowView {
|
||||
/// 开关行。
|
||||
pub fn toggle(key: &str, label: &str, description: &str, value: bool) -> Self {
|
||||
Self {
|
||||
key: key.to_string(),
|
||||
label: label.to_string(),
|
||||
description: description.to_string(),
|
||||
control: SettingControl::Toggle,
|
||||
flag: value,
|
||||
selected: 0,
|
||||
options: Vec::new(),
|
||||
value_text: String::new(),
|
||||
action: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 单选行。
|
||||
pub fn choice(
|
||||
key: &str,
|
||||
label: &str,
|
||||
description: &str,
|
||||
options: &[&str],
|
||||
selected: i32,
|
||||
) -> Self {
|
||||
Self {
|
||||
key: key.to_string(),
|
||||
label: label.to_string(),
|
||||
description: description.to_string(),
|
||||
control: SettingControl::Choice,
|
||||
flag: false,
|
||||
selected,
|
||||
options: options.iter().map(|item| (*item).to_string()).collect(),
|
||||
value_text: String::new(),
|
||||
action: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本行。
|
||||
pub fn text(key: &str, label: &str, description: &str, value: &str) -> Self {
|
||||
Self {
|
||||
key: key.to_string(),
|
||||
label: label.to_string(),
|
||||
description: description.to_string(),
|
||||
control: SettingControl::Text,
|
||||
flag: false,
|
||||
selected: 0,
|
||||
options: Vec::new(),
|
||||
value_text: value.to_string(),
|
||||
action: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 只读行。
|
||||
pub fn read_only(label: &str, value: &str) -> Self {
|
||||
Self {
|
||||
key: String::new(),
|
||||
label: label.to_string(),
|
||||
description: value.to_string(),
|
||||
control: SettingControl::ReadOnly,
|
||||
flag: false,
|
||||
selected: 0,
|
||||
options: Vec::new(),
|
||||
value_text: value.to_string(),
|
||||
action: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 动作行。
|
||||
pub fn action(key: &str, label: &str, description: &str, action: &str) -> Self {
|
||||
Self {
|
||||
key: key.to_string(),
|
||||
label: label.to_string(),
|
||||
description: description.to_string(),
|
||||
control: SettingControl::Action,
|
||||
flag: false,
|
||||
selected: 0,
|
||||
options: Vec::new(),
|
||||
value_text: String::new(),
|
||||
action: action.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个设置分组(设置页右栏的一段)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SettingGroupView {
|
||||
/// 分组 id(与设置页子导航的 `SettingSection` 同名:download / java / data / ai / mcp)
|
||||
pub id: String,
|
||||
/// 分组标题
|
||||
pub title: String,
|
||||
/// 行
|
||||
pub rows: Vec<SettingRowView>,
|
||||
}
|
||||
|
||||
impl SettingGroupView {
|
||||
/// 建组。
|
||||
pub fn new(id: &str, title: &str) -> Self {
|
||||
Self {
|
||||
id: id.to_string(),
|
||||
title: title.to_string(),
|
||||
rows: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 追加一行(链式)。
|
||||
pub fn with(mut self, row: SettingRowView) -> Self {
|
||||
self.rows.push(row);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 在分组快照里找一个键的当前值(设置页回填用)。
|
||||
pub fn find_row<'a>(groups: &'a [SettingGroupView], key: &str) -> Option<&'a SettingRowView> {
|
||||
groups
|
||||
.iter()
|
||||
.flat_map(|group| group.rows.iter())
|
||||
.find(|row| row.key == key)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rows_carry_exactly_one_control_kind() {
|
||||
let toggle = SettingRowView::toggle("agent.mcp.enabled", "MCP", "", true);
|
||||
assert_eq!(toggle.control, SettingControl::Toggle);
|
||||
assert!(toggle.flag);
|
||||
|
||||
let choice = SettingRowView::choice("download.region", "地区", "", &["auto", "cn"], 1);
|
||||
assert_eq!(choice.options, vec!["auto", "cn"]);
|
||||
assert_eq!(choice.selected, 1);
|
||||
|
||||
let action = SettingRowView::action("java.action", "检测", "", "detect_region");
|
||||
assert_eq!(action.action, "detect_region");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_row_scans_all_groups() {
|
||||
let groups = vec![
|
||||
SettingGroupView::new("download", "下载").with(SettingRowView::text(
|
||||
"download.proxy_url",
|
||||
"代理",
|
||||
"",
|
||||
"",
|
||||
)),
|
||||
SettingGroupView::new("mcp", "MCP").with(SettingRowView::toggle(
|
||||
"agent.mcp.enabled",
|
||||
"开关",
|
||||
"",
|
||||
false,
|
||||
)),
|
||||
];
|
||||
assert_eq!(
|
||||
find_row(&groups, "agent.mcp.enabled").map(|row| row.label.as_str()),
|
||||
Some("开关")
|
||||
);
|
||||
assert!(find_row(&groups, "nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_are_serde_round_trip_safe() {
|
||||
let group = SettingGroupView::new("download", "下载").with(SettingRowView::choice(
|
||||
"download.concurrency",
|
||||
"并发",
|
||||
"",
|
||||
&["4", "8", "16"],
|
||||
1,
|
||||
));
|
||||
let text = serde_json::to_string(&group).expect("序列化");
|
||||
let back: SettingGroupView = serde_json::from_str(&text).expect("反序列化");
|
||||
assert_eq!(back, group);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//! 下载页 DTO:版本列表与分类(含远古版子类,D16/Q24-4)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 版本类型(远古版按 unlisted-versions 的分类拆开)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum VersionKind {
|
||||
/// 正式版
|
||||
Release,
|
||||
/// 预览版(snapshot)
|
||||
Snapshot,
|
||||
/// 远古版(带子类)
|
||||
Ancient(AncientKind),
|
||||
}
|
||||
|
||||
/// 远古版子类。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AncientKind {
|
||||
/// classic(rd-*)
|
||||
Classic,
|
||||
/// old_alpha(a1.*)
|
||||
OldAlpha,
|
||||
/// old_beta(b1.*)
|
||||
OldBeta,
|
||||
/// 未列入官方清单的远古版汇总
|
||||
Unlisted,
|
||||
}
|
||||
|
||||
impl VersionKind {
|
||||
/// 是否属于「远古版」——列表行要加警示徽标。
|
||||
pub fn is_ancient(self) -> bool {
|
||||
matches!(self, Self::Ancient(_))
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载页版本行。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct VersionSummary {
|
||||
/// 版本 id(如 `1.20.4` / `rd-132211`)
|
||||
pub id: String,
|
||||
/// 版本类型
|
||||
pub kind: VersionKind,
|
||||
/// 发布日期(ISO-8601 日期部分)
|
||||
pub released: String,
|
||||
/// 本地是否已安装(行尾显示「已装」)
|
||||
pub installed: bool,
|
||||
}
|
||||
|
||||
/// 下载页左侧分类(页内常驻侧栏的选中项)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum VersionCategory {
|
||||
/// 正式版
|
||||
Release,
|
||||
/// 预览版
|
||||
Snapshot,
|
||||
/// 远古版某子类
|
||||
Ancient(AncientKind),
|
||||
/// 整合包(P1)
|
||||
Modpack,
|
||||
/// Mod(P1)
|
||||
Mod,
|
||||
}
|
||||
Reference in new issue
Block a user