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 执行器上移。
354 lines
14 KiB
Rust
354 lines
14 KiB
Rust
//! 启动全链:实例 → `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<String>,
|
||
}
|
||
|
||
/// 选中并用起来的 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>), 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<VersionJson> {
|
||
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<LibraryInput> = 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<String> = 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<PathBuf, String> {
|
||
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()
|
||
}
|