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

Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
This commit is contained in:
AskaEth committed 2026-10-31 21:54:10 +08:00
commit bc41dfcf7d
517 files changed
+92844

No files matched your search

+87
View File
@@ -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
})
},
)
}
}
+158
View File
@@ -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)
}
}
+201
View File
@@ -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"));
}
}