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

+103
View File
@@ -0,0 +1,103 @@
//! AI 页与覆盖层:会话消息、对话框(一次一条)、Toast。
use crate::dialogs as dlg;
use crate::dto::ChatRole as DtoChatRole;
use crate::state::AppState;
use crate::toast as toast_mod;
use crate::{ChatRole, DialogKind, UiChatMessage, UiDialog, UiToast};
/// AI 会话消息。
pub fn chat(state: &AppState) -> Vec<UiChatMessage> {
state
.ai
.messages
.iter()
.map(|message| UiChatMessage {
role: match message.role {
DtoChatRole::User => ChatRole::User,
DtoChatRole::Assistant => ChatRole::Assistant,
DtoChatRole::Tool => ChatRole::Tool,
DtoChatRole::System => ChatRole::System,
},
text: message.text.clone().into(),
tool_name: message
.tool
.as_ref()
.map(|tool| tool.name.clone())
.unwrap_or_default()
.into(),
tool_ok: message.tool.as_ref().is_none_or(|tool| tool.ok),
streaming: message.streaming,
})
.collect()
}
/// 当前对话框(一次一条)+ 是否显示 + 队列剩余条数。
pub fn dialog(state: &AppState) -> (UiDialog, bool, i32) {
let queued = i32::try_from(state.dialogs.pending_len()).unwrap_or(0);
match state.dialogs.current() {
None => (UiDialog::default(), false, queued),
Some(request) => {
let (kind, primary, secondary) = match &request.kind {
dlg::DialogKind::Confirm { primary, secondary } => {
(DialogKind::Confirm, primary.clone(), secondary.clone())
}
dlg::DialogKind::Input { primary, .. } => {
(DialogKind::Input, primary.clone(), String::new())
}
dlg::DialogKind::Progress { .. } => {
(DialogKind::Progress, "取消".to_string(), String::new())
}
dlg::DialogKind::Error { primary, .. } => {
(DialogKind::Error, primary.clone(), "忽略".to_string())
}
};
let progress = match &request.kind {
dlg::DialogKind::Progress { progress, .. } => *progress,
_ => 0.0,
};
let input_label = match &request.kind {
dlg::DialogKind::Input { label, .. } => label.clone(),
_ => String::new(),
};
let input_value = match &request.kind {
dlg::DialogKind::Input { value, .. } => value.clone(),
_ => String::new(),
};
(
UiDialog {
id: request.id.to_string().into(),
kind,
title: request.title.clone().into(),
body: request.body.clone().into(),
primary: primary.into(),
secondary: secondary.into(),
progress,
input_label: input_label.into(),
input_value: input_value.into(),
},
true,
queued,
)
}
}
}
/// Toast 列表。
pub fn toasts(state: &AppState) -> Vec<UiToast> {
state
.toasts
.items()
.iter()
.map(|toast| UiToast {
id: toast.id.to_string().into(),
kind: match toast.kind {
toast_mod::ToastKind::Info => crate::ToastKind::Info,
toast_mod::ToastKind::Success => crate::ToastKind::Success,
toast_mod::ToastKind::Warn => crate::ToastKind::Warn,
toast_mod::ToastKind::Error => crate::ToastKind::Error,
},
text: toast.text.clone().into(),
})
.collect()
}
+162
View File
@@ -0,0 +1,162 @@
//! 实例相关:磁贴外观(相位/进度/徽标/占位色)、运行中列表。
use slint::Color;
use crate::dto::{self, InstanceRuntime};
use crate::state::{AppState, TileCell};
use crate::{LoaderKind, RuntimePhase, UiTile};
/// 头像/图标占位的底色池(Metro 强调色系,按 id 哈希稳定取色)。
const HUES: [u32; 8] = [
0x0078D4, 0x8764B0, 0xD13438, 0xCA5010, 0x498205, 0x038387, 0x5C2D91, 0x0050EF,
];
/// 加载器枚举。
pub fn loader(kind: dto::LoaderKind) -> LoaderKind {
match kind {
dto::LoaderKind::Vanilla => LoaderKind::Vanilla,
dto::LoaderKind::Fabric => LoaderKind::Fabric,
dto::LoaderKind::Quilt => LoaderKind::Quilt,
dto::LoaderKind::Forge => LoaderKind::Forge,
dto::LoaderKind::NeoForge => LoaderKind::Neoforge,
dto::LoaderKind::OptiFine => LoaderKind::Optifine,
dto::LoaderKind::LiteLoader => LoaderKind::Liteloader,
}
}
/// 运行时状态 → (磁贴外观相位, 进度 0~1, 阶段文案)。
pub fn phase(runtime: &InstanceRuntime) -> (RuntimePhase, f32, String) {
match runtime {
InstanceRuntime::Idle => (RuntimePhase::Idle, 0.0, String::new()),
InstanceRuntime::Busy { stage, progress } => {
let phase = match stage {
dto::LaunchStage::Download => RuntimePhase::Downloading,
dto::LaunchStage::Verify => RuntimePhase::Verifying,
dto::LaunchStage::Launch => RuntimePhase::Launching,
};
(phase, progress.ratio(), stage.label().to_string())
}
InstanceRuntime::Running { .. } => (RuntimePhase::Running, 1.0, "运行中".to_string()),
InstanceRuntime::Failed { reason } => (RuntimePhase::Failed, 0.0, reason.clone()),
}
}
/// 名字首字(无图标时的占位字母)。
pub fn initial_of(name: &str) -> String {
name.chars()
.next()
.map(|c| c.to_string())
.unwrap_or_else(|| "?".to_string())
}
/// id → 稳定底色(同一实例每次启动同色)。
pub fn hue_of(id: &str) -> Color {
let hash = id.bytes().fold(0u32, |acc, byte| {
acc.wrapping_mul(31).wrapping_add(u32::from(byte))
});
Color::from_argb_encoded(0xFF00_0000 | HUES[(hash as usize) % HUES.len()])
}
/// ISO-8601 UTC → 逗号少的本地展示串(`2026-10-29 19:22`);None/空 → 从未启动。
pub fn last_played(raw: &Option<String>) -> String {
match raw {
None => "从未启动".to_string(),
Some(value) if value.is_empty() => "从未启动".to_string(),
Some(value) => {
let trimmed = value.trim_end_matches('Z');
let mut out = String::with_capacity(trimmed.len());
let mut seen_time = false;
for ch in trimmed.chars() {
match ch {
'T' => {
seen_time = true;
out.push(' ');
}
':' if seen_time && out.ends_with(|c: char| c.is_ascii_digit()) => {
// 只保留到分钟:秒(第三段冒号之后)直接截断
if out.matches(':').count() >= 1 {
break;
}
out.push(ch);
}
_ => out.push(ch),
}
}
out
}
}
}
/// 单个磁贴。
pub fn tile(cell: &TileCell<'_>) -> UiTile {
let instance = cell.instance;
let (phase, progress, label) = phase(&instance.runtime);
UiTile {
id: instance.id.clone().into(),
name: instance.name.clone().into(),
mc_version: instance.mc_version.clone().into(),
loader: loader(instance.loader.kind),
loader_version: instance.loader.version.clone().into(),
loader_label: instance.loader.badge().into(),
last_played: last_played(&instance.last_played).into(),
phase,
phase_label: label.into(),
progress,
icon: slint::Image::default(),
hue: hue_of(&instance.id),
initial: initial_of(&instance.name).into(),
row: i32::try_from(cell.row).unwrap_or(0),
col: i32::try_from(cell.col).unwrap_or(0),
}
}
/// 磁贴网格(已按当前尺寸算好 row/col)。
pub fn tiles(state: &AppState) -> Vec<UiTile> {
state.tiles().iter().map(tile).collect()
}
/// 运行中实例(顶部迷你状态条)。
pub fn running_tiles(state: &AppState) -> Vec<UiTile> {
state
.running()
.iter()
.enumerate()
.map(|(index, instance)| {
let cell = TileCell {
instance,
row: 0,
col: index,
};
tile(&cell)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::{hue_of, initial_of, last_played};
#[test]
fn initial_is_first_grapheme_or_question_mark() {
assert_eq!(initial_of("生存整合包"), "生");
assert_eq!(initial_of("Vanilla"), "V");
assert_eq!(initial_of(""), "?");
}
#[test]
fn hue_is_stable_per_id() {
assert_eq!(hue_of("survival"), hue_of("survival"));
// 不同 id 大体不同色(哈希池 8 色,这里只验证确定性 + 不 panic)
let _ = hue_of("another-instance");
}
#[test]
fn last_played_formats_iso_to_minutes() {
assert_eq!(
last_played(&Some("2026-10-29T19:22:45Z".to_string())),
"2026-10-29 19:22"
);
assert_eq!(last_played(&None), "从未启动");
assert_eq!(last_played(&Some(String::new())), "从未启动");
}
}
+84
View File
@@ -0,0 +1,84 @@
//! 日志页:会话列表、日志行、D8 诊断卡片。
use crate::dto::{LogLevel as DtoLogLevel, LogOutcome as DtoLogOutcome};
use crate::state::AppState;
use crate::{LogLevel, LogOutcome, UiDiagnosis, UiLogLine, UiLogSession};
/// 日志会话列表。
pub fn log_sessions(state: &AppState) -> Vec<UiLogSession> {
state
.log_sessions
.iter()
.map(|session| UiLogSession {
id: session.id.clone().into(),
when: session.when.clone().into(),
instance: session.instance.clone().into(),
outcome: match session.outcome {
DtoLogOutcome::Ok => LogOutcome::Ok,
DtoLogOutcome::Crashed => LogOutcome::Crashed,
DtoLogOutcome::SilentFail => LogOutcome::SilentFail,
},
duration: duration(session.duration_secs).into(),
})
.collect()
}
/// 秒 → `1h03m` / `62s`。
pub fn duration(secs: u64) -> String {
if secs >= 3_600 {
format!("{}h{:02}m", secs / 3_600, (secs % 3_600) / 60)
} else if secs >= 60 {
format!("{}m{:02}s", secs / 60, secs % 60)
} else {
format!("{secs}s")
}
}
/// 当前会话日志行。
pub fn log_lines(state: &AppState) -> Vec<UiLogLine> {
state
.log_lines
.iter()
.map(|line| UiLogLine {
text: line.text.clone().into(),
level: match line.level {
DtoLogLevel::Debug => LogLevel::Debug,
DtoLogLevel::Info => LogLevel::Info,
DtoLogLevel::Warn => LogLevel::Warn,
DtoLogLevel::Error => LogLevel::Error,
},
})
.collect()
}
/// 诊断卡片内容(无结论时返回默认值 + has=false)。
pub fn diagnosis(state: &AppState) -> (UiDiagnosis, bool) {
match &state.diagnosis {
None => (UiDiagnosis::default(), false),
Some(report) => {
let diagnosis = &report.diagnosis;
(
UiDiagnosis {
rule: diagnosis.rule.clone().into(),
title: diagnosis.title.clone().into(),
confidence: i32::from(diagnosis.confidence),
hint: diagnosis.hint.clone().into(),
has_fix: diagnosis.fix.is_some(),
},
true,
)
}
}
}
#[cfg(test)]
mod tests {
use super::duration;
#[test]
fn duration_is_human_readable() {
assert_eq!(duration(8), "8s");
assert_eq!(duration(62), "1m02s");
assert_eq!(duration(3_780), "1h03m");
}
}
+21
View File
@@ -0,0 +1,21 @@
//! 状态层 → Slint 视图模型的单向转换(DTO 值 → `models.slint` 结构体)。
//!
//! 只做「领域值 → 展示值」的搬运与格式化,不含业务判断;
//! 这样 `ui_bind.rs` 里的同步逻辑就是纯粹的「状态灌进属性」。
//! 按领域拆子模块:instances / versions / logs / agent / ui,全量 `pub use`,
//! 调用方统一写 `crate::conv::tiles(...)` 这类路径。
mod agent;
mod instances;
mod logs;
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 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,
};
pub use versions::versions;
+136
View File
@@ -0,0 +1,136 @@
//! UI 偏好/路由枚举的双向映射(Slint 枚举 ↔ 状态层枚举)。
use crate::dto::{self, VersionCategory};
use crate::prefs;
use crate::router;
use crate::{BgFillKind, DownloadCategory, InstanceView, RouteKind, ThemeMode, TileSize};
/// 路由 → 导航高亮/页面选择。
pub fn route(route: &router::Route) -> RouteKind {
match route {
router::Route::Instances => RouteKind::Instances,
router::Route::Download => RouteKind::Download,
router::Route::Logs => RouteKind::Logs,
router::Route::Settings => RouteKind::Settings,
router::Route::Detail(_) => RouteKind::Detail,
router::Route::Agent => RouteKind::Agent,
}
}
/// Slint 侧导航点击 → 路由(详情页保持当前实例,不在导航里触发)。
pub fn route_from(kind: RouteKind) -> Option<router::Route> {
match kind {
RouteKind::Instances => Some(router::Route::Instances),
RouteKind::Download => Some(router::Route::Download),
RouteKind::Logs => Some(router::Route::Logs),
RouteKind::Settings => Some(router::Route::Settings),
RouteKind::Agent => Some(router::Route::Agent),
RouteKind::Detail => None,
}
}
/// 磁贴尺寸。
pub fn tile_size(size: prefs::TileSize) -> TileSize {
match size {
prefs::TileSize::Small => TileSize::Small,
prefs::TileSize::Medium => TileSize::Medium,
prefs::TileSize::Large => TileSize::Large,
}
}
/// 视图模式。
pub fn instance_view(view: prefs::InstanceView) -> InstanceView {
match view {
prefs::InstanceView::Tiles => InstanceView::Tiles,
prefs::InstanceView::List => InstanceView::List,
}
}
/// 主题三态。
pub fn theme_mode(mode: prefs::ThemeMode) -> ThemeMode {
match mode {
prefs::ThemeMode::Auto => ThemeMode::Auto,
prefs::ThemeMode::Light => ThemeMode::Light,
prefs::ThemeMode::Dark => ThemeMode::Dark,
}
}
/// Slint 主题三态 → 偏好枚举。
pub fn theme_mode_from(mode: ThemeMode) -> prefs::ThemeMode {
match mode {
ThemeMode::Auto => prefs::ThemeMode::Auto,
ThemeMode::Light => prefs::ThemeMode::Light,
ThemeMode::Dark => prefs::ThemeMode::Dark,
}
}
/// Slint 磁贴尺寸 → 偏好枚举。
pub fn tile_size_from(size: TileSize) -> prefs::TileSize {
match size {
TileSize::Small => prefs::TileSize::Small,
TileSize::Medium => prefs::TileSize::Medium,
TileSize::Large => prefs::TileSize::Large,
}
}
/// Slint 视图模式 → 偏好枚举。
pub fn instance_view_from(view: InstanceView) -> prefs::InstanceView {
match view {
InstanceView::Tiles => prefs::InstanceView::Tiles,
InstanceView::List => prefs::InstanceView::List,
}
}
/// 下载分类 → core 侧分类枚举。
pub fn version_category(category: DownloadCategory) -> VersionCategory {
match category {
DownloadCategory::Release => VersionCategory::Release,
DownloadCategory::Snapshot => VersionCategory::Snapshot,
DownloadCategory::AncientClassic => VersionCategory::Ancient(dto::AncientKind::Classic),
DownloadCategory::AncientAlpha => VersionCategory::Ancient(dto::AncientKind::OldAlpha),
DownloadCategory::AncientBeta => VersionCategory::Ancient(dto::AncientKind::OldBeta),
DownloadCategory::AncientUnlisted => VersionCategory::Ancient(dto::AncientKind::Unlisted),
DownloadCategory::Modpack => VersionCategory::Modpack,
DownloadCategory::Mod => VersionCategory::Mod,
}
}
/// 下载分类(偏好 → Slint)——供启动时回填。
pub fn category_from(category: VersionCategory) -> DownloadCategory {
match category {
VersionCategory::Release => DownloadCategory::Release,
VersionCategory::Snapshot => DownloadCategory::Snapshot,
VersionCategory::Ancient(dto::AncientKind::Classic) => DownloadCategory::AncientClassic,
VersionCategory::Ancient(dto::AncientKind::OldAlpha) => DownloadCategory::AncientAlpha,
VersionCategory::Ancient(dto::AncientKind::OldBeta) => DownloadCategory::AncientBeta,
VersionCategory::Ancient(dto::AncientKind::Unlisted) => DownloadCategory::AncientUnlisted,
VersionCategory::Modpack => DownloadCategory::Modpack,
VersionCategory::Mod => DownloadCategory::Mod,
}
}
/// 背景图填充模式(core 侧枚举 → Slint)。
pub fn bg_fill(fill: unlucky_core::setting::appearance::BgFill) -> BgFillKind {
match fill {
unlucky_core::setting::appearance::BgFill::Cover => BgFillKind::Cover,
unlucky_core::setting::appearance::BgFill::Contain => BgFillKind::Contain,
unlucky_core::setting::appearance::BgFill::Tile => BgFillKind::Tile,
}
}
/// Slint 填充模式 → core 侧枚举。
pub fn bg_fill_from(fill: BgFillKind) -> unlucky_core::setting::appearance::BgFill {
match fill {
BgFillKind::Cover => unlucky_core::setting::appearance::BgFill::Cover,
BgFillKind::Contain => unlucky_core::setting::appearance::BgFill::Contain,
BgFillKind::Tile => unlucky_core::setting::appearance::BgFill::Tile,
}
}
/// 强调色十六进制串 → Slint 颜色;空串/非法值给透明(= 用出厂 accent)。
pub fn accent_color(hex: &str) -> slint::Color {
match unlucky_core::setting::appearance::hex_to_rgb(hex) {
Some((r, g, b)) => slint::Color::from_rgb_u8(r, g, b),
None => slint::Color::from_argb_u8(0, 0, 0, 0),
}
}
+81
View File
@@ -0,0 +1,81 @@
//! 版本列表与下载分类映射(下载页)。
use crate::dto::{AncientKind, VersionKind as DtoVersionKind};
use crate::state::AppState;
use crate::{UiVersion, VersionKind};
/// 下载页版本行(已按「仅已装」+ 版本号筛选词过滤)。
pub fn versions(state: &AppState) -> Vec<UiVersion> {
let query = state.download_query.trim().to_lowercase();
state
.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),
released: version.released.clone().into(),
installed: version.installed,
ancient: version.kind.is_ancient(),
})
.collect()
}
fn version_kind(kind: DtoVersionKind) -> VersionKind {
match kind {
DtoVersionKind::Release => VersionKind::Release,
DtoVersionKind::Snapshot => VersionKind::Snapshot,
DtoVersionKind::Ancient(AncientKind::Classic) => VersionKind::AncientClassic,
DtoVersionKind::Ancient(AncientKind::OldAlpha) => VersionKind::AncientAlpha,
DtoVersionKind::Ancient(AncientKind::OldBeta) => VersionKind::AncientBeta,
DtoVersionKind::Ancient(AncientKind::Unlisted) => VersionKind::AncientUnlisted,
}
}
#[cfg(test)]
mod tests {
use super::versions;
use crate::dto::{VersionKind, VersionSummary};
use crate::state::AppState;
fn state_with_versions() -> AppState {
let mut state = AppState::new();
let row = |id: &str, installed: bool| VersionSummary {
id: id.to_string(),
kind: VersionKind::Release,
released: "2026-01-01".to_string(),
installed,
};
state.versions = vec![
row("1.21.9", false),
row("1.20.4", true),
row("1.20.1", true),
];
state
}
#[test]
fn installed_only_filter_hides_uninstalled_versions() {
let mut state = state_with_versions();
assert_eq!(versions(&state).len(), 3);
state.installed_only = true;
let filtered = versions(&state);
assert_eq!(filtered.len(), 2);
assert!(filtered.iter().all(|version| version.installed));
}
#[test]
fn query_filters_by_version_id_substring() {
let mut state = state_with_versions();
state.download_query = "1.20".to_string();
let filtered = versions(&state);
assert_eq!(filtered.len(), 2);
state.download_query = "1.21".to_string();
state.installed_only = true;
// 「仅已装」优先:1.21.9 未安装 → 空列表
assert!(versions(&state).is_empty());
}
}