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 执行器上移。
333 lines
13 KiB
Rust
333 lines
13 KiB
Rust
//! 绑定层(核心):窗口/状态/后端的持有与同步;回调接线在 [`crate::callbacks`]。
|
||
//!
|
||
//! P0 的“后端”是 [`MockBackend`](脚本化假数据);把 `Box<dyn Backend>` 换成真实的 core/agent
|
||
//! host 时,除本文件、`conv.rs`、`callbacks.rs` 外都不需要改
|
||
//! (通道语义见 `DevDocs/SubAgentsTalk/app-DTO通道约定.md`)。
|
||
//!
|
||
//! 主题与外观(D17 / 09 §101-113):落盘配置由 [`SettingsBridge`] 读写(`config/launcher.json`,
|
||
//! 与 `ull config set` 同一份),tick 里 1s 一次 `mtime` 热切;系统主题由
|
||
//! `core::platform::theme_probe` 做启动对齐 + 后端不支持时的兜底轮询。
|
||
|
||
use std::cell::{Cell, RefCell};
|
||
use std::rc::Rc;
|
||
use std::time::Duration;
|
||
|
||
use slint::{ComponentHandle, ModelRc, VecModel};
|
||
|
||
use crate::backend::{build_backend, config_path, AppOptions};
|
||
use crate::bg_image::BackgroundLayers;
|
||
use crate::conv;
|
||
use crate::dto::{AppCommand, Backend};
|
||
use crate::prefs::BgFill;
|
||
use crate::settings_bridge::SettingsBridge;
|
||
use crate::state::AppState;
|
||
use crate::{MainWindow, PageState, SettingSection};
|
||
|
||
/// 定时器节拍:33ms ≈ 30fps(磁贴进度环 / 流式文本 / Toast TTL 的刷新下限)。
|
||
const TICK_MS: u64 = 33;
|
||
|
||
/// 配置热切轮询间隔(拍数):约 1s 一次 `mtime` 比对,AI/CLI 改完下一拍即生效。
|
||
pub(crate) const CONFIG_POLL_TICKS: u64 = 30;
|
||
|
||
/// 系统主题兜底轮询间隔(拍数):只在 `auto` 且后端不认识系统主题时启动(约 3s)。
|
||
pub(crate) const SYSTEM_THEME_POLL_TICKS: u64 = 90;
|
||
|
||
/// tile 模式重建画布的窗口尺寸粒度(像素):避免拖动窗口时反复重算平铺画布。
|
||
pub(crate) const TILE_REGENERATE_STEP: u32 = 128;
|
||
|
||
/// 应用外壳:持有窗口、状态与后端,把三者缝在一起。
|
||
pub struct AppUi {
|
||
inner: Rc<Inner>,
|
||
timer: slint::Timer,
|
||
}
|
||
|
||
pub(crate) struct Inner {
|
||
pub(crate) ui: MainWindow,
|
||
pub(crate) state: RefCell<AppState>,
|
||
pub(crate) backend: RefCell<Box<dyn Backend>>,
|
||
/// `config/launcher.json` 读写桥(app 不自己写盘:一律走 core::setting 的 schema API)
|
||
pub(crate) bridge: RefCell<SettingsBridge>,
|
||
/// 当前背景图 / 模糊层(避免每次同步都重新解码)
|
||
pub(crate) background: RefCell<BackgroundLayers>,
|
||
/// 生成 `background` 时的输入(路径 / 填充模式 / 窗口粒度尺寸)
|
||
pub(crate) background_key: RefCell<Option<BackgroundKey>>,
|
||
/// 拍数(轮询节拍)
|
||
pub(crate) ticks: Cell<u64>,
|
||
/// 是否已经做过启动时的系统主题对齐
|
||
pub(crate) system_theme_probed: Cell<bool>,
|
||
}
|
||
|
||
/// 背景图加工输入(值相同就不重新解码)。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub(crate) struct BackgroundKey {
|
||
pub(crate) path: String,
|
||
pub(crate) fill: BgFill,
|
||
/// tile 用的窗口尺寸(按 [`TILE_REGENERATE_STEP`] 量化;cover/contain 恒为 (0, 0))
|
||
pub(crate) viewport: (u32, u32),
|
||
}
|
||
|
||
impl AppUi {
|
||
/// 建状态 + 真实后端、读配置、接全部回调、灌初始事件并做第一次同步。
|
||
pub fn new(ui: MainWindow) -> Self {
|
||
Self::with_options(ui, AppOptions::default())
|
||
}
|
||
|
||
/// 带启动选项装配(数据根覆盖 / 强制 mock)。
|
||
pub fn with_options(ui: MainWindow, options: AppOptions) -> Self {
|
||
// 先读一次落盘配置:主题三态 + 外观四件必须在首帧前就位(否则会闪一下出厂外观)
|
||
let bridge = match &options.data_root {
|
||
Some(root) => SettingsBridge::with_path(config_path(root)),
|
||
None => SettingsBridge::open(),
|
||
};
|
||
let (appearance, theme) = bridge.load();
|
||
let mut state = AppState::new();
|
||
state.prefs.appearance = appearance;
|
||
state.prefs.theme = theme;
|
||
|
||
let backend = build_backend(&options);
|
||
let inner = Rc::new(Inner {
|
||
ui,
|
||
state: RefCell::new(state),
|
||
backend: RefCell::new(backend),
|
||
bridge: RefCell::new(bridge),
|
||
background: RefCell::new(BackgroundLayers::default()),
|
||
background_key: RefCell::new(None),
|
||
ticks: Cell::new(0),
|
||
system_theme_probed: Cell::new(false),
|
||
});
|
||
|
||
crate::callbacks::wire(Rc::downgrade(&inner));
|
||
crate::callbacks_pages::wire_pages(Rc::downgrade(&inner));
|
||
inner.drain_events();
|
||
inner.align_system_theme();
|
||
inner.apply_theme();
|
||
inner.apply_appearance();
|
||
inner.sync();
|
||
|
||
Self {
|
||
inner,
|
||
timer: slint::Timer::default(),
|
||
}
|
||
}
|
||
|
||
/// 进入 Slint 事件循环(窗口句柄由本结构持有,直到循环退出)。
|
||
pub fn run(&self) -> Result<(), slint::PlatformError> {
|
||
self.inner.ui.run()
|
||
}
|
||
|
||
/// 启动 33ms 节拍(后端 tick → 事件 → 状态 → UI)。
|
||
pub fn start_timer(&self) {
|
||
let inner = Rc::clone(&self.inner);
|
||
self.timer.start(
|
||
slint::TimerMode::Repeated,
|
||
Duration::from_millis(TICK_MS),
|
||
move || inner.tick(),
|
||
);
|
||
}
|
||
|
||
/// 推进一拍(截图通道用:没有事件循环时手动把 tick 走起来,让 mock 事件灌进状态)。
|
||
pub fn tick_once(&self) {
|
||
self.inner.tick();
|
||
slint::platform::update_timers_and_animations();
|
||
}
|
||
|
||
/// 直接跳到「设置 → 外观」页(截图验收用:证明外观分组的四组控件真的接线了)。
|
||
pub fn open_settings_appearance(&self) {
|
||
self.inner
|
||
.state
|
||
.borrow_mut()
|
||
.navigate(crate::router::Route::Settings);
|
||
self.inner.sync();
|
||
self.inner
|
||
.ui
|
||
.set_settings_section(SettingSection::Appearance);
|
||
slint::platform::update_timers_and_animations();
|
||
}
|
||
|
||
/// 直接跳到一个页面(截图验收用:每页一张)。
|
||
pub fn open_page(&self, page: &str) {
|
||
let route = match page {
|
||
"download" | "java" => crate::router::Route::Download,
|
||
"logs" => crate::router::Route::Logs,
|
||
"settings" | "account" | "appearance" => crate::router::Route::Settings,
|
||
"agent" => crate::router::Route::Agent,
|
||
_ => crate::router::Route::Instances,
|
||
};
|
||
{
|
||
let mut state = self.inner.state.borrow_mut();
|
||
state.navigate(route);
|
||
match page {
|
||
"account" => state.settings_tab = "account".to_string(),
|
||
"java" => state.settings_tab = "java".to_string(),
|
||
"settings" => state.settings_tab = "download".to_string(),
|
||
_ => {}
|
||
}
|
||
let download_tab = i32::from(page == "java");
|
||
self.inner
|
||
.ui
|
||
.global::<PageState<'_>>()
|
||
.set_download_tab(download_tab);
|
||
}
|
||
match page {
|
||
"account" => self.inner.ui.set_settings_section(SettingSection::Account),
|
||
"java" => self.inner.ui.set_settings_section(SettingSection::Java),
|
||
"appearance" => self
|
||
.inner
|
||
.ui
|
||
.set_settings_section(SettingSection::Appearance),
|
||
"settings" => self.inner.ui.set_settings_section(SettingSection::Download),
|
||
_ => {}
|
||
}
|
||
self.inner.sync();
|
||
slint::platform::update_timers_and_animations();
|
||
}
|
||
|
||
/// 打开新建实例向导(截图验收用)。
|
||
pub fn open_wizard(&self) {
|
||
self.inner.state.borrow_mut().wizard = crate::wizard::WizardState::open();
|
||
self.inner.sync();
|
||
slint::platform::update_timers_and_animations();
|
||
}
|
||
|
||
/// 打开一个日志会话(截图验收:日志行 + 诊断卡片 + 头部标题都要有值)。
|
||
pub fn open_session(&self, id: &str) {
|
||
self.inner.state.borrow_mut().selected_session = Some(id.to_string());
|
||
self.inner
|
||
.dispatch(AppCommand::OpenLogSession(id.to_string()));
|
||
self.inner.sync();
|
||
slint::platform::update_timers_and_animations();
|
||
}
|
||
|
||
/// 派发一条指令并跑若干毫秒(截图中间态:安装进度 / 启动事件流)。
|
||
pub fn dispatch_and_run(&self, command: AppCommand, millis: u64) {
|
||
self.inner.dispatch(command);
|
||
let steps = (millis / TICK_MS).max(1);
|
||
for _ in 0..steps {
|
||
self.inner.tick();
|
||
slint::platform::update_timers_and_animations();
|
||
std::thread::sleep(Duration::from_millis(TICK_MS));
|
||
}
|
||
}
|
||
|
||
/// 渲染当前场景并存成 PNG(`--screenshot` 通道;见 `crate::png`)。
|
||
///
|
||
/// 为什么要这条通道:验收环境可能没有交互桌面(`CopyFromScreen` 直接失败),
|
||
/// 而 Slint 的软件渲染器可以把场景渲染进内存像素缓冲(`Window::take_snapshot`),
|
||
/// 结果与屏幕上看到的逐像素一致,且不需要 GPU。
|
||
///
|
||
/// # Errors
|
||
/// 窗口显示失败、快照失败或写文件失败时返回错误。
|
||
pub fn render_screenshot(&self, path: &str) -> Result<(), slint::PlatformError> {
|
||
let ui = &self.inner.ui;
|
||
ui.show()?;
|
||
// 固定逻辑尺寸 = main.slint 的默认窗口口径,保证验收图可比
|
||
ui.window()
|
||
.set_size(slint::WindowSize::Logical(slint::LogicalSize::new(
|
||
1180.0, 760.0,
|
||
)));
|
||
// 先走几拍:把后端事件(实例/版本/日志/账户/设置)与 33ms 节拍的效果灌进去
|
||
for _ in 0..8 {
|
||
self.tick_once();
|
||
std::thread::sleep(Duration::from_millis(TICK_MS));
|
||
}
|
||
let buffer = ui.window().take_snapshot()?;
|
||
let bytes = crate::png::encode_rgba8(&buffer);
|
||
std::fs::write(path, bytes)
|
||
.map_err(|error| slint::PlatformError::Other(format!("写截图失败 {path}: {error}")))?;
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
/// 发送一条 AI 提问:**只发指令 + 回流事件**,绝不做本地乐观回显。
|
||
///
|
||
/// 契约 §3 规定 host 先发 `MessageAppended(user)`:本地再 push 一次用户消息就会重复;
|
||
/// 且流式气泡的收敛依赖事件里的 `MessageFinished{index}`,本地多建的占位会变成
|
||
/// 永不收敛的「幽灵气泡」(review P1-1)。callbacks 与单测都走这一个入口。
|
||
pub(crate) fn send_agent_message(state: &mut AppState, backend: &mut dyn Backend, text: &str) {
|
||
backend.dispatch(AppCommand::AskAgent(text.to_string()));
|
||
for event in backend.drain_events() {
|
||
state.apply(event);
|
||
}
|
||
}
|
||
|
||
impl Inner {
|
||
/// 走一次完整的「提问 → 事件回流」。
|
||
pub(crate) fn ask_agent(&self, text: &str) {
|
||
let mut state = self.state.borrow_mut();
|
||
let mut backend = self.backend.borrow_mut();
|
||
send_agent_message(&mut state, backend.as_mut(), text);
|
||
}
|
||
|
||
pub(crate) fn dispatch(&self, command: AppCommand) {
|
||
self.backend.borrow_mut().dispatch(command);
|
||
self.drain_events();
|
||
}
|
||
|
||
pub(crate) fn drain_events(&self) {
|
||
let events = self.backend.borrow_mut().drain_events();
|
||
if events.is_empty() {
|
||
return;
|
||
}
|
||
let mut state = self.state.borrow_mut();
|
||
for event in events {
|
||
state.apply(event);
|
||
}
|
||
}
|
||
|
||
fn tick(&self) {
|
||
self.backend
|
||
.borrow_mut()
|
||
.tick(Duration::from_millis(TICK_MS));
|
||
self.drain_events();
|
||
self.state.borrow_mut().toasts.expire(TICK_MS);
|
||
self.ticks.set(self.ticks.get() + 1);
|
||
self.poll_settings();
|
||
self.poll_system_theme();
|
||
self.sync();
|
||
}
|
||
|
||
pub(crate) fn sync(&self) {
|
||
let state = self.state.borrow();
|
||
let ui = &self.ui;
|
||
|
||
ui.set_route(conv::route(state.route()));
|
||
ui.set_nav_collapsed(state.prefs.nav_collapsed);
|
||
ui.set_ai_collapsed(state.prefs.ai_collapsed);
|
||
ui.set_tile_size(conv::tile_size(state.prefs.tile_size));
|
||
ui.set_instance_view(conv::instance_view(state.prefs.instance_view));
|
||
ui.set_download_category(conv::category_from(state.prefs.download_category));
|
||
ui.set_download_installed_only(state.installed_only);
|
||
ui.set_theme_mode(conv::theme_mode(state.prefs.theme));
|
||
|
||
ui.set_tiles(model(conv::tiles(&state)));
|
||
ui.set_running_tiles(model(conv::running_tiles(&state)));
|
||
ui.set_versions(model(conv::versions(&state)));
|
||
ui.set_log_sessions(model(conv::log_sessions(&state)));
|
||
ui.set_log_lines(model(conv::log_lines(&state)));
|
||
ui.set_toasts(model(conv::toasts(&state)));
|
||
|
||
let (diagnosis, has_diagnosis) = conv::diagnosis(&state);
|
||
ui.set_diagnosis(diagnosis);
|
||
ui.set_has_diagnosis(has_diagnosis);
|
||
ui.set_selected_session(state.selected_session.clone().unwrap_or_default().into());
|
||
|
||
ui.set_ai_messages(model(conv::chat(&state)));
|
||
ui.set_ai_streaming(state.ai.streaming);
|
||
ui.set_yolo(state.ai.yolo);
|
||
ui.set_ai_endpoint_label(state.ai.endpoint_label.clone().into());
|
||
|
||
let (dialog, open, queued) = conv::dialog(&state);
|
||
ui.set_dialog(dialog);
|
||
ui.set_dialog_open(open);
|
||
ui.set_dialog_queued(queued);
|
||
|
||
// PageState(账户/Java/安装/设置/通知/向导)在独立模块里灌,守 400 行
|
||
crate::page_sync::apply(ui, &state);
|
||
}
|
||
}
|
||
|
||
/// Slint 列表模型(VecModel → ModelRc)。
|
||
pub(crate) fn model<T: Clone + 'static>(items: Vec<T>) -> ModelRc<T> {
|
||
ModelRc::new(VecModel::from(items))
|
||
}
|