From ddc0577f8344a8fb509e46a35bbc771055c87c2f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 31 Oct 2026 21:05:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(app):=20GUI=20=E5=85=A8=E9=87=8F=E6=8E=A5?= =?UTF-8?q?=E7=BA=BF=EF=BC=8C=E4=BA=94=E9=A1=B5=E6=94=B9=E5=90=83=20core?= =?UTF-8?q?=20=E7=9C=9F=E5=AE=9E=E6=95=B0=E6=8D=AE=EF=BC=88M43=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 执行器上移。 --- Cargo.lock | 2 + crates/app/Cargo.toml | 7 + crates/app/src/backend.rs | 36 ++ crates/app/src/callbacks.rs | 6 +- crates/app/src/callbacks_pages.rs | 256 ++++++++++++ crates/app/src/conv/instances.rs | 1 + crates/app/src/conv/mod.rs | 5 + crates/app/src/conv/pages.rs | 300 ++++++++++++++ crates/app/src/conv/pages_tests.rs | 156 ++++++++ crates/app/src/conv/versions.rs | 38 +- crates/app/src/dialogs.rs | 8 +- crates/app/src/dto/accounts.rs | 224 +++++++++++ crates/app/src/dto/agent.rs | 10 +- crates/app/src/dto/events.rs | 171 +++++++- crates/app/src/dto/install.rs | 249 ++++++++++++ crates/app/src/dto/instances.rs | 159 +++++++- crates/app/src/dto/java.rs | 234 +++++++++++ crates/app/src/dto/logs.rs | 14 +- crates/app/src/dto/mod.rs | 26 +- crates/app/src/dto/notices.rs | 73 ++++ crates/app/src/dto/settings.rs | 220 ++++++++++ crates/app/src/dto/versions.rs | 10 +- crates/app/src/host/accounts.rs | 237 +++++++++++ crates/app/src/host/dispatch.rs | 152 +++++++ crates/app/src/host/engine.rs | 126 ++++++ crates/app/src/host/env.rs | 170 ++++++++ crates/app/src/host/game.rs | 260 ++++++++++++ crates/app/src/host/identity.rs | 103 +++++ crates/app/src/host/install.rs | 313 +++++++++++++++ crates/app/src/host/install_tests.rs | 86 ++++ crates/app/src/host/java.rs | 304 ++++++++++++++ crates/app/src/host/job.rs | 136 +++++++ crates/app/src/host/launch.rs | 353 ++++++++++++++++ crates/app/src/host/logs.rs | 342 ++++++++++++++++ crates/app/src/host/logs_tests.rs | 86 ++++ crates/app/src/host/meter.rs | 70 ++++ crates/app/src/host/mod.rs | 370 +++++++++++++++++ crates/app/src/host/settings.rs | 398 +++++++++++++++++++ crates/app/src/host/settings_tests.rs | 77 ++++ crates/app/src/host/versions.rs | 173 ++++++++ crates/app/src/main.rs | 30 +- crates/app/src/mock.rs | 6 +- crates/app/src/mock_data.rs | 12 + crates/app/src/page_sync.rs | 36 ++ crates/app/src/screenshot.rs | 160 ++++++++ crates/app/src/state.rs | 100 ++++- crates/app/src/state_tests.rs | 3 + crates/app/src/toast.rs | 6 +- crates/app/src/ui_bind.rs | 175 ++++---- crates/app/src/wizard.rs | 275 +++++++++++++ crates/app/ui/components/install-panel.slint | 73 ++++ crates/app/ui/components/notice-bar.slint | 57 +++ crates/app/ui/components/progress-bar.slint | 34 ++ crates/app/ui/main.slint | 2 + crates/app/ui/models.slint | 146 +++++++ crates/app/ui/page_state.slint | 76 ++++ crates/app/ui/pages/download-java.slint | 204 ++++++++++ crates/app/ui/pages/download.slint | 298 ++++++++------ crates/app/ui/pages/logs.slint | 68 +++- crates/app/ui/pages/settings-account.slint | 194 +++++++++ crates/app/ui/pages/settings-groups.slint | 117 ++++++ crates/app/ui/pages/settings.slint | 107 +---- crates/app/ui/shell.slint | 14 + crates/app/ui/widgets/wizard.slint | 301 ++++++++++++++ 64 files changed, 8047 insertions(+), 408 deletions(-) create mode 100644 crates/app/src/backend.rs create mode 100644 crates/app/src/callbacks_pages.rs create mode 100644 crates/app/src/conv/pages.rs create mode 100644 crates/app/src/conv/pages_tests.rs create mode 100644 crates/app/src/dto/accounts.rs create mode 100644 crates/app/src/dto/install.rs create mode 100644 crates/app/src/dto/java.rs create mode 100644 crates/app/src/dto/notices.rs create mode 100644 crates/app/src/dto/settings.rs create mode 100644 crates/app/src/host/accounts.rs create mode 100644 crates/app/src/host/dispatch.rs create mode 100644 crates/app/src/host/engine.rs create mode 100644 crates/app/src/host/env.rs create mode 100644 crates/app/src/host/game.rs create mode 100644 crates/app/src/host/identity.rs create mode 100644 crates/app/src/host/install.rs create mode 100644 crates/app/src/host/install_tests.rs create mode 100644 crates/app/src/host/java.rs create mode 100644 crates/app/src/host/job.rs create mode 100644 crates/app/src/host/launch.rs create mode 100644 crates/app/src/host/logs.rs create mode 100644 crates/app/src/host/logs_tests.rs create mode 100644 crates/app/src/host/meter.rs create mode 100644 crates/app/src/host/mod.rs create mode 100644 crates/app/src/host/settings.rs create mode 100644 crates/app/src/host/settings_tests.rs create mode 100644 crates/app/src/host/versions.rs create mode 100644 crates/app/src/page_sync.rs create mode 100644 crates/app/src/screenshot.rs create mode 100644 crates/app/src/wizard.rs create mode 100644 crates/app/ui/components/install-panel.slint create mode 100644 crates/app/ui/components/notice-bar.slint create mode 100644 crates/app/ui/components/progress-bar.slint create mode 100644 crates/app/ui/page_state.slint create mode 100644 crates/app/ui/pages/download-java.slint create mode 100644 crates/app/ui/pages/settings-account.slint create mode 100644 crates/app/ui/pages/settings-groups.slint create mode 100644 crates/app/ui/widgets/wizard.slint diff --git a/Cargo.lock b/Cargo.lock index 09ae405..c9f3efe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6032,6 +6032,8 @@ dependencies = [ name = "unlucky-app" version = "0.1.0" dependencies = [ + "serde", + "serde_json", "slint", "slint-build", "unlucky-agent", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 2d13068..6f6022c 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -15,6 +15,11 @@ path = "src/main.rs" [dependencies] slint.workspace = true +# 跨界 DTO 必须可序列化(AGENTS §6 / 01c §5):事件与快照是 IPC 契约, +# 序列化能力由 derive 钉住,测试里做 round-trip 断言。 +serde.workspace = true +# host 层要读 core 设置/事件里的 JSON 值(serde_json 是 workspace 既有依赖) +serde_json.workspace = true # 边界(01c §5 / D3):app 只经 DTO 消费 core/agent,**不 import 内部类型**。 # P0 的 host 是 src/mock.rs 的假后端;这两个依赖在这里登记,是为了后续把真实 host @@ -25,5 +30,7 @@ unlucky-agent = { path = "../agent", version = "0.1" } [build-dependencies] slint-build.workspace = true +[dev-dependencies] + [lints] workspace = true diff --git a/crates/app/src/backend.rs b/crates/app/src/backend.rs new file mode 100644 index 0000000..224b786 --- /dev/null +++ b/crates/app/src/backend.rs @@ -0,0 +1,36 @@ +//! 后端选择与启动选项:真实 core host 优先,解析不出数据根时退回 mock。 + +use std::path::{Path, PathBuf}; + +use crate::dto::Backend; +use crate::host::CoreHost; +use crate::host::HostEnv; +use crate::mock::MockBackend; + +/// 启动选项(截图/演示通道用;正常启动全用默认值)。 +#[derive(Debug, Clone, Default)] +pub struct AppOptions { + /// 钉死数据根(`/.ULL` + `/.minecraft`);None = 按便携配置解析 + pub data_root: Option, + /// 强制 mock 后端(UI 回归/无数据根演示) + pub mock: bool, +} + +/// `/.ULL/config/launcher.json`(与 `SettingsBridge::open` 同落点)。 +pub(crate) fn config_path(root: &Path) -> PathBuf { + root.join(".ULL").join("config").join("launcher.json") +} + +/// 选后端:真实 host 优先;数据根解析不出来或显式 `--mock` 时退回假后端。 +pub(crate) fn build_backend(options: &AppOptions) -> Box { + if options.mock { + return Box::new(MockBackend::new()); + } + match &options.data_root { + Some(root) => Box::new(CoreHost::new(HostEnv::at_root(root))), + None => match CoreHost::open() { + Some(host) => Box::new(host), + None => Box::new(MockBackend::new()), + }, + } +} diff --git a/crates/app/src/callbacks.rs b/crates/app/src/callbacks.rs index 35e7e92..d3ddcdd 100644 --- a/crates/app/src/callbacks.rs +++ b/crates/app/src/callbacks.rs @@ -180,10 +180,8 @@ fn wire_instances(ui: &crate::MainWindow, weak: &Weak) { let weak = weak.clone(); ui.on_new_instance(move || { let Some(this) = weak.upgrade() else { return }; - this.state - .borrow_mut() - .toasts - .push(ToastKind::Info, "新建实例向导(G5)在 P1 落地"); + // G5:打开向导(版本 → 加载器 → 名字 → 确认 → 安装进度) + this.state.borrow_mut().wizard = crate::wizard::WizardState::open(); this.sync(); }); } diff --git a/crates/app/src/callbacks_pages.rs b/crates/app/src/callbacks_pages.rs new file mode 100644 index 0000000..3b22bbc --- /dev/null +++ b/crates/app/src/callbacks_pages.rs @@ -0,0 +1,256 @@ +//! `PageState` global 的回调接线:账户 / Java / 设置 / 通知 / 向导 / 崩溃导入 / 日志刷新。 +//! +//! 与 `callbacks.rs` 同一纪律:每个回调 = 「改状态 + 发 AppCommand + 同步 UI」,一处看全。 +//! 用 global 而不是逐层转发属性(见 `ui/page_state.slint` 顶部的取舍说明)。 + +use std::rc::Weak; + +use slint::ComponentHandle; + +use crate::conv; +use crate::dto::{AppCommand, CreateRequest, LoaderKind}; +use crate::ui_bind::Inner; + +/// 接全部页面状态回调。 +pub(crate) fn wire_pages(weak: Weak) { + let Some(inner) = weak.upgrade() else { return }; + let page = inner.ui.global::>(); + + // ── 账户 ────────────────────────────────────────────────── + { + let weak = weak.clone(); + page.on_account_add_offline(move |name| { + let name = name.to_string(); + if name.trim().is_empty() { + return; + } + let Some(this) = weak.upgrade() else { return }; + this.dispatch(AppCommand::AddOfflineAccount(name)); + this.ui + .global::>() + .set_offline_name("".into()); + this.sync(); + }); + } + { + let weak = weak.clone(); + page.on_account_remove(move |id| { + let Some(this) = weak.upgrade() else { return }; + this.dispatch(AppCommand::RemoveAccount(id.to_string())); + this.sync(); + }); + } + { + let weak = weak.clone(); + page.on_account_switch(move |id| { + let Some(this) = weak.upgrade() else { return }; + this.dispatch(AppCommand::SwitchAccount(id.to_string())); + this.sync(); + }); + } + { + let weak = weak.clone(); + page.on_msa_begin(move || { + let Some(this) = weak.upgrade() else { return }; + this.dispatch(AppCommand::StartDeviceLogin); + this.sync(); + }); + } + { + let weak = weak.clone(); + page.on_msa_cancel(move || { + let Some(this) = weak.upgrade() else { return }; + this.dispatch(AppCommand::CancelDeviceLogin); + this.sync(); + }); + } + + // ── Java ────────────────────────────────────────────────── + { + let weak = weak.clone(); + page.on_java_install(move |vendor| { + let Some(this) = weak.upgrade() else { return }; + this.dispatch(AppCommand::InstallJava(vendor.to_string())); + this.sync(); + }); + } + + // ── 设置 ────────────────────────────────────────────────── + { + let weak = weak.clone(); + page.on_setting_changed(move |key, value| { + let Some(this) = weak.upgrade() else { return }; + this.dispatch(AppCommand::SetSetting { + key: key.to_string(), + value: value.to_string(), + }); + this.sync(); + }); + } + { + let weak = weak.clone(); + page.on_setting_action(move |action| { + let Some(this) = weak.upgrade() else { return }; + let action = action.to_string(); + match action.as_str() { + // 清 24h 地区缓存 → 下次下载重新判定 + "detect_region" => this.dispatch(AppCommand::SetSetting { + key: "download.action.detect_region".to_string(), + value: String::new(), + }), + // Java 分组的「安装/管理」→ 按回退链装一个托管 JDK + "install_java" => this.dispatch(AppCommand::InstallJava(String::new())), + _ => {} + } + this.sync(); + }); + } + { + let weak = weak.clone(); + page.on_settings_tab_changed(move |tab| { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().settings_tab = tab.to_string(); + this.sync(); + }); + } + + // ── 通知 ────────────────────────────────────────────────── + { + let weak = weak.clone(); + page.on_notice_dismiss(move |id| { + let Some(this) = weak.upgrade() else { return }; + this.dispatch(AppCommand::DismissNotice(id.to_string())); + this.sync(); + }); + } + + // ── 向导 ────────────────────────────────────────────────── + { + let weak = weak.clone(); + page.on_wizard_open(move || { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().wizard = crate::wizard::WizardState::open(); + this.sync(); + }); + } + { + let weak = weak.clone(); + page.on_wizard_close(move || { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().wizard.open = false; + this.sync(); + }); + } + { + let weak = weak.clone(); + page.on_wizard_select_version(move |version| { + let Some(this) = weak.upgrade() else { return }; + this.state + .borrow_mut() + .wizard + .select_version(version.as_ref()); + this.sync(); + }); + } + { + let weak = weak.clone(); + page.on_wizard_select_loader(move |index| { + let Some(this) = weak.upgrade() else { return }; + let loader = match index { + 1 => LoaderKind::Fabric, + 2 => LoaderKind::Quilt, + 3 => LoaderKind::Forge, + 4 => LoaderKind::NeoForge, + _ => LoaderKind::Vanilla, + }; + this.state.borrow_mut().wizard.select_loader(loader); + this.sync(); + }); + } + { + let weak = weak.clone(); + page.on_wizard_name_changed(move |name| { + let Some(this) = weak.upgrade() else { return }; + let mut state = this.state.borrow_mut(); + state.wizard.name = name.to_string(); + state.wizard.error.clear(); + drop(state); + this.sync(); + }); + } + { + let weak = weak.clone(); + page.on_wizard_next(move || { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().wizard.advance(); + this.sync(); + }); + } + { + let weak = weak.clone(); + page.on_wizard_back(move || { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().wizard.step_back(); + this.sync(); + }); + } + { + let weak = weak.clone(); + page.on_wizard_confirm(move || { + let Some(this) = weak.upgrade() else { return }; + let request = { + let state = this.state.borrow(); + let wizard = &state.wizard; + wizard.ready_to_submit().map(|()| CreateRequest { + version: wizard.version.trim().to_string(), + loader: wizard.loader, + loader_version: wizard.loader_version.trim().to_string(), + name: wizard.name.trim().to_string(), + }) + }; + match request { + Ok(request) => { + this.dispatch(AppCommand::CreateInstance(request)); + this.state.borrow_mut().wizard.submitted(); + } + Err(error) => { + this.state.borrow_mut().wizard.error = error; + } + } + this.sync(); + }); + } + { + let weak = weak.clone(); + page.on_install_cancel(move || { + let Some(this) = weak.upgrade() else { return }; + this.dispatch(AppCommand::CancelInstall); + this.sync(); + }); + } + + // ── 日志页 ──────────────────────────────────────────────── + { + let weak = weak.clone(); + page.on_crash_import(move |path| { + let Some(this) = weak.upgrade() else { return }; + let path = path.to_string(); + if path.trim().is_empty() { + return; + } + this.dispatch(AppCommand::ImportCrashLog(path)); + this.sync(); + }); + } + { + let weak = weak.clone(); + page.on_logs_refresh(move || { + let Some(this) = weak.upgrade() else { return }; + this.dispatch(AppCommand::RefreshLogSessions); + this.sync(); + }); + } + + // 设置页分组切换同时刷新一遍分组快照(外部改配置后手点分组即最新) + let _ = conv::setting_groups; +} diff --git a/crates/app/src/conv/instances.rs b/crates/app/src/conv/instances.rs index 1d47a93..11ce32a 100644 --- a/crates/app/src/conv/instances.rs +++ b/crates/app/src/conv/instances.rs @@ -33,6 +33,7 @@ pub fn phase(runtime: &InstanceRuntime) -> (RuntimePhase, f32, String) { dto::LaunchStage::Download => RuntimePhase::Downloading, dto::LaunchStage::Verify => RuntimePhase::Verifying, dto::LaunchStage::Launch => RuntimePhase::Launching, + dto::LaunchStage::Window => RuntimePhase::Running, }; (phase, progress.ratio(), stage.label().to_string()) } diff --git a/crates/app/src/conv/mod.rs b/crates/app/src/conv/mod.rs index d556a50..dd50cf3 100644 --- a/crates/app/src/conv/mod.rs +++ b/crates/app/src/conv/mod.rs @@ -8,12 +8,17 @@ mod agent; mod instances; mod logs; +mod pages; mod ui; mod versions; pub use agent::{chat, dialog, toasts}; pub use instances::{running_tiles, tiles}; pub use logs::{diagnosis, log_lines, log_sessions}; +pub use pages::{ + accounts, active_account_name, device, install, java_chain, java_runtimes, java_vendors, + notices, setting_groups, wizard, wizard_versions, +}; pub use ui::{ accent_color, bg_fill, bg_fill_from, category_from, instance_view, instance_view_from, route, route_from, theme_mode, theme_mode_from, tile_size, tile_size_from, version_category, diff --git a/crates/app/src/conv/pages.rs b/crates/app/src/conv/pages.rs new file mode 100644 index 0000000..2161f55 --- /dev/null +++ b/crates/app/src/conv/pages.rs @@ -0,0 +1,300 @@ +//! 状态 → `page_state.slint` 的视图模型(账户 / Java / 安装 / 设置分组 / 通知 / 向导)。 +//! +//! 与 `conv/{instances,versions,logs,agent}.rs` 同一套路:只做「领域值 → 展示值」的搬运, +//! 不含业务判断。`ui_bind::sync` 把这些灌进 `PageState` global。 + +use slint::{ModelRc, SharedString, VecModel}; + +use crate::dto::{ + AccountKind as DtoAccountKind, DeviceLoginPhase, InstallStage as DtoInstallStage, + JavaChainOutcome, NoticeLevel, SettingControl, VersionKind as DtoVersionKind, +}; +use crate::state::AppState; +use crate::wizard::WizardStep; +use crate::{ + AccountKind, InstallStage, JavaStepOutcome, NoticeKind, SettingControl as UiControl, UiAccount, + UiDeviceCode, UiInstallProgress, UiJavaRuntime, UiJavaStep, UiJavaVendor, UiNotice, + UiSettingGroup, UiSettingRow, UiVersion, UiWizard, +}; + +fn strings(items: &[String]) -> ModelRc { + ModelRc::new(VecModel::from( + items + .iter() + .map(|item| SharedString::from(item.as_str())) + .collect::>(), + )) +} + +/// 账户行。 +pub fn accounts(state: &AppState) -> Vec { + state + .accounts + .iter() + .map(|account| UiAccount { + id: account.id.as_str().into(), + name: account.name.as_str().into(), + kind: match account.kind { + DtoAccountKind::Offline => AccountKind::Offline, + DtoAccountKind::Microsoft => AccountKind::Microsoft, + DtoAccountKind::Authlib => AccountKind::Authlib, + }, + kind_label: account.kind.label().into(), + profile_id: account.profile_id.as_str().into(), + login_name: account.login_name.as_str().into(), + created: account.created.as_str().into(), + active: account.active, + }) + .collect() +} + +/// 当前启动账户的展示名(导航底部头像)。 +pub fn active_account_name(state: &AppState) -> String { + state + .accounts + .iter() + .find(|account| account.active) + .map(|account| account.name.clone()) + .unwrap_or_else(|| "未登录".to_string()) +} + +/// 设备码登录面板。 +pub fn device(state: &AppState) -> UiDeviceCode { + let view = &state.device_login; + UiDeviceCode { + visible: view.phase.visible(), + busy: view.phase.busy(), + user_code: view.user_code.as_str().into(), + verification_uri: view.verification_uri.as_str().into(), + qr_link: view.qr_link.as_str().into(), + expires: i32::try_from(view.expires_in).unwrap_or(i32::MAX), + message: view.message.as_str().into(), + phase_label: match view.phase { + DeviceLoginPhase::Idle => "空闲", + DeviceLoginPhase::Requesting => "申请中", + DeviceLoginPhase::Waiting => "等待确认", + DeviceLoginPhase::Succeeded => "成功", + DeviceLoginPhase::Failed => "失败", + } + .into(), + } +} + +/// Java 发行版行。 +pub fn java_vendors(state: &AppState) -> Vec { + state + .java + .vendors + .iter() + .map(|vendor| UiJavaVendor { + id: vendor.id.as_str().into(), + display: vendor.display.as_str().into(), + rank: i32::try_from(vendor.rank).unwrap_or(0), + majors: vendor.majors.as_str().into(), + sources: vendor.sources.join(" · ").into(), + installed: vendor.installed, + }) + .collect() +} + +/// Java 回退链。 +pub fn java_chain(state: &AppState) -> Vec { + state + .java + .chain + .iter() + .map(|step| UiJavaStep { + vendor: step.vendor.as_str().into(), + outcome: match step.outcome { + JavaChainOutcome::Pending => JavaStepOutcome::Pending, + JavaChainOutcome::Skip => JavaStepOutcome::Skip, + JavaChainOutcome::Failed => JavaStepOutcome::Failed, + JavaChainOutcome::Hit => JavaStepOutcome::Hit, + }, + outcome_label: step.outcome.label().into(), + detail: step.detail.as_str().into(), + }) + .collect() +} + +/// Java 本地运行时。 +pub fn java_runtimes(state: &AppState) -> Vec { + state + .java + .runtimes + .iter() + .map(|runtime| UiJavaRuntime { + binary: runtime.binary.as_str().into(), + version: runtime.version.as_str().into(), + vendor: runtime.vendor.as_str().into(), + origin: runtime.origin.as_str().into(), + }) + .collect() +} + +/// 安装进度卡片。 +pub fn install(state: &AppState) -> UiInstallProgress { + let Some(progress) = &state.install else { + return UiInstallProgress { + visible: false, + stage: InstallStage::Planning, + ..UiInstallProgress::default() + }; + }; + UiInstallProgress { + visible: true, + version: progress.version.as_str().into(), + instance: progress.instance.as_str().into(), + loader: progress.loader.as_str().into(), + stage: match progress.stage { + DtoInstallStage::Planning => InstallStage::Planning, + DtoInstallStage::Downloading => InstallStage::Downloading, + DtoInstallStage::Installing => InstallStage::Installing, + DtoInstallStage::Registering => InstallStage::Registering, + DtoInstallStage::Done => InstallStage::Done, + DtoInstallStage::Failed => InstallStage::Failed, + DtoInstallStage::Cancelled => InstallStage::Cancelled, + }, + stage_label: progress.stage.label().into(), + ratio: progress.ratio(), + known: progress.is_known(), + files_label: progress.files_label().into(), + bytes_label: if progress.bytes_total > 0 { + format!( + "{}/{}", + crate::dto::human_bytes(progress.bytes_done), + crate::dto::human_bytes(progress.bytes_total) + ) + } else { + String::new() + } + .into(), + speed: progress.speed_label().into(), + eta: progress.eta_label().into(), + message: progress.message.as_str().into(), + terminal: progress.stage.is_terminal(), + failed: progress.stage == DtoInstallStage::Failed, + } +} + +/// 设置分组(行 + 控件)。 +pub fn setting_groups(state: &AppState) -> Vec { + state + .settings_groups + .iter() + .map(|group| UiSettingGroup { + id: group.id.as_str().into(), + title: group.title.as_str().into(), + rows: ModelRc::new(VecModel::from( + group + .rows + .iter() + .map(|row| UiSettingRow { + key: row.key.as_str().into(), + label: row.label.as_str().into(), + description: row.description.as_str().into(), + control: match row.control { + SettingControl::Toggle => UiControl::Toggle, + SettingControl::Choice => UiControl::Choice, + SettingControl::Text => UiControl::Text, + SettingControl::ReadOnly => UiControl::Readonly, + SettingControl::Action => UiControl::Action, + }, + flag: row.flag, + selected: row.selected, + options: strings(&row.options), + value_text: row.value_text.as_str().into(), + action: row.action.as_str().into(), + }) + .collect::>(), + )), + }) + .collect() +} + +/// 横幅通知。 +pub fn notices(state: &AppState) -> Vec { + state + .notices + .iter() + .map(|notice| UiNotice { + id: notice.id.as_str().into(), + kind: match notice.level { + NoticeLevel::Info => NoticeKind::Info, + NoticeLevel::Warn => NoticeKind::Warn, + NoticeLevel::Error => NoticeKind::Error, + }, + title: notice.title.as_str().into(), + body: notice.body.as_str().into(), + }) + .collect() +} + +/// 向导状态。 +pub fn wizard(state: &AppState) -> UiWizard { + let wizard = &state.wizard; + let step = wizard.step; + let can_next = match step { + WizardStep::Version => !wizard.version.trim().is_empty(), + WizardStep::Name => crate::wizard::validate_name(&wizard.name).is_ok(), + WizardStep::Loader | WizardStep::Confirm => true, + WizardStep::Progress => false, + }; + UiWizard { + open: wizard.open, + step: i32::try_from(step.index()).unwrap_or(0), + step_label: step.title().into(), + step_index: i32::try_from(step.index()).unwrap_or(0), + step_total: 5, + version: wizard.version.as_str().into(), + loader: loader_index(wizard.loader), + loader_label: if wizard.loader == crate::dto::LoaderKind::Vanilla { + "原版".to_string() + } else { + format!("{} {}", wizard.loader.label(), wizard.loader_version.trim()) + .trim() + .to_string() + } + .into(), + loader_version: wizard.loader_version.as_str().into(), + name: wizard.name.as_str().into(), + error: wizard.error.as_str().into(), + busy: wizard.submitting, + can_next, + } +} + +/// 加载器在向导里的单选序号(与 `wizard.slint` 的 LoaderChoice 顺序一致)。 +pub fn loader_index(loader: crate::dto::LoaderKind) -> i32 { + match loader { + crate::dto::LoaderKind::Vanilla => 0, + crate::dto::LoaderKind::Fabric => 1, + crate::dto::LoaderKind::Quilt => 2, + crate::dto::LoaderKind::Forge => 3, + crate::dto::LoaderKind::NeoForge => 4, + crate::dto::LoaderKind::OptiFine | crate::dto::LoaderKind::LiteLoader => 0, + } +} + +/// 向导里的版本列表(正式版 + 快照;远古版走下载页的显式入口)。 +pub fn wizard_versions(state: &AppState) -> Vec { + state + .versions + .iter() + .filter(|version| !version.kind.is_ancient()) + .map(|version| UiVersion { + id: version.id.as_str().into(), + kind: match version.kind { + DtoVersionKind::Release => crate::VersionKind::Release, + _ => crate::VersionKind::Snapshot, + }, + released: version.released.as_str().into(), + installed: version.installed, + ancient: false, + }) + .collect() +} + +#[cfg(test)] +#[path = "pages_tests.rs"] +mod tests; diff --git a/crates/app/src/conv/pages_tests.rs b/crates/app/src/conv/pages_tests.rs new file mode 100644 index 0000000..db4469a --- /dev/null +++ b/crates/app/src/conv/pages_tests.rs @@ -0,0 +1,156 @@ +//! conv/pages.rs 的单测(拆文件守 400 行上限)。 + +use super::*; +use crate::dto::{ + AccountView, DeviceLoginView, InstallProgressView, JavaPageView, JavaVendorView, NoticeView, + SettingGroupView, SettingRowView, VersionSummary, +}; +use crate::wizard::WizardState; +use slint::Model; + +#[test] +fn accounts_and_device_login_render() { + let mut state = AppState::new(); + state.accounts = vec![AccountView { + id: "account:1".to_string(), + name: "Steve".to_string(), + kind: DtoAccountKind::Offline, + profile_id: "0f8f".to_string(), + login_name: String::new(), + created: "2026-10-31 20:12".to_string(), + active: true, + }]; + state.device_login = + DeviceLoginView::waiting("ABCD-EFGH", "https://www.microsoft.com/link", 900, ""); + + let rows = accounts(&state); + assert_eq!(rows.len(), 1); + assert!(rows[0].active); + assert_eq!(rows[0].kind_label, "离线"); + assert_eq!(active_account_name(&state), "Steve"); + + let device = device(&state); + assert!(device.visible && device.busy); + assert_eq!(device.user_code, "ABCD-EFGH"); + assert_eq!(device.phase_label, "等待确认"); +} + +#[test] +fn install_progress_renders_labels_and_terminal_flags() { + let mut state = AppState::new(); + state.install = Some(InstallProgressView { + version: "1.20.4".to_string(), + instance: "生存".to_string(), + loader: "Fabric 0.16.5".to_string(), + stage: DtoInstallStage::Downloading, + files_done: 3, + files_total: 6, + bytes_done: 1024, + bytes_total: 4096, + speed_bps: 2048, + eta_secs: 2, + message: "下载中".to_string(), + }); + let ui = install(&state); + assert!(ui.visible && !ui.terminal && !ui.failed); + assert!((ui.ratio - 0.5).abs() < f32::EPSILON); + assert_eq!(ui.files_label, "3/6 文件"); + assert_eq!(ui.speed, "2.0 KB/s"); + assert_eq!(ui.eta, "剩 2s"); + + state.install = None; + assert!(!install(&state).visible); +} + +#[test] +fn java_chain_and_vendors_map_outcomes() { + let mut state = AppState::new(); + state.java = JavaPageView { + vendors: vec![JavaVendorView { + id: "temurin".to_string(), + display: "Eclipse Temurin".to_string(), + rank: 1, + majors: "17/21".to_string(), + sources: vec!["BMCLAPI".to_string(), "GitHub".to_string()], + installed: true, + }], + chain: vec![crate::dto::JavaChainStep { + vendor: "temurin".to_string(), + outcome: JavaChainOutcome::Hit, + detail: "21".to_string(), + }], + ..JavaPageView::new() + }; + let vendors = java_vendors(&state); + assert_eq!(vendors.len(), 1); + assert_eq!(vendors[0].sources, "BMCLAPI · GitHub"); + let chain = java_chain(&state); + assert!(matches!(chain[0].outcome, JavaStepOutcome::Hit)); + assert_eq!(chain[0].outcome_label, "命中"); +} + +#[test] +fn settings_groups_map_controls() { + let mut state = AppState::new(); + state.settings_groups = vec![SettingGroupView::new("download", "下载") + .with(SettingRowView::toggle("agent.mcp.enabled", "MCP", "", true)) + .with(SettingRowView::choice( + "download.region", + "地区", + "", + &["auto", "cn"], + 1, + ))]; + let groups = setting_groups(&state); + assert_eq!(groups.len(), 1); + let rows = groups[0].rows.clone(); + assert_eq!(rows.row_count(), 2); + let first = rows.row_data(0).expect("第一行"); + let second = rows.row_data(1).expect("第二行"); + assert!(first.flag); + assert!(matches!(second.control, UiControl::Choice)); + assert_eq!(second.selected, 1); +} + +#[test] +fn notices_and_wizard_render() { + let mut state = AppState::new(); + state.notices = vec![NoticeView::new( + "isolation:x", + NoticeLevel::Warn, + "隔离档位已切换", + "两侧都有内容", + )]; + let notices = notices(&state); + assert!(matches!(notices[0].kind, NoticeKind::Warn)); + + state.wizard = WizardState::open(); + state.wizard.select_version("1.20.4"); + let wizard = wizard(&state); + assert!(wizard.open); + assert_eq!(wizard.step_label, "选择版本"); + assert!(wizard.can_next); + assert_eq!(wizard.loader, 0); +} + +#[test] +fn wizard_versions_skip_ancient_ones() { + let mut state = AppState::new(); + state.versions = vec![ + VersionSummary { + id: "1.20.4".to_string(), + kind: DtoVersionKind::Release, + released: "2023-12-07".to_string(), + installed: false, + }, + VersionSummary { + id: "rd-132211".to_string(), + kind: DtoVersionKind::Ancient(crate::dto::AncientKind::Classic), + released: "2009-05-13".to_string(), + installed: false, + }, + ]; + let rows = wizard_versions(&state); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].id, "1.20.4"); +} diff --git a/crates/app/src/conv/versions.rs b/crates/app/src/conv/versions.rs index 35e5667..3459dd4 100644 --- a/crates/app/src/conv/versions.rs +++ b/crates/app/src/conv/versions.rs @@ -1,17 +1,25 @@ //! 版本列表与下载分类映射(下载页)。 -use crate::dto::{AncientKind, VersionKind as DtoVersionKind}; +use crate::dto::{AncientKind, VersionCategory, VersionKind as DtoVersionKind, VersionSummary}; use crate::state::AppState; use crate::{UiVersion, VersionKind}; -/// 下载页版本行(已按「仅已装」+ 版本号筛选词过滤)。 -pub fn versions(state: &AppState) -> Vec { - let query = state.download_query.trim().to_lowercase(); - state - .versions +/// 分类侧栏 → 该分类下的版本(远古版按子类细分)。 +pub fn in_category(category: VersionCategory, kind: DtoVersionKind) -> bool { + match (category, kind) { + (VersionCategory::Release, DtoVersionKind::Release) => true, + (VersionCategory::Snapshot, DtoVersionKind::Snapshot) => true, + (VersionCategory::Ancient(expected), DtoVersionKind::Ancient(actual)) => expected == actual, + // 未接线的分类(整合包 / Mod)不显示任何版本,而不是把全部版本倒进去 + (VersionCategory::Modpack | VersionCategory::Mod, _) => false, + _ => false, + } +} + +/// 版本行(下载页与新建向导共用)。 +pub fn version_rows(versions: &[VersionSummary]) -> Vec { + versions .iter() - .filter(|version| !state.installed_only || version.installed) - .filter(|version| query.is_empty() || version.id.to_lowercase().contains(&query)) .map(|version| UiVersion { id: version.id.clone().into(), kind: version_kind(version.kind), @@ -22,6 +30,20 @@ pub fn versions(state: &AppState) -> Vec { .collect() } +/// 下载页版本行(按当前分类 + 「仅已装」+ 版本号筛选词过滤)。 +pub fn versions(state: &AppState) -> Vec { + let query = state.download_query.trim().to_lowercase(); + let filtered: Vec = state + .versions + .iter() + .filter(|version| in_category(state.prefs.download_category, version.kind)) + .filter(|version| !state.installed_only || version.installed) + .filter(|version| query.is_empty() || version.id.to_lowercase().contains(&query)) + .cloned() + .collect(); + version_rows(&filtered) +} + fn version_kind(kind: DtoVersionKind) -> VersionKind { match kind { DtoVersionKind::Release => VersionKind::Release, diff --git a/crates/app/src/dialogs.rs b/crates/app/src/dialogs.rs index 7097d57..542eb8b 100644 --- a/crates/app/src/dialogs.rs +++ b/crates/app/src/dialogs.rs @@ -4,11 +4,13 @@ //! 当前条被确认/取消后由 [`DialogQueue::advance`] 推进到下一条,UI 只渲染 `current`。 //! 非阻塞提示走 [`crate::toast`],不占模态队列。 +use serde::{Deserialize, Serialize}; + /// 对话框标识。 pub type DialogId = u64; /// 对话框来源:决定插队优先级。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub enum DialogSource { /// 提示类(教程、首次使用说明) Hint = 0, @@ -26,7 +28,7 @@ impl DialogSource { } /// 对话框类型(09 §3:确认 / 进度 / 输入 / 错误)。 -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum DialogKind { /// 确认:主按钮 + 次按钮 Confirm { @@ -87,7 +89,7 @@ impl DialogKind { } /// 一条对话框请求。 -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct DialogRequest { /// 队列内唯一 id(由 [`DialogQueue::push_request`] 重新编号) pub id: DialogId, diff --git a/crates/app/src/dto/accounts.rs b/crates/app/src/dto/accounts.rs new file mode 100644 index 0000000..346feff --- /dev/null +++ b/crates/app/src/dto/accounts.rs @@ -0,0 +1,224 @@ +//! 账户页 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(), + } + } + + /// 失败态。 + pub fn failed(message: impl Into) -> Self { + Self { + phase: DeviceLoginPhase::Failed, + message: message.into(), + ..Self::new() + } + } + + /// 成功态。 + pub fn succeeded(account_name: impl Into) -> 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 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, "网络超时"); + } +} diff --git a/crates/app/src/dto/agent.rs b/crates/app/src/dto/agent.rs index 1ecfd29..979afe4 100644 --- a/crates/app/src/dto/agent.rs +++ b/crates/app/src/dto/agent.rs @@ -1,7 +1,9 @@ //! AI 会话 DTO:消息、工具调用可视化与 agent 事件。 +use serde::{Deserialize, Serialize}; + /// 会话消息角色。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ChatRole { /// 用户 User, @@ -14,7 +16,7 @@ pub enum ChatRole { } /// 工具调用可视化条目。 -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ToolCallView { /// 工具名(与 ToolSpec 注册表同名) pub name: String, @@ -25,7 +27,7 @@ pub struct ToolCallView { } /// 一条会话消息。 -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ChatMessage { /// 角色 pub role: ChatRole, @@ -60,7 +62,7 @@ impl ChatMessage { } /// agent 侧事件。 -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum AgentEvent { /// 追加一条完整消息 MessageAppended(ChatMessage), diff --git a/crates/app/src/dto/events.rs b/crates/app/src/dto/events.rs index 2de323e..739d621 100644 --- a/crates/app/src/dto/events.rs +++ b/crates/app/src/dto/events.rs @@ -1,12 +1,46 @@ //! 事件与指令: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::{AgentEvent, DiagnosisReport, InstanceId, InstanceRuntime, InstanceSummary}; -use crate::dto::{LogLine, LogSession, SessionId, VersionCategory, VersionSummary}; +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, + /// 会话是否还在跑(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)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum CoreEvent { /// 实例列表整体刷新 InstancesChanged(Vec), @@ -21,15 +55,31 @@ pub enum CoreEvent { VersionListChanged(Vec), /// 日志会话列表刷新 LogSessionsChanged(Vec), - /// 当前会话日志行刷新(P0 整批替换;P1 改增量追加) + /// 当前会话日志行刷新(整批替换) LogLinesChanged(Vec), + /// 实时日志增量(启动中尾随 JSONL;`running=false` 表示会话收尾) + LogAppended(LiveLogBatch), + /// 启动收尾(磁贴状态回写 + toast) + LaunchFinished(LaunchFinished), /// 诊断结论产出 DiagnosisReady(DiagnosisReport), + /// 账户列表刷新 + AccountsChanged(Vec), + /// 设备码登录状态刷新 + DeviceLoginChanged(DeviceLoginView), + /// Java 页快照(发行版/回退链/运行时) + JavaChanged(JavaPageView), + /// 安装进度(`None` = 清掉进度面板) + InstallProgress(Option), + /// 设置分组刷新(设置页右栏) + SettingsChanged(Vec), + /// 横幅通知刷新(隔离切换提示/启动失败等) + NoticesChanged(Vec), } /// 统一事件队列的元素:core / agent / UI 自产(对话框、Toast)都走这一条路, /// 这样 [`crate::state::AppState::apply`] 是唯一的状态变更入口。 -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum AppEvent { /// 来自 core Core(CoreEvent), @@ -44,22 +94,56 @@ pub enum AppEvent { } /// app → core/agent 的用户意图。 -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum AppCommand { - /// 重新扫描实例目录 - RefreshInstances, + /// 重新扫描实例目录 + 全部页面快照(启动时与手改文件后) + 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), + /// 开始微软设备码登录 + StartDeviceLogin, + /// 取消/关掉设备码登录面板(已发出的请求会在收尾时丢弃结果) + 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 流 @@ -68,7 +152,17 @@ pub enum AppCommand { SetYolo(bool), } -/// 后端通道:core/agent host 实现它;P0 用 [`crate::mock::MockBackend`] 验证通道语义。 +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 模型。 @@ -79,8 +173,63 @@ pub trait Backend { /// 下发一条用户意图。 fn dispatch(&mut self, command: AppCommand); - /// 时间推进(mock 用脚本推进进度;真实后端可以是空实现)。 + /// 时间推进(真实后端在这里收后台线程的结果并尾随日志)。 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::(&serde_json::to_string(&login).expect("序列化")) + .expect("反序列化"), + login + ); + assert!(DeviceLoginPhase::Waiting.busy()); + } +} diff --git a/crates/app/src/dto/install.rs b/crates/app/src/dto/install.rs new file mode 100644 index 0000000..2537f78 --- /dev/null +++ b/crates/app/src/dto/install.rs @@ -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"); + } +} diff --git a/crates/app/src/dto/instances.rs b/crates/app/src/dto/instances.rs index 41e0ec8..47b9078 100644 --- a/crates/app/src/dto/instances.rs +++ b/crates/app/src/dto/instances.rs @@ -1,12 +1,14 @@ //! 实例相关 DTO:磁贴与列表视图的唯一数据源。 +use serde::{Deserialize, Serialize}; + use std::path::PathBuf; /// 实例标识:core::game 生成的稳定 id,app 视为不透明字符串。 pub type InstanceId = String; /// 加载器种类(磁贴徽标 + 详情页版本链)。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum LoaderKind { /// 原版(无加载器) Vanilla, @@ -37,10 +39,48 @@ impl LoaderKind { 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 { + 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)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LoaderInfo { /// 加载器种类 pub kind: LoaderKind, @@ -48,6 +88,15 @@ pub struct LoaderInfo { 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 { @@ -60,7 +109,7 @@ impl LoaderInfo { } /// 字节进度;`total == 0` 表示总量未知(UI 转不确定态)。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] pub struct Progress { /// 已完成量 pub done: u64, @@ -85,7 +134,7 @@ impl Progress { } /// 启动管线阶段(磁贴进度环的文案来源;D8 的 10 阶段在 P1 细化)。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum LaunchStage { /// 下载资源 Download, @@ -93,6 +142,8 @@ pub enum LaunchStage { Verify, /// 组装命令行并拉起进程 Launch, + /// 进程已起、等待游戏窗口(M28 的窗口检测阶段) + Window, } impl LaunchStage { @@ -101,13 +152,14 @@ impl LaunchStage { match self { Self::Download => "下载资源", Self::Verify => "校验文件", - Self::Launch => "启动中", + Self::Launch => "组装命令行", + Self::Window => "等待窗口", } } } /// 实例运行时状态(磁贴的四种外观)。 -#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] pub enum InstanceRuntime { /// 空闲 #[default] @@ -132,7 +184,7 @@ pub enum InstanceRuntime { } /// 实例摘要:磁贴/列表视图唯一数据源。 -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct InstanceSummary { /// 稳定 id pub id: InstanceId, @@ -148,4 +200,97 @@ pub struct InstanceSummary { pub runtime: InstanceRuntime, /// 实例图标路径(P0 直接给路径;P1 换成 core 提供的图像句柄) pub icon: Option, + /// 隔离档位(`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); + } } diff --git a/crates/app/src/dto/java.rs b/crates/app/src/dto/java.rs new file mode 100644 index 0000000..dad588f --- /dev/null +++ b/crates/app/src/dto/java.rs @@ -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, + /// 是否已装过这家(托管目录里有现成的) + 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, + /// 回退链(计划链或上一次安装的真实链) + pub chain: Vec, + /// 本地运行时 + pub runtimes: Vec, + /// 托管目录 + pub managed_roots: Vec, + /// 是否有 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 { + 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 { + let mut majors: Vec = 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]); + } +} diff --git a/crates/app/src/dto/logs.rs b/crates/app/src/dto/logs.rs index f73a75f..6166bd6 100644 --- a/crates/app/src/dto/logs.rs +++ b/crates/app/src/dto/logs.rs @@ -1,10 +1,12 @@ //! 日志页 DTO:会话列表、日志行与 D8 诊断结论。 +use serde::{Deserialize, Serialize}; + /// 一次启动会话(日志页左栏单位)标识。 pub type SessionId = String; /// 一次运行的结局(G3 左栏图标:✓ 正常 / ✗ 崩溃 / ⚠ 静默失败)。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum LogOutcome { /// 正常退出 Ok, @@ -15,7 +17,7 @@ pub enum LogOutcome { } /// 会话摘要。 -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LogSession { /// 会话 id pub id: SessionId, @@ -30,7 +32,7 @@ pub struct LogSession { } /// 日志行等级(错误行高亮)。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum LogLevel { /// debug Debug, @@ -43,7 +45,7 @@ pub enum LogLevel { } /// 一行日志。 -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LogLine { /// 原始文本(含时间戳前缀) pub text: String, @@ -52,7 +54,7 @@ pub struct LogLine { } /// D8 规则引擎结论(日志页崩溃诊断卡片)。 -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Diagnosis { /// 命中规则 id(`gpu.driver_old`) pub rule: String, @@ -67,7 +69,7 @@ pub struct Diagnosis { } /// 诊断结论 + 归属会话(UI 用它决定卡片显隐)。 -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DiagnosisReport { /// 归属会话 pub session: SessionId, diff --git a/crates/app/src/dto/mod.rs b/crates/app/src/dto/mod.rs index 28c9c4a..38c2cf0 100644 --- a/crates/app/src/dto/mod.rs +++ b/crates/app/src/dto/mod.rs @@ -2,22 +2,38 @@ //! //! 这里只允许出现「扁平 DTO / 事件 / 指令」:`crates/app` 任何模块都不得 `use unlucky_core::*` //! 或 `use unlucky_agent::*` 的内部类型。core/agent 侧的 host 层负责把自身类型翻译成本模块的类型, -//! 反向的 [`AppCommand`] 也一样。P0 阶段由 [`crate::mock`] 的假后端实现 [`Backend`] 驱动骨架。 +//! 反向的 [`AppCommand`] 也一样。P0 阶段由 [`crate::mock`] 的假后端实现 [`Backend`] 驱动骨架, +//! P3 起由 [`crate::host`] 的真实后端驱动全部页面。 //! -//! 结构:**按领域拆子模块**(instances / versions / logs / agent / events), -//! 全量 `pub use` 出去,调用方依旧写 `crate::dto::InstanceSummary` 这类路径。 +//! 结构:**按领域拆子模块**(instances / versions / logs / agent / accounts / java / +//! settings / install / notices / events),全量 `pub use` 出去,调用方依旧写 +//! `crate::dto::InstanceSummary` 这类路径。 //! 契约说明(字段口径、事件顺序、错误语义)见 `DevDocs/SubAgentsTalk/app-DTO通道约定.md`。 +//! +//! 全部跨界类型 `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}; +pub use events::{AppCommand, AppEvent, Backend, CoreEvent, LaunchFinished, LiveLogBatch}; +pub use install::{derive_speed, human_bytes, InstallProgressView, InstallStage}; pub use instances::{ - InstanceId, InstanceRuntime, InstanceSummary, LaunchStage, LoaderInfo, LoaderKind, Progress, + 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}; diff --git a/crates/app/src/dto/notices.rs b/crates/app/src/dto/notices.rs new file mode 100644 index 0000000..36e0ac4 --- /dev/null +++ b/crates/app/src/dto/notices.rs @@ -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(), "注意"); + } +} diff --git a/crates/app/src/dto/settings.rs b/crates/app/src/dto/settings.rs new file mode 100644 index 0000000..428decc --- /dev/null +++ b/crates/app/src/dto/settings.rs @@ -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, + /// 文本值 + 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, +} + +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); + } +} diff --git a/crates/app/src/dto/versions.rs b/crates/app/src/dto/versions.rs index eb2218f..844c6e6 100644 --- a/crates/app/src/dto/versions.rs +++ b/crates/app/src/dto/versions.rs @@ -1,7 +1,9 @@ //! 下载页 DTO:版本列表与分类(含远古版子类,D16/Q24-4)。 +use serde::{Deserialize, Serialize}; + /// 版本类型(远古版按 unlisted-versions 的分类拆开)。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum VersionKind { /// 正式版 Release, @@ -12,7 +14,7 @@ pub enum VersionKind { } /// 远古版子类。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum AncientKind { /// classic(rd-*) Classic, @@ -32,7 +34,7 @@ impl VersionKind { } /// 下载页版本行。 -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct VersionSummary { /// 版本 id(如 `1.20.4` / `rd-132211`) pub id: String, @@ -45,7 +47,7 @@ pub struct VersionSummary { } /// 下载页左侧分类(页内常驻侧栏的选中项)。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum VersionCategory { /// 正式版 Release, diff --git a/crates/app/src/host/accounts.rs b/crates/app/src/host/accounts.rs new file mode 100644 index 0000000..652a363 --- /dev/null +++ b/crates/app/src/host/accounts.rs @@ -0,0 +1,237 @@ +//! 账户:三轨统一列表(只读公开字段)+ 离线新增 + 删除/切换 + 微软设备码登录。 +//! +//! 凭据红线(D9):`Credentials` / `MicrosoftSession` 只在本模块与 `launch.rs` 里出现, +//! 出口一律是 [`AccountView`] / [`DeviceLoginView`]。设备码本身不是凭据,可以直接展示。 +//! +//! 取消语义:设备码轮询跑在 `core` 里、没有取消钩子,所以取消只把面板收回;线程收尾时 +//! 若发现「已取消」就不入库(避免用户点了取消却凭空多出一个账户)。 + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use unlucky_core::auth::account::Credentials; +use unlucky_core::auth::microsoft::{now_ms, MicrosoftAuth}; +use unlucky_core::auth::offline::offline_meta; +use unlucky_core::auth::AccountStore; +use unlucky_core::download::blocking::block_on; + +use crate::dto::{AccountKind, AccountView, DeviceLoginView}; + +use super::env::HostEnv; +use super::job::{spawn, HostTask, JobSender}; + +/// 账户列表(公开字段)。 +pub fn accounts(env: &HostEnv, active: Option<&str>) -> Vec { + let store = AccountStore::open(env.layout.auth_data()); + store + .accounts() + .iter() + .map(|meta| AccountView { + id: meta.id.as_str().to_string(), + name: meta.display_name().to_string(), + kind: AccountKind::parse(meta.kind.as_str()), + profile_id: meta.profile_id.clone(), + login_name: meta.login_name.clone().unwrap_or_default(), + created: display_ms(meta.created_at_ms), + active: active.is_some_and(|id| id == meta.id.as_str()), + }) + .collect() +} + +/// 毫秒时间戳 → `2026-10-31 20:12`(UTC;账户列表只用来排序/辨认)。 +pub fn display_ms(ms: u64) -> String { + if ms == 0 { + return String::new(); + } + let total_secs = ms / 1000; + let days = total_secs / 86_400; + let rem = total_secs % 86_400; + let (year, month, day) = civil_from_days(i64::try_from(days).unwrap_or(0)); + format!( + "{year:04}-{month:02}-{day:02} {:02}:{:02}", + rem / 3_600, + (rem % 3_600) / 60 + ) +} + +/// Howard Hinnant 的 `civil_from_days`(1970-01-01 为 0)。 +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + (if m <= 2 { y + 1 } else { y }, m as u32, d as u32) +} + +/// 加一个离线账户。 +/// +/// # Errors +/// +/// 名字不合法、同名冲突、落盘失败时返回用户可读文案。 +pub fn add_offline(env: &HostEnv, name: &str) -> Result { + let name = name.trim(); + let meta = offline_meta(name, now_ms()).map_err(|error| error.to_string())?; + let display = meta.display_name().to_string(); + let mut store = AccountStore::open(env.layout.auth_data()); + store + .insert(meta, Credentials::Offline) + .map_err(|error| format!("添加账户失败:{error}"))?; + store + .save() + .map_err(|error| format!("保存账户失败:{error}"))?; + Ok(display) +} + +/// 删除账户(按 id 或名字)。 +/// +/// # Errors +/// +/// 找不到、落盘失败时返回用户可读文案。 +pub fn remove(env: &HostEnv, key: &str) -> Result { + let mut store = AccountStore::open(env.layout.auth_data()); + let meta = store + .remove(key) + .map_err(|error| format!("删除账户失败:{error}"))?; + store + .save() + .map_err(|error| format!("保存账户失败:{error}"))?; + Ok(meta.display_name().to_string()) +} + +/// 设备码登录任务句柄(取消标志 + 账户名回执)。 +#[derive(Debug, Clone, Default)] +pub struct LoginJob { + /// 用户点了取消(线程收尾时据此丢弃结果) + pub cancelled: Arc, +} + +impl LoginJob { + /// 新建一个未取消的句柄。 + pub fn new() -> Self { + Self { + cancelled: Arc::new(AtomicBool::new(false)), + } + } + + /// 取消。 + pub fn cancel(&self) { + self.cancelled.store(true, Ordering::SeqCst); + } + + /// 是否已取消。 + pub fn is_cancelled(&self) -> bool { + self.cancelled.load(Ordering::SeqCst) + } +} + +/// 起一次设备码登录(后台线程;`present` 时刻发 Waiting 事件)。 +pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) { + spawn("msa-login", move || { + let auth = match MicrosoftAuth::from_env() { + Ok(auth) => auth.with_journal(Some(unlucky_core::auth::AuthJournal::in_logs_dir( + env.layout.logs(), + ))), + Err(error) => { + let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::failed( + error.to_string(), + )))); + return; + } + }; + let tx_prompt = tx.clone(); + let result = block_on(auth.device_code_login(|prompt| { + let _ = tx_prompt.send(HostTask::Login(Box::new(DeviceLoginView::waiting( + &prompt.user_code, + &prompt.verification_uri, + prompt.expires_in.as_secs(), + &format!("{}#{}", prompt.verification_uri, prompt.user_code), + )))); + })); + if job.is_cancelled() { + let _ = tx.send(HostTask::Login(Box::default())); + return; + } + match result { + Ok(session) => { + let meta = unlucky_core::auth::account::AccountMeta::new( + unlucky_core::auth::account::AccountKind::Microsoft, + session.profile_id.clone(), + session.profile_name.clone(), + now_ms(), + ); + let name = meta.display_name().to_string(); + let mut store = AccountStore::open(env.layout.auth_data()); + let saved = store + .insert(meta, Credentials::Microsoft(session)) + .and_then(|()| store.save()); + match saved { + Ok(()) => { + let _ = + tx.send(HostTask::Login(Box::new(DeviceLoginView::succeeded(&name)))); + let _ = tx.send(HostTask::Accounts(accounts(&env, None))); + } + Err(error) => { + let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::failed( + format!("账户入库失败:{error}"), + )))); + } + } + } + Err(error) => { + let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::failed( + error.to_string(), + )))); + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn time_display_matches_known_timestamps() { + assert_eq!(display_ms(0), ""); + // 1_769_000_000_000 ms = 2026-01-21T12:53:20Z(取整到分钟) + assert_eq!(display_ms(1_769_000_000_000), "2026-01-21 12:53"); + } + + #[test] + fn offline_add_list_remove_round_trips() { + let dir = std::env::temp_dir().join(format!("ull-host-accounts-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let env = HostEnv::at_root(&dir); + std::fs::create_dir_all(env.layout.auth_data()).expect("auth 目录"); + + let name = add_offline(&env, "Steve").expect("加账户"); + assert_eq!(name, "Steve"); + let list = accounts(&env, None); + assert_eq!(list.len(), 1); + assert_eq!(list[0].kind, AccountKind::Offline); + assert!(!list[0].active); + + let active = accounts(&env, Some(list[0].id.as_str())); + assert!(active[0].active); + + let removed = remove(&env, "Steve").expect("删账户"); + assert_eq!(removed, "Steve"); + assert!(accounts(&env, None).is_empty()); + assert!(add_offline(&env, " ").is_err()); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn login_job_cancel_flag_is_shared() { + let job = LoginJob::new(); + let clone = job.clone(); + assert!(!clone.is_cancelled()); + job.cancel(); + assert!(clone.is_cancelled()); + } +} diff --git a/crates/app/src/host/dispatch.rs b/crates/app/src/host/dispatch.rs new file mode 100644 index 0000000..ba24226 --- /dev/null +++ b/crates/app/src/host/dispatch.rs @@ -0,0 +1,152 @@ +//! `CoreHost::dispatch` 的指令实现(从 `host/mod.rs` 拆出,守 400 行上限)。 +//! +//! 父模块的私有字段对子模块可见,所以这里直接读写 `self.env` / `self.tx` 等。 + +use crate::dto::{AppCommand, Backend, CoreEvent, CreateRequest, LoaderKind}; +use crate::toast::ToastKind; + +use super::accounts::{self, LoginJob}; +use super::{game, install, java, launch, logs, settings, versions, CoreHost}; + +impl CoreHost { + /// 下发一条用户意图(`Backend::dispatch` 的实现体)。 + pub(super) fn dispatch_command(&mut self, command: AppCommand) { + self.dispatch_inner(command); + } +} +impl CoreHost { + /// 指令分发(见 `dispatch_command`)。 + fn dispatch_inner(&mut self, command: AppCommand) { + match command { + AppCommand::RefreshAll => self.refresh_all(), + AppCommand::LaunchInstance(id) => self.start_launch(&id), + AppCommand::StopInstance(id) => match self.running_pid(&id) { + Some(pid) => match launch::stop(pid) { + Ok(()) => self.toast(ToastKind::Info, "已请求停止游戏进程"), + Err(reason) => self.toast(ToastKind::Error, reason), + }, + None => self.toast(ToastKind::Warn, "这个实例当前没有在跑"), + }, + AppCommand::ListVersions(_) => versions::spawn_list(self.env.clone(), self.tx.clone()), + AppCommand::InstallVersion(version) => { + let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + self.install_cancel = Some(std::sync::Arc::clone(&cancel)); + install::spawn_install( + self.env.clone(), + self.tx.clone(), + CreateRequest { + name: version.clone(), + version, + loader: LoaderKind::Vanilla, + loader_version: String::new(), + }, + cancel, + ); + } + AppCommand::CreateInstance(request) => { + let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + self.install_cancel = Some(std::sync::Arc::clone(&cancel)); + install::spawn_install(self.env.clone(), self.tx.clone(), request, cancel); + } + AppCommand::CancelInstall => { + // 取消只在阶段边界生效(core 的下载循环没有取消钩子):这里先把面板收回, + // 后台线程会在下一阶段开始前看到标记并收尾。 + if let Some(cancel) = &self.install_cancel { + cancel.store(true, std::sync::atomic::Ordering::SeqCst); + } + self.install = None; + self.send(CoreEvent::InstallProgress(None)); + } + AppCommand::SetIsolation { id, mode } => { + match game::set_isolation(&self.env, &id, &mode) { + Ok(()) => { + self.toast(ToastKind::Success, "隔离档位已更新"); + self.refresh_instances(); + } + Err(reason) => self.toast(ToastKind::Error, reason), + } + } + AppCommand::RefreshLogSessions => { + self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env))); + } + AppCommand::OpenLogSession(id) => self.open_session(&id), + AppCommand::ImportCrashLog(path) => match logs::analyze_crash_file(&path) { + Ok((label, report)) => { + self.send(CoreEvent::DiagnosisReady(report)); + self.toast(ToastKind::Info, label); + } + Err(reason) => self.toast(ToastKind::Warn, reason), + }, + AppCommand::AddOfflineAccount(name) => match accounts::add_offline(&self.env, &name) { + Ok(display) => { + self.toast(ToastKind::Success, format!("已添加离线账户 {display}")); + self.refresh_accounts(); + } + Err(reason) => self.toast(ToastKind::Error, reason), + }, + AppCommand::RemoveAccount(key) => match accounts::remove(&self.env, &key) { + Ok(display) => { + if self.active_account.as_deref() == Some(key.as_str()) { + self.active_account = None; + } + self.toast(ToastKind::Info, format!("已删除账户 {display}")); + self.refresh_accounts(); + } + Err(reason) => self.toast(ToastKind::Error, reason), + }, + AppCommand::SwitchAccount(id) => { + self.active_account = Some(id); + self.refresh_accounts(); + self.toast(ToastKind::Info, "已切换启动账户"); + } + AppCommand::StartDeviceLogin => { + self.login = Some(LoginJob::new()); + let job = self.login.clone().unwrap_or_default(); + self.send(CoreEvent::DeviceLoginChanged(crate::dto::DeviceLoginView { + phase: crate::dto::DeviceLoginPhase::Requesting, + message: "正在向微软申请设备码…".to_string(), + ..crate::dto::DeviceLoginView::new() + })); + accounts::start_login(self.env.clone(), self.tx.clone(), job); + } + AppCommand::CancelDeviceLogin => { + if let Some(job) = &self.login { + job.cancel(); + } + self.login = None; + self.send(CoreEvent::DeviceLoginChanged( + crate::dto::DeviceLoginView::new(), + )); + } + AppCommand::InstallJava(vendor) => { + self.java_busy = true; + self.java_message = format!("正在安装 Java({vendor})…"); + self.refresh_java(); + java::spawn_install( + self.env.clone(), + self.tx.clone(), + unlucky_core::game::java::JdkVendor::parse(&vendor), + ); + } + AppCommand::SetSetting { key, value } => match settings::set(&self.env, &key, &value) { + Ok(()) => { + self.send(CoreEvent::SettingsChanged(settings::groups(&self.env))); + self.java_chain.clear(); + self.refresh_java(); + } + Err(reason) => self.toast(ToastKind::Error, reason), + }, + AppCommand::DismissNotice(id) => { + self.notices.retain(|notice| notice.id != id); + self.send(CoreEvent::NoticesChanged(self.notices.clone())); + } + // AI 对话:agent 的 GUI host 未落地前用脚本回复(不接真实模型) + other @ (AppCommand::AskAgent(_) | AppCommand::StopAgent | AppCommand::SetYolo(_)) => { + self.chat.dispatch(other); + for event in self.chat.drain_events() { + self.events.push_back(event); + } + } + } + } +} diff --git a/crates/app/src/host/engine.rs b/crates/app/src/host/engine.rs new file mode 100644 index 0000000..03bf98d --- /dev/null +++ b/crates/app/src/host/engine.rs @@ -0,0 +1,126 @@ +//! 下载引擎与 Java 落点的装配(versions / install / java 三个任务共用)。 +//! +//! 与 CLI `install::build_engine` 同源:内容寻址缓存落 `.ULL/cache/`,地区判定走 +//! 24h 缓存 + 环境信号(**不做网络探测**,与 D7 的「不探测时」约定一致)。 + +use std::path::PathBuf; +use std::sync::Arc; + +use unlucky_core::download::cache::now_ms; +use unlucky_core::download::region::{decide_from, load_cached, store_cached}; +use unlucky_core::download::region_system::detect_signals; +use unlucky_core::download::{ + ContentCache, DownloadEngine, EngineConfig, HealthRegistry, MirrorTable, +}; +use unlucky_core::game::java::vendor::JdkVendor; +use unlucky_core::game::java::{self, JavaInstallRoot}; +use unlucky_core::platform::paths::ProcessEnv; + +use super::env::HostEnv; + +/// 建下载引擎(缓存/健康度/地区判定都在数据根里)。 +pub fn engine(env: &HostEnv) -> DownloadEngine { + let cache_root = env.layout.cache(); + let now = now_ms(); + let region_path = cache_root.join("region.json"); + let region = load_cached(®ion_path, now).unwrap_or_else(|| { + let profile = decide_from(&detect_signals(), now); + let _ = store_cached(®ion_path, &profile); + profile + }); + DownloadEngine::new( + EngineConfig::default(), + Arc::new(MirrorTable::builtin()), + Arc::new(region), + Arc::new(HealthRegistry::open_in(&cache_root, now)), + Arc::new(ContentCache::open(&cache_root)), + ) +} + +/// 是否国内网络(决定默认发行版顺序与镜像优先级)。 +pub fn domestic(env: &HostEnv) -> bool { + engine(env).region().is_domestic() +} + +/// 设置里的 `java.vendorPriority`(空 = 按地区默认序)。 +pub fn java_priority(env: &HostEnv) -> Vec { + env.store() + .effective("java.vendorPriority") + .and_then(serde_json::Value::as_array) + .map(|items| { + let texts: Vec = items + .iter() + .filter_map(serde_json::Value::as_str) + .map(str::to_string) + .collect(); + unlucky_core::game::java::vendor::parse_priority(&texts) + }) + .unwrap_or_default() +} + +/// 设置里的 `java.installRoot`(空 = 按数据目录模式派生)。 +pub fn java_install_override(env: &HostEnv) -> Option { + env.store() + .doc() + .effective_str("java.installRoot") + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(PathBuf::from) +} + +/// 托管 JDK 落点(解析失败 → 用户可读文案)。 +pub fn java_install_root(env: &HostEnv) -> Result { + let override_root = java_install_override(env); + JavaInstallRoot::resolve( + env.mode(), + &env.layout, + override_root.as_deref(), + env.platform, + &ProcessEnv, + ) + .map_err(|error| error.to_string()) +} + +/// 托管 JDK 可能存在的全部安装根(扫描运行时用)。 +pub fn java_scan_roots(env: &HostEnv) -> Vec { + java::location::scan_roots( + env.mode(), + &env.layout, + java_install_override(env).as_deref(), + env.platform, + &ProcessEnv, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn engine_uses_the_data_root_cache() { + let dir = std::env::temp_dir().join(format!("ull-host-engine-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let env = HostEnv::at_root(&dir); + std::fs::create_dir_all(env.layout.cache()).expect("cache"); + let engine = engine(&env); + assert_eq!(engine.config().max_concurrency, 64); + // 地区判定不联网:只读缓存/信号 + let _ = domestic(&env); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn java_merge_priority_reads_settings() { + let dir = std::env::temp_dir().join(format!("ull-host-engine-p-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let env = HostEnv::at_root(&dir); + std::fs::create_dir_all(env.layout.config()).expect("config"); + std::fs::write( + env.launcher_config(), + r#"{"java":{"vendorPriority":["zulu"]}}"#, + ) + .expect("写配置"); + assert_eq!(java_priority(&env), vec![JdkVendor::Zulu]); + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/crates/app/src/host/env.rs b/crates/app/src/host/env.rs new file mode 100644 index 0000000..a1b6fd7 --- /dev/null +++ b/crates/app/src/host/env.rs @@ -0,0 +1,170 @@ +//! host 的环境快照:一份 `ResolvedData` + 由其派生的布局视图。 +//! +//! app 自己不解析三模式数据根(那是 `core::platform::data_dir` 的活);这里只做两件事: +//! ①按「exe 同级便携 `launcher.json` 的 `core.*` 四键」拼请求;②把解析结果与 +//! `Platform` / `DataMode` 打成一个可克隆的结构,交给后台线程。 +//! +//! 截图/演示通道用 [`HostEnv::at_root`] 直接钉一个根(`/.ULL` + `/.minecraft`)。 + +use std::path::{Path, PathBuf}; + +use unlucky_core::game::GameLayout; +use unlucky_core::platform::data_dir::{DataDirRequest, DataMode, ResolvedData, Resolver}; +use unlucky_core::platform::layout::LauncherLayout; +use unlucky_core::platform::paths::Platform; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::store::SettingStore; + +/// 数据根决定键(与 CLI 的 `DATA_ROOT_KEYS` 同源同义)。 +pub(crate) const DATA_ROOT_KEYS: [&str; 4] = [ + "core.data_mode", + "core.data_custom_root", + "core.launcher_home", + "core.game_home", +]; + +/// host 运行期需要的一切路径/平台信息(可克隆进线程)。 +#[derive(Debug, Clone)] +pub struct HostEnv { + /// 数据根解析结果 + pub data: ResolvedData, + /// `.ULL/` 布局 + pub layout: LauncherLayout, + /// `.minecraft/` 布局 + pub game: GameLayout, + /// 平台 + pub platform: Platform, +} + +impl HostEnv { + /// 按便携目录的 `core.*` 解析真实数据根;解析不出来返回 `None`(host 退回 mock)。 + pub fn resolve() -> Option { + let exe_dir = std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(Path::to_path_buf))?; + let request = request_from_portable(&exe_dir); + let resolver = Resolver::new(Platform::current(), Some(exe_dir)); + let data = resolver.resolve_default(&request).ok()?.clone(); + Some(Self::from_data(data)) + } + + /// 直接钉一个根:`/.ULL` + `/.minecraft`(截图/演示通道)。 + /// + /// 根下已有 `config/launcher.json` 时**尊重其中的 `core.game_home` / `core.launcher_home`**: + /// 演示根常用它把游戏目录指到既有真数据上(避免复制几 GB 的 assets/libraries)。 + pub fn at_root(root: impl AsRef) -> Self { + let root = root.as_ref(); + let request = request_from_portable(root); + let absolute = |path: Option| path.filter(|path| path.is_absolute()); + Self::from_data(ResolvedData { + mode: DataMode::Portable, + launcher_home: absolute(request.launcher_home).unwrap_or_else(|| root.join(".ULL")), + game_home: absolute(request.game_home).unwrap_or_else(|| root.join(".minecraft")), + redirected_from: None, + }) + } + + /// 由解析结果建环境。 + pub fn from_data(data: ResolvedData) -> Self { + Self { + layout: data.layout(), + game: GameLayout::new(data.game_home.clone()), + platform: Platform::current(), + data, + } + } + + /// `config/launcher.json` 路径。 + pub fn launcher_config(&self) -> PathBuf { + self.layout.launcher_config() + } + + /// 设置存储(每次现读一份;写操作自己 save)。 + pub fn store(&self) -> SettingStore { + SettingStore::load(self.launcher_config(), launcher_schema()) + } + + /// 启动会话根(`.ULL/data/agent/sessions`)。 + pub fn sessions_root(&self) -> PathBuf { + self.layout.agent_sessions() + } + + /// 数据目录模式。 + pub fn mode(&self) -> DataMode { + self.data.mode + } + + /// 人读一行(设置页「数据」分组展示)。 + pub fn data_line(&self) -> String { + format!( + "{} · 启动器 {} · 游戏 {}", + self.data.mode, + self.data.launcher_home.display(), + self.data.game_home.display() + ) + } +} + +/// 便携目录那份 `launcher.json` → 数据根请求(只读 `core.*` 四键)。 +pub(crate) fn request_from_portable(exe_dir: &Path) -> DataDirRequest { + let mut request = DataDirRequest::default(); + let path = portable_config_path(exe_dir); + if !path.is_file() { + return request; + } + let store = SettingStore::load(&path, launcher_schema()); + let text = |key: &str| store.doc().effective_str(key).map(str::to_string); + if let Some(mode) = text(DATA_ROOT_KEYS[0]).as_deref().and_then(DataMode::parse) { + request.mode = mode; + } + if let Some(root) = text(DATA_ROOT_KEYS[1]) { + request.custom_root = Some(PathBuf::from(root)); + } + if let Some(home) = text(DATA_ROOT_KEYS[2]) { + request.launcher_home = Some(PathBuf::from(home)); + } + if let Some(home) = text(DATA_ROOT_KEYS[3]) { + request.game_home = Some(PathBuf::from(home)); + } + request +} + +/// `/.ULL/config/launcher.json`。 +pub(crate) fn portable_config_path(exe_dir: &Path) -> PathBuf { + LauncherLayout::new(exe_dir.join(".ULL")).launcher_config() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn at_root_splits_launcher_and_game_homes() { + let env = HostEnv::at_root("D:/tmp/ull-demo"); + assert_eq!(env.layout.home(), Path::new("D:/tmp/ull-demo/.ULL")); + assert_eq!(env.game.root(), Path::new("D:/tmp/ull-demo/.minecraft")); + assert_eq!(env.mode(), DataMode::Portable); + assert!(env.data_line().contains("portable")); + } + + #[test] + fn portable_request_reads_core_keys_only_when_file_exists() { + let dir = std::env::temp_dir().join(format!("ull-host-env-{}", std::process::id())); + std::fs::create_dir_all(dir.join(".ULL/config")).expect("建目录"); + let request = request_from_portable(&dir); + assert_eq!(request, DataDirRequest::default()); + + std::fs::write( + portable_config_path(&dir), + r#"{"core":{"data_mode":"custom","data_custom_root":"D:/data/ull"}}"#, + ) + .expect("写配置"); + let request = request_from_portable(&dir); + assert_eq!(request.mode, DataMode::Custom); + assert_eq!( + request.custom_root.as_deref(), + Some(Path::new("D:/data/ull")) + ); + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/crates/app/src/host/game.rs b/crates/app/src/host/game.rs new file mode 100644 index 0000000..7960b3b --- /dev/null +++ b/crates/app/src/host/game.rs @@ -0,0 +1,260 @@ +//! 实例快照:`profiles.json` + 版本 JSON + 会话索引 + 实例设置 → [`InstanceSummary`]。 +//! +//! 「磁贴上的每个字」都在这里翻译完:版本号取自继承链根、加载器取自版本 id 前缀、 +//! 最后游玩时间取自会话索引、隔离档位取自实例设置(缺省跟全局 `instances.mode`)。 + +use std::collections::BTreeMap; +use std::path::Path; + +use unlucky_core::game::install::instance::{ + read_instance_config, read_profiles, write_instance_config, +}; +use unlucky_core::game::isolation::IsolationMode; +use unlucky_core::game::{ProfileList, VersionJson}; +use unlucky_core::launch::session_index; + +use crate::dto::{ + InstanceRuntime, InstanceSummary, LoaderInfo, LoaderKind, NoticeLevel, NoticeView, +}; + +use super::env::HostEnv; + +/// 全局默认隔离档位(`instances.mode`;缺键/坏值按 D20 默认 `isolated`)。 +pub fn global_mode(env: &HostEnv) -> IsolationMode { + env.store() + .doc() + .effective_str("instances.mode") + .and_then(IsolationMode::parse) + .unwrap_or_default() +} + +/// 读实例清单(文件缺失 = 空列表,不是错误)。 +pub fn profiles(env: &HostEnv) -> ProfileList { + read_profiles(&env.layout).unwrap_or_default() +} + +/// 全会话索引里「每个实例最近一次启动」的时间串。 +pub fn last_played(env: &HostEnv) -> BTreeMap { + let index = session_index::index_path(&env.sessions_root()); + let entries = session_index::read_all(&index).unwrap_or_default(); + let mut map: BTreeMap = BTreeMap::new(); + for entry in entries { + if entry.instance_id.is_empty() || entry.started_at.is_empty() { + continue; + } + let current = map.get(&entry.instance_id); + if current.is_none_or(|existing| existing.as_str() < entry.started_at.as_str()) { + map.insert(entry.instance_id.clone(), entry.started_at); + } + } + map +} + +/// 版本 JSON 的继承链根 id + 加载器(读不到返回 `(version_id, 原版)`)。 +pub fn describe_version(env: &HostEnv, version_id: &str) -> (String, LoaderInfo) { + let loader = loader_from_id(version_id); + let mut current = version_id.to_string(); + let mut seen = 0; + while seen < 16 { + seen += 1; + let path = env.game.version_json(¤t); + let Ok(text) = std::fs::read_to_string(&path) else { + break; + }; + let Ok(json) = VersionJson::from_json_str(&text) else { + break; + }; + match json.inherits_from { + Some(parent) if !parent.is_empty() => current = parent, + _ => break, + } + } + (current, loader) +} + +/// 版本 id → 加载器(HMCL 同款命名口径:`1.20.1-forge-47.2.0` / `fabric-loader-0.16.5-1.20.1`)。 +pub fn loader_from_id(version_id: &str) -> LoaderInfo { + let lower = version_id.to_ascii_lowercase(); + let grab = |marker: &str| -> String { + lower + .split_once(marker) + .map(|(_, rest)| rest.trim_start_matches('-').to_string()) + .unwrap_or_default() + }; + let (kind, version) = if lower.contains("neoforge") { + (LoaderKind::NeoForge, grab("neoforge-")) + } else if lower.contains("fabric-loader") { + (LoaderKind::Fabric, grab("fabric-loader-")) + } else if lower.contains("quilt-loader") { + (LoaderKind::Quilt, grab("quilt-loader-")) + } else if lower.contains("forge") { + (LoaderKind::Forge, grab("forge-")) + } else if lower.contains("optifine") { + (LoaderKind::OptiFine, grab("optifine")) + } else if lower.contains("liteloader") { + (LoaderKind::LiteLoader, grab("liteloader-")) + } else { + (LoaderKind::Vanilla, String::new()) + }; + LoaderInfo { kind, version } +} + +/// 实例的隔离档位(实例文件优先,缺省显示全局档位)。 +pub fn isolation_of(env: &HostEnv, id: &str) -> String { + let global = global_mode(env); + match read_instance_config(&env.layout, id) { + Ok(Some(config)) => config.effective_isolation(global).as_str().to_string(), + _ => global.as_str().to_string(), + } +} + +/// 实例快照。 +pub fn instances(env: &HostEnv) -> Vec { + let profiles = profiles(env); + let played = last_played(env); + profiles + .profiles + .iter() + .map(|profile| { + let version_id = profile.version_id.clone(); + let (mc_version, loader) = describe_version(env, &version_id); + InstanceSummary { + id: profile.id.as_str().to_string(), + name: profile.name.clone(), + mc_version, + loader, + last_played: played.get(profile.id.as_str()).cloned(), + runtime: InstanceRuntime::Idle, + icon: None, + isolation: isolation_of(env, profile.id.as_str()), + version_present: env.game.is_version_installed(&version_id), + version_id, + } + }) + .collect() +} + +/// 改实例隔离档位(写实例设置文件;不动任何数据)。 +/// +/// # Errors +/// +/// 实例不存在、文件读不了/写不了时返回用户可读文案。 +pub fn set_isolation(env: &HostEnv, id: &str, mode: &str) -> Result<(), String> { + let parsed = IsolationMode::parse(mode) + .ok_or_else(|| format!("未知隔离档位 `{mode}`(isolated / shared)"))?; + let profiles = profiles(env); + let profile = profiles + .profiles + .iter() + .find(|profile| profile.id.as_str() == id) + .ok_or_else(|| format!("找不到实例 `{id}`"))?; + let mut config = read_instance_config(&env.layout, id) + .map_err(|error| format!("读实例设置失败:{error}"))? + .unwrap_or_else(|| { + unlucky_core::game::install::instance::InstanceConfig::new( + profile.id.as_str(), + &profile.version_id, + ) + }); + config.isolation = Some(match parsed { + IsolationMode::Isolated => unlucky_core::game::install::instance::Isolation::Isolated, + IsolationMode::Shared => unlucky_core::game::install::instance::Isolation::Shared, + }); + write_instance_config(&env.layout, &config) + .map(|_| ()) + .map_err(|error| format!("写实例设置失败:{error}")) +} + +/// 启动前的切换检测:把 M30 的 `ModeChangeWarning` 翻成横幅通知。 +/// +/// 只在真启动时调用(会写 `lastModeChange.warned`,保证同一次切换只提示一次)。 +pub fn mode_change_notice(env: &HostEnv, id: &str) -> Option { + let mut config = read_instance_config(&env.layout, id).ok()??; + let warning = config.take_mode_change_warning()?; + let _ = write_instance_config(&env.layout, &config); + Some(NoticeView::new( + &format!("isolation:{id}"), + NoticeLevel::Warn, + "隔离档位已切换", + &warning.message(), + )) +} + +/// 建目录(数据根首次运行时)。 +pub fn ensure_root(env: &HostEnv) { + let _ = env.layout.ensure(); + let _ = std::fs::create_dir_all(env.game.root()); +} + +/// 版本本体目录是否存在(启动前快速判断)。 +pub fn version_present(env: &HostEnv, version_id: &str) -> bool { + env.game.is_version_installed(version_id) +} + +/// 路径显示成短串(通知/错误文案用)。 +pub fn short_path(path: &Path) -> String { + path.display().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn loader_parsing_covers_all_shapes() { + let cases = [ + ("1.20.4", LoaderKind::Vanilla, ""), + ( + "fabric-loader-0.16.5-1.20.1", + LoaderKind::Fabric, + "0.16.5-1.20.1", + ), + ( + "quilt-loader-0.24.0-1.20.1", + LoaderKind::Quilt, + "0.24.0-1.20.1", + ), + ("1.20.1-forge-47.2.0", LoaderKind::Forge, "47.2.0"), + ("1.21.1-neoforge-21.1.80", LoaderKind::NeoForge, "21.1.80"), + ]; + for (id, kind, version) in cases { + let info = loader_from_id(id); + assert_eq!(info.kind, kind, "{id}"); + assert_eq!(info.version, version, "{id}"); + assert!(!info.badge().is_empty()); + } + } + + #[test] + fn isolation_write_then_read_round_trips() { + let dir = std::env::temp_dir().join(format!("ull-host-game-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let env = HostEnv::at_root(&dir); + std::fs::create_dir_all(env.layout.config()).expect("config 目录"); + + // 直接建一份 profiles.json(register 需要版本本体,这里只测隔离写读) + let profiles = ProfileList { + profiles: vec![unlucky_core::game::InstanceProfile { + id: unlucky_core::game::ProfileId::generate(), + name: "demo".to_string(), + version_id: "1.20.1".to_string(), + game_dir: None, + running_dir: None, + java_path: None, + created_at: None, + last_played_at: None, + extra: BTreeMap::new(), + }], + ..ProfileList::default() + }; + unlucky_core::game::install::instance::write_profiles(&env.layout, &profiles) + .expect("写清单"); + let id = profiles.profiles[0].id.as_str().to_string(); + + assert_eq!(isolation_of(&env, &id), "isolated"); + set_isolation(&env, &id, "shared").expect("切档位"); + assert_eq!(isolation_of(&env, &id), "shared"); + assert!(set_isolation(&env, &id, "nope").is_err()); + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/crates/app/src/host/identity.rs b/crates/app/src/host/identity.rs new file mode 100644 index 0000000..fd5df29 --- /dev/null +++ b/crates/app/src/host/identity.rs @@ -0,0 +1,103 @@ +//! 启动身份:选中账户 → `AuthPlaceholders`(**凭据只在这里读,读完即丢**)+ 收尾结局映射。 +//! +//! 与 CLI `commands/identity.rs` 同源:微软轨过期就静默续期并回写凭据(refresh_token 轮换, +//! 旧值作废);零账户回落离线占位,保证没有账户时照样能启动。 + +use unlucky_core::auth::account::{AccountKind, Credentials}; +use unlucky_core::auth::microsoft::MicrosoftAuth; +use unlucky_core::auth::offline::offline_login; +use unlucky_core::auth::AccountStore; +use unlucky_core::download::blocking::block_on; +use unlucky_core::game::ProfileId; +use unlucky_core::launch::request::AuthPlaceholders; + +use crate::dto::LogOutcome; + +use super::env::HostEnv; + +/// 把选中账户翻成启动占位符(**凭据只在这里读,读完即丢**)。 +/// +/// 微软轨:过期就静默续期并回写凭据(refresh_token 轮换,旧值作废)。 +/// +/// # Errors +/// +/// 账户不存在/凭据类型不符/令牌过期且刷新失败时返回用户可读文案。 +pub fn resolve(env: &HostEnv, account: Option<&str>) -> Result<(AuthPlaceholders, String), String> { + let store = AccountStore::open(env.layout.auth_data()); + let chosen = match account { + Some(key) => store + .find(key) + .cloned() + .ok_or_else(|| format!("找不到账户 `{key}`"))?, + None => match store.accounts() { + [] => { + let uuid = ProfileId::generate().as_str().replace('-', ""); + return Ok(( + AuthPlaceholders { + player_name: "Player".to_string(), + uuid, + user_type: "legacy".to_string(), + user_properties: "{}".to_string(), + ..AuthPlaceholders::default() + }, + "未登录账户,按离线占位启动".to_string(), + )); + } + [only] => only.clone(), + many => return Err(format!("有 {} 个账户,请在账户页选一个再启动", many.len())), + }, + }; + let line = format!("账户 {}({})", chosen.display_name(), chosen.kind.label()); + match chosen.kind { + AccountKind::Offline => { + let placeholders = + offline_login(&chosen).map_err(|error| format!("离线账户解析失败:{error}"))?; + Ok((placeholders, line)) + } + AccountKind::Microsoft => { + let Some(Credentials::Microsoft(session)) = store.credentials(&chosen.id) else { + return Err(format!( + "账户 {} 的微软凭据缺失或类型不符,请重新登录", + chosen.display_name() + )); + }; + let auth = + MicrosoftAuth::from_env().map_err(|error| format!("微软登录不可用:{error}"))?; + let (fresh, _) = block_on(auth.ensure_fresh(session)) + .map_err(|error| format!("微软令牌刷新失败:{error}"))?; + if &fresh != session { + // refresh_token 已轮换:立刻回写,旧值作废。 + let mut store = AccountStore::open(env.layout.auth_data()); + let _ = store.update_credentials(&chosen.id, Credentials::Microsoft(fresh.clone())); + let _ = store.save(); + } + let placeholders = fresh + .to_placeholders() + .map_err(|error| format!("微软身份转换失败:{error}"))?; + Ok((placeholders, line)) + } + AccountKind::AuthlibInjector => { + let Some(Credentials::AuthlibInjector(session)) = store.credentials(&chosen.id) else { + return Err(format!( + "账户 {} 的第三方凭据缺失或类型不符", + chosen.display_name() + )); + }; + let placeholders = session + .to_placeholders() + .map_err(|error| format!("第三方身份转换失败:{error}"))?; + Ok((placeholders, line)) + } + } +} + +/// `SessionHandle` → 日志页的三种结局。 +pub fn outcome_of(handle: &unlucky_core::launch::SessionHandle) -> LogOutcome { + if handle.exit.silent_failure.is_some() { + LogOutcome::SilentFail + } else if handle.succeeded() { + LogOutcome::Ok + } else { + LogOutcome::Crashed + } +} diff --git a/crates/app/src/host/install.rs b/crates/app/src/host/install.rs new file mode 100644 index 0000000..59596d6 --- /dev/null +++ b/crates/app/src/host/install.rs @@ -0,0 +1,313 @@ +//! 安装:新建实例向导 / 下载页「安装」→ core 安装管线(原版 + Fabric/Quilt)→ 登记实例。 +//! +//! 与 CLI `ull install` 同序:`RemoteSource::resolve → InstallPlan::build → execute → register`。 +//! 差异:进度经 [`super::job::HostTask::Install`] 回流(文件计数 + 字节 + 速度/ETA), +//! 且整段跑在后台线程里(`block_on` 不占 UI 线程)。 +//! +//! 加载器范围:原版 + Fabric/Quilt(纯库层)。Forge/NeoForge 要外调 java 跑 installer +//! processors(M35 的执行器在 CLI 侧),GUI 这一轮**显式拒装并说清去哪装**, +//! 而不是装出一个启动必崩的骨架。 + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use unlucky_core::addon::loader::{ + LoaderInstallPlan, LoaderKind as CoreLoaderKind, LoaderPlanInput, LoaderSource, +}; +use unlucky_core::download::blocking::block_on; +use unlucky_core::download::cache::now_ms; +use unlucky_core::download::DownloadEngine; +use unlucky_core::game::arguments::Environment; +use unlucky_core::game::install::{ + execute, InstallError, InstallPlan, InstallReport, PlanInput, RemoteSource, +}; + +use crate::dto::{CreateRequest, InstallStage, LoaderKind}; +use crate::toast::{Toast, ToastKind}; + +use super::env::HostEnv; +use super::job::{spawn, HostTask, JobSender}; +use super::meter::{sink_for, Meter}; + +/// 文件级并发上限(与 CLI 同值:16 路压满带宽又不至于打爆镜像)。 +pub const CONCURRENCY: usize = 16; + +/// 用户取消的稳定文案(阶段边界生效)。 +pub const CANCELLED: &str = "已取消(阶段边界停下;已下载的文件保留,重装会续用)"; + +/// 加载器徽标(`原版` / `Fabric 0.16.5`)。 +pub fn loader_label(kind: LoaderKind, version: &str) -> String { + if kind == LoaderKind::Vanilla || version.trim().is_empty() { + kind.label().to_string() + } else { + format!("{} {}", kind.label(), version) + } +} + +/// GUI 侧支持的加载器(Forge/NeoForge 需要 installer processors)。 +pub fn supported(kind: LoaderKind) -> bool { + matches!( + kind, + LoaderKind::Vanilla | LoaderKind::Fabric | LoaderKind::Quilt + ) +} + +/// 不支持的加载器 → 给用户的可执行建议。 +pub fn unsupported_reason(kind: LoaderKind) -> String { + format!( + "{} 需要 installer processors(外调 java 改 jar),GUI 这一轮还没接入;请先在终端跑 `ull install <版本> --loader {}`,装完回实例页刷新即可启动。", + kind.label(), + kind.as_str() + ) +} + +/// 后台安装(向导提交 / 下载页行内安装共用)。 +pub fn spawn_install(env: HostEnv, tx: JobSender, request: CreateRequest, cancel: Arc) { + spawn("install", move || { + let meter = Arc::new(std::sync::Mutex::new(Meter::new(&request))); + let result = run(&env, &tx, &request, &meter, &cancel); + let mut progress = meter + .lock() + .map(|guard| guard.progress.clone()) + .unwrap_or_else(|_| Meter::new(&request).progress); + match result { + Ok(name) => { + progress.stage = InstallStage::Done; + progress.files_done = progress.files_total; + progress.message = format!("已安装 {name}({})", progress.loader); + let _ = tx.send(HostTask::InstallDone { + progress: Box::new(progress), + result: Ok(name), + }); + let _ = tx.send(HostTask::Toast(Box::new(Toast { + id: 0, + kind: ToastKind::Success, + text: "安装完成,实例已登记".to_string(), + remaining_ms: 0, + }))); + } + Err(reason) => { + progress.stage = if reason == CANCELLED { + InstallStage::Cancelled + } else { + InstallStage::Failed + }; + progress.message.clone_from(&reason); + let _ = tx.send(HostTask::InstallDone { + progress: Box::new(progress), + result: Err(reason), + }); + } + } + }); +} + +/// 真安装(同步跑完,调用方在后台线程里)。 +fn run( + env: &HostEnv, + tx: &JobSender, + request: &CreateRequest, + meter: &Arc>, + cancel: &Arc, +) -> Result { + if request.version.trim().is_empty() { + return Err("没有选择版本".to_string()); + } + if !supported(request.loader) { + return Err(unsupported_reason(request.loader)); + } + let name = request.instance_name(); + let engine = super::engine::engine(env); + { + let mut guard = meter.lock().map_err(|_| "进度锁失效".to_string())?; + guard.stage(InstallStage::Planning, "正在解析版本清单…"); + let _ = tx.send(HostTask::Install(Box::new(guard.progress.clone()))); + } + + if cancel.load(Ordering::SeqCst) { + return Err(CANCELLED.to_string()); + } + let version_id = if request.loader == LoaderKind::Vanilla { + install_vanilla(env, &engine, request, tx, meter, cancel)? + } else { + install_loader(env, &engine, request, tx, meter, cancel)? + }; + + { + let mut guard = meter.lock().map_err(|_| "进度锁失效".to_string())?; + guard.stage(InstallStage::Registering, "正在登记实例…"); + let _ = tx.send(HostTask::Install(Box::new(guard.progress.clone()))); + } + let created_at = + unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now()); + unlucky_core::game::install::instance::register( + &env.layout, + &name, + &version_id, + Some(&created_at), + ) + .map_err(|error| format!("登记实例失败:{error}"))?; + Ok(name) +} + +/// 原版安装(`resolve → plan → [prefetch 资源索引] → execute`)。 +fn install_vanilla( + env: &HostEnv, + engine: &DownloadEngine, + request: &CreateRequest, + tx: &JobSender, + meter: &Arc>, + cancel: &Arc, +) -> Result { + let version = request.version.trim(); + let meta_cache = env.layout.meta_cache(); + let now = now_ms(); + let sink = sink_for(tx.clone(), Arc::clone(meter)); + block_on(async { + let source = RemoteSource::new(engine, &env.game, &meta_cache, now); + let resolved = source.resolve(version).await.map_err(install_error)?; + let fetched = match resolved + .asset_index + .as_ref() + .filter(|index| index.url.is_some()) + { + Some(reference) => { + let (index, bytes) = source.asset_index(reference).await.map_err(install_error)?; + Some((index, env.game.asset_index(&reference.id), bytes)) + } + None => None, + }; + let mut plan = InstallPlan::build(PlanInput { + layout: &env.game, + version_id: version, + resolved: &resolved, + asset_index: fetched.as_ref().map(|(index, _, _)| index), + environment: &Environment::host(), + }) + .map_err(|error| error.to_string())?; + if let Some((_, dest, bytes)) = fetched { + plan.prefetch(dest, bytes); + } + if !plan.is_installable() { + return Err(format!( + "版本 {version} 装不全:{}", + plan.warnings.join(";") + )); + } + let (bytes_total, _) = plan.total_bytes(); + { + let mut guard = meter.lock().map_err(|_| "进度锁失效".to_string())?; + guard.progress.files_total = u64::try_from(plan.file_count()).unwrap_or(u64::MAX); + guard.progress.bytes_total = bytes_total; + guard.stage( + InstallStage::Downloading, + &format!("开始下载 {} 个文件…", plan.file_count()), + ); + } + if cancel.load(Ordering::SeqCst) { + return Err(CANCELLED.to_string()); + } + let report = execute(engine, &plan, CONCURRENCY, Some(sink)) + .await + .map_err(install_error)?; + ensure_complete(&report)?; + Ok::(version.to_string()) + }) +} + +/// 加载器安装(原版前置 → 加载器库 → 版本 JSON 落盘)。 +fn install_loader( + env: &HostEnv, + engine: &DownloadEngine, + request: &CreateRequest, + tx: &JobSender, + meter: &Arc>, + cancel: &Arc, +) -> Result { + let version = request.version.trim(); + let kind = CoreLoaderKind::parse(request.loader.as_str()) + .ok_or_else(|| format!("未知加载器 `{}`", request.loader.as_str()))?; + let meta_cache = env.layout.meta_cache(); + let loader_version = request.loader_version.trim().to_string(); + let explicit = (!loader_version.is_empty()).then_some(loader_version.as_str()); + let release = block_on(async { + LoaderSource::new(engine, &meta_cache) + .resolve(kind, version, explicit) + .await + }) + .map_err(|error| error.to_string())?; + + let plan = LoaderInstallPlan::build(LoaderPlanInput { + layout: &env.game, + release: &release, + environment: &Environment::host(), + vanilla_installed: env.game.is_version_installed(version), + }); + if plan.vanilla_install_needed { + let mut vanilla = request.clone(); + vanilla.loader = LoaderKind::Vanilla; + install_vanilla(env, engine, &vanilla, tx, meter, cancel)?; + } + { + let mut guard = meter.lock().map_err(|_| "进度锁失效".to_string())?; + guard.progress.files_total += u64::try_from(plan.files().len()).unwrap_or(u64::MAX); + let (bytes, _) = plan.install.total_bytes(); + guard.progress.bytes_total = guard.progress.bytes_total.max(bytes); + guard.stage( + InstallStage::Downloading, + &format!("开始下载加载器库({} 个文件)…", plan.files().len()), + ); + } + if cancel.load(Ordering::SeqCst) { + return Err(CANCELLED.to_string()); + } + let sink = sink_for(tx.clone(), Arc::clone(meter)); + let report = block_on(async { + execute(engine, &plan.install, CONCURRENCY, Some(sink)) + .await + .map_err(install_error) + })?; + ensure_complete(&report)?; + Ok(release.version_id) +} + +/// 安装错误 → 用户可读文案。 +fn install_error(error: InstallError) -> String { + match error.hint() { + Some(hint) => format!("{}({hint})", error.message()), + None => error.message().to_string(), + } +} + +/// 有文件失败就报错(列前三条明细)。 +fn ensure_complete(report: &InstallReport) -> Result<(), String> { + if report.is_complete() { + return Ok(()); + } + let details: Vec = report + .failures(3) + .iter() + .filter_map(|outcome| { + outcome + .failure + .as_ref() + .map(|failure| format!("{}:{}", outcome.name, failure.message)) + }) + .collect(); + Err(format!( + "{} 个文件没下成(共 {} 个):{}", + report.failed(), + report.files.len(), + details.join(";") + )) +} + +/// 安装目录的短显示(诊断/Toast 用)。 +pub fn game_root(env: &HostEnv) -> PathBuf { + env.game.root().to_path_buf() +} + +#[cfg(test)] +#[path = "install_tests.rs"] +mod tests; diff --git a/crates/app/src/host/install_tests.rs b/crates/app/src/host/install_tests.rs new file mode 100644 index 0000000..4e724b5 --- /dev/null +++ b/crates/app/src/host/install_tests.rs @@ -0,0 +1,86 @@ +//! host/install.rs 的单测(拆文件守 400 行上限)。 + +use std::path::PathBuf; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use unlucky_core::game::install::{FileOutcome, FileRole, FileStatus}; + +use super::{run, supported, unsupported_reason}; +use crate::dto::{CreateRequest, LoaderKind}; +use crate::host::env::HostEnv; +use crate::host::meter::Meter; + +#[test] +fn loader_support_matrix_is_explicit() { + assert!(supported(LoaderKind::Vanilla)); + assert!(supported(LoaderKind::Fabric)); + assert!(supported(LoaderKind::Quilt)); + assert!(!supported(LoaderKind::Forge)); + assert!(!supported(LoaderKind::NeoForge)); + assert!(unsupported_reason(LoaderKind::Forge).contains("ull install")); +} + +#[test] +fn meter_counts_files_and_skips_skipped_bytes() { + let request = CreateRequest { + version: "1.20.4".to_string(), + loader: LoaderKind::Fabric, + loader_version: "0.16.5".to_string(), + name: "生存".to_string(), + }; + let mut meter = Meter::new(&request); + assert_eq!(meter.progress.loader, "Fabric 0.16.5"); + assert_eq!(meter.progress.instance, "生存"); + meter.progress.files_total = 2; + + let mut outcome = FileOutcome { + role: FileRole::Library, + name: "a.jar".to_string(), + dest: PathBuf::from("a.jar"), + status: FileStatus::Downloaded, + bytes: 100, + source: Some("bmclapi".to_string()), + failure: None, + }; + meter.note(&outcome); + assert_eq!(meter.progress.files_done, 1); + assert_eq!(meter.progress.bytes_done, 100); + + outcome.status = FileStatus::Skipped; + outcome.bytes = 500; + meter.note(&outcome); + assert_eq!(meter.progress.files_done, 2); + assert_eq!(meter.progress.bytes_done, 100, "跳过的文件不计入已下载字节"); + assert!((meter.progress.ratio() - 1.0).abs() < f32::EPSILON); +} + +#[test] +fn empty_version_is_rejected_before_network() { + let dir = std::env::temp_dir().join(format!("ull-host-install-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let env = HostEnv::at_root(&dir); + let (tx, _rx) = super::super::job::channel(); + let request = CreateRequest { + version: String::new(), + loader: LoaderKind::Vanilla, + loader_version: String::new(), + name: String::new(), + }; + let meter = Arc::new(std::sync::Mutex::new(Meter::new(&request))); + let cancel = Arc::new(AtomicBool::new(false)); + let err = run(&env, &tx, &request, &meter, &cancel).unwrap_err(); + assert!(err.contains("没有选择版本"), "{err}"); + + let forge = CreateRequest { + version: "1.20.1".to_string(), + loader: LoaderKind::Forge, + ..request + }; + let meter = Arc::new(std::sync::Mutex::new(Meter::new(&forge))); + let cancel = Arc::new(AtomicBool::new(false)); + assert!(run(&env, &tx, &forge, &meter, &cancel) + .unwrap_err() + .contains("installer processors")); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/app/src/host/java.rs b/crates/app/src/host/java.rs new file mode 100644 index 0000000..71229bb --- /dev/null +++ b/crates/app/src/host/java.rs @@ -0,0 +1,304 @@ +//! Java 页:四家发行版 + 回退链可视化 + 本地运行时 + 托管 JDK 安装。 +//! +//! 回退链的两态: +//! - **计划链**(还没装):按生效顺序铺一排「待尝试」,让用户先看清会按什么顺序找; +//! - **真实链**(装过一次/装失败):`core::game::java::ChainLog` 的每跳结局与原因。 +//! +//! 安装走 `java::install`(core 自己跑回退链),host 只负责把 `ChainLog` 翻成 DTO。 + +use unlucky_core::game::java::probe::JavaOrigin; +use unlucky_core::game::java::source::ChainLog; +use unlucky_core::game::java::{self, JdkVendor}; + +use crate::dto::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView}; + +use super::env::HostEnv; +use super::job::{spawn, HostTask, JobSender}; + +/// 托管 JDK 的默认目标大版本(D13 基准 JDK17)。 +pub const DEFAULT_JDK_MAJOR: u32 = 17; + +/// 该装哪个大版本:取第一个实例版本的要求,没有实例就给 17。 +pub fn target_major(env: &HostEnv) -> u32 { + let profiles = super::game::profiles(env); + profiles + .profiles + .first() + .map(|profile| { + java::requirement_for_version(&env.game, &profile.version_id) + .minimum + .max(8) + }) + .unwrap_or(DEFAULT_JDK_MAJOR) +} + +/// 四家发行版的展示行(镜像与吞吐口径与 CLI `ull java vendors` 一致)。 +pub fn vendors(env: &HostEnv, installed_vendors: &[String]) -> Vec { + let domestic = super::engine::domestic(env); + let order = java::vendor::order(&super::engine::java_priority(env), domestic); + let table = unlucky_core::download::MirrorTable::builtin(); + order + .iter() + .enumerate() + .map(|(index, vendor)| { + let id = vendor.id(); + JavaVendorView { + id: id.to_string(), + display: vendor.display().to_string(), + rank: u32::try_from(index + 1).unwrap_or(u32::MAX), + majors: vendor + .hint_majors() + .iter() + .map(u32::to_string) + .collect::>() + .join("/"), + sources: sources_of(&table, *vendor), + installed: installed_vendors.iter().any(|known| known == id), + } + }) + .collect() +} + +/// 一家的源清单(与 CLI 展示同形)。 +fn sources_of(table: &unlucky_core::download::MirrorTable, vendor: JdkVendor) -> Vec { + match vendor { + JdkVendor::Temurin => table + .adoptium + .iter() + .filter(|mirror| mirror.enabled) + .map(|mirror| mirror.name.clone()) + .chain(std::iter::once( + "GitHub 官方(国内套 gh-proxy)".to_string(), + )) + .collect(), + JdkVendor::Oracle => table + .oracle_mirrors() + .map(|mirror| format!("{} {}", mirror.name, mirror.base())) + .collect(), + JdkVendor::Microsoft => vec!["aka.ms → VS CDN(无国内镜像)".to_string()], + JdkVendor::Zulu => table + .zulu() + .map(|zulu| vec![format!("{}({})", zulu.api, zulu.measured_speed)]) + .unwrap_or_default(), + } +} + +/// 本地运行时(托管 / JAVA_HOME / PATH / 系统安装位置,同一路径只留优先级高者)。 +pub fn runtimes(env: &HostEnv) -> Vec { + let roots = super::engine::java_scan_roots(env); + let candidates = java::discover::dedupe(java::candidates_in_roots(&roots)); + candidates + .iter() + .map(|runtime| JavaRuntimeView { + binary: runtime.binary.display().to_string(), + version: runtime + .version + .clone() + .unwrap_or_else(|| "版本未知".to_string()), + major: runtime.major.unwrap_or(java::DEFAULT_MAJOR), + vendor: runtime.vendor.clone().unwrap_or_else(|| "—".to_string()), + origin: runtime.origin.category().to_string(), + }) + .collect() +} + +/// 托管目录(展示「装到哪」)。 +pub fn managed_roots(env: &HostEnv) -> Vec { + super::engine::java_scan_roots(env) + .iter() + .map(|root| root.display().to_string()) + .collect() +} + +/// 计划链(按生效顺序铺「待尝试」)。 +pub fn planned_chain(env: &HostEnv) -> Vec { + let domestic = super::engine::domestic(env); + let order = java::vendor::order(&super::engine::java_priority(env), domestic); + order + .iter() + .map(|vendor| JavaChainStep { + vendor: vendor.id().to_string(), + outcome: JavaChainOutcome::Pending, + detail: String::new(), + }) + .collect() +} + +/// 真实链(`ChainLog` → DTO)。 +pub fn chain_steps(log: &ChainLog) -> Vec { + log.attempts() + .iter() + .map(|attempt| JavaChainStep { + vendor: attempt.vendor.id().to_string(), + outcome: if attempt.ok { + JavaChainOutcome::Hit + } else if attempt.detail.contains("不支持") { + JavaChainOutcome::Skip + } else { + JavaChainOutcome::Failed + }, + detail: attempt.detail.clone(), + }) + .collect() +} + +/// 整页快照。 +pub fn page(env: &HostEnv, chain: Vec, busy: bool, message: &str) -> JavaPageView { + let runtimes = runtimes(env); + let installed_vendors: Vec = runtimes + .iter() + .filter(|runtime| runtime.origin == JavaOrigin::Managed.category()) + .map(|runtime| runtime.vendor.clone()) + .collect(); + let vendor_rows = vendors(env, &installed_vendors); + let domestic = super::engine::domestic(env); + let priority_set = !super::engine::java_priority(env).is_empty(); + let chain = if chain.is_empty() { + planned_chain(env) + } else { + chain + }; + JavaPageView { + region: if domestic { "domestic" } else { "global" }.to_string(), + priority_source: if priority_set { + "java.vendorPriority" + } else { + "region_default" + } + .to_string(), + order_text: vendor_rows + .iter() + .map(|vendor| vendor.display.clone()) + .collect::>() + .join(" → "), + vendors: vendor_rows, + chain, + runtimes, + managed_roots: managed_roots(env), + busy, + message: message.to_string(), + } +} + +/// 后台装一个托管 JDK(`vendor` 为空 = 按回退链自己选)。 +pub fn spawn_install(env: HostEnv, tx: JobSender, vendor: Option) { + spawn("java-install", move || { + let major = target_major(&env); + let mut busy = page(&env, Vec::new(), true, &format!("正在安装 Java {major}…")); + busy.busy = true; + let _ = tx.send(HostTask::Java(Box::new(busy))); + + let engine = super::engine::engine(&env); + let root = match super::engine::java_install_root(&env) { + Ok(root) => root, + Err(reason) => { + let _ = tx.send(HostTask::Java(Box::new(page( + &env, + Vec::new(), + false, + &format!("解析 Java 安装位置失败:{reason}"), + )))); + return; + } + }; + let request = match vendor { + Some(vendor) => java::InstallRequest::with_vendor(major, vendor) + .with_priority(super::engine::java_priority(&env)), + None => { + java::InstallRequest::new(major).with_priority(super::engine::java_priority(&env)) + } + }; + let result = + unlucky_core::download::blocking::block_on(java::install(&engine, &root, request)); + match result { + Ok(installed) => { + let message = if installed.reused() { + format!( + "Java {}({})已就绪:{}", + installed.major, + installed.vendor.id(), + installed.root.display() + ) + } else { + format!( + "已安装 Java {}({})→ {} · {} 个文件", + installed.major, + installed.vendor.display(), + installed.root.display(), + installed.files + ) + }; + let chain = chain_steps(&installed.chain); + let _ = tx.send(HostTask::Java(Box::new(page(&env, chain, false, &message)))); + } + Err(error) => { + let _ = tx.send(HostTask::Java(Box::new(page( + &env, + Vec::new(), + false, + &format!("Java 安装失败:{error}"), + )))); + } + } + }); +} + +/// 「安装一个 Java」的 UI 文案(下载页 Java 分组的按钮说明)。 +pub fn install_hint(env: &HostEnv) -> String { + let major = target_major(env); + format!("按回退链取一个 Java {major}(托管目录,装完自动被启动链选中)") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn planned_chain_covers_four_vendors() { + let dir = std::env::temp_dir().join(format!("ull-host-java-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let env = HostEnv::at_root(&dir); + std::fs::create_dir_all(env.layout.config()).expect("config"); + let chain = planned_chain(&env); + assert_eq!(chain.len(), 4); + assert!(chain + .iter() + .all(|step| step.outcome == JavaChainOutcome::Pending)); + assert_eq!(chain[0].vendor, "temurin", "国内/国外默认都是 Temurin 第一"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn chain_log_maps_to_steps() { + let mut log = ChainLog::new(); + log.skip( + JdkVendor::Microsoft, + "不支持 Java 8(microsoft 只发 11/17/21/25)", + ); + log.hit(JdkVendor::Temurin, "命中 Adoptium 17"); + let steps = chain_steps(&log); + assert_eq!(steps.len(), 2); + assert_eq!(steps[0].outcome, JavaChainOutcome::Skip); + assert_eq!(steps[1].outcome, JavaChainOutcome::Hit); + } + + #[test] + fn page_reports_region_and_priority_source() { + let dir = std::env::temp_dir().join(format!("ull-host-java-page-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let env = HostEnv::at_root(&dir); + std::fs::create_dir_all(env.layout.config()).expect("config"); + std::fs::write( + env.launcher_config(), + r#"{"java":{"vendorPriority":["zulu"]}}"#, + ) + .expect("写配置"); + let page = page(&env, Vec::new(), false, ""); + assert_eq!(page.priority_source, "java.vendorPriority"); + assert_eq!(page.vendors[0].id, "zulu"); + assert_eq!(page.vendors[0].rank, 1); + assert!(page.order_text.contains("Azul Zulu")); + assert_eq!(page.vendors.len(), 4); + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/crates/app/src/host/job.rs b/crates/app/src/host/job.rs new file mode 100644 index 0000000..49cfed9 --- /dev/null +++ b/crates/app/src/host/job.rs @@ -0,0 +1,136 @@ +//! 后台任务的回流消息:`HostTask` 是唯一「线程 → host」的载荷。 +//! +//! 线程只做 core 调用(网络/磁盘),产出的都是 `crate::dto` 的 DTO; +//! host 的 `tick` 把它们翻成 [`crate::dto::CoreEvent`]。 +//! 这样「跨线程传的是什么」在一处看全,也不会出现半成品领域类型漏回 UI。 + +use std::sync::mpsc::{Receiver, Sender}; + +use crate::dto::{ + AccountView, DeviceLoginView, DiagnosisReport, InstallProgressView, InstanceId, + InstanceRuntime, JavaPageView, LaunchFinished, LiveLogBatch, LogLine, LogOutcome, LogSession, + NoticeView, SessionId, VersionSummary, +}; +use crate::toast::Toast; + +/// 后台任务 → host 的消息。 +#[derive(Debug, Clone, PartialEq)] +pub enum HostTask { + /// 版本清单(下载页) + Versions(Result, String>), + /// 安装进度(高频,host 只做聚合与转发) + Install(Box), + /// 安装收尾(成功带实例名,失败带文案) + InstallDone { + /// 最终进度快照 + progress: Box, + /// 成功时为 `Ok(实例名)` + result: Result, + }, + /// 账户列表 + Accounts(Vec), + /// 设备码登录状态 + Login(Box), + /// Java 页(发行版/回退链/运行时) + Java(Box), + /// 日志页快照(会话列表 + 选中会话的行 + 诊断) + Logs { + /// 会话列表 + sessions: Vec, + /// 选中会话的行 + lines: Vec, + /// 诊断结论 + diagnosis: Option, + }, + /// 实时日志增量 + Tail(Box), + /// 启动开始(pid + 会话 id) + LaunchStarted { + /// 实例 id + id: InstanceId, + /// 会话 id + session_id: SessionId, + /// 进程号 + pid: u32, + }, + /// 启动阶段推进(磁贴进度环) + Runtime { + /// 实例 id + id: InstanceId, + /// 阶段状态 + runtime: InstanceRuntime, + }, + /// 启动收尾 + LaunchEnd(Box), + /// 崩溃日志导入的分析结论 + Crash(Box), + /// 横幅通知(隔离切换提示等) + Notice(Box), + /// Toast + Toast(Box), + /// 会话列表刷新(只换左栏) + Sessions(Vec), +} + +/// 后台线程用的发送端(`Sender` 克隆即可)。 +pub type JobSender = Sender; + +/// host 侧的接收端。 +pub type JobReceiver = Receiver; + +/// 建一对通道。 +pub fn channel() -> (JobSender, JobReceiver) { + std::sync::mpsc::channel() +} + +/// 起一个带名字的后台线程(名字只进 panic 信息,方便定位)。 +pub fn spawn(name: &str, job: impl FnOnce() + Send + 'static) -> std::thread::JoinHandle<()> { + std::thread::Builder::new() + .name(format!("ull-host-{name}")) + .spawn(job) + .unwrap_or_else(|error| panic!("起不来后台线程 {name}: {error}")) +} + +/// 人读一行「启动结束」。 +pub fn launch_summary(outcome: LogOutcome, exit_code: Option) -> String { + match outcome { + LogOutcome::Ok => "正常退出".to_string(), + LogOutcome::Crashed => match exit_code { + Some(code) => format!("崩溃(exit {code})"), + None => "崩溃".to_string(), + }, + LogOutcome::SilentFail => "静默失败(exit 0 但游戏没起来)".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn channel_carries_tasks_across_threads() { + let (tx, rx) = channel(); + let handle = spawn("test", move || { + tx.send(HostTask::Toast(Box::new(Toast { + id: 1, + kind: crate::toast::ToastKind::Info, + text: "hi".to_string(), + remaining_ms: 0, + }))) + .expect("发送"); + }); + handle.join().expect("线程结束"); + let task = rx.recv().expect("收到"); + assert!(matches!(task, HostTask::Toast(toast) if toast.text == "hi")); + } + + #[test] + fn launch_summary_names_the_outcome() { + assert_eq!(launch_summary(LogOutcome::Ok, Some(0)), "正常退出"); + assert_eq!( + launch_summary(LogOutcome::Crashed, Some(1)), + "崩溃(exit 1)" + ); + assert!(launch_summary(LogOutcome::SilentFail, Some(0)).contains("静默")); + } +} diff --git a/crates/app/src/host/launch.rs b/crates/app/src/host/launch.rs new file mode 100644 index 0000000..48b2175 --- /dev/null +++ b/crates/app/src/host/launch.rs @@ -0,0 +1,353 @@ +//! 启动全链:实例 → `LaunchRequest` → 子进程 → JSONL 事件流(GUI 版)。 +//! +//! 与 CLI `ull launch` 同源同序(`core` 的 `game::resolve` / `arguments.expand` / +//! `launch::adapter::build_request` / `Launcher::spawn`),差异只有两处: +//! - **不阻塞 UI**:装配与等待都在后台线程里,进度/日志经 [`super::job::HostTask`] 回流; +//! - **身份来自 GUI 选中的账户**(`AccountStore` → `AuthPlaceholders`),凭据只在 +//! 本模块内被读一次,绝不进 DTO。 +//! +//! 停止:`kill_tree(pid)`(core 的三段式进程树回收),`finish()` 会照常收尾写索引。 + +use std::path::PathBuf; + +use unlucky_core::game::arguments::Environment; +use unlucky_core::game::install::instance::read_instance_config; +use unlucky_core::game::isolation::{derive_game_dir, observe_mode, IsolationMode}; +use unlucky_core::game::{self, VersionJson}; +use unlucky_core::launch::adapter::{ + self, AdapterOptions, AdapterWarning, LibraryInput, SnapshotInput, +}; +use unlucky_core::launch::request::{JavaSelection, LaunchRequest, MemoryOptions}; +use unlucky_core::launch::time::{format_rfc3339_millis, now}; +use unlucky_core::launch::tree_kill; +use unlucky_core::launch::{Launcher, LauncherConfig}; +use unlucky_core::platform::paths::ProcessEnv; + +use crate::dto::{ + InstanceId, InstanceRuntime, LaunchFinished, LaunchStage, LoaderKind, LogOutcome, Progress, +}; + +use super::env::HostEnv; +use super::job::{launch_summary, HostTask, JobSender}; + +/// 启动器名(与 CLI `meta::LAUNCHER_NAME` 同值)。 +pub const LAUNCHER_NAME: &str = "Unlucky Launcher"; + +/// 一次启动需要的实例信息。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LaunchTarget { + /// 实例 id + pub id: InstanceId, + /// 实例名 + pub name: String, + /// 绑定的版本 id + pub version_id: String, + /// 账户 id(`None` = 离线占位) + pub account: Option, +} + +/// 选中并用起来的 Java(`summary` 给 UI 一行提示)。 +pub struct JavaPick { + /// 交给 adapter 的选择 + pub selection: JavaSelection, + /// 人读一行 + pub summary: String, +} + +/// 选一个能用的 Java:托管 > JAVA_HOME > PATH(`core::game::java` 的探测与优先级)。 +pub fn pick_java(env: &HostEnv, version_id: &str) -> JavaPick { + let store = env.store(); + let install_root = store + .doc() + .effective_str("java.installRoot") + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(PathBuf::from); + let roots = unlucky_core::game::java::location::scan_roots( + env.mode(), + &env.layout, + install_root.as_deref(), + env.platform, + &ProcessEnv, + ); + let candidates = unlucky_core::game::java::candidates_in_roots(&roots); + let required = unlucky_core::game::java::requirement_for_version(&env.game, version_id); + let picked = unlucky_core::game::java::pick(&candidates, &required).cloned(); + match picked { + Some(runtime) => { + let major = runtime + .major + .unwrap_or(unlucky_core::game::java::DEFAULT_MAJOR); + let summary = format!( + "Java {}({}{})", + runtime.version.clone().unwrap_or_else(|| major.to_string()), + runtime.origin.category(), + runtime + .vendor + .as_ref() + .map(|vendor| format!(" · {vendor}")) + .unwrap_or_default() + ); + JavaPick { + selection: JavaSelection::new(runtime.binary, major, runtime.bits), + summary, + } + } + None => { + let binary = PathBuf::from(unlucky_core::game::java::java_binary_name()); + let major = required.minimum; + JavaPick { + selection: JavaSelection::new(binary, major, 64), + summary: format!("PATH 上的 Java(未探测到版本,按 Java {major} 启动)"), + } + } + } +} + +/// 装配 [`LaunchRequest`](与 CLI `snapshot::prepare` 同序)。 +/// +/// # Errors +/// +/// 版本 JSON 缺失/非法、继承链断裂、隔离派生失败时返回用户可读文案。 +pub fn build_request( + env: &HostEnv, + target: &LaunchTarget, +) -> Result<(LaunchRequest, Vec), String> { + let version_id = target.version_id.clone(); + let manifest_path = env.game.version_json(&version_id); + let text = std::fs::read_to_string(&manifest_path) + .map_err(|error| format!("读版本 JSON 失败 {}:{error}", manifest_path.display()))?; + let manifest = + VersionJson::from_json_str(&text).map_err(|error| format!("版本 JSON 非法:{error}"))?; + let source = |id: &str| -> Option { + std::fs::read_to_string(env.game.version_json(id)) + .ok() + .and_then(|text| VersionJson::from_json_str(&text).ok()) + }; + let resolved = + game::resolve(&source, &manifest).map_err(|error| format!("解析继承链失败:{error}"))?; + + let environment = Environment::host(); + let expanded = resolved + .arguments + .as_ref() + .map(|arguments| arguments.expand(&environment)) + .unwrap_or_default(); + + let mut libraries: Vec = Vec::new(); + for library in resolved.libraries.as_deref().unwrap_or_default() { + if !library.applies(&environment) || library.native_classifier(&environment).is_some() { + continue; + } + let Ok(coordinate) = library.coordinate() else { + continue; + }; + let path = if library.is_local() { + env.game.version_library(&version_id, &coordinate) + } else { + env.game.library(&coordinate) + }; + libraries.push(LibraryInput::new(path, library.name.clone())); + } + let natives = unlucky_core::launch::natives::collect( + &env.game, + &version_id, + resolved.libraries.as_deref().unwrap_or_default(), + &environment, + ); + + let game_dir = run_dir(env, target)?; + let jar_id = resolved.jar.clone().unwrap_or_else(|| version_id.clone()); + let assets_root = env.game.assets_dir(); + let assets_index = resolved + .assets + .clone() + .or_else(|| resolved.asset_index.as_ref().map(|index| index.id.clone())); + let snapshot = SnapshotInput { + version_id: &version_id, + version_name: Some(&target.name), + version_type: None, + game_version: None, + main_class: resolved.main_class.as_deref(), + jvm_arguments: &expanded.jvm, + game_arguments: &expanded.game, + legacy_arguments: resolved.minecraft_arguments.as_deref(), + libraries: &libraries, + natives: &natives, + primary_jar: env.game.version_jar(&jar_id), + game_dir, + assets_index: assets_index.as_deref(), + natives_dir: env.game.natives_dir(&version_id), + library_dir: env.game.libraries_dir(), + assets_root: assets_root.clone(), + game_assets: assets_root, + }; + + let java = pick_java(env, &version_id); + let (auth, identity_line) = super::identity::resolve(env, target.account.as_deref())?; + let memory = env + .store() + .effective("java.default_memory_mb") + .and_then(serde_json::Value::as_u64) + .and_then(|value| u32::try_from(value).ok()); + let options = AdapterOptions { + java: java.selection, + memory: MemoryOptions { + min_mb: None, + max_mb: memory, + }, + auth, + launcher_name: LAUNCHER_NAME.to_string(), + launcher_version: env!("CARGO_PKG_VERSION").to_string(), + language: "zh-CN".to_string(), + ..AdapterOptions::strict() + }; + let output = adapter::build_request(&snapshot, options); + let mut warnings: Vec = output.warnings.iter().map(describe_warning).collect(); + warnings.push(java.summary); + warnings.push(identity_line); + let mut request = output.request; + request.instance.id = target.id.clone(); + request.instance.name = target.name.clone(); + Ok((request, warnings)) +} + +/// 派生运行目录(D20 覆盖链:实例 `isolation` > 全局 `instances.mode`)。 +fn run_dir(env: &HostEnv, target: &LaunchTarget) -> Result { + let global = super::game::global_mode(env); + let config = read_instance_config(&env.layout, &target.id) + .map_err(|error| format!("读实例设置失败:{error}"))?; + let derivation = derive_game_dir(&env.game, &target.version_id, global, config.as_ref(), None) + .map_err(|error| format!("隔离目录派生失败:{error}"))?; + if let Some(mut config) = config { + let at = format_rfc3339_millis(now()); + if observe_mode(&mut config, &env.game, &at, derivation.isolation).unwrap_or(false) { + let _ = + unlucky_core::game::install::instance::write_instance_config(&env.layout, &config); + } + } + Ok(derivation.game_dir) +} + +/// 起一次启动(后台线程;进度/日志/收尾全部走 `HostTask`)。 +pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) { + let id = target.id.clone(); + let tx_progress = tx.clone(); + super::job::spawn("launch", move || { + let _ = tx_progress.send(HostTask::Runtime { + id: id.clone(), + runtime: InstanceRuntime::Busy { + stage: LaunchStage::Launch, + progress: Progress { done: 0, total: 1 }, + }, + }); + let (request, warnings) = match build_request(&env, &target) { + Ok(ok) => ok, + Err(reason) => { + let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished { + id, + session: String::new(), + outcome: LogOutcome::Crashed, + summary: format!("启动失败:{reason}"), + }))); + return; + } + }; + for warning in &warnings { + let _ = tx.send(HostTask::Notice(Box::new(crate::dto::NoticeView::new( + &format!("launch-warn:{}", target.id), + crate::dto::NoticeLevel::Info, + "启动提示", + warning, + )))); + } + let config = LauncherConfig::new(env.sessions_root()) + .with_launcher(LAUNCHER_NAME, env!("CARGO_PKG_VERSION")); + let session = match Launcher::new(config).spawn(&request) { + Ok(session) => session, + Err(error) => { + let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished { + id, + session: String::new(), + outcome: LogOutcome::Crashed, + summary: format!("启动失败:{error}"), + }))); + return; + } + }; + let session_id = session.session_id().to_string(); + let pid = session.pid(); + let _ = tx.send(HostTask::LaunchStarted { + id: id.clone(), + session_id: session_id.clone(), + pid, + }); + let _ = tx.send(HostTask::Runtime { + id: id.clone(), + runtime: InstanceRuntime::Running { pid }, + }); + match session.finish() { + Ok(handle) => { + let outcome = super::identity::outcome_of(&handle); + let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished { + id, + session: session_id, + outcome, + summary: format!( + "{}({},{:.1}s)", + launch_summary(outcome, Some(handle.exit.code)), + handle.exit.exit_type.as_str(), + handle.uptime_ms as f64 / 1000.0 + ), + }))); + } + Err(error) => { + let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished { + id, + session: session_id, + outcome: LogOutcome::SilentFail, + summary: format!("启动收尾失败:{error}"), + }))); + } + } + }); +} + +/// 停止一次启动(按 pid 回收进程树;`finish()` 会在后台线程里照常收尾)。 +pub fn stop(pid: u32) -> Result<(), String> { + tree_kill::kill_tree(pid, std::time::Duration::from_secs(3)) + .map_err(|error| format!("停止进程 {pid} 失败:{error}")) +} + +/// 适配层告警 → 人读一行。 +fn describe_warning(warning: &AdapterWarning) -> String { + match warning { + AdapterWarning::MissingLibrary { name, path } => { + format!("缺少库文件 {name}({})", path.display()) + } + AdapterWarning::MissingGameArguments => "版本 JSON 没有游戏参数".to_string(), + AdapterWarning::MissingAssetsIndex => "版本 JSON 没有资源索引".to_string(), + AdapterWarning::MissingMainClass => "版本 JSON 没有 mainClass".to_string(), + AdapterWarning::MissingNativeJar { name, path } => { + format!("缺少 natives 构件 {name}({})", path.display()) + } + } +} + +/// 加载器 → 适配层的模组加载器标记(GUI 只用于显示,不改命令行)。 +pub fn loader_label(kind: LoaderKind) -> &'static str { + match kind { + LoaderKind::Vanilla => "原版", + LoaderKind::Fabric => "Fabric", + LoaderKind::Quilt => "Quilt", + LoaderKind::Forge => "Forge", + LoaderKind::NeoForge => "NeoForge", + LoaderKind::OptiFine => "OptiFine", + LoaderKind::LiteLoader => "LiteLoader", + } +} + +/// 隔离档位的展示串(`isolated` / `shared`)。 +pub fn isolation_label(mode: IsolationMode) -> &'static str { + mode.as_str() +} diff --git a/crates/app/src/host/logs.rs b/crates/app/src/host/logs.rs new file mode 100644 index 0000000..f9fd901 --- /dev/null +++ b/crates/app/src/host/logs.rs @@ -0,0 +1,342 @@ +//! 日志 / 诊断:会话索引 → 会话列表,事件 JSONL → 日志行,规则引擎 → 诊断卡片。 +//! +//! 与 CLI `ull logs` / `ull diagnose` 同源(`launch::session_index` + `diagnose` 规则引擎): +//! 「同一份 jsonl 的两种读法」只有这一处实现(M39 ports/events.rs 的设计)。 +//! 实时滚动 = host 每拍尾随启动会话的 JSONL(只读新增字节),不额外开第二条事件通道。 + +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; + +use serde_json::Value; +use unlucky_core::diagnose::collectors::{ExitSignal, SignalOrigin}; +use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput}; +use unlucky_core::diagnose::loader::RuleLoader; +use unlucky_core::launch::session_index::{self, SessionIndexEntry}; + +use crate::dto::{ + Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSession, SessionId, +}; + +use super::env::HostEnv; + +/// 送进规则引擎的默认尾巴行数(与 CLI 同值,避免两条路看到不同证据)。 +pub const DEFAULT_TAIL: usize = 2000; + +/// 日志页一次最多渲染的行数(超出的截断,避免大文件把 UI 拖死)。 +pub const MAX_RENDERED_LINES: usize = 2_000; + +/// 会话索引目录(`.ULL/data/agent/sessions`)。 +pub fn root(env: &HostEnv) -> PathBuf { + env.sessions_root() +} + +/// 会话列表(新→旧)。 +pub fn sessions(env: &HostEnv) -> Vec { + let index = session_index::index_path(&root(env)); + let mut entries = session_index::read_all(&index).unwrap_or_default(); + entries.sort_by(|a, b| b.started_at.cmp(&a.started_at)); + entries.iter().map(session_of).collect() +} + +/// 索引条目 → 左栏会话行。 +pub fn session_of(entry: &SessionIndexEntry) -> LogSession { + LogSession { + id: entry.session_id.clone(), + instance: if entry.instance_name.is_empty() { + entry.instance_id.clone() + } else { + entry.instance_name.clone() + }, + when: display_time(&entry.started_at), + outcome: outcome_of(entry), + duration_secs: entry.duration_ms / 1000, + } +} + +/// 结局:静默失败 > 崩溃(非 0 / crash_found) > 正常。 +pub fn outcome_of(entry: &SessionIndexEntry) -> LogOutcome { + if entry.silent_failure { + LogOutcome::SilentFail + } else if entry.crash_found + || entry.exit_code.is_some_and(|code| code != 0) + || (entry.exit_code.is_none() && !entry.ended_at.is_empty()) + { + LogOutcome::Crashed + } else { + LogOutcome::Ok + } +} + +/// `2026-10-29T19:22:45.123Z` → `10-29 19:22`。 +pub fn display_time(started_at: &str) -> String { + let body = started_at.trim_end_matches('Z'); + let Some((date, time)) = body.split_once('T') else { + return started_at.to_string(); + }; + let date = date.get(5..).unwrap_or(date); + let time = time.get(..5).unwrap_or(time); + format!("{date} {time}") +} + +/// 事件流文件路径(`/game/`)。 +pub fn event_path(env: &HostEnv, entry: &SessionIndexEntry) -> PathBuf { + root(env).join("game").join(&entry.log_path) +} + +/// 会话 id 或唯一前缀 → 索引条目。 +fn find(env: &HostEnv, key: &str) -> Option { + let index = session_index::index_path(&root(env)); + let entries = session_index::read_all(&index).ok()?; + if let Some(exact) = entries.iter().find(|entry| entry.session_id == key) { + return Some(exact.clone()); + } + entries + .iter() + .find(|entry| entry.session_id.starts_with(key)) + .cloned() +} + +/// 事件 JSON → 一行日志(`stdout`/`stderr` 的 `payload.line`)。 +pub fn line_of(event: &Value) -> Option { + let text = event.pointer("/payload/line").and_then(Value::as_str)?; + let kind = event + .get("kind") + .and_then(Value::as_str) + .unwrap_or_default(); + let level = match event + .pointer("/payload/level") + .and_then(Value::as_str) + .unwrap_or_default() + { + "error" => LogLevel::Error, + "warn" | "warning" => LogLevel::Warn, + "debug" | "trace" => LogLevel::Debug, + _ => { + if kind == "stderr" { + LogLevel::Warn + } else { + LogLevel::Info + } + } + }; + Some(LogLine { + text: text.to_string(), + level, + }) +} + +/// 整个事件流 → 日志行(解析失败的行跳过,容忍被 kill 时截断的尾行)。 +pub fn read_lines(path: &Path) -> Vec { + let Ok(text) = std::fs::read_to_string(path) else { + return Vec::new(); + }; + text.lines() + .filter(|line| !line.trim().is_empty()) + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter_map(|event| line_of(&event)) + .collect() +} + +/// 打开一个会话:日志行 + 规则引擎结论。 +pub fn open(env: &HostEnv, key: &str) -> (Vec, Option) { + let Some(entry) = find(env, key) else { + return (Vec::new(), None); + }; + let path = event_path(env, &entry); + let lines = read_lines(&path); + let diagnosis = diagnose_lines(&entry, &lines); + (lines, diagnosis) +} + +/// 规则引擎跑一遍(**规则先跑,模型补位**:这里只出规则结论)。 +pub fn diagnose_lines(entry: &SessionIndexEntry, lines: &[LogLine]) -> Option { + let stderr_text: String = lines + .iter() + .filter(|line| line.level == LogLevel::Error || line.level == LogLevel::Warn) + .map(|line| line.text.as_str()) + .collect::>() + .join("\n"); + let body: String = lines + .iter() + .rev() + .take(DEFAULT_TAIL) + .map(|line| line.text.as_str()) + .collect::>() + .into_iter() + .rev() + .collect::>() + .join("\n"); + if body.is_empty() { + return None; + } + let error_lines = u32::try_from( + lines + .iter() + .filter(|line| line.level == LogLevel::Error) + .count(), + ) + .unwrap_or(u32::MAX); + let exit = ExitSignal::classify( + entry.exit_code, + Some(entry.duration_ms), + error_lines, + &stderr_text, + ); + let mut input = DiagnosisInput::new().with_exit(exit); + input.push_text(SignalOrigin::Stderr, body); + let loader = RuleLoader::embedded(); + let report = Diagnoser::new(loader.rule_set().rules().to_vec()).analyze(&input); + let finding = report + .findings + .first() + .or_else(|| report.secondary.first())?; + Some(DiagnosisReport { + session: entry.session_id.clone(), + diagnosis: Diagnosis { + rule: finding.rule_id.clone(), + title: finding.message.clone(), + confidence: confidence_percent(finding.confidence), + hint: finding.detail.clone().unwrap_or_else(|| { + report + .exit + .as_ref() + .map(|exit| exit.exit_type.as_str().to_string()) + .unwrap_or_else(|| "未知退出".to_string()) + }), + fix: finding + .materialized_actions() + .first() + .map(|action| action.tool.to_string()), + }, + }) +} + +/// 0.0~1.0 → 0~100(四舍五入)。 +pub fn confidence_percent(confidence: f32) -> u8 { + let percent = (confidence.clamp(0.0, 1.0) * 100.0).round(); + u8::try_from(percent as u32).unwrap_or(100) +} + +/// 导入崩溃日志(`hs_err_pid*.log` / `crash-*.txt` / `latest.log`)做规则分析。 +/// +/// 文件名含 `hs_err` 时先走 `hs_err::parse`(结构化字段进规则引擎),否则整份文本当信号喂进去。 +pub fn analyze_crash_file(path: &str) -> Result<(String, DiagnosisReport), String> { + let path = Path::new(path); + if !path.is_file() { + return Err(format!("找不到崩溃日志:{}", path.display())); + } + let text = std::fs::read_to_string(path).map_err(|error| format!("读崩溃日志失败:{error}"))?; + let name = path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| "crash.log".to_string()); + let is_hs_err = name.contains("hs_err"); + let mut input = DiagnosisInput::new(); + if is_hs_err { + input.push_hs_err(unlucky_core::diagnose::hs_err::parse(&text)); + } + input.push_text( + if is_hs_err { + SignalOrigin::HsErr + } else if name.contains("crash") { + SignalOrigin::CrashReport + } else { + SignalOrigin::LatestLog + }, + text, + ); + let loader = RuleLoader::embedded(); + let report = Diagnoser::new(loader.rule_set().rules().to_vec()).analyze(&input); + let finding = report + .findings + .first() + .or_else(|| report.secondary.first()) + .ok_or_else(|| "规则引擎没有从这份日志里认出已知问题".to_string())?; + Ok(( + format!("导入的日志:{name}"), + DiagnosisReport { + session: format!("file:{name}"), + diagnosis: Diagnosis { + rule: finding.rule_id.clone(), + title: finding.message.clone(), + confidence: confidence_percent(finding.confidence), + hint: finding + .detail + .clone() + .unwrap_or_else(|| "规则引擎命中(详见证据行)".to_string()), + fix: finding + .materialized_actions() + .first() + .map(|action| action.tool.to_string()), + }, + }, + )) +} + +/// 实时尾随器:只读文件新增的字节,解析出完整的 JSON 行。 +#[derive(Debug, Default)] +pub struct Tail { + path: Option, + session: Option, + offset: u64, +} + +impl Tail { + /// 开始尾随一个会话。 + pub fn attach(&mut self, path: PathBuf, session: &str) { + self.path = Some(path); + self.session = Some(session.to_string()); + self.offset = 0; + } + + /// 停止尾随。 + pub fn detach(&mut self) { + self.path = None; + self.session = None; + self.offset = 0; + } + + /// 当前尾随的会话。 + pub fn session(&self) -> Option<&str> { + self.session.as_deref() + } + + /// 读新行(返回 `(会话 id, 新行)`;没有新内容返回 `None`)。 + pub fn poll(&mut self) -> Option<(String, Vec)> { + let path = self.path.clone()?; + let session = self.session.clone()?; + let file = std::fs::File::open(&path).ok()?; + let len = file.metadata().ok()?.len(); + if len <= self.offset { + return None; + } + let mut file = file; + file.seek(SeekFrom::Start(self.offset)).ok()?; + let mut buffer = String::new(); + file.read_to_string(&mut buffer).ok()?; + // 只吃到最后一个换行:半行留到下一拍(避免把 JSON 截断解析成脏行) + let last_newline = buffer.rfind('\n')?; + let complete = &buffer[..=last_newline]; + self.offset += u64::try_from(complete.len()).unwrap_or(u64::MAX); + let lines: Vec = complete + .lines() + .filter(|line| !line.trim().is_empty()) + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter_map(|event| line_of(&event)) + .collect(); + Some((session, lines)) + } +} + +/// 会话是否已收尾(索引里出现 `ended_at` 或文件停止增长超过一拍)。 +pub fn session_finished(env: &HostEnv, session: &str) -> bool { + let index = session_index::index_path(&root(env)); + session_index::read_all(&index) + .unwrap_or_default() + .iter() + .any(|entry| entry.session_id == session && !entry.ended_at.is_empty()) +} + +#[cfg(test)] +#[path = "logs_tests.rs"] +mod tests; diff --git a/crates/app/src/host/logs_tests.rs b/crates/app/src/host/logs_tests.rs new file mode 100644 index 0000000..ccbb6a3 --- /dev/null +++ b/crates/app/src/host/logs_tests.rs @@ -0,0 +1,86 @@ +//! host/logs.rs 的单测(拆文件守 400 行上限)。 + +use super::*; + +#[test] +fn time_display_and_outcomes() { + assert_eq!(display_time("2026-10-29T19:22:45.123Z"), "10-29 19:22"); + assert_eq!(display_time("nope"), "nope"); + let mut entry = SessionIndexEntry { + v: 1, + session_id: "s".to_string(), + instance_id: String::new(), + instance_name: String::new(), + game_version: String::new(), + started_at: String::new(), + ended_at: "2026-10-29T19:22:45Z".to_string(), + duration_ms: 1000, + exit_code: Some(0), + exit_type: Some("normal".to_string()), + silent_failure: false, + window_detected: true, + event_count: 1, + log_path: "s.jsonl".to_string(), + crash_found: false, + diagnosis_top: None, + }; + assert_eq!(outcome_of(&entry), LogOutcome::Ok); + entry.exit_code = Some(1); + assert_eq!(outcome_of(&entry), LogOutcome::Crashed); + entry.silent_failure = true; + assert_eq!(outcome_of(&entry), LogOutcome::SilentFail); +} + +#[test] +fn lines_map_levels_and_skip_non_line_events() { + let stdout = serde_json::json!({"kind":"stdout","payload":{"line":"hi","level":"info"}}); + assert_eq!(line_of(&stdout).unwrap().level, LogLevel::Info); + let stderr = serde_json::json!({"kind":"stderr","payload":{"line":"boom","level":"error"}}); + assert_eq!(line_of(&stderr).unwrap().level, LogLevel::Error); + let other = serde_json::json!({"kind":"cmd_built","payload":{"argv":[]}}); + assert!(line_of(&other).is_none()); +} + +#[test] +fn tail_only_consumes_complete_lines() { + let dir = std::env::temp_dir().join(format!("ull-host-tail-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("目录"); + let path = dir.join("s.jsonl"); + std::fs::write( + &path, + "{\"kind\":\"stdout\",\"payload\":{\"line\":\"one\"}}\n", + ) + .expect("写"); + let mut tail = Tail::default(); + tail.attach(path.clone(), "s"); + let (session, lines) = tail.poll().expect("首拍有新行"); + assert_eq!(session, "s"); + assert_eq!(lines.len(), 1); + assert!(tail.poll().is_none(), "没有新增就不该再产出"); + + // 半行不消费 + let mut text = std::fs::read_to_string(&path).expect("读"); + text.push_str("{\"kind\":\"stdout\",\"payload\":{\"line\":\"tw"); + std::fs::write(&path, text).expect("写"); + assert!(tail.poll().is_none(), "半个 JSON 行不能解析"); + let mut text = std::fs::read_to_string(&path).expect("读"); + text.push_str("o\"}}\n"); + std::fs::write(&path, text).expect("写"); + let (_, lines) = tail.poll().expect("补全后应产出"); + assert_eq!(lines[0].text, "two"); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn crash_import_reports_missing_file() { + let err = analyze_crash_file("D:/definitely/missing/hs_err_pid1.log").unwrap_err(); + assert!(err.contains("找不到崩溃日志"), "{err}"); +} + +#[test] +fn confidence_percent_is_rounded_and_clamped() { + assert_eq!(confidence_percent(0.923), 92); + assert_eq!(confidence_percent(-1.0), 0); + assert_eq!(confidence_percent(2.0), 100); +} diff --git a/crates/app/src/host/meter.rs b/crates/app/src/host/meter.rs new file mode 100644 index 0000000..813a54f --- /dev/null +++ b/crates/app/src/host/meter.rs @@ -0,0 +1,70 @@ +//! 安装进度的记账器(文件计数 / 字节 / 速度 / ETA)。 +//! +//! `execute` 的文件级回调已经把每个文件的结局给了我们,这里只做聚合; +//! 速度用相邻两次采样的字节差算(core 的事件只给绝对值)。 + +use std::sync::Arc; +use std::time::Instant; + +use unlucky_core::game::install::{FileOutcome, FileStatus, ProgressSink}; + +use crate::dto::{derive_speed, CreateRequest, InstallProgressView, InstallStage}; + +use super::install::loader_label; +use super::job::{HostTask, JobSender}; +/// 一次安装的上下文(进度回调共享)。 +pub(super) struct Meter { + pub(super) progress: InstallProgressView, + previous: Option<(u64, Instant)>, +} + +impl Meter { + pub(super) fn new(request: &CreateRequest) -> Self { + Self { + progress: InstallProgressView::start( + &request.version, + &request.instance_name(), + &loader_label(request.loader, &request.loader_version), + ), + previous: None, + } + } + + /// 换阶段(重置速度采样)。 + pub(super) fn stage(&mut self, stage: InstallStage, message: &str) { + self.progress.stage = stage; + self.progress.message = message.to_string(); + self.previous = None; + } + + /// 记账一个文件的结局。 + pub(super) fn note(&mut self, outcome: &FileOutcome) { + self.progress.files_done += 1; + if outcome.status != FileStatus::Skipped { + self.progress.bytes_done = self.progress.bytes_done.saturating_add(outcome.bytes); + } + let now = Instant::now(); + if let Some(previous) = self.previous { + if let Some((speed, eta)) = derive_speed( + previous, + (self.progress.bytes_done, now), + self.progress.bytes_total, + ) { + self.progress.speed_bps = speed; + self.progress.eta_secs = eta; + } + } + self.previous = Some((self.progress.bytes_done, now)); + } +} + +/// 进度 sink(`execute` 的文件级回调)。 +pub(super) fn sink_for(tx: JobSender, meter: Arc>) -> ProgressSink { + Arc::new(move |outcome: &FileOutcome| { + let Ok(mut meter) = meter.lock() else { + return; + }; + meter.note(outcome); + let _ = tx.send(HostTask::Install(Box::new(meter.progress.clone()))); + }) +} diff --git a/crates/app/src/host/mod.rs b/crates/app/src/host/mod.rs new file mode 100644 index 0000000..8c1dee0 --- /dev/null +++ b/crates/app/src/host/mod.rs @@ -0,0 +1,370 @@ +//! 真实后端:把 core(game/launch/install/auth/java/diagnose/setting)装配成 GUI 的 host。 +//! +//! 边界:本模块是整个 app 里**唯一**允许 `use unlucky_core::…` 的地方(连同 `settings_bridge`); +//! 出口只有 [`crate::dto`] 的快照/事件。线程模型: +//! +//! ```text +//! dispatch(AppCommand) ──► 立即动作(读盘/写设置)或 spawn 后台线程 +//! 后台线程 ──HostTask──► rx ──tick()──► CoreEvent ──► state.apply() +//! tick() 另外每 ~600ms 尾随一次运行中会话的 JSONL(实时日志滚动) +//! ``` +//! +//! AI 对话暂时仍由 [`MockBackend`] 提供脚本回复(agent 的 GUI host 是后续 mission); +//! 其余页面全部走真实 core。 + +mod accounts; +mod dispatch; +mod engine; +pub mod env; +mod game; +mod identity; +mod install; +mod java; +mod job; +mod launch; +mod logs; +mod meter; +mod settings; +mod versions; + +use std::collections::VecDeque; +use std::time::Duration; + +use crate::dto::{ + AccountView, AppCommand, AppEvent, Backend, CoreEvent, DiagnosisReport, InstallProgressView, + InstanceRuntime, JavaChainStep, LogOutcome, NoticeView, +}; +use crate::mock::MockBackend; +use crate::toast::{Toast, ToastKind}; + +use accounts::LoginJob; +pub use env::HostEnv; +use job::{HostTask, JobReceiver, JobSender}; + +/// 每拍最多处理的后台消息数(防止一次 tick 吃掉整帧预算)。 +const MAX_TASKS_PER_TICK: usize = 64; + +/// 实时日志尾随间隔(拍数,33ms/拍 → 约 600ms 一次)。 +const TAIL_POLL_TICKS: u64 = 18; + +/// 真实 host。 +pub struct CoreHost { + env: HostEnv, + events: VecDeque, + tx: JobSender, + rx: JobReceiver, + ticks: u64, + /// 当前启动账户 id(内存态;跨会话持久化要 core schema 增键,见 handoff) + active_account: Option, + accounts: Vec, + login: Option, + java_chain: Vec, + java_busy: bool, + java_message: String, + install: Option, + notices: Vec, + /// 最近一次启动的 pid(停止按钮用) + launch_pid: Option, + /// 实时日志尾随器 + tail: logs::Tail, + /// AI 对话的临时后端(agent GUI host 落地前的占位) + chat: MockBackend, + /// 当前安装任务的取消标记(阶段边界检查) + install_cancel: Option>, +} + +impl CoreHost { + /// 按便携数据根装配;解析不出数据根返回 `None`(调用方退回 mock)。 + pub fn open() -> Option { + HostEnv::resolve().map(Self::new) + } + + /// 用给定环境装配(截图/单测通道)。 + pub fn new(env: HostEnv) -> Self { + let (tx, rx) = job::channel(); + game::ensure_root(&env); + let mut host = Self { + env, + events: VecDeque::new(), + tx, + rx, + ticks: 0, + active_account: None, + accounts: Vec::new(), + login: None, + java_chain: Vec::new(), + java_busy: false, + java_message: String::new(), + install: None, + notices: Vec::new(), + launch_pid: None, + tail: logs::Tail::default(), + chat: MockBackend::default(), + install_cancel: None, + }; + host.refresh_all(); + host + } + + /// 数据根(截图/诊断展示用)。 + pub fn env(&self) -> &HostEnv { + &self.env + } + + fn send(&mut self, event: CoreEvent) { + self.events.push_back(AppEvent::Core(event)); + } + + fn toast(&mut self, kind: ToastKind, text: impl Into) { + self.events.push_back(AppEvent::Toast(Toast { + id: 0, + kind, + text: text.into(), + remaining_ms: 0, + })); + } + + /// 全量刷新:实例 / 会话 / 账户 / 设置 / Java,并触发一次版本清单拉取。 + pub fn refresh_all(&mut self) { + self.refresh_instances(); + self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env))); + self.refresh_accounts(); + self.send(CoreEvent::SettingsChanged(settings::groups(&self.env))); + self.refresh_java(); + versions::spawn_list(self.env.clone(), self.tx.clone()); + } + + fn refresh_instances(&mut self) { + let instances = game::instances(&self.env); + self.send(CoreEvent::InstancesChanged(instances)); + } + + fn refresh_accounts(&mut self) { + self.accounts = accounts::accounts(&self.env, self.active_account.as_deref()); + if self.active_account.is_none() { + // 默认选中第一个账户(没有账户时保持 None = 离线占位) + self.active_account = self.accounts.first().map(|account| account.id.clone()); + self.accounts = accounts::accounts(&self.env, self.active_account.as_deref()); + } + self.send(CoreEvent::AccountsChanged(self.accounts.clone())); + } + + fn refresh_java(&mut self) { + let page = java::page( + &self.env, + self.java_chain.clone(), + self.java_busy, + &self.java_message, + ); + self.send(CoreEvent::JavaChanged(page)); + } + + fn push_notice(&mut self, notice: NoticeView) { + self.notices.retain(|existing| existing.id != notice.id); + self.notices.push(notice); + self.send(CoreEvent::NoticesChanged(self.notices.clone())); + } + + /// 打开一个会话(日志行 + 诊断)。 + fn open_session(&mut self, id: &str) { + let (lines, diagnosis) = logs::open(&self.env, id); + self.send(CoreEvent::LogLinesChanged(lines)); + self.send(CoreEvent::DiagnosisReady(diagnosis.unwrap_or( + DiagnosisReport { + session: id.to_string(), + diagnosis: crate::dto::Diagnosis { + rule: String::new(), + title: "该会话没有命中已知规则".to_string(), + confidence: 0, + hint: "可点「让 AI 详解」把日志交给模型进一步分析".to_string(), + fix: None, + }, + }, + ))); + } + + /// 由实例名/版本 id 找启动目标。 + fn launch_target(&self, id: &str) -> Option { + let instance = game::instances(&self.env) + .into_iter() + .find(|instance| instance.id == id || instance.name == id)?; + Some(launch::LaunchTarget { + id: instance.id, + name: instance.name, + version_id: instance.version_id, + account: self.active_account.clone(), + }) + } + + fn start_launch(&mut self, id: &str) { + let Some(target) = self.launch_target(id) else { + self.toast(ToastKind::Error, format!("找不到实例 {id}")); + return; + }; + let instance_id = target.id.clone(); + if let Some(notice) = game::mode_change_notice(&self.env, &instance_id) { + self.push_notice(notice); + } + self.send(CoreEvent::InstanceRuntimeChanged { + id: instance_id.clone(), + runtime: InstanceRuntime::Busy { + stage: crate::dto::LaunchStage::Launch, + progress: crate::dto::Progress { done: 0, total: 1 }, + }, + }); + launch::start(self.env.clone(), self.tx.clone(), target); + } + + /// 一台在跑的实例的 pid(从快照或最近一次启动里取)。 + fn running_pid(&self, id: &str) -> Option { + if let Some(pid) = self.launch_pid { + return Some(pid); + } + game::instances(&self.env) + .into_iter() + .find(|instance| instance.id == id) + .and_then(|instance| match instance.runtime { + InstanceRuntime::Running { pid } => Some(pid), + _ => None, + }) + } + + /// 后台消息 → 事件。 + fn handle(&mut self, task: HostTask) { + match task { + HostTask::Versions(Ok(versions)) => self.send(CoreEvent::VersionListChanged(versions)), + HostTask::Versions(Err(reason)) => { + self.toast(ToastKind::Warn, format!("拉取版本清单失败:{reason}")); + } + HostTask::Install(progress) => { + self.install = Some(*progress.clone()); + self.send(CoreEvent::InstallProgress(Some(*progress))); + } + HostTask::InstallDone { progress, result } => { + self.install_cancel = None; + self.install = Some(*progress.clone()); + self.send(CoreEvent::InstallProgress(Some(*progress))); + match result { + Ok(_) => { + self.refresh_instances(); + self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env))); + } + Err(reason) => self.toast(ToastKind::Error, reason), + } + } + HostTask::Accounts(accounts) => { + self.accounts = accounts.clone(); + self.send(CoreEvent::AccountsChanged(accounts)); + self.refresh_instances(); + } + HostTask::Login(view) => self.send(CoreEvent::DeviceLoginChanged(*view)), + HostTask::Java(page) => self.send(CoreEvent::JavaChanged(*page)), + HostTask::Logs { + sessions, + lines, + diagnosis, + } => { + self.send(CoreEvent::LogSessionsChanged(sessions)); + self.send(CoreEvent::LogLinesChanged(lines)); + if let Some(report) = diagnosis { + self.send(CoreEvent::DiagnosisReady(report)); + } + } + HostTask::Sessions(sessions) => self.send(CoreEvent::LogSessionsChanged(sessions)), + HostTask::Tail(batch) => { + if batch.running { + self.send(CoreEvent::LogLinesChanged(batch.lines)); + } else { + self.send(CoreEvent::LogAppended(*batch)); + } + } + HostTask::LaunchStarted { + id, + session_id, + pid, + } => { + self.launch_pid = Some(pid); + self.tail.attach( + self.env + .sessions_root() + .join("game") + .join(format!("{session_id}.jsonl")), + &session_id, + ); + self.send(CoreEvent::InstanceRuntimeChanged { + id, + runtime: InstanceRuntime::Running { pid }, + }); + } + HostTask::Runtime { id, runtime } => { + self.send(CoreEvent::InstanceRuntimeChanged { id, runtime }); + } + HostTask::LaunchEnd(finished) => { + self.launch_pid = None; + let runtime = if finished.outcome == LogOutcome::Ok { + InstanceRuntime::Idle + } else { + InstanceRuntime::Failed { + reason: finished.summary.clone(), + } + }; + self.send(CoreEvent::InstanceRuntimeChanged { + id: finished.id.clone(), + runtime, + }); + self.send(CoreEvent::LaunchFinished((*finished).clone())); + self.tail.detach(); + self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env))); + self.refresh_instances(); + } + HostTask::Crash(report) => { + self.send(CoreEvent::DiagnosisReady(*report)); + } + HostTask::Notice(notice) => self.push_notice(*notice), + HostTask::Toast(toast) => self.events.push_back(AppEvent::Toast(*toast)), + } + } + + /// 尾随运行中会话(每 `TAIL_POLL_TICKS` 拍一次)。 + fn poll_tail(&mut self) { + let Some((session, lines)) = self.tail.poll() else { + return; + }; + if lines.is_empty() { + return; + } + let running = self.launch_pid.is_some(); + self.send(CoreEvent::LogAppended(crate::dto::LiveLogBatch { + session, + lines, + running, + })); + } +} + +impl Backend for CoreHost { + fn drain_events(&mut self) -> Vec { + self.events.drain(..).collect() + } + + fn dispatch(&mut self, command: AppCommand) { + self.dispatch_command(command); + } + + fn tick(&mut self, elapsed: Duration) { + self.chat.tick(elapsed); + for event in self.chat.drain_events() { + self.events.push_back(event); + } + for _ in 0..MAX_TASKS_PER_TICK { + let Ok(task) = self.rx.try_recv() else { + break; + }; + self.handle(task); + } + self.ticks += 1; + if self.ticks.is_multiple_of(TAIL_POLL_TICKS) { + self.poll_tail(); + } + } +} diff --git a/crates/app/src/host/settings.rs b/crates/app/src/host/settings.rs new file mode 100644 index 0000000..5f3d39b --- /dev/null +++ b/crates/app/src/host/settings.rs @@ -0,0 +1,398 @@ +//! 设置页分组:按 `core::setting` schema 的键生成行,写回走 `SettingStore::set`。 +//! +//! 三件纪律(AGENTS §6): +//! 1. 读写同一份 `<数据根>/config/launcher.json`(与 `ull config set` / AI `set_config` 同源); +//! 2. 不自己拼 JSON,也不绕 schema(非法键/越界值由 `store.set` 拒绝); +//! 3. 这里只认「键名 + 字符串值」,领域语义留在 core。 +//! +//! 分组 id 与设置页子导航同名:`download` / `java` / `data` / `ai` / `mcp`。 + +use serde_json::Value; +use unlucky_core::game::java::JdkVendor; + +use crate::dto::{SettingGroupView, SettingRowView}; + +use super::env::HostEnv; + +/// 全部设置分组(设置页右栏一次拿全,切分组不再请求)。 +pub fn groups(env: &HostEnv) -> Vec { + vec![ + download_group(env), + java_group(env), + data_group(env), + ai_group(env), + mcp_group(env), + ] +} + +/// 读一个键的字符串值(缺省给空串)。 +fn text(env: &HostEnv, key: &str) -> String { + env.store() + .doc() + .effective_str(key) + .unwrap_or_default() + .to_string() +} + +/// 读一个键的整数(缺省给 `fallback`)。 +fn int(env: &HostEnv, key: &str, fallback: i64) -> i64 { + env.store() + .effective(key) + .and_then(Value::as_i64) + .unwrap_or(fallback) +} + +/// 读一个键的布尔(缺省给 `fallback`)。 +fn flag(env: &HostEnv, key: &str, fallback: bool) -> bool { + env.store() + .effective(key) + .and_then(Value::as_bool) + .unwrap_or(fallback) +} + +/// 枚举值在选项表里的序号(找不到给 0)。 +fn selected(options: &[&str], value: &str) -> i32 { + options + .iter() + .position(|option| *option == value) + .map(|index| i32::try_from(index).unwrap_or(0)) + .unwrap_or(0) +} + +/// 下载(D7):并发/重试/地区/镜像偏好/代理。 +fn download_group(env: &HostEnv) -> SettingGroupView { + let concurrency = int(env, "download.concurrency", 8).to_string(); + let retry = int(env, "download.retry_limit", 3).to_string(); + let concurrency_options = ["4", "8", "16"]; + let retry_options = ["0", "1", "3", "5"]; + let regions = ["auto", "cn", "global"]; + let mirrors = ["auto", "official", "mirror"]; + let proxies = ["none", "system", "manual"]; + let region_value = text(env, "download.region"); + let mirror_value = text(env, "download.mirror_preference"); + let proxy_value = text(env, "download.proxy_mode"); + SettingGroupView::new("download", "下载与镜像(D7)") + .with(SettingRowView::choice( + "download.concurrency", + "并发数", + "同时下载的文件数(海量小文件,16 路足够压满带宽)", + &concurrency_options, + selected(&concurrency_options, &concurrency), + )) + .with(SettingRowView::choice( + "download.retry_limit", + "单文件重试次数", + "失败后换源重试的上限", + &retry_options, + selected(&retry_options, &retry), + )) + .with(SettingRowView::choice( + "download.region", + "地区判定", + "auto = 时区+语言+探测;决定是否优先国内镜像", + ®ions, + selected( + ®ions, + if region_value.is_empty() { + "auto" + } else { + ®ion_value + }, + ), + )) + .with(SettingRowView::choice( + "download.mirror_preference", + "镜像偏好", + "auto = 按健康度评分;official = 官方源优先", + &mirrors, + selected( + &mirrors, + if mirror_value.is_empty() { + "auto" + } else { + &mirror_value + }, + ), + )) + .with(SettingRowView::choice( + "download.proxy_mode", + "代理模式", + "none / system(跟随系统)/ manual(下面填地址)", + &proxies, + selected( + &proxies, + if proxy_value.is_empty() { + "none" + } else { + &proxy_value + }, + ), + )) + .with(SettingRowView::text( + "download.proxy_url", + "HTTP 代理", + "manual 模式生效;留空则用系统代理", + &text(env, "download.proxy_url"), + )) + .with(SettingRowView::action( + "download.action.detect_region", + "重新检测地区", + "清掉 24h 地区缓存,下次下载重新判定", + "detect_region", + )) +} + +/// Java(D13/D23):自动安装/内存/选路/发行版优先级。 +fn java_group(env: &HostEnv) -> SettingGroupView { + let memory = int(env, "java.default_memory_mb", 2048).to_string(); + let memory_options = ["1024", "2048", "4096", "8192"]; + let priority: Vec = env + .store() + .effective("java.vendorPriority") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + let priority_text = if priority.is_empty() { + "地区默认序(Temurin → …)".to_string() + } else { + priority.join(" → ") + }; + let roots = super::engine::java_scan_roots(env); + SettingGroupView::new("java", "Java(D13)") + .with(SettingRowView::toggle( + "java.auto_install", + "缺 Java 时自动下载", + "按回退链装一个托管 JDK(基准 JDK17)", + flag(env, "java.auto_install", true), + )) + .with(SettingRowView::choice( + "java.default_memory_mb", + "默认最大堆内存", + "实例没单独指定时用这个值(MB)", + &memory_options, + selected(&memory_options, &memory), + )) + .with(SettingRowView::text( + "java.selected_path", + "指定 Java 可执行文件", + "留空 = 自动挑(托管 > JAVA_HOME > PATH)", + &text(env, "java.selected_path"), + )) + .with(SettingRowView::text( + "java.installRoot", + "托管 JDK 安装根", + "留空 = 按数据目录模式派生(portable 进数据根 / standard 进系统目录)", + &text(env, "java.installRoot"), + )) + .with(SettingRowView::text( + "java.extra_args", + "附加 JVM 参数", + "原样拼到命令行尾部", + &text(env, "java.extra_args"), + )) + .with(SettingRowView::read_only( + "发行版优先级(java.vendorPriority)", + &priority_text, + )) + .with(SettingRowView::read_only( + "托管根", + &roots + .iter() + .map(|root| root.display().to_string()) + .collect::>() + .join(" · "), + )) +} + +/// 数据目录(D6 三模式)。 +fn data_group(env: &HostEnv) -> SettingGroupView { + let modes = ["portable", "standard", "custom"]; + let mode = env.mode().as_str().to_string(); + SettingGroupView::new("data", "数据目录(D6 三模式)") + .with(SettingRowView::choice( + "core.data_mode", + "存储模式", + "便携(运行目录)/ 标准(系统应用数据目录)/ 自定义", + &modes, + selected(&modes, &mode), + )) + .with(SettingRowView::text( + "core.data_custom_root", + "自定义根目录", + "custom 模式生效(绝对路径)", + &text(env, "core.data_custom_root"), + )) + .with(SettingRowView::read_only( + "启动器数据目录", + &env.data.launcher_home.display().to_string(), + )) + .with(SettingRowView::read_only( + "游戏目录", + &env.data.game_home.display().to_string(), + )) + .with(SettingRowView::read_only( + "生效模式", + &format!("{}(改数据根需重启启动器)", env.mode()), + )) +} + +/// AI(D4):开关/端点/模型/YOLO。 +fn ai_group(env: &HostEnv) -> SettingGroupView { + let providers = ["openai_compatible", "anthropic", "ollama"]; + let provider = text(env, "ai.provider"); + SettingGroupView::new("ai", "AI 助手(D4)") + .with(SettingRowView::toggle( + "ai.enabled", + "启用 AI 助手", + "关闭后规则引擎诊断仍可用", + flag(env, "ai.enabled", true), + )) + .with(SettingRowView::choice( + "ai.provider", + "模型端点", + "OpenAI 兼容(含本地 llama.cpp)/ Anthropic / Ollama", + &providers, + selected( + &providers, + if provider.is_empty() { + "openai_compatible" + } else { + &provider + }, + ), + )) + .with(SettingRowView::text( + "ai.base_url", + "端点 base_url", + "如 http://127.0.0.1:8080/v1", + &text(env, "ai.base_url"), + )) + .with(SettingRowView::text( + "ai.model", + "模型名", + "如 deepseek-chat", + &text(env, "ai.model"), + )) + .with(SettingRowView::toggle( + "ai.yolo", + "YOLO 模式", + "跳过确认弹窗(审计照记;账户/凭据操作永不 YOLO)", + flag(env, "ai.yolo", false), + )) +} + +/// M39 的对外 MCP server(默认关、默认只读)。 +fn mcp_group(env: &HostEnv) -> SettingGroupView { + let port = int(env, "agent.mcp.port", 47_823); + SettingGroupView::new("mcp", "MCP server(M39)") + .with(SettingRowView::toggle( + "agent.mcp.enabled", + "对外提供 MCP server", + "127.0.0.1 上的 Streamable HTTP(stdio 需显式启动)", + flag(env, "agent.mcp.enabled", false), + )) + .with(SettingRowView::text( + "agent.mcp.port", + "端口", + "1024~65535,只绑 127.0.0.1", + &port.to_string(), + )) + .with(SettingRowView::toggle( + "agent.mcp.allow_write", + "允许外部客户端写配置", + "默认只读;写仍受 schema 白名单与分级确认约束", + flag(env, "agent.mcp.allow_write", false), + )) + .with(SettingRowView::read_only( + "客户端地址", + &format!("http://127.0.0.1:{port}/mcp(令牌见 agent.mcp.token,AI 不可写)"), + )) +} + +/// 写一条设置。`value` 是字符串形态,按 schema 键的类型转换后交 `store.set`。 +/// +/// # Errors +/// +/// 未知键、值类型不符、越界、落盘失败时返回用户可读文案。 +pub fn set(env: &HostEnv, key: &str, value: &str) -> Result<(), String> { + if key == "download.action.detect_region" { + return detect_region(env); + } + let parsed = parse_value(key, value)?; + let mut store = env.store(); + store + .set(key, parsed) + .map_err(|error| format!("设置 {key} 失败:{error}"))?; + store + .save() + .map_err(|error| format!("保存设置失败:{error}"))?; + Ok(()) +} + +/// 键 → 正确类型的 JSON 值(schema 会再校验范围/枚举)。 +fn parse_value(key: &str, value: &str) -> Result { + if matches!(key, "download.concurrency" | "download.retry_limit") { + let number: u32 = value + .trim() + .parse() + .map_err(|_| format!("{key} 需要整数:{value}"))?; + return Ok(Value::from(number)); + } + if matches!(key, "java.default_memory_mb" | "agent.mcp.port") { + let number: u32 = value + .trim() + .parse() + .map_err(|_| format!("{key} 需要整数:{value}"))?; + return Ok(Value::from(number)); + } + if matches!( + key, + "java.auto_install" + | "ai.enabled" + | "ai.yolo" + | "agent.mcp.enabled" + | "agent.mcp.allow_write" + ) { + let flag = match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "on" | "yes" => true, + "0" | "false" | "off" | "no" => false, + _ => return Err(format!("{key} 需要布尔值:{value}")), + }; + return Ok(Value::Bool(flag)); + } + Ok(Value::String(value.to_string())) +} + +/// 清掉 24h 地区缓存(下次建引擎重新判定)。 +fn detect_region(env: &HostEnv) -> Result<(), String> { + let path = env.layout.cache().join("region.json"); + if path.is_file() { + std::fs::remove_file(&path).map_err(|error| format!("清地区缓存失败:{error}"))?; + } + Ok(()) +} + +/// 设置页「重置发行版优先级」用(清空 enum_list)。 +/// +/// # Errors +/// +/// 落盘失败时返回用户可读文案。 +pub fn clear_vendor_priority(env: &HostEnv) -> Result<(), String> { + let mut store = env.store(); + store + .set("java.vendorPriority", Value::Array(Vec::new())) + .and_then(|()| store.save()) + .map_err(|error| format!("清空发行版优先级失败:{error}"))?; + let _ = JdkVendor::ALL; + Ok(()) +} + +#[cfg(test)] +#[path = "settings_tests.rs"] +mod tests; diff --git a/crates/app/src/host/settings_tests.rs b/crates/app/src/host/settings_tests.rs new file mode 100644 index 0000000..87b5b05 --- /dev/null +++ b/crates/app/src/host/settings_tests.rs @@ -0,0 +1,77 @@ +//! host/settings.rs 的单测(拆文件守 400 行上限)。 + +use super::*; + +fn env_in(dir: &std::path::Path) -> HostEnv { + std::fs::create_dir_all(dir.join(".ULL/config")).expect("config"); + HostEnv::at_root(dir) +} + +#[test] +fn groups_cover_the_six_required_areas() { + let dir = std::env::temp_dir().join(format!("ull-host-settings-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let env = env_in(&dir); + let groups = groups(&env); + let ids: Vec<&str> = groups.iter().map(|group| group.id.as_str()).collect(); + assert_eq!(ids, vec!["download", "java", "data", "ai", "mcp"]); + let keys: Vec<&str> = groups + .iter() + .flat_map(|group| group.rows.iter()) + .map(|row| row.key.as_str()) + .collect(); + for key in [ + "download.region", + "download.mirror_preference", + "java.default_memory_mb", + "core.data_mode", + "agent.mcp.enabled", + "agent.mcp.port", + "ai.base_url", + ] { + assert!(keys.contains(&key), "缺少 {key}"); + } + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn set_parses_types_and_persists() { + let dir = std::env::temp_dir().join(format!("ull-host-settings-w-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let env = env_in(&dir); + + set(&env, "download.concurrency", "16").expect("写并发"); + set(&env, "agent.mcp.enabled", "true").expect("写开关"); + set(&env, "agent.mcp.port", "48000").expect("写端口"); + set(&env, "download.region", "cn").expect("写地区"); + + let groups = groups(&env); + let row = |key: &str| { + groups + .iter() + .flat_map(|g| g.rows.iter()) + .find(|r| r.key == key) + .cloned() + }; + assert_eq!(row("download.concurrency").unwrap().selected, 2); + assert!(row("agent.mcp.enabled").unwrap().flag); + assert_eq!(row("agent.mcp.port").unwrap().value_text, "48000"); + assert_eq!(row("download.region").unwrap().selected, 1); + + assert!(set(&env, "download.concurrency", "abc").is_err()); + assert!(set(&env, "agent.mcp.enabled", "maybe").is_err()); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn detect_region_action_removes_cache() { + let dir = std::env::temp_dir().join(format!("ull-host-settings-r-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let env = env_in(&dir); + let path = env.layout.cache().join("region.json"); + std::fs::create_dir_all(env.layout.cache()).expect("cache"); + std::fs::write(&path, "{}").expect("写缓存"); + set(&env, "download.action.detect_region", "").expect("检测"); + assert!(!path.exists()); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/app/src/host/versions.rs b/crates/app/src/host/versions.rs new file mode 100644 index 0000000..32ddde4 --- /dev/null +++ b/crates/app/src/host/versions.rs @@ -0,0 +1,173 @@ +//! 版本清单(下载页):远端 manifest(12h 缓存)+ 内嵌 unlisted 清单 → 分类好的行。 +//! +//! 分类口径抄 analyse/03 §2.4:官方 `type` 决定 release/snapshot/远古;远古再按 id +//! 前缀拆 classic(`rd-*`)/ old_alpha(`a*`)/ old_beta(`b*`),其余归 unlisted 汇总。 +//! 拉取失败但有旧缓存时用旧缓存(离线可用),全无才报错。 + +use std::collections::BTreeSet; + +use unlucky_core::download::blocking::block_on; +use unlucky_core::download::cache::now_ms; +use unlucky_core::game::install::RemoteSource; +use unlucky_core::game::unlisted; +use unlucky_core::game::version::{ReleaseType, RemoteVersion}; + +use crate::dto::{AncientKind, VersionKind, VersionSummary}; + +use super::env::HostEnv; +use super::job::{spawn, HostTask, JobSender}; + +/// 版本 id / 官方类型 → 下载页的四种归类。 +pub fn kind_of(id: &str, release_type: ReleaseType) -> VersionKind { + match release_type { + ReleaseType::Release => VersionKind::Release, + ReleaseType::Snapshot | ReleaseType::Pending | ReleaseType::Unobfuscated => { + VersionKind::Snapshot + } + ReleaseType::OldAlpha | ReleaseType::OldBeta | ReleaseType::Modified => { + VersionKind::Ancient(ancient_kind(id)) + } + ReleaseType::Unknown => { + if id.starts_with("rd-") || id.starts_with("a") || id.starts_with("b") { + VersionKind::Ancient(ancient_kind(id)) + } else { + VersionKind::Release + } + } + } +} + +/// 远古版子类(`rd-*` → classic;`a1.*` → old_alpha;`b1.*` → old_beta;其余 unlisted)。 +pub fn ancient_kind(id: &str) -> AncientKind { + if id.starts_with("rd-") || id.starts_with("inf-") { + AncientKind::Classic + } else if id.starts_with("a1.") || id.starts_with("a0.") || id.starts_with('a') { + AncientKind::OldAlpha + } else if id.starts_with("b1.") || id.starts_with('b') { + AncientKind::OldBeta + } else { + AncientKind::Unlisted + } +} + +/// 发布日期取 `releaseTime` 的日期部分(缺失时退回 `time`,再退回空串)。 +fn released_of(version: &RemoteVersion) -> String { + version + .release_time + .as_deref() + .or(version.time.as_deref()) + .map(|text| text.get(..10).unwrap_or(text).to_string()) + .unwrap_or_default() +} + +/// 拉清单并翻译成 DTO(安装状态按盘上版本判定)。 +fn fetch(env: &HostEnv) -> Result, String> { + let engine = super::engine::engine(env); + let meta_cache = env.layout.meta_cache(); + let now = now_ms(); + let (mut list, _freshness) = block_on(async { + RemoteSource::new(&engine, &env.game, &meta_cache, now) + .manifest(false) + .await + }) + .map_err(|error| format!("拉取版本清单失败:{error}"))?; + // 内嵌 unlisted 清单先合(同 id 先插入者胜),保证远古版显式可达(09 §G2)。 + unlisted::merge_into(&mut list); + list.sort_newest_first(); + + let installed: BTreeSet = env + .game + .installed_versions() + .unwrap_or_default() + .into_iter() + .collect(); + Ok(list + .versions + .iter() + .map(|version| VersionSummary { + id: version.id.clone(), + kind: kind_of(&version.id, version.version_type), + released: released_of(version), + installed: installed.contains(&version.id), + }) + .collect()) +} + +/// 后台拉清单(网络在后台线程里,UI 不卡)。 +pub fn spawn_list(env: HostEnv, tx: JobSender) { + spawn("versions", move || { + let result = fetch(&env); + let _ = tx.send(HostTask::Versions(result)); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn kinds_follow_official_types() { + assert_eq!( + kind_of("1.21.9", ReleaseType::Release), + VersionKind::Release + ); + assert_eq!( + kind_of("1.21.9-pre3", ReleaseType::Snapshot), + VersionKind::Snapshot + ); + assert_eq!( + kind_of("rd-132211", ReleaseType::OldAlpha), + VersionKind::Ancient(AncientKind::Classic) + ); + assert_eq!( + kind_of("a1.1.1", ReleaseType::OldAlpha), + VersionKind::Ancient(AncientKind::OldAlpha) + ); + assert_eq!( + kind_of("b1.7.3", ReleaseType::OldBeta), + VersionKind::Ancient(AncientKind::OldBeta) + ); + assert_eq!( + kind_of("x-modified", ReleaseType::Modified), + VersionKind::Ancient(AncientKind::Unlisted) + ); + } + + #[test] + fn released_uses_date_part() { + let version = RemoteVersion { + id: "1.20.4".to_string(), + version_type: ReleaseType::Release, + url: String::new(), + time: None, + release_time: Some("2023-12-07T12:00:00+00:00".to_string()), + sha1: None, + size: None, + }; + assert_eq!(released_of(&version), "2023-12-07"); + let empty = RemoteVersion { + id: "x".to_string(), + version_type: ReleaseType::Unknown, + url: String::new(), + time: None, + release_time: None, + sha1: None, + size: None, + }; + assert_eq!(released_of(&empty), ""); + } + + #[test] + fn unlisted_merge_keeps_ancient_versions_reachable() { + // core 的内嵌清单里一定有远古版(`rd-*` / `a1.*` / `b1.*`) + let mut list = unlucky_core::game::version::VersionList::default(); + let added = unlisted::merge_into(&mut list); + assert!(added > 0, "内嵌 unlisted 清单不该为空"); + assert!( + list.versions.iter().any(|v| v.id.starts_with("a1.") + || v.id.starts_with("b1.") + || v.id.starts_with("rd-")), + "远古版必须显式可达" + ); + } +} diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index bea5596..a4eda55 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -32,36 +32,40 @@ #![allow(missing_docs, dead_code)] mod appearance_bind; +mod backend; mod bg_image; mod callbacks; +mod callbacks_pages; mod conv; mod dialogs; mod dto; +mod host; mod mock; mod mock_data; +mod page_sync; mod png; mod prefs; mod router; +mod screenshot; mod settings_bridge; mod state; mod theme_runtime; mod toast; mod ui_bind; +mod wizard; slint::include_modules!(); fn main() -> Result<(), slint::PlatformError> { apply_renderer_flag(); - let app = ui_bind::AppUi::new(MainWindow::new()?); + let app = ui_bind::AppUi::with_options(MainWindow::new()?, screenshot::app_options()); // 截图验收通道(M41 视觉验收;无交互桌面/无 GPU 环境下的唯一可靠取图方式: // 不截屏幕,而是让 Slint 把当前场景渲染进内存像素缓冲再自己编码 PNG)。 - if let Some(path) = screenshot_path() { - if std::env::args().any(|arg| arg == "--screenshot-settings") { - app.open_settings_appearance(); - } - app.render_screenshot(&path)?; + // P3 追加页面选择与中间态(安装进度 / 启动事件流),见 `crate::screenshot`。 + if let Some(options) = screenshot::shot_options() { + screenshot::capture(&app, &options)?; return Ok(()); } @@ -78,17 +82,3 @@ fn apply_renderer_flag() { std::env::set_var("SLINT_BACKEND", "winit-software"); } } - -/// `--screenshot ` / `--screenshot=`:渲染一帧并写 PNG 后退出。 -fn screenshot_path() -> Option { - let mut args = std::env::args().skip(1); - while let Some(arg) = args.next() { - if let Some(value) = arg.strip_prefix("--screenshot=") { - return Some(value.to_owned()); - } - if arg == "--screenshot" { - return args.next(); - } - } - None -} diff --git a/crates/app/src/mock.rs b/crates/app/src/mock.rs index 1d66b10..38d99be 100644 --- a/crates/app/src/mock.rs +++ b/crates/app/src/mock.rs @@ -260,9 +260,9 @@ impl Backend for MockBackend { crate::mock_data::mock_diagnosis(id), ))); } - AppCommand::RefreshInstances - | AppCommand::RefreshLogSessions - | AppCommand::StopAgent => {} + AppCommand::RefreshAll | AppCommand::RefreshLogSessions | AppCommand::StopAgent => {} + // 其余指令(安装向导/账户/Java/设置/通知)由真实 host 承接:mock 不模拟 + _ => {} } } diff --git a/crates/app/src/mock_data.rs b/crates/app/src/mock_data.rs index 9257f0a..edc1fd9 100644 --- a/crates/app/src/mock_data.rs +++ b/crates/app/src/mock_data.rs @@ -22,6 +22,9 @@ pub fn mock_instances() -> Vec { last_played: Some("2026-10-29T19:22:00Z".to_string()), runtime: InstanceRuntime::Idle, icon: None, + isolation: "isolated".to_string(), + version_id: "1.20.4".to_string(), + version_present: true, }, InstanceSummary { id: "vanilla-latest".to_string(), @@ -34,6 +37,9 @@ pub fn mock_instances() -> Vec { last_played: Some("2026-10-28T21:03:00Z".to_string()), runtime: InstanceRuntime::Idle, icon: None, + isolation: "isolated".to_string(), + version_id: "1.21.9".to_string(), + version_present: true, }, InstanceSummary { id: "ancient-1.7.10".to_string(), @@ -46,6 +52,9 @@ pub fn mock_instances() -> Vec { last_played: None, runtime: InstanceRuntime::Idle, icon: None, + isolation: "shared".to_string(), + version_id: "1.7.10".to_string(), + version_present: false, }, InstanceSummary { id: "modded-dev".to_string(), @@ -60,6 +69,9 @@ pub fn mock_instances() -> Vec { reason: "exit0 静默失败".to_string(), }, icon: None, + isolation: "isolated".to_string(), + version_id: "1.21.1-neoforge-21.1.72".to_string(), + version_present: true, }, ] } diff --git a/crates/app/src/page_sync.rs b/crates/app/src/page_sync.rs new file mode 100644 index 0000000..3ed06ea --- /dev/null +++ b/crates/app/src/page_sync.rs @@ -0,0 +1,36 @@ +//! `PageState` global 的状态灌入(从 `ui_bind` 拆出,守 400 行上限)。 +//! +//! 一个函数只做「AppState → Slint global 属性」的搬运,同步发生在每一拍末尾 +//! (`ui_bind::Inner::sync`)。分文件而不是砍内容:这些字段每一行都对应一个页面元素。 + +use crate::conv; +use crate::state::AppState; +use crate::ui_bind::model; +use crate::{MainWindow, PageState}; +use slint::ComponentHandle; + +/// 把页面状态灌进 `PageState`。 +pub(crate) fn apply(ui: &MainWindow, state: &AppState) { + // 导航底部账户 + PageState 全量页面状态(账户/Java/安装/设置/通知/向导) + ui.set_account_name(conv::active_account_name(state).into()); + let page = ui.global::>(); + page.set_accounts(model(conv::accounts(state))); + page.set_active_account(conv::active_account_name(state).into()); + page.set_device(conv::device(state)); + page.set_java_vendors(model(conv::java_vendors(state))); + page.set_java_chain(model(conv::java_chain(state))); + page.set_java_runtimes(model(conv::java_runtimes(state))); + page.set_java_order(state.java.order_text.clone().into()); + page.set_java_region(state.java.region.clone().into()); + page.set_java_priority_source(state.java.priority_source.clone().into()); + page.set_java_message(state.java.message.clone().into()); + page.set_java_busy(state.java.busy); + let major = state.java.majors().last().copied().unwrap_or(17); + page.set_java_target_major(i32::try_from(major).unwrap_or(17)); + page.set_install(conv::install(state)); + page.set_setting_groups(model(conv::setting_groups(state))); + page.set_setting_tab(state.settings_tab.clone().into()); + page.set_notices(model(conv::notices(state))); + page.set_wizard(conv::wizard(state)); + page.set_wizard_versions(model(conv::wizard_versions(state))); +} diff --git a/crates/app/src/screenshot.rs b/crates/app/src/screenshot.rs new file mode 100644 index 0000000..6704033 --- /dev/null +++ b/crates/app/src/screenshot.rs @@ -0,0 +1,160 @@ +//! 截图验收通道的参数解析与流程(`--screenshot*` / `--data-dir` / `--mock`)。 +//! +//! 为什么用进程内渲染而不是截屏:验收机没有交互桌面(`CopyFromScreen` 直接失败), +//! 而 Slint 的软件渲染器能把场景渲进内存像素缓冲(`Window::take_snapshot`), +//! 结果与屏幕上逐像素一致且不需要 GPU(M41 落地的通道,这里只加页面/中间态参数)。 +//! +//! 中间态(安装进度 / 启动事件流)走真实后端:`--screenshot-install <版本>` 会真的发起 +//! 一次安装并停若干毫秒后取图,`--screenshot-launch <实例>` 同理。 + +use std::path::PathBuf; + +use crate::backend::AppOptions; +use crate::dto::AppCommand; +use crate::ui_bind::AppUi; + +/// 一次截图请求。 +#[derive(Debug, Clone, Default)] +pub struct ShotOptions { + /// 输出 PNG 路径 + pub path: String, + /// 页面名(instances / download / java / logs / settings / account / appearance / agent) + pub page: Option, + /// 打开新建实例向导 + pub wizard: bool, + /// 发起一次真实安装并停 `dwell_ms` 后取图(中间态) + pub install: Option, + /// 发起一次真实启动并停 `dwell_ms` 后取图(中间态) + pub launch: Option, + /// 打开一个日志会话(日志页有数据态:日志行 + 诊断卡片) + pub session: Option, + /// 中间态停留毫秒(默认 2500) + pub dwell_ms: u64, +} + +impl ShotOptions { + /// 中间态默认停留时长。 + pub const DEFAULT_DWELL_MS: u64 = 2_500; +} + +/// 从命令行读启动选项(数据根 / mock)。 +pub fn app_options() -> AppOptions { + AppOptions { + data_root: value_of("--data-dir").map(PathBuf::from), + mock: flag("--mock"), + } +} + +/// 从命令行读截图请求;没有 `--screenshot` 时返回 `None`。 +pub fn shot_options() -> Option { + let path = value_of("--screenshot")?; + let page = value_of("--screenshot-page"); + let mut options = ShotOptions { + path, + page: if flag("--screenshot-settings") { + Some("appearance".to_string()) + } else { + page + }, + wizard: flag("--screenshot-wizard"), + install: value_of("--screenshot-install"), + launch: value_of("--screenshot-launch"), + session: value_of("--screenshot-session"), + dwell_ms: value_of("--screenshot-dwell") + .and_then(|value| value.parse().ok()) + .unwrap_or(ShotOptions::DEFAULT_DWELL_MS), + }; + if options.dwell_ms < 500 { + options.dwell_ms = 500; + } + options.page = options.page.filter(|page| !page.trim().is_empty()); + Some(options) +} + +/// 执行截图流程:切页 → (可选)中间态 → 渲染 PNG。 +/// +/// # Errors +/// 窗口显示/快照/写文件失败时返回错误。 +pub fn capture(app: &AppUi, options: &ShotOptions) -> Result<(), slint::PlatformError> { + if let Some(page) = &options.page { + app.open_page(page); + // 先给后端一次全量刷新:版本清单要在后台线程里拉(12h 缓存), + // 下载页/Java 页/向导需要它才有「有数据态」。 + let warmup = if matches!(page.as_str(), "download" | "java") { + 4_000 + } else { + 1_200 + }; + app.dispatch_and_run(AppCommand::RefreshAll, warmup); + } + if options.wizard { + app.open_wizard(); + } + if let Some(version) = &options.install { + app.dispatch_and_run( + AppCommand::InstallVersion(version.clone()), + options.dwell_ms, + ); + } + if let Some(session) = &options.session { + app.open_session(session); + app.dispatch_and_run(AppCommand::RefreshLogSessions, options.dwell_ms.min(1_000)); + } + if let Some(instance) = &options.launch { + app.dispatch_and_run( + AppCommand::LaunchInstance(instance.clone()), + options.dwell_ms, + ); + } + app.render_screenshot(&options.path)?; + // 启动中间态截完就把进程收掉:截图进程退出后子进程会变孤儿, + // 所以在取图之后显式 stop(kill_tree),不留给系统。 + if let Some(instance) = &options.launch { + app.dispatch_and_run(AppCommand::StopInstance(instance.clone()), 1_500); + } + Ok(()) +} + +/// `--flag` 是否存在。 +pub fn flag(name: &str) -> bool { + std::env::args().skip(1).any(|arg| arg == name) +} + +/// `--key value` / `--key=value` 的取值。 +pub fn value_of(name: &str) -> Option { + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + if let Some(value) = arg.strip_prefix(&format!("{name}=")) { + return Some(value.to_string()); + } + if arg == name { + return args.next(); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_dwell_is_a_visible_mid_state() { + const DEFAULT: u64 = ShotOptions::DEFAULT_DWELL_MS; + let dwell = DEFAULT; + assert!(dwell >= 1_000, "中间态至少要停 1 秒才看得见进度"); + } + + #[test] + fn options_without_screenshot_are_none() { + // 测试进程没有 --screenshot 参数 + assert!( + shot_options().is_none() || std::env::args().any(|arg| arg.contains("--screenshot")) + ); + } + + #[test] + fn value_of_ignores_absent_flags() { + assert!(value_of("--definitely-not-a-real-flag").is_none()); + } +} diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs index 3294358..97760c4 100644 --- a/crates/app/src/state.rs +++ b/crates/app/src/state.rs @@ -5,12 +5,14 @@ use crate::dialogs::DialogQueue; use crate::dto::{ - AgentEvent, AppEvent, ChatMessage, ChatRole, CoreEvent, DiagnosisReport, InstanceRuntime, - InstanceSummary, LogLine, LogSession, Progress, ToolCallView, VersionSummary, + AccountView, AgentEvent, AppEvent, ChatMessage, ChatRole, CoreEvent, DeviceLoginView, + DiagnosisReport, InstallProgressView, InstanceRuntime, InstanceSummary, JavaPageView, LogLine, + LogSession, NoticeView, Progress, SettingGroupView, ToolCallView, VersionSummary, }; use crate::prefs::UiPrefs; use crate::router::{AiPanelMode, Route, Router}; -use crate::toast::ToastCenter; +use crate::toast::{ToastCenter, ToastKind}; +use crate::wizard::WizardState; /// AI 会话状态(右侧栏与独立页共享同一份,切换不丢)。 #[derive(Debug, Clone, PartialEq, Eq)] @@ -75,6 +77,9 @@ pub fn grid_positions(count: usize, columns: usize) -> Vec<(usize, usize)> { .collect() } +/// 实时日志最多保留的行数(滚动窗口;更多行由日志页的筛选/历史会话承担)。 +pub const MAX_LIVE_LINES: usize = 2_000; + /// 应用全局状态。 #[derive(Debug)] pub struct AppState { @@ -106,6 +111,24 @@ pub struct AppState { pub installed_only: bool, /// 下载页版本号筛选词 pub download_query: String, + /// 账户列表(三轨统一视图;凭据不在其中) + pub accounts: Vec, + /// 微软设备码登录状态 + pub device_login: DeviceLoginView, + /// Java 页快照(发行版 / 回退链 / 运行时) + pub java: JavaPageView, + /// 安装进度(None = 没有安装任务) + pub install: Option, + /// 设置分组(设置页右栏) + pub settings_groups: Vec, + /// 当前选中的设置分组(download / java / data / ai / mcp) + pub settings_tab: String, + /// 横幅通知 + pub notices: Vec, + /// 新建实例向导 + pub wizard: WizardState, + /// 正在实时尾随的会话 + pub live_session: Option, } impl Default for AppState { @@ -132,6 +155,15 @@ impl AppState { instance_query: String::new(), installed_only: false, download_query: String::new(), + accounts: Vec::new(), + device_login: DeviceLoginView::new(), + java: JavaPageView::new(), + install: None, + settings_groups: Vec::new(), + settings_tab: "download".to_string(), + notices: Vec::new(), + wizard: WizardState::new(), + live_session: None, } } @@ -186,12 +218,51 @@ impl AppState { } CoreEvent::VersionListChanged(versions) => self.versions = versions, CoreEvent::LogSessionsChanged(sessions) => self.log_sessions = sessions, - CoreEvent::LogLinesChanged(lines) => self.log_lines = lines, + CoreEvent::LogLinesChanged(lines) => { + self.log_lines = lines; + self.live_session = None; + } + CoreEvent::LogAppended(batch) => self.append_live_lines(&batch), + CoreEvent::LaunchFinished(finished) => { + self.live_session = None; + self.selected_session = Some(finished.session.clone()); + let kind = if finished.outcome == crate::dto::LogOutcome::Ok { + ToastKind::Success + } else { + ToastKind::Error + }; + self.toasts.push(kind, finished.summary); + } CoreEvent::DiagnosisReady(report) => self.diagnosis = Some(report), + CoreEvent::AccountsChanged(accounts) => self.accounts = accounts, + CoreEvent::DeviceLoginChanged(view) => { + if view.phase == crate::dto::DeviceLoginPhase::Succeeded { + self.toasts + .push(ToastKind::Success, format!("已登录:{}", view.account_name)); + } + if view.phase == crate::dto::DeviceLoginPhase::Failed { + self.toasts.push(ToastKind::Error, view.message.clone()); + } + self.device_login = view; + } + CoreEvent::JavaChanged(page) => self.java = page, + CoreEvent::InstallProgress(progress) => match progress { + Some(progress) => { + let terminal = progress.stage.is_terminal(); + self.install = Some(progress); + if terminal { + self.wizard.open = false; + self.wizard.submitting = false; + } + } + None => self.install = None, + }, + CoreEvent::SettingsChanged(groups) => self.settings_groups = groups, + CoreEvent::NoticesChanged(notices) => self.notices = notices, } } - /// agent 事件。 + /// agent 事件(AI 会话的流式消息与工具调用)。 pub fn apply_agent(&mut self, event: AgentEvent) { match event { AgentEvent::MessageAppended(message) => { @@ -242,6 +313,25 @@ impl AppState { } } + /// 实时日志增量:只追加,封顶 [`MAX_LIVE_LINES`] 行(滚动窗口)。 + fn append_live_lines(&mut self, batch: &crate::dto::LiveLogBatch) { + if self + .live_session + .as_deref() + .is_some_and(|session| session != batch.session) + { + // 换了一个会话在跑:清掉上一场的行,避免两场日志混在一起 + self.log_lines.clear(); + } + self.live_session = batch.running.then(|| batch.session.clone()); + self.selected_session = Some(batch.session.clone()); + self.log_lines.extend(batch.lines.iter().cloned()); + if self.log_lines.len() > MAX_LIVE_LINES { + let drop = self.log_lines.len() - MAX_LIVE_LINES; + self.log_lines.drain(..drop); + } + } + /// 按搜索词过滤后的磁贴网格(顺序稳定 = 实例列表顺序)。 pub fn tiles(&self) -> Vec> { let query = self.instance_query.trim().to_lowercase(); diff --git a/crates/app/src/state_tests.rs b/crates/app/src/state_tests.rs index 9928b12..6001569 100644 --- a/crates/app/src/state_tests.rs +++ b/crates/app/src/state_tests.rs @@ -23,6 +23,9 @@ fn instance(id: &str, name: &str) -> InstanceSummary { last_played: None, runtime: InstanceRuntime::Idle, icon: None, + isolation: "isolated".to_string(), + version_id: "1.20.4".to_string(), + version_present: true, } } diff --git a/crates/app/src/toast.rs b/crates/app/src/toast.rs index 5d8ef8e..d2d9bd3 100644 --- a/crates/app/src/toast.rs +++ b/crates/app/src/toast.rs @@ -3,11 +3,13 @@ //! 与模态对话框队列([`crate::dialogs`])分开:Toast 不阻塞、不排序、可叠加, //! 只做「先进先出 + 上限裁剪 + TTL 到期」。UI 侧由 Rust 定时器每帧调用 [`ToastCenter::expire`]。 +use serde::{Deserialize, Serialize}; + /// Toast 标识。 pub type ToastId = u64; /// Toast 语义色。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ToastKind { /// 一般信息 Info, @@ -20,7 +22,7 @@ pub enum ToastKind { } /// 一条 Toast;`remaining_ms` 由 [`ToastCenter::expire`] 递减。 -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Toast { /// 队列内唯一 id(由 [`ToastCenter::push_request`] 重新编号) pub id: ToastId, diff --git a/crates/app/src/ui_bind.rs b/crates/app/src/ui_bind.rs index 3352b0c..b978ec2 100644 --- a/crates/app/src/ui_bind.rs +++ b/crates/app/src/ui_bind.rs @@ -14,14 +14,14 @@ 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, InstanceId}; -use crate::mock::MockBackend; +use crate::dto::{AppCommand, Backend}; use crate::prefs::BgFill; use crate::settings_bridge::SettingsBridge; use crate::state::AppState; -use crate::{MainWindow, SettingSection}; +use crate::{MainWindow, PageState, SettingSection}; /// 定时器节拍:33ms ≈ 30fps(磁贴进度环 / 流式文本 / Toast TTL 的刷新下限)。 const TICK_MS: u64 = 33; @@ -67,19 +67,28 @@ pub(crate) struct BackgroundKey { } 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 = SettingsBridge::open(); + 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(Box::new(MockBackend::new())), + backend: RefCell::new(backend), bridge: RefCell::new(bridge), background: RefCell::new(BackgroundLayers::default()), background_key: RefCell::new(None), @@ -88,6 +97,7 @@ impl AppUi { }); crate::callbacks::wire(Rc::downgrade(&inner)); + crate::callbacks_pages::wire_pages(Rc::downgrade(&inner)); inner.drain_events(); inner.align_system_theme(); inner.apply_theme(); @@ -134,6 +144,71 @@ impl AppUi { 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::>() + .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` 直接失败), @@ -150,8 +225,8 @@ impl AppUi { .set_size(slint::WindowSize::Logical(slint::LogicalSize::new( 1180.0, 760.0, ))); - // 先走几拍:把 mock 事件(实例/版本/日志)与 33ms 节拍的效果灌进去 - for _ in 0..4 { + // 先走几拍:把后端事件(实例/版本/日志/账户/设置)与 33ms 节拍的效果灌进去 + for _ in 0..8 { self.tick_once(); std::thread::sleep(Duration::from_millis(TICK_MS)); } @@ -245,89 +320,13 @@ impl Inner { 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(items: Vec) -> ModelRc { ModelRc::new(VecModel::from(items)) } - -/// 停止实例的指令构造(阶段 C 的磁贴「停止」键用;P0 只保留 API)。 -#[allow(dead_code)] -pub fn stop_instance(id: &InstanceId) -> AppCommand { - AppCommand::StopInstance(id.clone()) -} - -#[cfg(test)] -mod tests { - use std::time::Duration; - - use super::send_agent_message; - use crate::dto::{Backend, ChatRole}; - use crate::mock::MockBackend; - use crate::state::AppState; - - /// 集成测试(callbacks→state→mock 全链路):发一次提问后 - /// ①用户消息恰出现一次(无本地回显重复)②所有 assistant 气泡收敛(无幽灵气泡)。 - /// - /// 分层单测漏掉这条正是 review P1-1 的根因,故这里用真实 mock 后端跑完整事件流。 - #[test] - fn send_path_has_no_duplicate_user_message_and_no_ghost_stream() { - let mut state = AppState::new(); - let mut backend = MockBackend::new(); - // 先灌入种子事件(实例/版本/日志快照) - for event in backend.drain_events() { - state.apply(event); - } - - send_agent_message(&mut state, &mut backend, "装哪个版本"); - - fn user_count(state: &AppState) -> usize { - state - .ai - .messages - .iter() - .filter(|message| message.role == ChatRole::User) - .count() - } - assert_eq!( - user_count(&state), - 1, - "用户消息必须恰好一条(本地回显会造成两条)" - ); - assert!(state.ai.streaming); - - // 推进到流式收敛(mock 300ms/片 + 工具调用 + 收尾消息) - for _ in 0..12 { - backend.tick(Duration::from_millis(400)); - for event in backend.drain_events() { - state.apply(event); - } - } - - assert_eq!(user_count(&state), 1, "事件回流后用户消息仍应只有一条"); - assert!( - state.ai.messages.iter().all(|message| !message.streaming), - "所有气泡必须收敛(幽灵气泡会永久 streaming)" - ); - assert!(!state.ai.streaming); - assert!( - state - .ai - .messages - .iter() - .any(|message| message.role == ChatRole::Assistant && !message.text.is_empty()), - "增量应累积到 assistant 正文" - ); - assert_eq!( - state - .ai - .messages - .iter() - .filter(|message| message.role == ChatRole::Assistant) - .count(), - 2, - "一轮对话 = 流式回复 + 收尾回复" - ); - } -} diff --git a/crates/app/src/wizard.rs b/crates/app/src/wizard.rs new file mode 100644 index 0000000..2d8322b --- /dev/null +++ b/crates/app/src/wizard.rs @@ -0,0 +1,275 @@ +//! 新建实例向导(09 §G5)的纯状态机:版本 → 加载器 → 实例名 → 确认 → 进度。 +//! +//! 放纯 Rust 而不是 Slint 里:步骤推进、名字合法性、加载器约束都能单测, +//! Slint 只负责画。提交时由 host 转成 [`crate::dto::CreateRequest`] 走安装管线。 + +use crate::dto::LoaderKind; + +/// 向导步骤。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum WizardStep { + /// 选版本 + #[default] + Version, + /// 选加载器 + Loader, + /// 起名 + Name, + /// 确认(显示将下载的项) + Confirm, + /// 进度(提交后由安装进度接管) + Progress, +} + +impl WizardStep { + /// 步骤序号(0 起,用于「第 N 步」文案)。 + pub fn index(self) -> usize { + match self { + Self::Version => 0, + Self::Loader => 1, + Self::Name => 2, + Self::Confirm => 3, + Self::Progress => 4, + } + } + + /// 步骤标题。 + pub fn title(self) -> &'static str { + match self { + Self::Version => "选择版本", + Self::Loader => "选择加载器", + Self::Name => "实例名", + Self::Confirm => "确认", + Self::Progress => "安装中", + } + } + + /// 下一步(进度是终点)。 + pub fn next(self) -> Self { + match self { + Self::Version | Self::Progress => { + if self == Self::Progress { + Self::Progress + } else { + Self::Loader + } + } + Self::Loader => Self::Name, + Self::Name => Self::Confirm, + Self::Confirm => Self::Progress, + } + } + + /// 上一步(版本是起点)。 + pub fn back(self) -> Self { + match self { + Self::Version | Self::Progress => Self::Version, + Self::Loader => Self::Version, + Self::Name => Self::Loader, + Self::Confirm => Self::Name, + } + } +} + +/// 向导状态。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WizardState { + /// 是否打开(全屏模态) + pub open: bool, + /// 当前步骤 + pub step: WizardStep, + /// 选中的版本 id + pub version: String, + /// 选中的加载器 + pub loader: LoaderKind, + /// 加载器版本(空 = 最新) + pub loader_version: String, + /// 实例名草稿 + pub name: String, + /// 校验错误(显示在向导内,不弹 Toast) + pub error: String, + /// 已提交(防重复点) + pub submitting: bool, +} + +impl Default for WizardState { + fn default() -> Self { + Self::new() + } +} + +impl WizardState { + /// 关闭态。 + pub fn new() -> Self { + Self { + open: false, + step: WizardStep::Version, + version: String::new(), + loader: LoaderKind::Vanilla, + loader_version: String::new(), + name: String::new(), + error: String::new(), + submitting: false, + } + } + + /// 打开向导并复位。 + pub fn open() -> Self { + Self { + open: true, + ..Self::new() + } + } + + /// 选了版本:自动把实例名填成版本号(用户不改就用它)。 + pub fn select_version(&mut self, version: &str) { + self.version = version.to_string(); + if self.name.trim().is_empty() { + self.name = version.to_string(); + } + self.error.clear(); + } + + /// 选加载器(原版会清掉加载器版本)。 + pub fn select_loader(&mut self, loader: LoaderKind) { + self.loader = loader; + if loader == LoaderKind::Vanilla { + self.loader_version.clear(); + } + self.error.clear(); + } + + /// 推进到下一步;校验不通过时留在原地并写 `error`。 + pub fn advance(&mut self) -> bool { + match self.step { + WizardStep::Version => { + if self.version.trim().is_empty() { + self.error = "请先选择一个版本".to_string(); + return false; + } + } + WizardStep::Name => { + if let Err(error) = validate_name(&self.name) { + self.error = error; + return false; + } + } + WizardStep::Loader | WizardStep::Confirm | WizardStep::Progress => {} + } + self.error.clear(); + self.step = self.step.next(); + true + } + + /// 回退一步。 + pub fn step_back(&mut self) { + self.error.clear(); + self.step = self.step.back(); + } + + /// 提交前的最后校验(Confirm 步)。 + pub fn ready_to_submit(&self) -> Result<(), String> { + if self.version.trim().is_empty() { + return Err("版本为空".to_string()); + } + validate_name(&self.name)?; + Ok(()) + } + + /// 提交成功后进入进度步(关掉可编辑态)。 + pub fn submitted(&mut self) { + self.submitting = true; + self.step = WizardStep::Progress; + self.error.clear(); + } +} + +/// 实例名合法性(不能空、不能含路径分隔符/控制字符、长度上限 64)。 +pub fn validate_name(name: &str) -> Result<(), String> { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err("实例名不能为空".to_string()); + } + if trimmed.chars().count() > 64 { + return Err("实例名最多 64 个字符".to_string()); + } + if trimmed.contains(['/', '\\', ':', '*', '?', '"', '<', '>', '|']) { + return Err("实例名不能包含 / \\ : * ? \" < > |".to_string()); + } + if trimmed.chars().any(|ch| ch.is_control()) { + return Err("实例名不能包含控制字符".to_string()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn steps_walk_forward_and_back() { + assert_eq!(WizardStep::Version.next(), WizardStep::Loader); + assert_eq!(WizardStep::Loader.next(), WizardStep::Name); + assert_eq!(WizardStep::Name.next(), WizardStep::Confirm); + assert_eq!(WizardStep::Confirm.next(), WizardStep::Progress); + assert_eq!(WizardStep::Progress.next(), WizardStep::Progress); + assert_eq!(WizardStep::Confirm.back(), WizardStep::Name); + assert_eq!(WizardStep::Version.back(), WizardStep::Version); + assert_eq!(WizardStep::Progress.index(), 4); + } + + #[test] + fn version_selection_fills_name_but_does_not_overwrite() { + let mut wizard = WizardState::open(); + assert!(wizard.open); + wizard.select_version("1.20.4"); + assert_eq!(wizard.name, "1.20.4"); + wizard.name = "生存".to_string(); + wizard.select_version("1.21"); + assert_eq!(wizard.name, "生存", "用户改过的名字不该被覆盖"); + } + + #[test] + fn advance_requires_a_version_and_a_valid_name() { + let mut wizard = WizardState::open(); + assert!(!wizard.advance(), "没有版本不能进下一步"); + assert_eq!(wizard.error, "请先选择一个版本"); + + wizard.select_version("1.20.4"); + assert!(wizard.advance()); + assert_eq!(wizard.step, WizardStep::Loader); + assert!(wizard.advance()); + assert_eq!(wizard.step, WizardStep::Name); + + wizard.name = "a/b".to_string(); + assert!(!wizard.advance(), "非法名字不能确认"); + assert!(wizard.error.contains("不能包含")); + + wizard.name = "生存".to_string(); + assert!(wizard.advance()); + assert_eq!(wizard.step, WizardStep::Confirm); + assert!(wizard.ready_to_submit().is_ok()); + wizard.submitted(); + assert_eq!(wizard.step, WizardStep::Progress); + assert!(wizard.submitting); + } + + #[test] + fn loader_selection_clears_loader_version_for_vanilla() { + let mut wizard = WizardState::open(); + wizard.loader_version = "0.16.5".to_string(); + wizard.select_loader(LoaderKind::Fabric); + assert_eq!(wizard.loader_version, "0.16.5"); + wizard.select_loader(LoaderKind::Vanilla); + assert_eq!(wizard.loader, LoaderKind::Vanilla); + assert!(wizard.loader_version.is_empty()); + } + + #[test] + fn name_validation_rejects_long_and_control_input() { + assert!(validate_name("生存").is_ok()); + assert!(validate_name("").is_err()); + assert!(validate_name(&"x".repeat(65)).is_err()); + assert!(validate_name("a\u{7}b").is_err()); + } +} diff --git a/crates/app/ui/components/install-panel.slint b/crates/app/ui/components/install-panel.slint new file mode 100644 index 0000000..73e0e53 --- /dev/null +++ b/crates/app/ui/components/install-panel.slint @@ -0,0 +1,73 @@ +// 安装进度卡片(下载页底部 / 向导进度步共用):阶段 + 进度条 + 文件/字节/速度/剩余 + 取消。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { UiInstallProgress, InstallStage } from "../models.slint"; +import { UiText, TextRole } from "../components/label.slint"; +import { ProgressBar } from "../components/progress-bar.slint"; +import { AppIcon, IconKind } from "../components/icons.slint"; +import { MetroButton, ButtonKind } from "../widgets/button.slint"; + +export component InstallPanel inherits Rectangle { + in property data; + callback cancel(); + + height: 96px; + visible: root.data.visible; + background: Theme.bg-surface; + border-width: 1px; + border-color: Theme.stroke; + + VerticalLayout { + padding: Metrics.gap-3; + spacing: Metrics.gap-1; + + HorizontalLayout { + spacing: Metrics.gap-2; + + AppIcon { + icon-size: 16px; + kind: root.data.failed ? IconKind.error + : root.data.terminal ? IconKind.check + : IconKind.download; + tint: root.data.failed ? Theme.state-danger + : root.data.terminal ? Theme.state-success + : Theme.accent; + } + UiText { + role: TextRole.bodyStrong; + text: root.data.instance + " · " + root.data.loader; + horizontal-stretch: 1; + } + UiText { + role: TextRole.caption; + text: root.data.stage_label; + color: root.data.failed ? Theme.state-danger : Theme.fg-secondary; + } + if !root.data.terminal : MetroButton { + kind: ButtonKind.subtle; + text: "取消"; + button-height: 24px; + clicked => { root.cancel(); } + } + } + + ProgressBar { + ratio: root.data.ratio; + known: root.data.known; + } + + HorizontalLayout { + spacing: Metrics.gap-3; + UiText { + role: TextRole.caption; + text: root.data.message; + horizontal-stretch: 1; + overflow: elide; + } + UiText { role: TextRole.caption; text: root.data.files_label; } + UiText { role: TextRole.caption; text: root.data.bytes_label; } + UiText { role: TextRole.caption; text: root.data.speed; color: Theme.accent; } + UiText { role: TextRole.caption; text: root.data.eta; } + } + } +} diff --git a/crates/app/ui/components/notice-bar.slint b/crates/app/ui/components/notice-bar.slint new file mode 100644 index 0000000..895d432 --- /dev/null +++ b/crates/app/ui/components/notice-bar.slint @@ -0,0 +1,57 @@ +// 横幅通知条:隔离档位切换提示(M30 ModeChangeWarning)/ 启动失败 / 启动告警。 +// 与 Toast 的分工:Toast 是「刚发生了什么」,通知条是「现在需要你处理」(用户点了才收)。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { UiNotice, NoticeKind } from "../models.slint"; +import { PageState } from "../page_state.slint"; +import { UiText, TextRole } from "../components/label.slint"; +import { AppIcon, IconKind } from "../components/icons.slint"; +import { MetroButton, ButtonKind } from "../widgets/button.slint"; + +// 横幅通知:隔离档位切换提示(M30 ModeChangeWarning)/ 启动失败 / 启动告警。 +// 与 Toast 的分工:Toast 是「刚发生了什么」,通知条是「现在需要你处理」。 +export component NoticeBar inherits Rectangle { + in property <[UiNotice]> notices; + + visible: root.notices.length > 0; + height: root.notices.length > 0 ? 48px : 0px; + background: Theme.state-warn-soft; + + HorizontalLayout { + padding-left: Metrics.gap-4; + padding-right: Metrics.gap-3; + spacing: Metrics.gap-2; + + for notice in root.notices : Rectangle { + horizontal-stretch: 1; + background: transparent; + HorizontalLayout { + spacing: Metrics.gap-2; + AppIcon { + icon-size: 16px; + kind: notice.kind == NoticeKind.error ? IconKind.error : IconKind.warn; + tint: notice.kind == NoticeKind.error ? Theme.state-danger : Theme.state-warn; + } + VerticalLayout { + vertical-stretch: 1; + spacing: 0px; + UiText { role: TextRole.caption; text: notice.title; horizontal-stretch: 1; } + UiText { + role: TextRole.caption; + text: notice.body; + horizontal-stretch: 1; + overflow: elide; + color: Theme.fg-secondary; + } + } + MetroButton { + kind: ButtonKind.subtle; + text: "知道了"; + button-height: 22px; + clicked => { PageState.notice-dismiss(notice.id); } + } + } + } + } +} + diff --git a/crates/app/ui/components/progress-bar.slint b/crates/app/ui/components/progress-bar.slint new file mode 100644 index 0000000..c9adcfa --- /dev/null +++ b/crates/app/ui/components/progress-bar.slint @@ -0,0 +1,34 @@ +// 细进度条(0.0~1.0):安装进度 / 下载页顶部 / 向导进度共用。 +// 未知总量(known = false)时画一条不确定态的弱色轨道,而不是假装 0%。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; + +export component ProgressBar inherits Rectangle { + in property ratio: 0.0; + in property known: true; + in property bar-height: 6px; + + height: root.bar-height; + border-radius: root.bar-height / 2; + background: Theme.bg-hover; + clip: true; + + Rectangle { + x: 0; + y: 0; + width: root.known ? max(0px, root.width * clamp(root.ratio, 0.0, 1.0)) : 0px; + height: root.height; + border-radius: root.bar-height / 2; + background: Theme.accent; + animate width { duration: 180ms; easing: ease-out; } + } + + if !root.known : Rectangle { + x: 0; + y: 0; + width: root.width / 3; + height: root.height; + border-radius: root.bar-height / 2; + background: Theme.accent-soft; + } +} diff --git a/crates/app/ui/main.slint b/crates/app/ui/main.slint index 7e2e84a..2423fe9 100644 --- a/crates/app/ui/main.slint +++ b/crates/app/ui/main.slint @@ -9,6 +9,8 @@ // 亮/暗双套色板(LightPalette/DarkPalette)、主题三态解析、排版 ramp 与字体栈;M11 的占位 // NavItem/Tile 组件由 `components/nav.slint` 与 `components/tile.slint` 取代,占位内核不再保留。 +// P3:页面状态 global 从根文件再导出一遍,Rust 侧才能拿到 `PageState` 类型。 +export { PageState } from "page_state.slint"; import { AppShell } from "shell.slint"; import { Theme } from "theme/tokens.slint"; import { Metrics } from "theme/metrics.slint"; diff --git a/crates/app/ui/models.slint b/crates/app/ui/models.slint index f1704a3..efcc913 100644 --- a/crates/app/ui/models.slint +++ b/crates/app/ui/models.slint @@ -216,3 +216,149 @@ export struct UiToast { kind: ToastKind, text: string, } + +// ── P3 全量接线的视图模型(账户 / Java / 安装 / 设置分组 / 通知 / 向导)────────── +// 这些类型由 Rust 的 dto 单向转换(`conv/pages.rs`),Slint 只读不写业务真相。 +// 全部经 `page_state.slint` 的 `PageState` global 下发(避免 main/shell 的属性转发膨胀)。 + +export enum AccountKind { + offline, + microsoft, + authlib, +} + +export struct UiAccount { + id: string, + name: string, + kind: AccountKind, + kind_label: string, + profile_id: string, + login_name: string, + created: string, + active: bool, +} + +/// 设备码登录面板(用户码/链接不是凭据,可直接展示)。 +export struct UiDeviceCode { + visible: bool, + busy: bool, + user_code: string, + verification_uri: string, + qr_link: string, + expires: int, + message: string, + phase_label: string, +} + +export enum JavaStepOutcome { + pending, + skip, + failed, + hit, +} + +export struct UiJavaStep { + vendor: string, + outcome: JavaStepOutcome, + outcome_label: string, + detail: string, +} + +export struct UiJavaVendor { + id: string, + display: string, + rank: int, + majors: string, + sources: string, + installed: bool, +} + +export struct UiJavaRuntime { + binary: string, + version: string, + vendor: string, + origin: string, +} + +export enum InstallStage { + planning, + downloading, + installing, + registering, + done, + failed, + cancelled, +} + +export struct UiInstallProgress { + visible: bool, + version: string, + instance: string, + loader: string, + stage: InstallStage, + stage_label: string, + ratio: float, + known: bool, + files_label: string, + bytes_label: string, + speed: string, + eta: string, + message: string, + terminal: bool, + failed: bool, +} + +export enum SettingControl { + toggle, + choice, + text, + readonly, + action, +} + +export struct UiSettingRow { + key: string, + label: string, + description: string, + control: SettingControl, + flag: bool, + selected: int, + options: [string], + value_text: string, + action: string, +} + +export struct UiSettingGroup { + id: string, + title: string, + rows: [UiSettingRow], +} + +export enum NoticeKind { + info, + warn, + error, +} + +export struct UiNotice { + id: string, + kind: NoticeKind, + title: string, + body: string, +} + +export struct UiWizard { + open: bool, + step: int, + step_label: string, + step_index: int, + step_total: int, + version: string, + loader: int, + loader_label: string, + loader_version: string, + name: string, + error: string, + busy: bool, + can-next: bool, +} diff --git a/crates/app/ui/page_state.slint b/crates/app/ui/page_state.slint new file mode 100644 index 0000000..b5811c7 --- /dev/null +++ b/crates/app/ui/page_state.slint @@ -0,0 +1,76 @@ +// P3 全量接线的页面状态:一个导出的 global,Slint 任何页面都能直接读/回推。 +// +// 为什么用 global 而不是继续经 main.slint → shell.slint 逐层转发:属性表已经 100+ 条, +// 再加五页的数据会把 main.slint 顶破 400 行上限(AGENTS §3)。global 在同一个 Slint +// 编译单元内是单例,Rust 侧用 `ui.global::()` 读写,语义与 property 相同。 +// +// 纪律:这里只放「页面要显示什么」与「用户点了什么」,业务真相在 Rust 的 state.rs。 +import { UiAccount, UiDeviceCode, UiJavaVendor, UiJavaStep, UiJavaRuntime, UiInstallProgress, UiSettingGroup, UiNotice, UiWizard, UiVersion } from "models.slint"; + +export global PageState { + // ── 账户页 ──────────────────────────────────────────────── + in-out property <[UiAccount]> accounts; + in property active-account: ""; + in property device; + /// 离线添加表单的输入(不落盘,点「添加」才提交) + in-out property offline-name: ""; + + // ── Java(下载页 Java 分组 / 设置页 Java 分组)────────────── + in property <[UiJavaVendor]> java-vendors; + in property <[UiJavaStep]> java-chain; + in property <[UiJavaRuntime]> java-runtimes; + in property java-order: ""; + in property java-region: ""; + in property java-priority-source: ""; + in property java-message: ""; + in property java-busy: false; + in property java-target-major: 17; + + // ── 安装进度(下载页 + 向导共用)──────────────────────────── + in property install; + /// 下载页右侧视图:0 = 版本列表,1 = Java 运行时(截图通道也从这里切) + in-out property download-tab: 0; + + // ── 设置分组(settings_bridge 同源 launcher.json)────────── + in property <[UiSettingGroup]> setting-groups; + in-out property setting-tab: "download"; + + // ── 通知条 / 向导 ───────────────────────────────────────── + in property <[UiNotice]> notices; + in property wizard; + in property <[UiVersion]> wizard-versions; + + // ── 账户 ────────────────────────────────────────────────── + callback account-add-offline(string); + callback account-remove(string); + callback account-switch(string); + callback msa-begin(); + callback msa-cancel(); + + // ── Java ────────────────────────────────────────────────── + callback java-install(string); + + // ── 设置 ────────────────────────────────────────────────── + callback setting-changed(string, string); + callback setting-action(string); + callback settings-tab-changed(string); + + // ── 通知 ────────────────────────────────────────────────── + callback notice-dismiss(string); + + // ── 向导 ────────────────────────────────────────────────── + callback wizard-open(); + callback wizard-close(); + callback wizard-select-version(string); + callback wizard-select-loader(int); + callback wizard-name-changed(string); + callback wizard-next(); + callback wizard-back(); + callback wizard-confirm(); + callback install-cancel(); + + // ── 日志页:崩溃日志导入 / 手动刷新 ────────────────────────── + in-out property crash-path: ""; + callback crash-import(string); + callback logs-refresh(); +} diff --git a/crates/app/ui/pages/download-java.slint b/crates/app/ui/pages/download-java.slint new file mode 100644 index 0000000..358cd64 --- /dev/null +++ b/crates/app/ui/pages/download-java.slint @@ -0,0 +1,204 @@ +// 下载页 · Java 分组(G2 的第二个分类):四家发行版 + 回退链可视化 + 本地运行时。 +// +// 回退链是「可观测性」核心:安装失败时用户要能看见 Temurin 跳过 → Oracle 失败 → +// Microsoft 命中的每一步原因(D23),而不是只看到一句「下载失败」。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { UiJavaVendor, UiJavaStep, UiJavaRuntime, JavaStepOutcome } from "../models.slint"; +import { PageState } from "../page_state.slint"; +import { UiText, TextRole } from "../components/label.slint"; +import { Badge } from "../components/badge.slint"; +import { AppIcon, IconKind } from "../components/icons.slint"; +import { MetroButton, ButtonKind } from "../widgets/button.slint"; +import { ScrollView } from "std-widgets.slint"; + +component ChainStep inherits Rectangle { + in property step; + + height: 26px; + background: transparent; + + HorizontalLayout { + spacing: Metrics.gap-1; + + AppIcon { + icon-size: 12px; + kind: root.step.outcome == JavaStepOutcome.hit ? IconKind.check + : root.step.outcome == JavaStepOutcome.failed ? IconKind.error + : root.step.outcome == JavaStepOutcome.skip ? IconKind.warn + : IconKind.more; + tint: root.step.outcome == JavaStepOutcome.hit ? Theme.state-success + : root.step.outcome == JavaStepOutcome.failed ? Theme.state-danger + : Theme.fg-secondary; + } + UiText { + role: TextRole.caption; + text: root.step.vendor; + width: 72px; + color: root.step.outcome == JavaStepOutcome.hit ? Theme.state-success : Theme.fg-primary; + } + Badge { + text: root.step.outcome_label; + tint: root.step.outcome == JavaStepOutcome.hit ? Theme.state-success + : root.step.outcome == JavaStepOutcome.failed ? Theme.state-danger + : Theme.fg-secondary; + } + UiText { + role: TextRole.caption; + text: root.step.detail; + horizontal-stretch: 1; + overflow: elide; + } + } +} + +component VendorRow inherits Rectangle { + in property data; + callback install(); + + height: 56px; + background: vendor-touch.has-hover ? Theme.bg-hover : transparent; + + HorizontalLayout { + padding-left: Metrics.gap-3; + padding-right: Metrics.gap-3; + spacing: Metrics.gap-2; + + UiText { + role: TextRole.bodyStrong; + text: root.data.rank + ". " + root.data.display; + width: 170px; + } + if root.data.installed : Badge { text: "已装"; tint: Theme.state-success; } + UiText { + role: TextRole.caption; + text: "Java " + root.data.majors; + width: 96px; + } + UiText { + role: TextRole.caption; + text: root.data.sources; + horizontal-stretch: 1; + overflow: elide; + } + MetroButton { + kind: ButtonKind.standard; + text: "安装此家"; + button-height: 26px; + enabled: !PageState.java-busy; + clicked => { root.install(); } + } + } + + vendor-touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.install(); } + } +} + +export component JavaPanel inherits Rectangle { + background: Theme.bg-canvas; + + VerticalLayout { + // 顶栏:地区 / 优先级来源 / 目标大版本 / 一键按回退链安装 + Rectangle { + height: 56px; + background: Theme.bg-canvas; + + HorizontalLayout { + padding-left: Metrics.gap-4; + padding-right: Metrics.gap-4; + spacing: Metrics.gap-3; + + UiText { + role: TextRole.subtitle; + text: "Java 运行时"; + width: 110px; + } + UiText { + role: TextRole.caption; + text: "地区 " + PageState.java-region + " · 优先级 " + PageState.java-priority-source + + " · 目标 Java " + PageState.java-target-major; + horizontal-stretch: 1; + overflow: elide; + } + MetroButton { + kind: ButtonKind.accent; + text: PageState.java-busy ? "安装中…" : "按回退链安装"; + button-height: 28px; + enabled: !PageState.java-busy; + clicked => { PageState.java-install(""); } + } + } + } + + UiText { + x: Metrics.gap-4; + role: TextRole.caption; + text: PageState.java-order; + horizontal-stretch: 1; + overflow: elide; + } + + if PageState.java-message != "" : Rectangle { + height: 30px; + background: Theme.accent-soft; + UiText { + x: Metrics.gap-4; + role: TextRole.caption; + text: PageState.java-message; + overflow: elide; + } + } + + Rectangle { height: 1px; background: Theme.stroke; } + + ScrollView { + vertical-stretch: 1; + + VerticalLayout { + padding-bottom: Metrics.gap-4; + + // 回退链 + UiText { + x: Metrics.gap-4; + role: TextRole.bodyStrong; + text: "回退链"; + } + for step in PageState.java-chain : ChainStep { step: step; } + + UiText { + x: Metrics.gap-4; + role: TextRole.bodyStrong; + text: "四家发行版"; + } + for vendor in PageState.java-vendors : VendorRow { + data: vendor; + install => { PageState.java-install(vendor.id); } + } + + UiText { + x: Metrics.gap-4; + role: TextRole.bodyStrong; + text: "本地已发现"; + } + if PageState.java-runtimes.length == 0 : UiText { + x: Metrics.gap-4; + role: TextRole.caption; + text: "还没有探测到 Java:点上面的「按回退链安装」装一个托管 JDK。"; + } + for runtime in PageState.java-runtimes : Rectangle { + height: 24px; + HorizontalLayout { + padding-left: Metrics.gap-4; + spacing: Metrics.gap-2; + UiText { role: TextRole.caption; text: "Java " + runtime.version; width: 120px; } + UiText { role: TextRole.caption; text: runtime.vendor; width: 96px; } + UiText { role: TextRole.caption; text: runtime.origin; width: 80px; } + UiText { role: TextRole.caption; text: runtime.binary; horizontal-stretch: 1; overflow: elide; } + } + } + } + } + } +} diff --git a/crates/app/ui/pages/download.slint b/crates/app/ui/pages/download.slint index d3425b2..d7d14e3 100644 --- a/crates/app/ui/pages/download.slint +++ b/crates/app/ui/pages/download.slint @@ -1,14 +1,19 @@ // G2 下载页(09 §2 P0·G2 / Q24-4 定案):页内独立左侧分类栏(常驻,非 tab)+ 右侧版本列表。 // 远古版入口显式可达(ULL 对 HMCL「隐藏」行为的纠偏),下挂 classic/alpha/beta/unlisted 子类树, // 进入远古版分类时顶部提示兼容性 + 每行带「远古版」徽标(防误装)。 +// +// P3 接线:右侧多一个「Java 运行时」视图(四家发行版 + 回退链),页面底部常驻安装进度条。 import { Theme } from "../theme/tokens.slint"; import { Metrics } from "../theme/metrics.slint"; import { UiVersion, VersionKind, DownloadCategory } from "../models.slint"; +import { PageState } from "../page_state.slint"; import { UiText, TextRole } from "../components/label.slint"; import { AppIcon, IconKind } from "../components/icons.slint"; import { Badge } from "../components/badge.slint"; import { MetroButton, ButtonKind } from "../widgets/button.slint"; import { SearchField, CheckBox } from "../widgets/inputs.slint"; +import { InstallPanel } from "../components/install-panel.slint"; +import { JavaPanel } from "download-java.slint"; import { ListView } from "std-widgets.slint"; component CategoryRow inherits Rectangle { @@ -112,6 +117,8 @@ export component DownloadPage inherits Rectangle { in-out property query: ""; /// 远古版子类树是否展开(默认展开,显式可达) in-out property ancient-expanded: true; + /// 右侧视图:0 = 版本列表,1 = Java 运行时 + in-out property view-tab <=> PageState.download-tab; callback category-changed(DownloadCategory); callback install(string); @@ -123,148 +130,191 @@ export component DownloadPage inherits Rectangle { background: Theme.bg-canvas; - HorizontalLayout { - // ── 页内分类侧栏(常驻)─────────────────────────────────── + VerticalLayout { + // 视图切换(版本 / Java) Rectangle { - width: 200px; + height: 40px; background: Theme.bg-surface; - border-width: 1px; - border-color: Theme.stroke; + HorizontalLayout { + padding-left: Metrics.gap-4; + spacing: Metrics.gap-2; + UiText { + role: TextRole.subtitle; + text: "版本获取"; + width: 96px; + } + MetroButton { + kind: root.view-tab == 0 ? ButtonKind.accent : ButtonKind.subtle; + text: "版本列表"; + button-height: 26px; + clicked => { root.view-tab = 0; } + } + MetroButton { + kind: root.view-tab == 1 ? ButtonKind.accent : ButtonKind.subtle; + text: "Java 运行时"; + button-height: 26px; + clicked => { root.view-tab = 1; } + } + Rectangle { horizontal-stretch: 1; background: transparent; } + if PageState.java-busy : UiText { + role: TextRole.caption; + text: "Java 安装中…"; + color: Theme.accent; + } + } + } + Rectangle { height: 1px; background: Theme.stroke; } + if root.view-tab == 0 : HorizontalLayout { + vertical-stretch: 1; + + // ── 页内分类侧栏(常驻)─────────────────────────────────── + Rectangle { + width: 200px; + background: Theme.bg-surface; + border-width: 1px; + border-color: Theme.stroke; + + VerticalLayout { + padding-top: Metrics.gap-2; + + CategoryRow { + label: "正式版 release"; + active: root.category == DownloadCategory.release; + clicked => { root.category-changed(DownloadCategory.release); } + } + CategoryRow { + label: "预览版 snapshot"; + active: root.category == DownloadCategory.snapshot; + clicked => { root.category-changed(DownloadCategory.snapshot); } + } + + // 远古版:可展开的父节点 + 子类树 + CategoryRow { + label: root.ancient-expanded ? "远古版 ▾" : "远古版 ▸"; + active: root.category == DownloadCategory.ancientClassic + || root.category == DownloadCategory.ancientAlpha + || root.category == DownloadCategory.ancientBeta + || root.category == DownloadCategory.ancientUnlisted; + clicked => { root.toggle-ancient(); } + } + if root.ancient-expanded : CategoryRow { + label: "classic(rd-*)"; + sub: true; + active: root.category == DownloadCategory.ancientClassic; + clicked => { root.category-changed(DownloadCategory.ancientClassic); } + } + if root.ancient-expanded : CategoryRow { + label: "old_alpha(a1.*)"; + sub: true; + active: root.category == DownloadCategory.ancientAlpha; + clicked => { root.category-changed(DownloadCategory.ancientAlpha); } + } + if root.ancient-expanded : CategoryRow { + label: "old_beta(b1.*)"; + sub: true; + active: root.category == DownloadCategory.ancientBeta; + clicked => { root.category-changed(DownloadCategory.ancientBeta); } + } + if root.ancient-expanded : CategoryRow { + label: "unlisted 汇总"; + sub: true; + active: root.category == DownloadCategory.ancientUnlisted; + clicked => { root.category-changed(DownloadCategory.ancientUnlisted); } + } + + CategoryRow { + label: "整合包(P1)"; + disabled: true; + } + CategoryRow { + label: "Mod(P1)"; + disabled: true; + } + + Rectangle { vertical-stretch: 1; background: transparent; } + } + } + + // ── 右侧版本列表 ────────────────────────────────────────── VerticalLayout { - padding-top: Metrics.gap-2; + vertical-stretch: 1; - CategoryRow { - label: "正式版 release"; - active: root.category == DownloadCategory.release; - clicked => { root.category-changed(DownloadCategory.release); } - } - CategoryRow { - label: "预览版 snapshot"; - active: root.category == DownloadCategory.snapshot; - clicked => { root.category-changed(DownloadCategory.snapshot); } + Rectangle { + height: Metrics.page-header-height; + background: Theme.bg-canvas; + + HorizontalLayout { + padding-left: Metrics.gap-4; + padding-right: Metrics.gap-4; + spacing: Metrics.gap-3; + + SearchField { + width: 220px; + placeholder: "筛选版本号"; + text <=> root.query; + edited(t) => { root.query-changed(t); } + } + CheckBox { + text: "仅已装"; + checked: root.installed-only; + toggled(v) => { root.installed-only-changed(v); } + } + Rectangle { horizontal-stretch: 1; background: transparent; } + } } - // 远古版:可展开的父节点 + 子类树 - CategoryRow { - label: root.ancient-expanded ? "远古版 ▾" : "远古版 ▸"; - active: root.category == DownloadCategory.ancientClassic + // 远古版兼容性提示条(首次进入显示,显式可达的代价告知) + Rectangle { + height: 44px; + visible: root.category == DownloadCategory.ancientClassic || root.category == DownloadCategory.ancientAlpha || root.category == DownloadCategory.ancientBeta || root.category == DownloadCategory.ancientUnlisted; - clicked => { root.toggle-ancient(); } - } - if root.ancient-expanded : CategoryRow { - label: "classic(rd-*)"; - sub: true; - active: root.category == DownloadCategory.ancientClassic; - clicked => { root.category-changed(DownloadCategory.ancientClassic); } - } - if root.ancient-expanded : CategoryRow { - label: "old_alpha(a1.*)"; - sub: true; - active: root.category == DownloadCategory.ancientAlpha; - clicked => { root.category-changed(DownloadCategory.ancientAlpha); } - } - if root.ancient-expanded : CategoryRow { - label: "old_beta(b1.*)"; - sub: true; - active: root.category == DownloadCategory.ancientBeta; - clicked => { root.category-changed(DownloadCategory.ancientBeta); } - } - if root.ancient-expanded : CategoryRow { - label: "unlisted 汇总"; - sub: true; - active: root.category == DownloadCategory.ancientUnlisted; - clicked => { root.category-changed(DownloadCategory.ancientUnlisted); } + background: Theme.state-warn-soft; + + HorizontalLayout { + padding-left: Metrics.gap-4; + padding-right: Metrics.gap-4; + spacing: Metrics.gap-2; + + AppIcon { kind: IconKind.warn; icon-size: 16px; tint: Theme.state-warn; } + UiText { + role: TextRole.caption; + text: "远古版(classic/alpha/beta)需要旧版 Java 与旧启动参数,装完请先看兼容性说明。"; + wrap: word-wrap; + horizontal-stretch: 1; + } + MetroButton { + kind: ButtonKind.subtle; + text: "说明"; + button-height: 26px; + clicked => { root.ancient-help(); } + } + } } - CategoryRow { - label: "整合包(P1)"; - disabled: true; - } - CategoryRow { - label: "Mod(P1)"; - disabled: true; - } + Rectangle { height: 1px; background: Theme.stroke; } - Rectangle { vertical-stretch: 1; background: transparent; } + ListView { + vertical-stretch: 1; + + for v in root.versions : VersionRow { + data: v; + install => { root.install(v.id); } + } + } } } - // ── 右侧版本列表 ────────────────────────────────────────── - VerticalLayout { + if root.view-tab == 1 : JavaPanel { vertical-stretch: 1; + } - Rectangle { - height: Metrics.page-header-height; - background: Theme.bg-canvas; - - HorizontalLayout { - padding-left: Metrics.gap-4; - padding-right: Metrics.gap-4; - spacing: Metrics.gap-3; - - UiText { - role: TextRole.subtitle; - text: "版本获取"; - width: 96px; - } - SearchField { - width: 220px; - placeholder: "筛选版本号"; - text <=> root.query; - edited(t) => { root.query-changed(t); } - } - CheckBox { - text: "仅已装"; - checked: root.installed-only; - toggled(v) => { root.installed-only-changed(v); } - } - Rectangle { horizontal-stretch: 1; background: transparent; } - } - } - - // 远古版兼容性提示条(首次进入显示,显式可达的代价告知) - Rectangle { - height: 44px; - visible: root.category == DownloadCategory.ancientClassic - || root.category == DownloadCategory.ancientAlpha - || root.category == DownloadCategory.ancientBeta - || root.category == DownloadCategory.ancientUnlisted; - background: Theme.state-warn-soft; - - HorizontalLayout { - padding-left: Metrics.gap-4; - padding-right: Metrics.gap-4; - spacing: Metrics.gap-2; - - AppIcon { kind: IconKind.warn; icon-size: 16px; tint: Theme.state-warn; } - UiText { - role: TextRole.caption; - text: "远古版(classic/alpha/beta)需要旧版 Java 与旧启动参数,装完请先看兼容性说明。"; - wrap: word-wrap; - horizontal-stretch: 1; - } - MetroButton { - kind: ButtonKind.subtle; - text: "说明"; - button-height: 26px; - clicked => { root.ancient-help(); } - } - } - } - - Rectangle { height: 1px; background: Theme.stroke; } - - ListView { - vertical-stretch: 1; - - for v in root.versions : VersionRow { - data: v; - install => { root.install(v.id); } - } - } + // 安装进度(下载页行内安装 / 向导共用同一份进度) + InstallPanel { + data: PageState.install; + cancel => { PageState.install-cancel(); } } } } diff --git a/crates/app/ui/pages/logs.slint b/crates/app/ui/pages/logs.slint index 26f2c42..a7699ea 100644 --- a/crates/app/ui/pages/logs.slint +++ b/crates/app/ui/pages/logs.slint @@ -8,6 +8,7 @@ import { AppIcon, IconKind } from "../components/icons.slint"; import { Badge } from "../components/badge.slint"; import { MetroButton, ButtonKind } from "../widgets/button.slint"; import { SearchField } from "../widgets/inputs.slint"; +import { PageState } from "../page_state.slint"; import { ListView } from "std-widgets.slint"; component SessionRow inherits Rectangle { @@ -106,6 +107,8 @@ export component LogsPage inherits Rectangle { callback ask-ai(string); callback apply-fix(); callback query-changed(string); + /// 重新扫会话索引(手动刷新) + callback refresh(); background: Theme.bg-canvas; @@ -157,30 +160,57 @@ export component LogsPage inherits Rectangle { VerticalLayout { vertical-stretch: 1; + // 会话头:标题 + 实时标记 + 筛选 + 刷新(第一行),崩溃日志导入(第二行) Rectangle { - height: 44px; + height: 84px; background: Theme.bg-surface; - HorizontalLayout { - padding-left: Metrics.gap-3; - padding-right: Metrics.gap-3; - spacing: Metrics.gap-2; + VerticalLayout { + HorizontalLayout { + height: 44px; + padding-left: Metrics.gap-3; + padding-right: Metrics.gap-3; + spacing: Metrics.gap-2; - UiText { - role: TextRole.bodyStrong; - text: root.selected == "" ? "未选择会话" : root.selected; - horizontal-stretch: 1; + UiText { + role: TextRole.bodyStrong; + text: root.selected == "" ? "未选择会话" : root.selected; + horizontal-stretch: 1; + overflow: elide; + } + if root.live : Badge { text: "实时"; tint: Theme.state-success; } + SearchField { + width: 140px; + placeholder: "筛选日志行"; + text <=> root.query; + edited(t) => { root.query-changed(t); } + } + MetroButton { + kind: ButtonKind.subtle; + text: "刷新"; + button-height: 26px; + clicked => { root.refresh(); } + } } - SearchField { - width: 200px; - placeholder: "筛选日志行"; - text <=> root.query; - edited(t) => { root.query-changed(t); } - } - MetroButton { - kind: ButtonKind.subtle; - text: "打开目录"; - button-height: 26px; + + // 崩溃日志导入分析(hs_err_pid*.log / crash-*.txt / latest.log) + HorizontalLayout { + height: 40px; + padding-left: Metrics.gap-3; + padding-right: Metrics.gap-3; + spacing: Metrics.gap-2; + SearchField { + horizontal-stretch: 1; + placeholder: "崩溃日志路径(如 .minecraft/crash-reports/crash-*.txt)"; + text <=> PageState.crash-path; + } + MetroButton { + kind: ButtonKind.standard; + text: "导入分析"; + button-height: 26px; + enabled: PageState.crash-path != ""; + clicked => { PageState.crash-import(PageState.crash-path); } + } } } } diff --git a/crates/app/ui/pages/settings-account.slint b/crates/app/ui/pages/settings-account.slint new file mode 100644 index 0000000..e18c918 --- /dev/null +++ b/crates/app/ui/pages/settings-account.slint @@ -0,0 +1,194 @@ +// 设置 → 账户(D9 / G6):三轨账户列表 + 离线添加 + 微软设备码登录 + 删除/切换。 +// +// 凭据红线:这里能看到的只有「名字 / 轨道 / 公开 UUID / 登录名 / 创建时间」—— +// token 与密码永远不进 UI(Rust 侧的 AccountView 不含这些字段)。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { AccountKind, UiAccount } from "../models.slint"; +import { PageState } from "../page_state.slint"; +import { UiText, TextRole } from "../components/label.slint"; +import { Badge } from "../components/badge.slint"; +import { AppIcon, IconKind } from "../components/icons.slint"; +import { MetroButton, ButtonKind } from "../widgets/button.slint"; +import { SearchField } from "../widgets/inputs.slint"; +import { SettingGroup } from "../widgets/setting-item.slint"; + +component AccountRow inherits Rectangle { + in property data; + callback activate(); + callback remove(); + + height: 56px; + background: root.data.active ? Theme.accent-soft : transparent; + + if root.data.active : Rectangle { + x: 0; + y: 0; + width: 3px; + height: root.height; + background: Theme.accent; + } + + HorizontalLayout { + padding-left: Metrics.gap-3; + padding-right: Metrics.gap-3; + spacing: Metrics.gap-2; + + AppIcon { + icon-size: 16px; + kind: root.data.kind == AccountKind.offline ? IconKind.instances : IconKind.ai; + tint: root.data.active ? Theme.accent : Theme.fg-secondary; + } + VerticalLayout { + spacing: 0px; + vertical-stretch: 1; + UiText { role: TextRole.bodyStrong; text: root.data.name; horizontal-stretch: 1; } + UiText { + role: TextRole.caption; + text: root.data.profile_id + (root.data.login_name == "" ? "" : " · " + root.data.login_name); + horizontal-stretch: 1; + overflow: elide; + } + } + Badge { text: root.data.kind_label; tint: Theme.fg-secondary; } + UiText { role: TextRole.caption; text: root.data.created; width: 108px; } + if !root.data.active : MetroButton { + kind: ButtonKind.standard; + text: "用这个"; + button-height: 24px; + clicked => { root.activate(); } + } + if root.data.active : Badge { text: "启动中"; tint: Theme.accent; } + MetroButton { + kind: ButtonKind.subtle; + text: "删除"; + button-height: 24px; + clicked => { root.remove(); } + } + } +} + +export component AccountSection inherits Rectangle { + background: transparent; + + VerticalLayout { + // 设备码登录面板(Waiting 时显示用户码 + 链接;失败显示原因) + if PageState.device.visible : Rectangle { + height: PageState.device.busy ? 96px : 84px; + background: PageState.device.phase_label == "失败" ? Theme.state-danger-soft : Theme.accent-soft; + + VerticalLayout { + padding: Metrics.gap-3; + spacing: Metrics.gap-1; + + HorizontalLayout { + spacing: Metrics.gap-2; + AppIcon { + icon-size: 16px; + kind: PageState.device.phase_label == "失败" ? IconKind.error : IconKind.ai; + tint: PageState.device.phase_label == "失败" ? Theme.state-danger : Theme.accent; + } + UiText { + role: TextRole.bodyStrong; + text: "微软登录 · " + PageState.device.phase_label; + horizontal-stretch: 1; + } + if PageState.device.busy : MetroButton { + kind: ButtonKind.subtle; + text: "取消"; + button-height: 24px; + clicked => { PageState.msa-cancel(); } + } + if !PageState.device.busy : MetroButton { + kind: ButtonKind.subtle; + text: "关闭"; + button-height: 24px; + clicked => { PageState.msa-cancel(); } + } + } + + if PageState.device.user_code != "" : HorizontalLayout { + spacing: Metrics.gap-3; + UiText { + role: TextRole.title; + text: PageState.device.user_code; + color: Theme.accent; + } + UiText { + role: TextRole.caption; + text: "在浏览器打开 " + PageState.device.verification_uri + " 输入上面的用户码"; + horizontal-stretch: 1; + overflow: elide; + } + } + UiText { + role: TextRole.caption; + text: PageState.device.message; + horizontal-stretch: 1; + wrap: word-wrap; + } + if PageState.device.qr_link != "" : UiText { + role: TextRole.monoSmall; + text: PageState.device.qr_link; + horizontal-stretch: 1; + overflow: elide; + } + } + } + + // 离线添加表单 + SettingGroup { title: "添加离线账户"; } + HorizontalLayout { + padding-left: Metrics.gap-4; + padding-right: Metrics.gap-4; + spacing: Metrics.gap-2; + SearchField { + width: 220px; + text <=> PageState.offline-name; + placeholder: "玩家名(离线 UUID 自动生成)"; + } + MetroButton { + kind: ButtonKind.accent; + text: "添加离线"; + button-height: 28px; + enabled: PageState.offline-name != ""; + clicked => { PageState.account-add-offline(PageState.offline-name); } + } + MetroButton { + kind: ButtonKind.standard; + text: "微软登录(设备码)"; + button-height: 28px; + enabled: !PageState.device.busy; + clicked => { PageState.msa-begin(); } + } + } + + // 账户列表 + SettingGroup { title: "账户(" + PageState.accounts.length + ")"; } + if PageState.accounts.length == 0 : Rectangle { + height: 72px; + VerticalLayout { + alignment: center; + spacing: 2px; + UiText { + role: TextRole.caption; + text: "还没有账户:启动时会用「Player」离线占位。"; + horizontal-alignment: center; + } + } + } + for account in PageState.accounts : AccountRow { + data: account; + activate => { PageState.account-switch(account.id); } + remove => { PageState.account-remove(account.id); } + } + + UiText { + role: TextRole.caption; + x: Metrics.gap-4; + text: "凭据只存在 <数据根>/data/auth/(账号文件与凭据文件分离),界面与 AI 都拿不到 token。"; + horizontal-stretch: 1; + wrap: word-wrap; + } + } +} diff --git a/crates/app/ui/pages/settings-groups.slint b/crates/app/ui/pages/settings-groups.slint new file mode 100644 index 0000000..a8f1c25 --- /dev/null +++ b/crates/app/ui/pages/settings-groups.slint @@ -0,0 +1,117 @@ +// 设置页右栏的「schema 分组」渲染器:下载 / Java / 数据 / AI / MCP 五组。 +// +// 行来自 Rust 侧的 `SettingGroupView`(core::setting schema 的键),这里只按控件类型画: +// 开关 → toggle;分段 → segmented;文本 → text;只读 → 纯文案;动作 → 按钮。 +// 写回一律 `PageState.setting-changed(key, value)` / `setting-action(action)`, +// 由 host 走 `SettingStore::set`(同源 launcher.json)。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { UiSettingGroup, UiSettingRow, SettingControl } from "../models.slint"; +import { PageState } from "../page_state.slint"; +import { UiText, TextRole } from "../components/label.slint"; +import { SettingItem, SettingGroup, ControlKind } from "../widgets/setting-item.slint"; + +component RowView inherits Rectangle { + in property item; + /// 文本编辑的本地缓冲(编辑中不回写模型,失焦/回车才提交) + in-out property draft; + + height: root.item.control == SettingControl.readonly ? 48px + : root.item.description == "" ? 44px : 58px; + background: transparent; + + init => { self.draft = root.item.value_text; } + changed item => { self.draft = root.item.value_text; } + + if root.item.control == SettingControl.toggle : SettingItem { + label: root.item.label; + description: root.item.description; + kind: ControlKind.toggle; + checked: root.item.flag; + toggled(v) => { PageState.setting-changed(root.item.key, v ? "true" : "false"); } + } + + if root.item.control == SettingControl.choice : SettingItem { + label: root.item.label; + description: root.item.description; + kind: ControlKind.segmented; + options: root.item.options; + selected: root.item.selected; + changed(i) => { + if (i >= 0 && i < root.item.options.length) { + PageState.setting-changed(root.item.key, root.item.options[i]); + } + } + } + + if root.item.control == SettingControl.text : SettingItem { + label: root.item.label; + description: root.item.description; + kind: ControlKind.text; + value: root.draft; + edited(t) => { + root.draft = t; + PageState.setting-changed(root.item.key, t); + } + } + + if root.item.control == SettingControl.action : SettingItem { + label: root.item.label; + description: root.item.description; + kind: ControlKind.button; + button-text: "执行"; + clicked => { PageState.setting-action(root.item.action); } + } + + if root.item.control == SettingControl.readonly : HorizontalLayout { + padding-left: Metrics.gap-4; + padding-right: Metrics.gap-4; + spacing: Metrics.gap-3; + + UiText { + role: TextRole.body; + text: root.item.label; + width: 200px; + } + UiText { + role: TextRole.caption; + text: root.item.description; + horizontal-stretch: 1; + wrap: word-wrap; + } + } +} + +export component SettingsGroups inherits Rectangle { + in property tab: ""; + + background: transparent; + + VerticalLayout { + padding-bottom: Metrics.gap-5; + + if PageState.setting-groups.length == 0 : Rectangle { + height: 120px; + VerticalLayout { + alignment: center; + spacing: 2px; + UiText { + role: TextRole.caption; + text: "设置还没加载出来(host 未就绪或数据根不可写)"; + horizontal-alignment: center; + } + } + } + + for group in PageState.setting-groups : Rectangle { + visible: group.id == root.tab; + height: group.rows.length * 58px + 40px; + background: transparent; + + VerticalLayout { + SettingGroup { title: group.title; } + for setting-row in group.rows : RowView { item: setting-row; } + } + } + } +} diff --git a/crates/app/ui/pages/settings.slint b/crates/app/ui/pages/settings.slint index ed176a7..d810995 100644 --- a/crates/app/ui/pages/settings.slint +++ b/crates/app/ui/pages/settings.slint @@ -8,6 +8,9 @@ import { SettingSection, TileSize, ThemeMode, BgFillKind } from "../models.slint import { UiText, TextRole } from "../components/label.slint"; import { SettingItem, SettingGroup, ControlKind } from "../widgets/setting-item.slint"; import { AppearanceSection } from "settings-appearance.slint"; +import { AccountSection } from "settings-account.slint"; +import { SettingsGroups } from "settings-groups.slint"; +import { PageState } from "../page_state.slint"; import { SearchField } from "../widgets/inputs.slint"; import { AboutSlint, ScrollView } from "std-widgets.slint"; @@ -235,101 +238,15 @@ export component SettingsPage inherits Rectangle { toggled(v) => { root.close-to-tray-changed(v); } } - // ── 数据 ──────────────────────────────────────── - if root.section == SettingSection.data : SettingGroup { title: "数据目录(D6 三模式)"; } - if root.section == SettingSection.data : SettingItem { - label: "存储模式"; - description: "便携(运行目录 .ULL/.minecraft)/ 标准(系统应用数据目录)/ 自定义"; - kind: ControlKind.segmented; - options: ["便携", "标准", "自定义"]; - selected: root.data-mode; - changed(i) => { - root.data-mode = i; - root.data-mode-changed(i); - } - } - if root.section == SettingSection.data : SettingItem { - label: "当前路径"; - description: root.data-path; - kind: ControlKind.button; - button-text: "迁移…"; - clicked => { root.migrate-data(); } - } - - // ── 下载 ──────────────────────────────────────── - if root.section == SettingSection.download : SettingGroup { title: "下载(D7)"; } - if root.section == SettingSection.download : SettingItem { - label: "并发数"; - kind: ControlKind.segmented; - options: ["4", "8", "16"]; - selected: root.download-concurrency; - changed(i) => { - root.download-concurrency = i; - root.download-concurrency-changed(i); - } - } - if root.section == SettingSection.download : SettingItem { - label: "地区判定"; - description: "当前:" + root.region-label + "(决定是否走国内镜像)"; - kind: ControlKind.button; - button-text: "重新检测"; - clicked => { root.detect-region(); } - } - - // ── Java ──────────────────────────────────────── - if root.section == SettingSection.java : SettingGroup { title: "Java(D13)"; } - if root.section == SettingSection.java : SettingItem { - label: "Java 管理"; - description: "基准 JDK17 自动下载 · 多版本可选 · 不匹配时黄条提示"; - kind: ControlKind.button; - button-text: "管理多版本"; - clicked => { root.manage-java(); } - } - - // ── 账户 ──────────────────────────────────────── - if root.section == SettingSection.account : SettingGroup { title: "账户(D9)"; } - if root.section == SettingSection.account : SettingItem { - label: "当前账户"; - description: root.account-label; - kind: ControlKind.button; - button-text: "登录 / 切换"; - clicked => { root.login(); } - } - - // ── AI ────────────────────────────────────────── - if root.section == SettingSection.ai : SettingGroup { title: "AI 助手(D4)"; } - if root.section == SettingSection.ai : SettingItem { - label: "模型端点"; - description: "OpenAI 兼容 / 本地 llama.cpp / Anthropic / 自定义"; - kind: ControlKind.segmented; - options: ["OpenAI", "llama.cpp", "Anthropic", "自定义"]; - selected: root.ai-endpoint; - changed(i) => { - root.ai-endpoint = i; - root.ai-endpoint-changed(i); - } - } - if root.section == SettingSection.ai : SettingItem { - label: "YOLO 模式"; - description: "跳过全部确认弹窗(审计照记),账户/凭据操作永不 YOLO"; - kind: ControlKind.toggle; - checked: root.yolo; - toggled(v) => { root.yolo-changed(v); } - } - if root.section == SettingSection.ai : SettingItem { - label: "MCP server"; - description: "允许外部客户端(如 Claude Desktop)调用工具集"; - kind: ControlKind.toggle; - checked: root.mcp-enabled; - toggled(v) => { root.mcp-changed(v); } - } - if root.section == SettingSection.ai : SettingItem { - label: "审计日志"; - description: "AI 的所有写操作都有记录"; - kind: ControlKind.button; - button-text: "查看"; - clicked => { root.open-audit-log(); } - } + // ── 数据 / 下载 / Java / 账户 / AI / MCP ──────────── + // 行来自 core::setting schema(`PageState.setting-groups`),写回走 + // `SettingStore::set`——与 `ull config set` / AI `set_config` 同一份 launcher.json。 + if root.section == SettingSection.data : SettingsGroups { tab: "data"; } + if root.section == SettingSection.download : SettingsGroups { tab: "download"; } + if root.section == SettingSection.java : SettingsGroups { tab: "java"; } + if root.section == SettingSection.ai : SettingsGroups { tab: "ai"; } + if root.section == SettingSection.ai : SettingsGroups { tab: "mcp"; } + if root.section == SettingSection.account : AccountSection { } // ── 代理 ──────────────────────────────────────── if root.section == SettingSection.proxy : SettingGroup { title: "代理"; } diff --git a/crates/app/ui/shell.slint b/crates/app/ui/shell.slint index 7d5e9ce..65770b7 100644 --- a/crates/app/ui/shell.slint +++ b/crates/app/ui/shell.slint @@ -7,6 +7,8 @@ import { RouteKind, TileSize, InstanceView, SettingSection, DownloadCategory, ThemeMode, BgFillKind, UiTile, UiVersion, UiLogSession, UiLogLine, UiDiagnosis, UiChatMessage, UiDialog, UiToast, } from "models.slint"; +import { PageState } from "page_state.slint"; +import { NewInstanceWizard } from "widgets/wizard.slint"; import { TitleBar } from "components/titlebar.slint"; import { Nav } from "components/nav.slint"; import { AiPanel } from "components/ai-panel.slint"; @@ -14,11 +16,13 @@ import { UiText, TextRole } from "components/label.slint"; import { MetroButton, ButtonKind } from "widgets/button.slint"; import { DialogHost } from "widgets/dialog.slint"; import { ToastHost } from "widgets/toast.slint"; +import { NoticeBar } from "components/notice-bar.slint"; import { InstancesPage } from "pages/instances.slint"; import { DownloadPage } from "pages/download.slint"; import { LogsPage } from "pages/logs.slint"; import { SettingsPage } from "pages/settings.slint"; + export component AppShell inherits Rectangle { // ── 窗口 ──────────────────────────────────────────────────── in property app-title: "Unlucky Launcher"; @@ -189,6 +193,10 @@ export component AppShell inherits Rectangle { request-close => { root.request-close(); } } + NoticeBar { + notices: PageState.notices; + } + HorizontalLayout { // 左侧主导航 Nav { @@ -259,6 +267,7 @@ export component AppShell inherits Rectangle { select(id) => { root.log-select(id); } ask-ai(id) => { root.log-ask-ai(id); } apply-fix => { root.log-apply-fix(); } + refresh => { PageState.logs-refresh(); } } if root.route == RouteKind.settings : SettingsPage { @@ -356,6 +365,11 @@ export component AppShell inherits Rectangle { } } + // 新建实例向导(全屏模态;数据来自 PageState) + NewInstanceWizard { + versions: root.versions; + } + // 覆盖层:模态队列(一次一条)+ 右下 Toast DialogHost { current: root.dialog; diff --git a/crates/app/ui/widgets/wizard.slint b/crates/app/ui/widgets/wizard.slint new file mode 100644 index 0000000..fdbc33b --- /dev/null +++ b/crates/app/ui/widgets/wizard.slint @@ -0,0 +1,301 @@ +// 新建实例向导(G5,全屏模态):版本 → 加载器 → 名字 → 确认 → 进度。 +// 步骤推进/校验在 Rust(`crate::wizard`),这里只画当前步与提交按钮。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { UiVersion, VersionKind } from "../models.slint"; +import { PageState } from "../page_state.slint"; +import { UiText, TextRole } from "../components/label.slint"; +import { Badge } from "../components/badge.slint"; +import { AppIcon, IconKind } from "../components/icons.slint"; +import { MetroButton, ButtonKind } from "../widgets/button.slint"; +import { SearchField } from "../widgets/inputs.slint"; +import { InstallPanel } from "../components/install-panel.slint"; +import { ScrollView } from "std-widgets.slint"; + +component StepDot inherits Rectangle { + in property index: 0; + in property label: ""; + + height: 28px; + background: transparent; + + HorizontalLayout { + spacing: Metrics.gap-1; + alignment: center; + Rectangle { + width: 18px; + height: 18px; + border-radius: 9px; + background: PageState.wizard.step >= root.index ? Theme.accent : Theme.bg-hover; + UiText { + role: TextRole.caption; + text: root.index + 1; + horizontal-alignment: center; + color: PageState.wizard.step >= root.index ? Theme.fg-on-accent : Theme.fg-secondary; + } + } + UiText { + role: TextRole.caption; + text: root.label; + color: PageState.wizard.step >= root.index ? Theme.fg-primary : Theme.fg-disabled; + } + } +} + +component VersionChoice inherits Rectangle { + in property data; + callback picked(); + + height: 36px; + background: PageState.wizard.version == root.data.id ? Theme.accent-soft + : (touch.has-hover ? Theme.bg-hover : transparent); + + HorizontalLayout { + padding-left: Metrics.gap-3; + padding-right: Metrics.gap-3; + spacing: Metrics.gap-2; + UiText { role: TextRole.body; text: root.data.id; width: 160px; } + Badge { + text: root.data.kind == VersionKind.release ? "正式版" : "预览版"; + tint: Theme.fg-secondary; + } + UiText { role: TextRole.caption; text: root.data.released; horizontal-stretch: 1; } + if PageState.wizard.version == root.data.id : AppIcon { + kind: IconKind.check; + icon-size: 14px; + tint: Theme.accent; + } + } + + touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.picked(); } + } +} + +component LoaderChoice inherits Rectangle { + in property label: ""; + in property hint: ""; + in property index: 0; + callback picked(); + + height: 48px; + background: PageState.wizard.loader == root.index ? Theme.accent-soft + : (touch.has-hover ? Theme.bg-hover : transparent); + + HorizontalLayout { + padding-left: Metrics.gap-3; + padding-right: Metrics.gap-3; + spacing: Metrics.gap-2; + VerticalLayout { + vertical-stretch: 1; + spacing: 0px; + UiText { role: TextRole.body; text: root.label; } + UiText { role: TextRole.caption; text: root.hint; horizontal-stretch: 1; } + } + if PageState.wizard.loader == root.index : AppIcon { + kind: IconKind.check; + icon-size: 14px; + tint: Theme.accent; + } + } + + touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.picked(); } + } +} + +export component NewInstanceWizard inherits Rectangle { + // 版本列表由 shell 传入(与下载页共用一份,避免二次拉取) + in property <[UiVersion]> versions; + in-out property query: ""; + + visible: PageState.wizard.open; + background: rgba(0, 0, 0, 0.45); + + // 吃掉底层点击(模态) + TouchArea { } + + Rectangle { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: min(parent.width - 80px, 720px); + height: min(parent.height - 60px, 560px); + background: Theme.bg-surface; + border-radius: Metrics.radius-md; + border-width: 1px; + border-color: Theme.stroke; + + VerticalLayout { + // 标题栏 + Rectangle { + height: 52px; + background: transparent; + HorizontalLayout { + padding-left: Metrics.gap-4; + padding-right: Metrics.gap-3; + spacing: Metrics.gap-2; + UiText { + role: TextRole.subtitle; + text: "新建实例 · " + PageState.wizard.step_label; + horizontal-stretch: 1; + } + MetroButton { + kind: ButtonKind.subtle; + text: "关闭"; + button-height: 24px; + clicked => { PageState.wizard-close(); } + } + } + } + + // 步骤指示 + HorizontalLayout { + padding-left: Metrics.gap-4; + spacing: Metrics.gap-3; + StepDot { index: 0; label: "版本"; } + StepDot { index: 1; label: "加载器"; } + StepDot { index: 2; label: "名字"; } + StepDot { index: 3; label: "确认"; } + StepDot { index: 4; label: "进度"; } + } + + Rectangle { height: 1px; background: Theme.stroke; } + + // 步骤内容 + Rectangle { + vertical-stretch: 1; + background: transparent; + + // ① 选版本(只列正式版/快照,远古版走下载页的显式入口) + if PageState.wizard.step == 0 : VerticalLayout { + padding: Metrics.gap-3; + spacing: Metrics.gap-2; + SearchField { + width: 220px; + text <=> root.query; + placeholder: "筛选版本号"; + } + ScrollView { + vertical-stretch: 1; + VerticalLayout { + for v in root.versions : VersionChoice { + visible: !v.ancient; + data: v; + picked => { PageState.wizard-select-version(v.id); } + } + } + } + } + + // ② 选加载器 + if PageState.wizard.step == 1 : VerticalLayout { + padding: Metrics.gap-3; + spacing: 0px; + LoaderChoice { index: 0; label: "原版(Vanilla)"; hint: "不装加载器,最快最稳"; picked => { PageState.wizard-select-loader(0); } } + LoaderChoice { index: 1; label: "Fabric"; hint: "轻量模组加载器(GUI 可直接装)"; picked => { PageState.wizard-select-loader(1); } } + LoaderChoice { index: 2; label: "Quilt"; hint: "Fabric 的分支(GUI 可直接装)"; picked => { PageState.wizard-select-loader(2); } } + LoaderChoice { index: 3; label: "Forge"; hint: "需要 installer processors:本轮请用 ull install"; picked => { PageState.wizard-select-loader(3); } } + LoaderChoice { index: 4; label: "NeoForge"; hint: "需要 installer processors:本轮请用 ull install"; picked => { PageState.wizard-select-loader(4); } } + } + + // ③ 名字 + if PageState.wizard.step == 2 : VerticalLayout { + padding: Metrics.gap-4; + spacing: Metrics.gap-2; + UiText { role: TextRole.body; text: "实例名(默认用版本号)"; } + SearchField { + width: 320px; + text: PageState.wizard.name; + placeholder: "例如:生存 1.20.4"; + edited(t) => { PageState.wizard-name-changed(t); } + } + if PageState.wizard.error != "" : UiText { + role: TextRole.caption; + text: PageState.wizard.error; + color: Theme.state-danger; + } + UiText { + role: TextRole.caption; + text: "不能包含 / \\ : * ? \" < > |,最多 64 个字符。"; + } + } + + // ④ 确认 + if PageState.wizard.step == 3 : VerticalLayout { + padding: Metrics.gap-4; + spacing: Metrics.gap-2; + UiText { role: TextRole.bodyStrong; text: "将创建实例"; } + UiText { role: TextRole.body; text: "版本 " + PageState.wizard.version; } + UiText { role: TextRole.body; text: "加载器 " + PageState.wizard.loader_label; } + UiText { role: TextRole.body; text: "实例名 " + PageState.wizard.name; } + UiText { + role: TextRole.caption; + text: "确认后会按 core 安装管线下载:版本 JSON → 客户端 jar → 库 → 资源索引 → 登记实例。"; + wrap: word-wrap; + } + if PageState.wizard.error != "" : UiText { + role: TextRole.caption; + text: PageState.wizard.error; + color: Theme.state-danger; + } + } + + // ⑤ 进度 + if PageState.wizard.step == 4 : VerticalLayout { + padding: Metrics.gap-3; + spacing: Metrics.gap-2; + InstallPanel { + data: PageState.install; + cancel => { PageState.install-cancel(); } + } + UiText { + role: TextRole.caption; + text: "装完会自动跳到实例页;失败原因会显示在上面。"; + } + } + } + + Rectangle { height: 1px; background: Theme.stroke; } + + // 底部按钮 + Rectangle { + height: 56px; + background: transparent; + HorizontalLayout { + padding: Metrics.gap-4; + spacing: Metrics.gap-2; + Rectangle { horizontal-stretch: 1; background: transparent; } + MetroButton { + kind: ButtonKind.subtle; + text: "上一步"; + button-height: 30px; + enabled: PageState.wizard.step > 0 && PageState.wizard.step < 4; + clicked => { PageState.wizard-back(); } + } + if PageState.wizard.step < 3 : MetroButton { + kind: ButtonKind.accent; + text: "下一步"; + button-height: 30px; + enabled: PageState.wizard.can-next; + clicked => { PageState.wizard-next(); } + } + if PageState.wizard.step == 3 : MetroButton { + kind: ButtonKind.accent; + text: "开始安装"; + button-height: 30px; + enabled: PageState.wizard.can-next && !PageState.wizard.busy; + clicked => { PageState.wizard-confirm(); } + } + if PageState.wizard.step == 4 : MetroButton { + kind: ButtonKind.standard; + text: "后台继续"; + button-height: 30px; + clicked => { PageState.wizard-close(); } + } + } + } + } + } +}