Unlucky Launcher v0.0.1-alpha
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent), MCP server 接入外部 Agent 工具,GUI 开发中。
This commit is contained in:
commit
bc41dfcf7d
517 files changed
+92844
No files matched your search
@@ -0,0 +1,87 @@
|
||||
//! 内存诊断端口(`DiagnosePort` 的测试替身)。
|
||||
|
||||
use crate::port::{
|
||||
CrashDiagnosis, DiagnosePort, DiagnosisCandidate, ExitSummary, FixAction, FixActionKind,
|
||||
PortResult,
|
||||
};
|
||||
|
||||
/// 内存诊断端口(固定返回一份规则结论)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MockDiagnose {
|
||||
report: Option<CrashDiagnosis>,
|
||||
}
|
||||
|
||||
impl Default for MockDiagnose {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
report: Some(Self::forge_missing_library()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MockDiagnose {
|
||||
/// 构造使用默认结论的端口。
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 让端口对所有会话返回「会话不存在」。
|
||||
#[must_use]
|
||||
pub fn unavailable() -> Self {
|
||||
Self { report: None }
|
||||
}
|
||||
|
||||
/// 默认结论:Forge 缺库(对应 analyse/02 的高发场景)。
|
||||
#[must_use]
|
||||
pub fn forge_missing_library() -> CrashDiagnosis {
|
||||
CrashDiagnosis {
|
||||
session_id: String::new(),
|
||||
source: "rules".to_string(),
|
||||
needs_more_info: false,
|
||||
candidates: vec![DiagnosisCandidate {
|
||||
id: "integrity.forge_missing_library".to_string(),
|
||||
severity: "error".to_string(),
|
||||
category: "integrity".to_string(),
|
||||
confidence: 0.86,
|
||||
message_key: "diagnose.cause.integrity.forge_missing_library".to_string(),
|
||||
message: "Forge 启动缺少 bootstraplauncher 库,通常是版本组件未下载完整"
|
||||
.to_string(),
|
||||
detail: "在实例上重装 Forge 组件(或先用官方启动器补全一次再回来)".to_string(),
|
||||
evidence: vec![
|
||||
"java.lang.NoClassDefFoundError: cpw/mods/bootstraplauncher/BootstrapLauncher"
|
||||
.to_string(),
|
||||
],
|
||||
fix_actions: vec![FixAction {
|
||||
kind: FixActionKind::Reinstall,
|
||||
tool: "component.reinstall".to_string(),
|
||||
label: "重装该版本的 Forge 组件".to_string(),
|
||||
args: [("component".to_string(), "forge".to_string())]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
}],
|
||||
}],
|
||||
secondary: Vec::new(),
|
||||
evidence: vec!["Description: Mod loading failure".to_string()],
|
||||
exit: ExitSummary {
|
||||
code: Some(1),
|
||||
exit_type: Some("application_error".to_string()),
|
||||
silent_failure: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DiagnosePort for MockDiagnose {
|
||||
fn diagnose(&self, session_id: &str) -> PortResult<CrashDiagnosis> {
|
||||
self.report.clone().map_or_else(
|
||||
|| Err(format!("会话 `{session_id}` 不存在")),
|
||||
|report| {
|
||||
Ok(CrashDiagnosis {
|
||||
session_id: session_id.to_string(),
|
||||
..report
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! 实例 / 版本 / Java / 账户四个只读端口的测试替身。
|
||||
|
||||
use crate::port::game::{
|
||||
AccountPort, AccountSummary, GamePort, InstanceSummary, JavaRuntimeSummary, JavaStatus,
|
||||
VersionSummary,
|
||||
};
|
||||
use crate::port::PortResult;
|
||||
|
||||
/// 内存游戏端口:一个 1.20.1 Forge 实例 + 两个已安装版本 + 两个 Java 候选。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MockGame {
|
||||
instances: Vec<InstanceSummary>,
|
||||
versions: Vec<VersionSummary>,
|
||||
java: JavaStatus,
|
||||
fail: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for MockGame {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl MockGame {
|
||||
/// 默认假数据(够跑通 `list_instances` / `list_versions` / `get_java_status`)。
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
instances: vec![InstanceSummary {
|
||||
id: "inst-1".to_string(),
|
||||
name: "1.20.1 Forge".to_string(),
|
||||
version_id: "1.20.1-forge-47.2.20".to_string(),
|
||||
version_present: true,
|
||||
game_dir: None,
|
||||
java_path: Some(r"D:\jdk17\bin\java.exe".to_string()),
|
||||
last_played_at: Some(1_793_304_753_123),
|
||||
}],
|
||||
versions: vec![
|
||||
VersionSummary {
|
||||
id: "1.20.1-forge-47.2.20".to_string(),
|
||||
kind: Some("release".to_string()),
|
||||
instance_count: 1,
|
||||
},
|
||||
VersionSummary {
|
||||
id: "1.21.1".to_string(),
|
||||
kind: Some("release".to_string()),
|
||||
instance_count: 0,
|
||||
},
|
||||
],
|
||||
java: JavaStatus {
|
||||
default_memory_mb: Some(2048),
|
||||
managed_roots: vec![r"D:\ULL\.ULL\data\java-managed".to_string()],
|
||||
candidates: vec![
|
||||
JavaRuntimeSummary {
|
||||
binary: r"D:\ULL\.ULL\data\java-managed\jdk-17-temurin\bin\java.exe"
|
||||
.to_string(),
|
||||
version: Some("17.0.13".to_string()),
|
||||
major: Some(17),
|
||||
vendor: Some("temurin".to_string()),
|
||||
origin: "managed".to_string(),
|
||||
},
|
||||
JavaRuntimeSummary {
|
||||
binary: "/usr/bin/java".to_string(),
|
||||
version: Some("21.0.2".to_string()),
|
||||
major: Some(21),
|
||||
vendor: None,
|
||||
origin: "PATH".to_string(),
|
||||
},
|
||||
],
|
||||
},
|
||||
fail: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 让三个查询全部返回同一个错误(测端口错误透传)。
|
||||
#[must_use]
|
||||
pub fn failing(message: &str) -> Self {
|
||||
Self {
|
||||
fail: Some(message.to_string()),
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn fail(&self) -> Option<String> {
|
||||
self.fail.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl GamePort for MockGame {
|
||||
fn list_instances(&self) -> PortResult<Vec<InstanceSummary>> {
|
||||
self.fail().map_or_else(|| Ok(self.instances.clone()), Err)
|
||||
}
|
||||
|
||||
fn list_versions(&self) -> PortResult<Vec<VersionSummary>> {
|
||||
self.fail().map_or_else(|| Ok(self.versions.clone()), Err)
|
||||
}
|
||||
|
||||
fn java_status(&self) -> PortResult<JavaStatus> {
|
||||
self.fail().map_or_else(|| Ok(self.java.clone()), Err)
|
||||
}
|
||||
}
|
||||
|
||||
/// 内存账户端口:一个离线账户。**只有公开字段**,凭据无处可放。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MockAccounts {
|
||||
accounts: Vec<AccountSummary>,
|
||||
fail: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for MockAccounts {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl MockAccounts {
|
||||
/// 一个离线账户(Steve)。
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
accounts: vec![AccountSummary {
|
||||
id: "acc-1".to_string(),
|
||||
kind: "offline".to_string(),
|
||||
profile_id: "8667ba71b85a4004af54457a9734eed7".to_string(),
|
||||
profile_name: "Steve".to_string(),
|
||||
login_name: None,
|
||||
created_at_ms: 1_793_304_753_123,
|
||||
}],
|
||||
fail: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 空账户列表(测「没有账户」的提示文案)。
|
||||
#[must_use]
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
accounts: Vec::new(),
|
||||
fail: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 让查询失败(测端口错误透传)。
|
||||
#[must_use]
|
||||
pub fn failing(message: &str) -> Self {
|
||||
Self {
|
||||
fail: Some(message.to_string()),
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AccountPort for MockAccounts {
|
||||
fn list_accounts(&self) -> PortResult<Vec<AccountSummary>> {
|
||||
self.fail
|
||||
.clone()
|
||||
.map_or_else(|| Ok(self.accounts.clone()), Err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! 内存配置端口(`SettingPort` 的测试替身)。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::confirm::Sensitivity;
|
||||
use crate::port::{PortResult, SettingChange, SettingKey, SettingPort};
|
||||
|
||||
/// 内存配置端口。
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MockSettings {
|
||||
keys: Vec<SettingKey>,
|
||||
values: Mutex<BTreeMap<String, Value>>,
|
||||
fail_set: Mutex<Option<String>>,
|
||||
set_calls: Mutex<usize>,
|
||||
}
|
||||
|
||||
/// 与 `PlanDoc/04` §3 / M12 `setting-接口约定.md` §8 的键表一致的默认白名单
|
||||
/// (覆盖三档敏感度;`ai.api_key` 这类凭据是 `writable=false`,**故意不在这里**)。
|
||||
fn default_keys() -> Vec<SettingKey> {
|
||||
fn key(
|
||||
path: &str,
|
||||
sensitivity: Sensitivity,
|
||||
value_type: &str,
|
||||
description: &str,
|
||||
) -> SettingKey {
|
||||
SettingKey {
|
||||
path: path.to_string(),
|
||||
sensitivity,
|
||||
value_type: value_type.to_string(),
|
||||
description: description.to_string(),
|
||||
}
|
||||
}
|
||||
vec![
|
||||
key(
|
||||
"appearance.theme_mode",
|
||||
Sensitivity::Low,
|
||||
"enum",
|
||||
"界面主题",
|
||||
),
|
||||
key(
|
||||
"download.concurrency",
|
||||
Sensitivity::Low,
|
||||
"integer",
|
||||
"下载并发(1~256)",
|
||||
),
|
||||
key(
|
||||
"java.default_memory_mb",
|
||||
Sensitivity::Low,
|
||||
"integer",
|
||||
"最大内存(512~65536 MB)",
|
||||
),
|
||||
key("download.region", Sensitivity::Medium, "enum", "下载源区域"),
|
||||
key("ai.model", Sensitivity::Medium, "text", "AI 模型名"),
|
||||
key(
|
||||
"java.selected_path",
|
||||
Sensitivity::High,
|
||||
"path",
|
||||
"Java 可执行文件路径",
|
||||
),
|
||||
key("download.proxy_url", Sensitivity::High, "text", "HTTP 代理"),
|
||||
key("ai.base_url", Sensitivity::High, "text", "AI 端点"),
|
||||
key(
|
||||
"core.data_mode",
|
||||
Sensitivity::High,
|
||||
"enum",
|
||||
"数据目录模式(portable/standard/custom)",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
impl MockSettings {
|
||||
/// 用默认白名单 + 一批初值造一个。
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
let mut values = BTreeMap::new();
|
||||
values.insert("appearance.theme_mode".to_string(), json!("metro_dark"));
|
||||
values.insert("download.concurrency".to_string(), json!(8));
|
||||
values.insert("java.default_memory_mb".to_string(), json!(2048));
|
||||
Self {
|
||||
keys: default_keys(),
|
||||
values: Mutex::new(values),
|
||||
fail_set: Mutex::new(None),
|
||||
set_calls: Mutex::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// 让接下来的 `set` 全部失败(测错误透传)。
|
||||
pub fn fail_set_with(&self, message: &str) {
|
||||
if let Ok(mut slot) = self.fail_set.lock() {
|
||||
*slot = Some(message.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前值(断言「未确认时没有写进去」)。
|
||||
#[must_use]
|
||||
pub fn value_of(&self, path: &str) -> Option<Value> {
|
||||
self.values.lock().ok().and_then(|v| v.get(path).cloned())
|
||||
}
|
||||
|
||||
/// 直接塞一个值,模拟 M12 真实实现里存在的**任意键**(含凭据键)。
|
||||
///
|
||||
/// 白名单以外的键也能塞——测「`get_config` 不把凭据读给模型」需要它
|
||||
/// (`ai.api_key` 是 `writable=false`,故意不在白名单里)。
|
||||
pub fn seed_value(&self, path: &str, value: Value) {
|
||||
if let Ok(mut values) = self.values.lock() {
|
||||
values.insert(path.to_string(), value);
|
||||
}
|
||||
}
|
||||
|
||||
/// 成功写入次数。
|
||||
#[must_use]
|
||||
pub fn set_calls(&self) -> usize {
|
||||
self.set_calls.lock().map(|n| *n).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl SettingPort for MockSettings {
|
||||
fn summary(&self) -> Value {
|
||||
let values = self.values.lock().map(|v| v.clone()).unwrap_or_default();
|
||||
let mut map = serde_json::Map::new();
|
||||
for key in &self.keys {
|
||||
let value = values.get(&key.path).cloned().unwrap_or(Value::Null);
|
||||
map.insert(key.path.clone(), value);
|
||||
}
|
||||
Value::Object(map)
|
||||
}
|
||||
|
||||
fn get(&self, path: &str) -> PortResult<Value> {
|
||||
self.values
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|v| v.get(path).cloned())
|
||||
.ok_or_else(|| format!("配置键 `{path}` 没有值"))
|
||||
}
|
||||
|
||||
fn set(&self, path: &str, value: &Value) -> PortResult<SettingChange> {
|
||||
if let Ok(slot) = self.fail_set.lock() {
|
||||
if let Some(message) = slot.as_ref() {
|
||||
return Err(message.clone());
|
||||
}
|
||||
}
|
||||
let old = self.get(path).ok();
|
||||
if let Ok(mut values) = self.values.lock() {
|
||||
values.insert(path.to_string(), value.clone());
|
||||
}
|
||||
if let Ok(mut calls) = self.set_calls.lock() {
|
||||
*calls += 1;
|
||||
}
|
||||
Ok(SettingChange {
|
||||
path: path.to_string(),
|
||||
old,
|
||||
new: value.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn writable_keys(&self) -> Vec<SettingKey> {
|
||||
self.keys.clone()
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
// 与 `core::setting::catalog::SettingSchema::to_json_schema` 同形:
|
||||
// 只含白名单键 + `x-ai-sensitivity`,凭据键天然缺席。
|
||||
let mut properties = Map::new();
|
||||
for key in &self.keys {
|
||||
properties.insert(
|
||||
key.path.clone(),
|
||||
json!({
|
||||
"type": key.value_type,
|
||||
"description": key.description,
|
||||
"x-ai-sensitivity": key.sensitivity,
|
||||
}),
|
||||
);
|
||||
}
|
||||
json!({
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "ull.launcher",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": properties,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn schema_excludes_credential_keys() {
|
||||
let schema = MockSettings::new().schema();
|
||||
let json = schema.to_string();
|
||||
assert!(
|
||||
!json.contains("ai.api_key"),
|
||||
"schema 不得包含凭据键:{json}"
|
||||
);
|
||||
assert!(json.contains("download.concurrency"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user