app 从「mock 骨架」切到「真实 core host」:新增 src/host 装配层(env/job/ game/launch/identity/versions/install/meter/java/accounts/logs/settings/engine/ dispatch),是 app 里除 settings_bridge 外唯一 use unlucky_core 的地方,出口只有 crate::dto 的 DTO/事件(三通道纪律,AGENTS §6)。 页面接线: - 实例页:profiles+版本 JSON 继承链+会话索引 → 磁贴(真实版本/加载器徽标/最后启动/ 隔离档位/载荷缺失);启动按钮走 LaunchRequest 装配 → Launcher::spawn → JSONL 尾随 → 运行/失败回写;M30 ModeChangeWarning 进横幅通知条。 - 新建实例向导:版本 → 加载器 → 名字 → 确认 → 真实安装进度;Fabric/Quilt 可装, Forge/NeoForge 因需要 installer processors(仅 CLI 有)显式拒装并给出 CLI 命令。 - 下载页:远端 manifest(12h 缓存)+ 内嵌 unlisted → 分类(正式/预览/远古四子类); 安装进度(文件数/字节/速度/ETA,速度由相邻采样差分)+ 阶段边界可取消; Java 分组:四家发行版 + 回退链可视化(计划链/真实 ChainLog)+ 本地运行时。 - 账户页:三轨账户列表(只读公开字段)+ 离线新增 + 删除/切换 + 微软设备码登录 (用户码/链接/轮询状态;取消后丢弃结果)。凭据只在 host/identity 与 host/accounts 出现,DTO 无 token 字段(单测钉住)。 - 设置页:新增 download/java/data/ai/mcp 五组,行由 core::setting schema 键生成, 写回走 SettingStore::set(与 ull config set / AI set_config 同源 launcher.json)。 - 日志页:会话索引 → 列表;事件 JSONL → 日志行(等级高亮)+ D8 规则引擎诊断卡片; 启动中实时尾随(600ms/拍,只读新增字节);崩溃日志导入分析(hs_err/crash/latest)。 架构与守线: - 新页面状态走 ui/page_state.slint 的 PageState global(main.slint 再导出), Rust 侧 page_sync.rs 灌入、callbacks_pages.rs 接线;避免属性逐层转发顶破 400 行。 - DTO 全部 Serialize+Deserialize(IPC 契约),新增 accounts/java/settings/install/ notices 五个子模块;事件扩展 LogAppended/LaunchFinished/AccountsChanged/ DeviceLoginChanged/JavaChanged/InstallProgress/SettingsChanged/NoticesChanged。 - 截图通道扩展:--data-dir(钉数据根且 settings 同源)/--mock/--screenshot-page/ --screenshot-wizard/--screenshot-session/--screenshot-install/--screenshot-launch (真实中间态,取图后自动 stop 防孤儿进程)。 已知边界:GUI 账户「当前启动账户」仅内存态(core schema 无 auth.* 键,app 不自建键); Forge/NeoForge GUI 安装待 processors 执行器上移。
367 lines
12 KiB
Rust
367 lines
12 KiB
Rust
//! P0 假后端:用脚本化事件驱动骨架,验证 app ↔ core/agent 通道语义。
|
||
//!
|
||
//! 覆盖四条通道:实例运行状态(磁贴进度环)、AI 流式会话(含工具调用)、
|
||
//! 对话框队列(进度对话框)、Toast。阶段 B 里由真正的 core/agent host 替换
|
||
//! [`MockBackend`],[`crate::dto::Backend`] 之上的 state/reducer 不动。
|
||
//! 假数据本体在 [`crate::mock_data`]。
|
||
|
||
use std::collections::VecDeque;
|
||
use std::time::Duration;
|
||
|
||
use crate::dialogs::{DialogKind, DialogRequest, DialogSource};
|
||
use crate::dto::{
|
||
AgentEvent, AppCommand, AppEvent, Backend, ChatMessage, ChatRole, CoreEvent, InstanceId,
|
||
InstanceRuntime, LaunchStage, Progress,
|
||
};
|
||
use crate::mock_data::{mock_instances, mock_log_lines, mock_log_sessions, mock_versions};
|
||
use crate::toast::{Toast, ToastKind};
|
||
|
||
/// 启动脚本:按时间推进 download → verify → launch → running。
|
||
#[derive(Debug)]
|
||
struct LaunchScript {
|
||
id: InstanceId,
|
||
elapsed_ms: u64,
|
||
}
|
||
|
||
/// AI 流式脚本:每条提问回一段可切分的话 + 一次工具调用。
|
||
#[derive(Debug)]
|
||
struct ChatScript {
|
||
elapsed_ms: u64,
|
||
chunks: VecDeque<String>,
|
||
/// 流式助手消息在 app 会话里的序号
|
||
message_index: usize,
|
||
}
|
||
|
||
/// 假后端。
|
||
#[derive(Debug, Default)]
|
||
pub struct MockBackend {
|
||
events: VecDeque<AppEvent>,
|
||
launch: Option<LaunchScript>,
|
||
chat: Option<ChatScript>,
|
||
/// 已投递给 app 的会话消息条数(用来给 MessageDelta 算序号)
|
||
emitted_messages: usize,
|
||
next_dialog: u64,
|
||
}
|
||
|
||
impl MockBackend {
|
||
/// 预置一批假数据事件(实例/版本/日志会话)。
|
||
pub fn new() -> Self {
|
||
let mut backend = Self::default();
|
||
backend.seed();
|
||
backend
|
||
}
|
||
|
||
fn seed(&mut self) {
|
||
self.events.push_back(AppEvent::Core(
|
||
CoreEvent::InstancesChanged(mock_instances()),
|
||
));
|
||
self.events
|
||
.push_back(AppEvent::Core(CoreEvent::VersionListChanged(
|
||
mock_versions(),
|
||
)));
|
||
self.events
|
||
.push_back(AppEvent::Core(CoreEvent::LogSessionsChanged(
|
||
mock_log_sessions(),
|
||
)));
|
||
self.events
|
||
.push_back(AppEvent::Core(CoreEvent::LogLinesChanged(mock_log_lines())));
|
||
}
|
||
|
||
fn emit(&mut self, event: AppEvent) {
|
||
self.events.push_back(event);
|
||
}
|
||
|
||
fn next_dialog_id(&mut self) -> u64 {
|
||
self.next_dialog += 1;
|
||
self.next_dialog
|
||
}
|
||
|
||
fn script_launch(&mut self, id: InstanceId) {
|
||
self.emit(AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
|
||
id: id.clone(),
|
||
runtime: InstanceRuntime::Busy {
|
||
stage: LaunchStage::Download,
|
||
progress: Progress {
|
||
done: 0,
|
||
total: 1_500,
|
||
},
|
||
},
|
||
}));
|
||
self.launch = Some(LaunchScript { id, elapsed_ms: 0 });
|
||
}
|
||
|
||
fn step_launch(&mut self, elapsed: Duration) {
|
||
let Some((id, elapsed_ms)) = self.launch.as_mut().map(|script| {
|
||
script.elapsed_ms += u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
|
||
(script.id.clone(), script.elapsed_ms)
|
||
}) else {
|
||
return;
|
||
};
|
||
|
||
// 0~1.5s 下载、1.5~2.5s 校验、2.5~3s 启动、3s 后转为运行中
|
||
let runtime = if elapsed_ms < 1_500 {
|
||
InstanceRuntime::Busy {
|
||
stage: LaunchStage::Download,
|
||
progress: Progress {
|
||
done: elapsed_ms,
|
||
total: 1_500,
|
||
},
|
||
}
|
||
} else if elapsed_ms < 2_500 {
|
||
InstanceRuntime::Busy {
|
||
stage: LaunchStage::Verify,
|
||
progress: Progress {
|
||
done: elapsed_ms - 1_500,
|
||
total: 1_000,
|
||
},
|
||
}
|
||
} else if elapsed_ms < 3_000 {
|
||
InstanceRuntime::Busy {
|
||
stage: LaunchStage::Launch,
|
||
progress: Progress {
|
||
done: elapsed_ms - 2_500,
|
||
total: 500,
|
||
},
|
||
}
|
||
} else {
|
||
self.launch = None;
|
||
self.emit(AppEvent::Toast(Toast {
|
||
id: 0,
|
||
kind: ToastKind::Success,
|
||
text: "游戏已启动".to_string(),
|
||
remaining_ms: 0,
|
||
}));
|
||
InstanceRuntime::Running { pid: 12_345 }
|
||
};
|
||
|
||
self.emit(AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
|
||
id,
|
||
runtime,
|
||
}));
|
||
}
|
||
|
||
fn script_chat(&mut self, text: String) {
|
||
let user_index = self.emitted_messages;
|
||
self.emit(AppEvent::Agent(AgentEvent::MessageAppended(
|
||
ChatMessage::user(text),
|
||
)));
|
||
self.emit(AppEvent::Agent(AgentEvent::MessageAppended(
|
||
ChatMessage::assistant_stream(),
|
||
)));
|
||
self.emitted_messages += 2;
|
||
self.chat = Some(ChatScript {
|
||
elapsed_ms: 0,
|
||
chunks: VecDeque::from(vec![
|
||
"先确认版本:".to_string(),
|
||
"官方 1.20.4 是正式版,".to_string(),
|
||
"装完再用 Fabric 加加载器。".to_string(),
|
||
]),
|
||
message_index: user_index + 1,
|
||
});
|
||
}
|
||
|
||
fn step_chat(&mut self, elapsed: Duration) {
|
||
let Some(chat) = self.chat.as_mut() else {
|
||
return;
|
||
};
|
||
chat.elapsed_ms += u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
|
||
if chat.elapsed_ms < 300 {
|
||
return;
|
||
}
|
||
chat.elapsed_ms = 0;
|
||
let index = chat.message_index;
|
||
let chunk = chat.chunks.pop_front();
|
||
|
||
if let Some(delta) = chunk {
|
||
self.emit(AppEvent::Agent(AgentEvent::MessageDelta { index, delta }));
|
||
return;
|
||
}
|
||
|
||
// 流结束后补一次工具调用(工具执行在会话里可见)+ 收尾消息
|
||
self.emit(AppEvent::Agent(AgentEvent::ToolCallStarted {
|
||
name: "read_game_log".to_string(),
|
||
summary: "读取最近一次崩溃日志".to_string(),
|
||
}));
|
||
self.emit(AppEvent::Agent(AgentEvent::ToolCallFinished {
|
||
name: "read_game_log".to_string(),
|
||
ok: true,
|
||
summary: "读到 128 行,无 ERROR".to_string(),
|
||
}));
|
||
self.emit(AppEvent::Agent(AgentEvent::MessageFinished { index }));
|
||
self.emit(AppEvent::Agent(AgentEvent::MessageAppended(ChatMessage {
|
||
role: ChatRole::Assistant,
|
||
text: "日志没有 ERROR,问题更可能在 Java 版本。".to_string(),
|
||
tool: None,
|
||
streaming: false,
|
||
})));
|
||
self.emitted_messages += 2;
|
||
self.chat = None;
|
||
}
|
||
|
||
fn script_install(&mut self, version: String) {
|
||
let id = self.next_dialog_id();
|
||
self.emit(AppEvent::DialogRequested(DialogRequest {
|
||
id,
|
||
source: DialogSource::BackgroundTask,
|
||
title: format!("正在安装 {version}"),
|
||
body: String::new(),
|
||
kind: DialogKind::Progress {
|
||
label: "下载资源".to_string(),
|
||
progress: 0.0,
|
||
},
|
||
}));
|
||
self.emit(AppEvent::DialogClosed(id));
|
||
self.emit(AppEvent::Toast(Toast {
|
||
id: 0,
|
||
kind: ToastKind::Success,
|
||
text: format!("{version} 安装完成"),
|
||
remaining_ms: 0,
|
||
}));
|
||
}
|
||
}
|
||
|
||
impl Backend for MockBackend {
|
||
fn drain_events(&mut self) -> Vec<AppEvent> {
|
||
self.events.drain(..).collect()
|
||
}
|
||
|
||
fn dispatch(&mut self, command: AppCommand) {
|
||
match command {
|
||
AppCommand::LaunchInstance(id) => self.script_launch(id),
|
||
AppCommand::StopInstance(id) => {
|
||
self.launch = None;
|
||
self.emit(AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
|
||
id,
|
||
runtime: InstanceRuntime::Idle,
|
||
}));
|
||
}
|
||
AppCommand::AskAgent(text) => self.script_chat(text),
|
||
AppCommand::InstallVersion(version) => self.script_install(version),
|
||
AppCommand::SetYolo(enabled) => self.emit(AppEvent::Toast(Toast {
|
||
id: 0,
|
||
kind: ToastKind::Info,
|
||
text: if enabled {
|
||
"YOLO 已开启(审计照记)"
|
||
} else {
|
||
"YOLO 已关闭"
|
||
}
|
||
.to_string(),
|
||
remaining_ms: 0,
|
||
})),
|
||
AppCommand::ListVersions(category) => {
|
||
self.emit(AppEvent::Core(CoreEvent::VersionListChanged(
|
||
crate::mock_data::mock_versions_for(category),
|
||
)));
|
||
}
|
||
AppCommand::OpenLogSession(id) => {
|
||
// P0:换会话 = 换一批假日志行 + 一条诊断结论(日志页诊断卡片可见)
|
||
self.emit(AppEvent::Core(CoreEvent::LogLinesChanged(mock_log_lines())));
|
||
self.emit(AppEvent::Core(CoreEvent::DiagnosisReady(
|
||
crate::mock_data::mock_diagnosis(id),
|
||
)));
|
||
}
|
||
AppCommand::RefreshAll | AppCommand::RefreshLogSessions | AppCommand::StopAgent => {}
|
||
// 其余指令(安装向导/账户/Java/设置/通知)由真实 host 承接:mock 不模拟
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
fn tick(&mut self, elapsed: Duration) {
|
||
self.step_launch(elapsed);
|
||
self.step_chat(elapsed);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::MockBackend;
|
||
use crate::dto::{AgentEvent, AppCommand, AppEvent, Backend, CoreEvent, InstanceRuntime};
|
||
|
||
#[test]
|
||
fn seeds_instances_and_versions() {
|
||
let mut backend = MockBackend::new();
|
||
let events = backend.drain_events();
|
||
assert_eq!(events.len(), 4);
|
||
assert!(events
|
||
.iter()
|
||
.all(|event| matches!(event, AppEvent::Core(_))));
|
||
assert!(backend.drain_events().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn launching_emits_progress_then_running() {
|
||
let mut backend = MockBackend::new();
|
||
backend.drain_events();
|
||
backend.dispatch(AppCommand::LaunchInstance("survival".to_string()));
|
||
|
||
let first = backend.drain_events();
|
||
assert!(matches!(
|
||
first[0],
|
||
AppEvent::Core(CoreEvent::InstanceRuntimeChanged { .. })
|
||
));
|
||
|
||
backend.tick(std::time::Duration::from_millis(1_600));
|
||
let verify = backend.drain_events();
|
||
match &verify[0] {
|
||
AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
|
||
runtime: InstanceRuntime::Busy { stage, .. },
|
||
..
|
||
}) => assert_eq!(*stage, crate::dto::LaunchStage::Verify),
|
||
other => panic!("unexpected event: {other:?}"),
|
||
}
|
||
|
||
backend.tick(std::time::Duration::from_millis(2_000));
|
||
let done = backend.drain_events();
|
||
assert!(done.iter().any(|event| matches!(
|
||
event,
|
||
AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
|
||
runtime: InstanceRuntime::Running { .. },
|
||
..
|
||
})
|
||
)));
|
||
}
|
||
|
||
#[test]
|
||
fn stopping_cancels_the_launch_script() {
|
||
let mut backend = MockBackend::new();
|
||
backend.drain_events();
|
||
backend.dispatch(AppCommand::LaunchInstance("a".to_string()));
|
||
backend.drain_events();
|
||
|
||
backend.dispatch(AppCommand::StopInstance("a".to_string()));
|
||
let events = backend.drain_events();
|
||
assert!(matches!(
|
||
events[0],
|
||
AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
|
||
runtime: InstanceRuntime::Idle,
|
||
..
|
||
})
|
||
));
|
||
|
||
// 脚本已取消:再推进时间也不会再产出事件
|
||
backend.tick(std::time::Duration::from_millis(5_000));
|
||
assert!(backend.drain_events().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn asking_the_agent_streams_and_finishes() {
|
||
let mut backend = MockBackend::new();
|
||
backend.drain_events();
|
||
backend.dispatch(AppCommand::AskAgent("装哪个版本".to_string()));
|
||
|
||
let start = backend.drain_events();
|
||
assert_eq!(start.len(), 2);
|
||
|
||
let mut deltas = 0;
|
||
for _ in 0..6 {
|
||
backend.tick(std::time::Duration::from_millis(400));
|
||
let events = backend.drain_events();
|
||
deltas += events
|
||
.iter()
|
||
.filter(|event| matches!(event, AppEvent::Agent(AgentEvent::MessageDelta { .. })))
|
||
.count();
|
||
}
|
||
assert_eq!(deltas, 3);
|
||
}
|
||
}
|