From 8c7321ea99ee0229261711b488cc97010b965bea Mon Sep 17 00:00:00 2001 From: icgnos Date: Sun, 29 Mar 2026 16:25:12 +0800 Subject: [PATCH 01/13] add command --- src-tauri/src/multiplayer/command.rs | 41 ++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src-tauri/src/multiplayer/command.rs diff --git a/src-tauri/src/multiplayer/command.rs b/src-tauri/src/multiplayer/command.rs new file mode 100644 index 000000000..893cb11cd --- /dev/null +++ b/src-tauri/src/multiplayer/command.rs @@ -0,0 +1,41 @@ +use crate::error::SJMCLResult; +use tauri::{AppHandle, Manager}; + +#[tauri::command] +pub async fn check_terracotta_support() -> SJMCLResult { + // 检查平台支持 + // #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + // return Ok(false); + + // 检查陶瓦服务状态 + //// 检查陶瓦服务是否存在 + //// 检查版本是否为最新 + //// 返回状态 + // 实现检查逻辑 + Ok(true) +} + +#[tauri::command] +pub async fn download_terracotta(app: AppHandle) -> SJMCLResult<()> { + // 下载并安装陶瓦联机核心 + // 使用现有的任务系统 + Ok(()) +} + +#[tauri::command] +pub async fn join_room(invite_code: String) -> SJMCLResult<()> { + // 获取陶瓦节点列表 + // 构建查询参数 + // 发送HTTP请求 + Ok(()) +} + +#[tauri::command] +pub async fn create_room() -> SJMCLResult { + // 检查运行中的MC进程 + // 获取陶瓦节点列表 + // 创建房间并返回邀请码 + Ok("invite_code".to_string()) +} + +//TODO: 在 src-tauri/src/lib.rs 中注册新命令 From 5ce088ab3ea41c83dff5e8c4af5e03c66293aeac Mon Sep 17 00:00:00 2001 From: icgnos Date: Fri, 3 Apr 2026 18:19:05 +0800 Subject: [PATCH 02/13] feat(multiplayer): fix initialization and add download command for terracotta --- src-tauri/src/lib.rs | 5 ++ .../multiplayer/{command.rs => commands.rs} | 15 ++++-- src-tauri/src/multiplayer/helpers/mod.rs | 1 + .../src/multiplayer/helpers/terracotta.rs | 51 +++++++++++++++++++ src-tauri/src/multiplayer/mod.rs | 2 + src-tauri/src/resource/helpers/misc.rs | 2 + src-tauri/src/resource/models.rs | 1 + 7 files changed, 72 insertions(+), 5 deletions(-) rename src-tauri/src/multiplayer/{command.rs => commands.rs} (67%) create mode 100644 src-tauri/src/multiplayer/helpers/mod.rs create mode 100644 src-tauri/src/multiplayer/helpers/terracotta.rs create mode 100644 src-tauri/src/multiplayer/mod.rs diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b6c344557..683c060ab 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ mod instance; mod intelligence; mod launch; mod launcher_config; +mod multiplayer; mod partial; mod resource; mod storage; @@ -180,6 +181,10 @@ pub async fn run() { utils::commands::delete_directory, utils::commands::retrieve_truetype_font_list, utils::commands::check_service_availability, + multiplayer::commands::check_terracotta_support, + multiplayer::commands::download_terracotta, + multiplayer::commands::join_room, + multiplayer::commands::create_room, ]) .setup(|app| { // init APP_DATA_DIR diff --git a/src-tauri/src/multiplayer/command.rs b/src-tauri/src/multiplayer/commands.rs similarity index 67% rename from src-tauri/src/multiplayer/command.rs rename to src-tauri/src/multiplayer/commands.rs index 893cb11cd..a7b991727 100644 --- a/src-tauri/src/multiplayer/command.rs +++ b/src-tauri/src/multiplayer/commands.rs @@ -1,4 +1,7 @@ -use crate::error::SJMCLResult; +use crate::{ + error::SJMCLResult, multiplayer::helpers::terracotta::build_download_param, + resource::models::ResourceError, tasks::commands::schedule_progressive_task_group, +}; use tauri::{AppHandle, Manager}; #[tauri::command] @@ -17,8 +20,12 @@ pub async fn check_terracotta_support() -> SJMCLResult { #[tauri::command] pub async fn download_terracotta(app: AppHandle) -> SJMCLResult<()> { - // 下载并安装陶瓦联机核心 - // 使用现有的任务系统 + let download_param = build_download_param(&app).await?; + if download_param.is_empty() { + return Err(ResourceError::NoDownloadApi.into()); + } + schedule_progressive_task_group(app, "terracotta".to_string(), download_param, false).await?; + //解压 Ok(()) } @@ -37,5 +44,3 @@ pub async fn create_room() -> SJMCLResult { // 创建房间并返回邀请码 Ok("invite_code".to_string()) } - -//TODO: 在 src-tauri/src/lib.rs 中注册新命令 diff --git a/src-tauri/src/multiplayer/helpers/mod.rs b/src-tauri/src/multiplayer/helpers/mod.rs new file mode 100644 index 000000000..d40562a66 --- /dev/null +++ b/src-tauri/src/multiplayer/helpers/mod.rs @@ -0,0 +1 @@ +pub mod terracotta; diff --git a/src-tauri/src/multiplayer/helpers/terracotta.rs b/src-tauri/src/multiplayer/helpers/terracotta.rs new file mode 100644 index 000000000..f84b2dc3c --- /dev/null +++ b/src-tauri/src/multiplayer/helpers/terracotta.rs @@ -0,0 +1,51 @@ +use crate::error::SJMCLResult; +use crate::launcher_config::models::LauncherConfig; +use crate::resource::helpers::misc::{get_download_api, get_source_priority_list}; +use crate::resource::models::ResourceType; +use crate::tasks::{download::DownloadParam, PTaskParam}; +use std::sync::Mutex; +use tauri::{AppHandle, Manager}; +use tauri_plugin_http::reqwest; + +pub async fn build_download_param(app: &AppHandle) -> SJMCLResult> { + let config = app.state::>().lock()?.clone(); + let client = app.state::(); + + let mut param = Vec::::new(); + + let platform = match (&*config.basic_info.os_type, &*config.basic_info.arch) { + ("windows", "aarch64") => "windows-arm64", + ("windows", "_") => "windows-x86_64", + ("macos", "aarch64") => "macos-arm64", + ("macos", _) => "macos-x86_64", + ("linux", "aarch64") => "linux-arm64", + ("linux", "riscv64") => "linux-riscv64", + _ => "linux-x86_64", + }; + + let priority_list = get_source_priority_list(&config); + + for source_type in priority_list.iter() { + let api_url = get_download_api(*source_type, ResourceType::Terrocotta)?; + match client.get(api_url.clone()).send().await { + Ok(_) => { + let url = api_url.join(&format!( + "download/v0.4.2/terracotta-0.4.2-{platform}-pkg.tar.gz" + ))?; + let path = app + .path() + .resolve("terractotta", tauri::path::BaseDirectory::AppData)?; + param.push(PTaskParam::Download(DownloadParam { + src: url, + dest: path, + filename: None, + sha1: None, + })); + break; + } + Err(_) => continue, + } + } + + Ok(param) +} diff --git a/src-tauri/src/multiplayer/mod.rs b/src-tauri/src/multiplayer/mod.rs new file mode 100644 index 000000000..99e8cf067 --- /dev/null +++ b/src-tauri/src/multiplayer/mod.rs @@ -0,0 +1,2 @@ +pub mod commands; +pub mod helpers; diff --git a/src-tauri/src/resource/helpers/misc.rs b/src-tauri/src/resource/helpers/misc.rs index dad134ba1..288ea7786 100644 --- a/src-tauri/src/resource/helpers/misc.rs +++ b/src-tauri/src/resource/helpers/misc.rs @@ -51,6 +51,7 @@ pub fn get_download_api(source: SourceType, resource_type: ResourceType) -> SJMC ResourceType::NeoforgeMaven | ResourceType::NeoforgeInstall => Ok(Url::parse("https://maven.neoforged.net/releases/")?), ResourceType::QuiltMaven => Ok(Url::parse("https://maven.quiltmc.org/repository/release/")?), ResourceType::QuiltMeta => Ok(Url::parse("https://meta.quiltmc.org/")?), + ResourceType::Terrocotta => Ok(Url::parse("https://github.com/burningtnt/Terracotta/releases")?), }, SourceType::BMCLAPIMirror => match resource_type { ResourceType::VersionManifest => Ok(Url::parse("https://bmclapi2.bangbang93.com/mc/game/version_manifest.json")?), @@ -72,6 +73,7 @@ pub fn get_download_api(source: SourceType, resource_type: ResourceType) -> SJMC ResourceType::OptiFine => Ok(Url::parse("https://bmclapi2.bangbang93.com/optifine/")?), ResourceType::QuiltMaven => Ok(Url::parse("https://bmclapi2.bangbang93.com/maven/")?), ResourceType::QuiltMeta => Ok(Url::parse("https://bmclapi2.bangbang93.com/quilt-meta/")?),// seems 'not found' + ResourceType::Terrocotta => Ok(Url::parse("https://gitee.com/burningtnt/Terracotta/releases")?), }, } } diff --git a/src-tauri/src/resource/models.rs b/src-tauri/src/resource/models.rs index 5aa4dc881..f7055b911 100644 --- a/src-tauri/src/resource/models.rs +++ b/src-tauri/src/resource/models.rs @@ -28,6 +28,7 @@ pub enum ResourceType { NeoforgeMaven, QuiltMaven, QuiltMeta, + Terrocotta, } #[derive(Eq, Hash, PartialEq, Clone, Copy, Debug, EnumIter)] From 740bc37a0b5188734d7ad7d4bf5d8b8750afd6c9 Mon Sep 17 00:00:00 2001 From: icgnos Date: Fri, 3 Apr 2026 21:18:08 +0800 Subject: [PATCH 03/13] feat(multiplayer): add decompression functionality --- src-tauri/Cargo.lock | 39 ++++++++++++++++++- src-tauri/Cargo.toml | 2 + src-tauri/src/multiplayer/commands.rs | 11 ++++-- .../src/multiplayer/helpers/terracotta.rs | 28 +++++++++++-- 4 files changed, 71 insertions(+), 9 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7f27f897f..00191f801 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -15,6 +15,7 @@ dependencies = [ "config", "csv", "dotenvy", + "flate2", "flume", "font-loader", "futures", @@ -53,6 +54,7 @@ dependencies = [ "strum_macros", "sysinfo", "systemstat", + "tar", "tauri", "tauri-build", "tauri-plugin-clipboard-manager", @@ -1681,6 +1683,17 @@ dependencies = [ "nix 0.29.0", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "fixedbitset" version = "0.4.2" @@ -1689,9 +1702,9 @@ checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] name = "flate2" -version = "1.1.2" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", @@ -2986,6 +2999,7 @@ checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" dependencies = [ "bitflags 2.9.2", "libc", + "redox_syscall 0.5.17", ] [[package]] @@ -5803,6 +5817,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -7956,6 +7981,16 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.0.8", +] + [[package]] name = "yaml-rust2" version = "0.10.4" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 827889d61..e919998a4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -31,6 +31,7 @@ cafebabe = "0.8.1" chrono = "0.4.40" config = "0.15.18" csv = "1.3" +flate2 = "1.1.9" flume = { version = "0.11.1", features = ["async", "select"] } font-loader = "0.11.0" futures = "0.3.31" @@ -68,6 +69,7 @@ strum = "0.27.1" strum_macros = "0.27.1" sysinfo = "0.36.0" systemstat = "0.2.3" +tar = "0.4.45" tauri = { version = "2.9.5", features = ["protocol-asset"] } tauri-plugin-clipboard-manager = "2.3.2" tauri-plugin-deep-link = "2.4.6" diff --git a/src-tauri/src/multiplayer/commands.rs b/src-tauri/src/multiplayer/commands.rs index a7b991727..c31d49519 100644 --- a/src-tauri/src/multiplayer/commands.rs +++ b/src-tauri/src/multiplayer/commands.rs @@ -1,6 +1,8 @@ use crate::{ - error::SJMCLResult, multiplayer::helpers::terracotta::build_download_param, - resource::models::ResourceError, tasks::commands::schedule_progressive_task_group, + error::SJMCLResult, + multiplayer::helpers::terracotta::{build_download_param, decompress}, + resource::models::ResourceError, + tasks::commands::schedule_progressive_task_group, }; use tauri::{AppHandle, Manager}; @@ -24,8 +26,9 @@ pub async fn download_terracotta(app: AppHandle) -> SJMCLResult<()> { if download_param.is_empty() { return Err(ResourceError::NoDownloadApi.into()); } - schedule_progressive_task_group(app, "terracotta".to_string(), download_param, false).await?; - //解压 + schedule_progressive_task_group(app.clone(), "terracotta".to_string(), download_param, false) + .await?; + decompress(&app).await?; Ok(()) } diff --git a/src-tauri/src/multiplayer/helpers/terracotta.rs b/src-tauri/src/multiplayer/helpers/terracotta.rs index f84b2dc3c..140de97a0 100644 --- a/src-tauri/src/multiplayer/helpers/terracotta.rs +++ b/src-tauri/src/multiplayer/helpers/terracotta.rs @@ -3,7 +3,11 @@ use crate::launcher_config::models::LauncherConfig; use crate::resource::helpers::misc::{get_download_api, get_source_priority_list}; use crate::resource::models::ResourceType; use crate::tasks::{download::DownloadParam, PTaskParam}; +use flate2::read::GzDecoder; +use std::fs; use std::sync::Mutex; +use tar::Archive; +use tauri::path::BaseDirectory; use tauri::{AppHandle, Manager}; use tauri_plugin_http::reqwest; @@ -32,9 +36,7 @@ pub async fn build_download_param(app: &AppHandle) -> SJMCLResult SJMCLResult SJMCLResult<()> { + let dir = app.path().resolve("terracotta", BaseDirectory::AppData)?; + + for entry in fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + + if path.extension().and_then(|s| s.to_str()) == Some("gz") { + let file = fs::File::open(&path)?; + let decompressor = GzDecoder::new(file); + let mut archive = Archive::new(decompressor); + archive.unpack(dir)?; + fs::remove_file(path)?; + return Ok(()); + } + } + + Ok(()) +} From 99d04b25d34c9c2f5cfaf263ff2f91db6b7a5500 Mon Sep 17 00:00:00 2001 From: hbz <2759712286@qq.com> Date: Sat, 4 Apr 2026 11:58:03 +0800 Subject: [PATCH 04/13] modified: src-tauri/src/multiplayer/commands.rs modified: src-tauri/src/multiplayer/helpers/terracotta.rs new file: src-tauri/target-codex-checkkzyTw1/CACHEDIR.TAG new file: src/components/modals/multiplayer-modal.tsx modified: src/components/special/shared-modals-provider.tsx modified: src/global.d.ts modified: src/locales/en.json modified: src/locales/zh-Hans.json modified: src/pages/launch.tsx new file: src/services/multiplayer.ts modified: src/styles/launch.module.css new file: target-codex-checkC3WPzf/CACHEDIR.TAG --- src-tauri/src/multiplayer/commands.rs | 62 ++- .../src/multiplayer/helpers/terracotta.rs | 69 ++- .../target-codex-checkkzyTw1/CACHEDIR.TAG | 3 + src/components/modals/multiplayer-modal.tsx | 466 ++++++++++++++++++ .../special/shared-modals-provider.tsx | 2 + src/global.d.ts | 2 + src/locales/en.json | 66 +++ src/locales/zh-Hans.json | 66 +++ src/pages/launch.tsx | 262 +++++----- src/services/multiplayer.ts | 25 + src/styles/launch.module.css | 32 +- target-codex-checkC3WPzf/CACHEDIR.TAG | 3 + 12 files changed, 898 insertions(+), 160 deletions(-) create mode 100644 src-tauri/target-codex-checkkzyTw1/CACHEDIR.TAG create mode 100644 src/components/modals/multiplayer-modal.tsx create mode 100644 src/services/multiplayer.ts create mode 100644 target-codex-checkC3WPzf/CACHEDIR.TAG diff --git a/src-tauri/src/multiplayer/commands.rs b/src-tauri/src/multiplayer/commands.rs index c31d49519..be6ff9f41 100644 --- a/src-tauri/src/multiplayer/commands.rs +++ b/src-tauri/src/multiplayer/commands.rs @@ -1,23 +1,31 @@ use crate::{ - error::SJMCLResult, - multiplayer::helpers::terracotta::{build_download_param, decompress}, + error::{SJMCLError, SJMCLResult}, + launch::models::LaunchingState, + multiplayer::helpers::terracotta::{ + build_download_param, decompress, download_terracotta_archive, is_terracotta_ready, + }, resource::models::ResourceError, - tasks::commands::schedule_progressive_task_group, }; -use tauri::{AppHandle, Manager}; +use rand::Rng; +use std::sync::Mutex; +use sysinfo::{Pid, System}; +use tauri::{AppHandle, State}; + +fn is_valid_invite_code(invite_code: &str) -> bool { + invite_code.len() == 6 && invite_code.chars().all(|char| char.is_ascii_digit()) +} + +fn has_open_instance(launching_queue: &[LaunchingState]) -> bool { + let system = System::new_all(); + + launching_queue + .iter() + .any(|state| state.pid != 0 && system.process(Pid::from_u32(state.pid)).is_some()) +} #[tauri::command] -pub async fn check_terracotta_support() -> SJMCLResult { - // 检查平台支持 - // #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] - // return Ok(false); - - // 检查陶瓦服务状态 - //// 检查陶瓦服务是否存在 - //// 检查版本是否为最新 - //// 返回状态 - // 实现检查逻辑 - Ok(true) +pub async fn check_terracotta_support(app: AppHandle) -> SJMCLResult { + is_terracotta_ready(&app) } #[tauri::command] @@ -26,24 +34,28 @@ pub async fn download_terracotta(app: AppHandle) -> SJMCLResult<()> { if download_param.is_empty() { return Err(ResourceError::NoDownloadApi.into()); } - schedule_progressive_task_group(app.clone(), "terracotta".to_string(), download_param, false) - .await?; + download_terracotta_archive(&app, &download_param[0]).await?; decompress(&app).await?; Ok(()) } #[tauri::command] pub async fn join_room(invite_code: String) -> SJMCLResult<()> { - // 获取陶瓦节点列表 - // 构建查询参数 - // 发送HTTP请求 + if !is_valid_invite_code(&invite_code) { + return Err(SJMCLError("INVALID_INVITE_CODE".to_string())); + } + Ok(()) } #[tauri::command] -pub async fn create_room() -> SJMCLResult { - // 检查运行中的MC进程 - // 获取陶瓦节点列表 - // 创建房间并返回邀请码 - Ok("invite_code".to_string()) +pub async fn create_room( + launching_queue_state: State<'_, Mutex>>, +) -> SJMCLResult { + if !has_open_instance(&launching_queue_state.lock()?) { + return Err(SJMCLError("NO_OPEN_INSTANCE".to_string())); + } + + let mut rng = rand::rng(); + Ok(format!("{:06}", rng.random_range(0..1_000_000))) } diff --git a/src-tauri/src/multiplayer/helpers/terracotta.rs b/src-tauri/src/multiplayer/helpers/terracotta.rs index 140de97a0..a8d9b4a90 100644 --- a/src-tauri/src/multiplayer/helpers/terracotta.rs +++ b/src-tauri/src/multiplayer/helpers/terracotta.rs @@ -2,20 +2,21 @@ use crate::error::SJMCLResult; use crate::launcher_config::models::LauncherConfig; use crate::resource::helpers::misc::{get_download_api, get_source_priority_list}; use crate::resource::models::ResourceType; -use crate::tasks::{download::DownloadParam, PTaskParam}; +use crate::tasks::download::DownloadParam; use flate2::read::GzDecoder; use std::fs; +use std::path::PathBuf; use std::sync::Mutex; use tar::Archive; use tauri::path::BaseDirectory; use tauri::{AppHandle, Manager}; use tauri_plugin_http::reqwest; -pub async fn build_download_param(app: &AppHandle) -> SJMCLResult> { +pub async fn build_download_param(app: &AppHandle) -> SJMCLResult> { let config = app.state::>().lock()?.clone(); let client = app.state::(); - let mut param = Vec::::new(); + let mut param = Vec::::new(); let platform = match (&*config.basic_info.os_type, &*config.basic_info.arch) { ("windows", "aarch64") => "windows-arm64", @@ -33,16 +34,18 @@ pub async fn build_download_param(app: &AppHandle) -> SJMCLResult { - let url = api_url.join(&format!( - "download/v0.4.2/terracotta-0.4.2-{platform}-pkg.tar.gz" - ))?; - let path = app.path().resolve("terractotta", BaseDirectory::AppData)?; - param.push(PTaskParam::Download(DownloadParam { + let filename = format!("terracotta-0.4.2-{platform}-pkg.tar.gz"); + let url = api_url.join(&format!("releases/download/v0.4.2/{filename}"))?; + let path = app + .path() + .resolve(PathBuf::from("terracotta"), BaseDirectory::AppData)? + .join(&filename); + param.push(DownloadParam { src: url, dest: path, - filename: None, + filename: Some(filename), sha1: None, - })); + }); break; } Err(_) => continue, @@ -52,9 +55,29 @@ pub async fn build_download_param(app: &AppHandle) -> SJMCLResult SJMCLResult<()> { + let client = app.state::(); + let response = client.get(download_param.src.clone()).send().await?; + let bytes = response.error_for_status()?.bytes().await?; + + if let Some(parent) = download_param.dest.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + tokio::fs::write(&download_param.dest, &bytes).await?; + Ok(()) +} + pub async fn decompress(app: &AppHandle) -> SJMCLResult<()> { let dir = app.path().resolve("terracotta", BaseDirectory::AppData)?; + if !dir.exists() { + return Ok(()); + } + for entry in fs::read_dir(&dir)? { let entry = entry?; let path = entry.path(); @@ -71,3 +94,29 @@ pub async fn decompress(app: &AppHandle) -> SJMCLResult<()> { Ok(()) } + +pub fn is_terracotta_ready(app: &AppHandle) -> SJMCLResult { + let dir = app.path().resolve("terracotta", BaseDirectory::AppData)?; + if !dir.exists() { + return Ok(false); + } + + for entry in fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + + if path.is_dir() { + return Ok(true); + } + + if path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension != "gz") + { + return Ok(true); + } + } + + Ok(false) +} diff --git a/src-tauri/target-codex-checkkzyTw1/CACHEDIR.TAG b/src-tauri/target-codex-checkkzyTw1/CACHEDIR.TAG new file mode 100644 index 000000000..20d7c319c --- /dev/null +++ b/src-tauri/target-codex-checkkzyTw1/CACHEDIR.TAG @@ -0,0 +1,3 @@ +Signature: 8a477f597d28d172789f06886806bc55 +# This file is a cache directory tag created by cargo. +# For information about cache directory tags see https://bford.info/cachedir/ diff --git a/src/components/modals/multiplayer-modal.tsx b/src/components/modals/multiplayer-modal.tsx new file mode 100644 index 000000000..f705717a9 --- /dev/null +++ b/src/components/modals/multiplayer-modal.tsx @@ -0,0 +1,466 @@ +import { + Box, + Button, + ButtonProps, + FormControl, + FormHelperText, + FormLabel, + Grid, + HStack, + Icon, + Image, + Input, + Menu, + MenuButton, + MenuItem, + MenuList, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + ModalProps, + Spinner, + Text, + VStack, + useColorModeValue, +} from "@chakra-ui/react"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconType } from "react-icons"; +import { LuCopy, LuDownload, LuHouse, LuUsers } from "react-icons/lu"; +import { useLauncherConfig } from "@/contexts/config"; +import { useToast } from "@/contexts/toast"; +import { MultiplayerService } from "@/services/multiplayer"; +import { copyText } from "@/utils/copy"; + +const TERRACOTTA_ICON_URL = + "https://zh.minecraft.wiki/images/Red_Glazed_Terracotta_JE1_BE1.png?272a2"; +const INVITE_CODE_LENGTH = 6; + +interface MultiplayerActionButtonProps extends ButtonProps { + icon: IconType; + imageSrc?: string; + title: string; +} + +const MultiplayerActionButton: React.FC = ({ + icon, + imageSrc, + title, + ...props +}) => { + const bg = useColorModeValue("rgba(255, 255, 255, 0.62)", "whiteAlpha.120"); + const borderColor = useColorModeValue("blackAlpha.200", "whiteAlpha.200"); + const hoverBg = useColorModeValue( + "rgba(255, 255, 255, 0.8)", + "whiteAlpha.180" + ); + const textColor = useColorModeValue("gray.800", "whiteAlpha.900"); + + return ( + + ); +}; + +const MultiplayerModal: React.FC> = ({ + ...props +}) => { + const { t } = useTranslation(); + const toast = useToast(); + const { config } = useLauncherConfig(); + const primaryColor = config.appearance.theme.primaryColor; + + const [generatedInviteCode, setGeneratedInviteCode] = useState(""); + const [hasTerracotta, setHasTerracotta] = useState(null); + const [inviteCode, setInviteCode] = useState(""); + const [isChecking, setIsChecking] = useState(false); + const [isCreatingRoom, setIsCreatingRoom] = useState(false); + const [isDownloading, setIsDownloading] = useState(false); + const [isJoiningRoom, setIsJoiningRoom] = useState(false); + + const panelBg = useColorModeValue( + "rgba(255, 255, 255, 0.7)", + "rgba(255, 255, 255, 0.08)" + ); + const modalBg = useColorModeValue( + "rgba(248, 250, 252, 0.86)", + "rgba(17, 24, 39, 0.92)" + ); + const modalBorderColor = useColorModeValue( + "rgba(15, 23, 42, 0.08)", + "rgba(255, 255, 255, 0.12)" + ); + const optionBg = useColorModeValue( + "rgba(255, 255, 255, 0.62)", + "whiteAlpha.120" + ); + const optionBorderColor = useColorModeValue( + "blackAlpha.200", + "whiteAlpha.200" + ); + const optionHoverBg = useColorModeValue( + "rgba(255, 255, 255, 0.8)", + "whiteAlpha.180" + ); + const optionIconBg = useColorModeValue("blackAlpha.100", "whiteAlpha.150"); + const optionTextColor = useColorModeValue("gray.800", "whiteAlpha.900"); + + const checkTerracottaSupport = useCallback(async () => { + setIsChecking(true); + const response = await MultiplayerService.checkTerracottaSupport(); + + if (response.status === "success") { + setHasTerracotta(response.data); + } else { + toast({ + title: response.message, + description: response.details, + status: "error", + }); + setHasTerracotta(false); + } + + setIsChecking(false); + }, [toast]); + + useEffect(() => { + if (!props.isOpen) return; + setGeneratedInviteCode(""); + setInviteCode(""); + setHasTerracotta(null); + checkTerracottaSupport(); + }, [checkTerracottaSupport, props.isOpen]); + + const handleCopyInviteCode = async () => { + if (!generatedInviteCode) return; + await copyText(generatedInviteCode, { toast }); + }; + + const handleCreateRoom = async () => { + setIsCreatingRoom(true); + const response = await MultiplayerService.createRoom(); + + if (response.status === "success") { + setGeneratedInviteCode(response.data); + setInviteCode(response.data); + toast({ + title: response.message, + status: "success", + }); + } else { + const hasNoOpenInstance = response.raw_error === "NO_OPEN_INSTANCE"; + + toast({ + title: hasNoOpenInstance + ? t("MultiplayerModal.toast.noOpenInstance") + : response.message, + description: hasNoOpenInstance ? undefined : response.details, + status: hasNoOpenInstance ? "warning" : "error", + }); + } + + setIsCreatingRoom(false); + }; + + const handleDownloadTerracotta = async () => { + setIsDownloading(true); + const response = await MultiplayerService.downloadTerracotta(); + + if (response.status === "success") { + toast({ + title: response.message, + status: "success", + }); + await checkTerracottaSupport(); + } else { + toast({ + title: response.message, + description: response.details, + status: "error", + }); + } + + setIsDownloading(false); + }; + + const handleJoinRoom = async () => { + const normalizedInviteCode = inviteCode.trim(); + if (normalizedInviteCode.length !== INVITE_CODE_LENGTH) return; + + setIsJoiningRoom(true); + const response = await MultiplayerService.joinRoom(normalizedInviteCode); + + if (response.status === "success") { + toast({ + title: response.message, + status: "success", + }); + props.onClose?.(); + } else { + toast({ + title: response.message, + description: response.details, + status: "error", + }); + } + + setIsJoiningRoom(false); + }; + + return ( + + + + {t("MultiplayerModal.header.title")} + + + + + {isChecking || hasTerracotta === null ? ( + + + + {t("MultiplayerModal.status.checking")} + + + ) : ( + + {t( + `MultiplayerModal.status.${hasTerracotta ? "ready" : "notReady"}` + )} + + )} + + + {hasTerracotta ? ( + <> + + + {t("MultiplayerModal.field.inviteCode.label")} + + + setInviteCode( + event.target.value + .replace(/\D/g, "") + .slice(0, INVITE_CODE_LENGTH) + ) + } + placeholder={t( + "MultiplayerModal.field.inviteCode.placeholder" + )} + inputMode="numeric" + maxLength={INVITE_CODE_LENGTH} + bg={panelBg} + borderColor={modalBorderColor} + _hover={{ borderColor: `${primaryColor}.300` }} + _focusVisible={{ + borderColor: `${primaryColor}.400`, + boxShadow: `0 0 0 1px var(--chakra-colors-${primaryColor}-400)`, + }} + /> + + {t("MultiplayerModal.field.inviteCode.helper")} + + + + {generatedInviteCode && ( + + + + + {t("MultiplayerModal.label.roomInviteCode")} + + + {generatedInviteCode} + + + + + + )} + + + + + + + ) : ( + + + + + + + {t("MultiplayerModal.button.thirdPartyChannels")} + + + {t("MultiplayerModal.button.thirdPartyChannels")} + + + + + + openUrl("https://github.com/burningtnt/Terracotta") + } + > + {t("MultiplayerModal.menu.githubReleasePage")} + + + + + )} + + + + + + + + ); +}; + +export default MultiplayerModal; diff --git a/src/components/special/shared-modals-provider.tsx b/src/components/special/shared-modals-provider.tsx index d9f306a50..83f0ae26c 100644 --- a/src/components/special/shared-modals-provider.tsx +++ b/src/components/special/shared-modals-provider.tsx @@ -8,6 +8,7 @@ import DownloadResourceModal from "@/components/modals/download-resource-modal"; import GenericConfirmDialog from "@/components/modals/generic-confirm-dialog"; import ImportModpackModal from "@/components/modals/import-modpack-modal"; import LaunchProcessModal from "@/components/modals/launch-process-modal"; +import MultiplayerModal from "@/components/modals/multiplayer-modal"; import NotifyNewVersionModal from "@/components/modals/notify-new-version-modal"; import ReLoginPlayerModal from "@/components/modals/relogin-player-modal"; import SpotlightSearchModal from "@/components/modals/spotlight-search-modal"; @@ -40,6 +41,7 @@ const SharedModals: React.FC<{ children: React.ReactNode }> = ({ "generic-confirm": GenericConfirmDialog, "import-modpack": ImportModpackModal, launch: LaunchProcessModal, + multiplayer: MultiplayerModal, "notify-new-version": NotifyNewVersionModal, relogin: ReLoginPlayerModal, "spotlight-search": SpotlightSearchModal, diff --git a/src/global.d.ts b/src/global.d.ts index fea93a6b1..c5ab50190 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -1,5 +1,7 @@ export {}; +declare module "*.css"; + declare global { interface Window { logger: { diff --git a/src/locales/en.json b/src/locales/en.json index 116a76a84..4e2f09edb 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1447,6 +1447,7 @@ }, "LaunchPage": { "button": { + "multiplayer": "Multiplayer", "launch": "Launch Game", "instanceSettings": "Instance Settings" }, @@ -1533,6 +1534,39 @@ "MenuSelector": { "selectedCount": "{{count}} selected" }, + "MultiplayerModal": { + "button": { + "copyInviteCode": "Copy Invite Code", + "createRoom": "Start as Host", + "downloadCore": "Download Core", + "joinRoom": "Join as Guest", + "thirdPartyChannels": "Third-Party Downloads" + }, + "field": { + "inviteCode": { + "helper": "6 digits", + "label": "Invite Code", + "placeholder": "Enter 6-digit code" + } + }, + "header": { + "title": "Multiplayer" + }, + "label": { + "roomInviteCode": "Room Invite Code" + }, + "menu": { + "githubReleasePage": "GitHub Releases" + }, + "status": { + "checking": "Checking multiplayer core", + "notReady": "Multiplayer core is not installed", + "ready": "Multiplayer core is ready" + }, + "toast": { + "noOpenInstance": "No open instance" + } + }, "NoSuitableJavaDialog": { "title": "No suitable Java runtime found for this instance", "body": "No suitable Java runtime is available for this instance. Click \"Confirm\" to open Java Management page and add or download Java manually." @@ -2246,6 +2280,38 @@ } } }, + "multiplayer": { + "checkTerracottaSupport": { + "error": { + "title": "Failed to check multiplayer core" + }, + "success": "Multiplayer core check completed" + }, + "createRoom": { + "error": { + "description": { + "NO_OPEN_INSTANCE": "No open instance" + }, + "title": "Failed to create multiplayer room" + }, + "success": "Multiplayer room created" + }, + "downloadTerracotta": { + "error": { + "title": "Failed to download multiplayer core" + }, + "success": "Multiplayer core downloaded" + }, + "joinRoom": { + "error": { + "description": { + "INVALID_INVITE_CODE": "Invite code must be 6 digits" + }, + "title": "Failed to join multiplayer room" + }, + "success": "Joined multiplayer room" + } + }, "resource": { "fetchGameVersionList": { "error": { diff --git a/src/locales/zh-Hans.json b/src/locales/zh-Hans.json index 5276378cc..215471751 100644 --- a/src/locales/zh-Hans.json +++ b/src/locales/zh-Hans.json @@ -1447,6 +1447,7 @@ }, "LaunchPage": { "button": { + "multiplayer": "联机", "launch": "启动游戏", "instanceSettings": "实例设置" }, @@ -1533,6 +1534,39 @@ "MenuSelector": { "selectedCount": "已选 {{count}} 项" }, + "MultiplayerModal": { + "button": { + "copyInviteCode": "复制邀请码", + "createRoom": "以房主身份开始游戏", + "downloadCore": "下载联机核心", + "joinRoom": "以房客身份加入游戏", + "thirdPartyChannels": "第三方下载渠道" + }, + "field": { + "inviteCode": { + "helper": "6位数字", + "label": "邀请码", + "placeholder": "请输入6位邀请码" + } + }, + "header": { + "title": "联机" + }, + "label": { + "roomInviteCode": "房间邀请码" + }, + "menu": { + "githubReleasePage": "GitHub 发布页" + }, + "status": { + "checking": "正在检查联机核心", + "notReady": "未下载联机核心", + "ready": "联机核心已就绪" + }, + "toast": { + "noOpenInstance": "没有打开的实例" + } + }, "NoSuitableJavaDialog": { "title": "未找到合适的 Java 运行时", "body": "对于当前实例,没有合适的 Java 运行时可供选择。点击 “确定” 以前往 Java 管理页面手动添加或下载 Java。" @@ -2246,6 +2280,38 @@ } } }, + "multiplayer": { + "checkTerracottaSupport": { + "error": { + "title": "检查联机核心失败" + }, + "success": "联机核心检查完成" + }, + "createRoom": { + "error": { + "description": { + "NO_OPEN_INSTANCE": "没有打开的实例" + }, + "title": "创建联机房间失败" + }, + "success": "联机房间已创建" + }, + "downloadTerracotta": { + "error": { + "title": "下载联机核心失败" + }, + "success": "联机核心已下载" + }, + "joinRoom": { + "error": { + "description": { + "INVALID_INVITE_CODE": "邀请码必须为6位数字" + }, + "title": "加入联机房间失败" + }, + "success": "已加入联机房间" + } + }, "resource": { "fetchGameVersionList": { "error": { diff --git a/src/pages/launch.tsx b/src/pages/launch.tsx index 123415126..d7f56c3d3 100644 --- a/src/pages/launch.tsx +++ b/src/pages/launch.tsx @@ -4,6 +4,7 @@ import { Card, Center, HStack, + Icon, IconButton, IconButtonProps, Popover, @@ -19,7 +20,7 @@ import { import { useRouter } from "next/router"; import { cloneElement, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { LuArrowLeftRight, LuPlus, LuSettings } from "react-icons/lu"; +import { LuArrowLeftRight, LuGlobe, LuPlus, LuSettings } from "react-icons/lu"; import { CommonIconButton } from "@/components/common/common-icon-button"; import { CompactButtonGroup } from "@/components/common/compact-button-group"; import InstancesView from "@/components/instances-view"; @@ -56,9 +57,6 @@ const ButtonWithPopover: React.FC = ({ const [tooltipDisabled, setTooltipDisabled] = useState(false); - // To use Popover and Tooltip together, refer to: https://github.com/chakra-ui/chakra-ui/issues/2843 - // However, when the Popover is closed, the Tooltip will wrongly show again. - // To prevent this, we temporarily disable the Tooltip using a timeout. const handleClose = () => { setTooltipDisabled(true); onClose(); @@ -70,11 +68,10 @@ const ButtonWithPopover: React.FC = ({ isOpen={showAdd ? false : isOpen} onClose={handleClose} placement="top-end" - gutter={12} // add more gutter to show clear space from the launch button's shadow + gutter={12} > - {/* anchor for Tooltip */} = ({ {cloneElement(popoverContent, { - // Delay close after selecting an item for better UX. onSelectCallback: () => setTimeout(handleClose, 100), })} @@ -126,136 +122,156 @@ const LaunchPage = () => { const hasInstances = instanceList.length > 0; return ( - - + - - - - - - {selectedInstance && hasInstances && ( - - router.push( - `/instances/details/${encodeURIComponent( - selectedInstance.id - )}/settings` - ) - } - /> - )} - + } - onClick={() => router.push("/instances/list")} - showAdd={!hasInstances} - onAddClick={() => router.push("/instances/add-import")} + onClick={() => router.push("/accounts")} + showAdd={!hasPlayers} + onAddClick={() => router.push("/accounts?add=true")} /> - + + + + {selectedPlayer ? ( + <> + + + + {selectedPlayer.name} + + + {t( + `Enums.playerTypes.${selectedPlayer.playerType === PlayerType.ThirdParty ? "3rdpartyShort" : selectedPlayer.playerType}` + )} + + + {selectedPlayer.playerType === PlayerType.ThirdParty && + selectedPlayer.authServer?.name} + + + + ) : ( +
+ + {t("LaunchPage.Text.noSelectedPlayer")} + +
+ )} +
+
+ + + + + + + {selectedInstance && hasInstances && ( + + router.push( + `/instances/details/${encodeURIComponent( + selectedInstance.id + )}/settings` + ) + } + /> + )} + + + } + onClick={() => router.push("/instances/list")} + showAdd={!hasInstances} + onAddClick={() => router.push("/instances/add-import")} + /> + + -
- + + ); }; diff --git a/src/services/multiplayer.ts b/src/services/multiplayer.ts new file mode 100644 index 000000000..3cd094386 --- /dev/null +++ b/src/services/multiplayer.ts @@ -0,0 +1,25 @@ +import { invoke } from "@tauri-apps/api/core"; +import { InvokeResponse } from "@/models/response"; +import { responseHandler } from "@/utils/response"; + +export class MultiplayerService { + @responseHandler("multiplayer") + static async checkTerracottaSupport(): Promise> { + return await invoke("check_terracotta_support"); + } + + @responseHandler("multiplayer") + static async createRoom(): Promise> { + return await invoke("create_room"); + } + + @responseHandler("multiplayer") + static async downloadTerracotta(): Promise> { + return await invoke("download_terracotta"); + } + + @responseHandler("multiplayer") + static async joinRoom(inviteCode: string): Promise> { + return await invoke("join_room", { inviteCode }); + } +} diff --git a/src/styles/launch.module.css b/src/styles/launch.module.css index 96ac9ce27..ab624e4d3 100644 --- a/src/styles/launch.module.css +++ b/src/styles/launch.module.css @@ -9,7 +9,6 @@ transition: box-shadow 0.2s ease; } - .launch-button:hover { box-shadow: -2px 0 5px 2px rgba(233, 121, 57, 0.5), @@ -24,9 +23,38 @@ 3px 0 5px 3px rgba(222, 55, 188, 0.7); } +.multiplayer-button { + height: 4.5rem !important; + width: 10.5rem !important; + backdrop-filter: blur(12px); + background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(59, 130, 246, 0.28)) !important; + border: 1px solid rgba(255, 255, 255, 0.2) !important; + box-shadow: + -1px 0 8px 1px rgba(34, 197, 94, 0.24), + 1px 0 8px 1px rgba(59, 130, 246, 0.28); + transition: + box-shadow 0.2s ease, + transform 0.2s ease, + background 0.2s ease; +} + +.multiplayer-button:hover { + transform: translateY(-1px); + box-shadow: + -2px 0 10px 2px rgba(34, 197, 94, 0.3), + 2px 0 10px 2px rgba(59, 130, 246, 0.34); +} + +.multiplayer-button:active { + transform: translateY(0); + box-shadow: + -2px 0 12px 3px rgba(34, 197, 94, 0.38), + 2px 0 12px 3px rgba(59, 130, 246, 0.4); +} + .selected-user-card { height: 4.5rem !important; width: 10.5rem !important; backdrop-filter: blur(7px); padding: var(--chakra-space-3); -} \ No newline at end of file +} diff --git a/target-codex-checkC3WPzf/CACHEDIR.TAG b/target-codex-checkC3WPzf/CACHEDIR.TAG new file mode 100644 index 000000000..20d7c319c --- /dev/null +++ b/target-codex-checkC3WPzf/CACHEDIR.TAG @@ -0,0 +1,3 @@ +Signature: 8a477f597d28d172789f06886806bc55 +# This file is a cache directory tag created by cargo. +# For information about cache directory tags see https://bford.info/cachedir/ From 5f3f4f3ee6ade79c86f1eebb4918cbda8454a3be Mon Sep 17 00:00:00 2001 From: icgnos Date: Sun, 5 Apr 2026 21:51:27 +0800 Subject: [PATCH 05/13] feat(multiplayer): refactor terracotta commands and add fetch port functionality --- src-tauri/src/lib.rs | 6 +- src-tauri/src/multiplayer/commands.rs | 80 +++++++++++++------ .../src/multiplayer/helpers/terracotta.rs | 6 +- 3 files changed, 63 insertions(+), 29 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 683c060ab..020f969f4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -181,10 +181,10 @@ pub async fn run() { utils::commands::delete_directory, utils::commands::retrieve_truetype_font_list, utils::commands::check_service_availability, - multiplayer::commands::check_terracotta_support, + multiplayer::commands::check_terracotta, + multiplayer::commands::launch_terracotta, multiplayer::commands::download_terracotta, - multiplayer::commands::join_room, - multiplayer::commands::create_room, + multiplayer::commands::fetch_port, ]) .setup(|app| { // init APP_DATA_DIR diff --git a/src-tauri/src/multiplayer/commands.rs b/src-tauri/src/multiplayer/commands.rs index c31d49519..322083661 100644 --- a/src-tauri/src/multiplayer/commands.rs +++ b/src-tauri/src/multiplayer/commands.rs @@ -1,23 +1,51 @@ +use std::{ffi::OsStr, fs}; + use crate::{ error::SJMCLResult, multiplayer::helpers::terracotta::{build_download_param, decompress}, resource::models::ResourceError, tasks::commands::schedule_progressive_task_group, }; -use tauri::{AppHandle, Manager}; +use serde_json::Value; +use tauri::{path::BaseDirectory, AppHandle, Manager}; +use tokio::process::Command; + +#[tauri::command] +pub async fn check_terracotta(app: AppHandle) -> SJMCLResult { + let dir = &app.path().resolve("terracotta", BaseDirectory::AppData)?; + Ok(dir.exists()) +} #[tauri::command] -pub async fn check_terracotta_support() -> SJMCLResult { - // 检查平台支持 - // #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] - // return Ok(false); +pub async fn launch_terracotta(app: AppHandle) -> SJMCLResult<()> { + let dir = &app.path().resolve("terracotta", BaseDirectory::AppData)?; + + for entry in fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); - // 检查陶瓦服务状态 - //// 检查陶瓦服务是否存在 - //// 检查版本是否为最新 - //// 返回状态 - // 实现检查逻辑 - Ok(true) + if path.is_file() + && path.extension() + == if cfg!(target_os = "windows") { + Some(OsStr::new("exe")) + } else if cfg!(target_os = "macos") { + None // TODO: 不知道安装后是什么 + } else { + None + } + { + Command::new(path) + .arg("--hmcl") + .arg( + &app + .path() + .resolve("sjmcl-terracotta", BaseDirectory::Temp)?, + ) + .spawn()?; + return Ok(()); + } + } + Ok(()) } #[tauri::command] @@ -28,22 +56,24 @@ pub async fn download_terracotta(app: AppHandle) -> SJMCLResult<()> { } schedule_progressive_task_group(app.clone(), "terracotta".to_string(), download_param, false) .await?; - decompress(&app).await?; - Ok(()) -} - -#[tauri::command] -pub async fn join_room(invite_code: String) -> SJMCLResult<()> { - // 获取陶瓦节点列表 - // 构建查询参数 - // 发送HTTP请求 + decompress(&app)?; + // TODO: 如果是 macOS 还需要安装 Ok(()) } #[tauri::command] -pub async fn create_room() -> SJMCLResult { - // 检查运行中的MC进程 - // 获取陶瓦节点列表 - // 创建房间并返回邀请码 - Ok("invite_code".to_string()) +pub async fn fetch_port(app: AppHandle) -> SJMCLResult { + let path = &app + .path() + .resolve("sjmcl-terracotta", BaseDirectory::Temp)?; + loop { + if path.exists() { + let content = fs::read_to_string(path)?; + let json: Value = serde_json::from_str(&content)?; + if let Some(port) = json.get("port").and_then(|v| v.as_u64()) { + return Ok(port as u16); + } + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } + } } diff --git a/src-tauri/src/multiplayer/helpers/terracotta.rs b/src-tauri/src/multiplayer/helpers/terracotta.rs index 140de97a0..729bcddd9 100644 --- a/src-tauri/src/multiplayer/helpers/terracotta.rs +++ b/src-tauri/src/multiplayer/helpers/terracotta.rs @@ -52,7 +52,7 @@ pub async fn build_download_param(app: &AppHandle) -> SJMCLResult SJMCLResult<()> { +pub fn decompress(app: &AppHandle) -> SJMCLResult<()> { let dir = app.path().resolve("terracotta", BaseDirectory::AppData)?; for entry in fs::read_dir(&dir)? { @@ -71,3 +71,7 @@ pub async fn decompress(app: &AppHandle) -> SJMCLResult<()> { Ok(()) } + +pub fn _install() { + todo!() +} From 8a5df3827a905046bf62bfea7cfc360582d3df7a Mon Sep 17 00:00:00 2001 From: hbz <2759712286@qq.com> Date: Mon, 6 Apr 2026 15:40:09 +0800 Subject: [PATCH 06/13] modified: src-tauri/src/multiplayer/commands.rs modified: src-tauri/src/multiplayer/helpers/terracotta.rs modified: src/components/modals/multiplayer-modal.tsx modified: src/locales/en.json modified: src/locales/zh-Hans.json modified: src/services/multiplayer.ts --- src-tauri/src/multiplayer/commands.rs | 8 +-- .../src/multiplayer/helpers/terracotta.rs | 47 ++++---------- src/components/modals/multiplayer-modal.tsx | 61 +++++++++++-------- src/locales/en.json | 57 ++++++++++++----- src/locales/zh-Hans.json | 57 ++++++++++++----- src/services/multiplayer.ts | 13 +++- 6 files changed, 148 insertions(+), 95 deletions(-) diff --git a/src-tauri/src/multiplayer/commands.rs b/src-tauri/src/multiplayer/commands.rs index 2b78aee3e..12ec083e2 100644 --- a/src-tauri/src/multiplayer/commands.rs +++ b/src-tauri/src/multiplayer/commands.rs @@ -1,11 +1,8 @@ use std::{ffi::OsStr, fs}; use crate::{ - error::{SJMCLError, SJMCLResult}, - launch::models::LaunchingState, - multiplayer::helpers::terracotta::{ - build_download_param, decompress, download_terracotta_archive, is_terracotta_ready, - }, + error::SJMCLResult, + multiplayer::helpers::terracotta::{build_download_param, decompress}, resource::models::ResourceError, tasks::commands::schedule_progressive_task_group, }; @@ -16,6 +13,7 @@ use tokio::process::Command; #[tauri::command] pub async fn check_terracotta(app: AppHandle) -> SJMCLResult { let dir = &app.path().resolve("terracotta", BaseDirectory::AppData)?; + println!("Checking if Terracotta is installed at: {:?}", dir); Ok(dir.exists()) } diff --git a/src-tauri/src/multiplayer/helpers/terracotta.rs b/src-tauri/src/multiplayer/helpers/terracotta.rs index 3aed62cba..9abe79de0 100644 --- a/src-tauri/src/multiplayer/helpers/terracotta.rs +++ b/src-tauri/src/multiplayer/helpers/terracotta.rs @@ -2,50 +2,47 @@ use crate::error::SJMCLResult; use crate::launcher_config::models::LauncherConfig; use crate::resource::helpers::misc::{get_download_api, get_source_priority_list}; use crate::resource::models::ResourceType; -use crate::tasks::download::DownloadParam; +use crate::tasks::{download::DownloadParam, PTaskParam}; use flate2::read::GzDecoder; use std::fs; -use std::path::PathBuf; use std::sync::Mutex; use tar::Archive; use tauri::path::BaseDirectory; use tauri::{AppHandle, Manager}; use tauri_plugin_http::reqwest; -pub async fn build_download_param(app: &AppHandle) -> SJMCLResult> { +pub async fn build_download_param(app: &AppHandle) -> SJMCLResult> { let config = app.state::>().lock()?.clone(); let client = app.state::(); - let mut param = Vec::::new(); + let mut param = Vec::::new(); let platform = match (&*config.basic_info.os_type, &*config.basic_info.arch) { ("windows", "aarch64") => "windows-arm64", - ("windows", "_") => "windows-x86_64", + ("windows", _) => "windows-x86_64", ("macos", "aarch64") => "macos-arm64", ("macos", _) => "macos-x86_64", ("linux", "aarch64") => "linux-arm64", ("linux", "riscv64") => "linux-riscv64", _ => "linux-x86_64", }; - let priority_list = get_source_priority_list(&config); for source_type in priority_list.iter() { let api_url = get_download_api(*source_type, ResourceType::Terrocotta)?; match client.get(api_url.clone()).send().await { Ok(_) => { - let filename = format!("terracotta-0.4.2-{platform}-pkg.tar.gz"); - let url = api_url.join(&format!("releases/download/v0.4.2/{filename}"))?; - let path = app - .path() - .resolve(PathBuf::from("terracotta"), BaseDirectory::AppData)? - .join(&filename); - param.push(DownloadParam { + let url = api_url.join(&format!( + "/burningtnt/Terracotta/releases/download/v0.4.2/terracotta-0.4.2-{platform}-pkg.tar.gz" + ))?; + let path = app.path().resolve("terracotta", BaseDirectory::AppData)?; + fs::create_dir_all(&path)?; + param.push(PTaskParam::Download(DownloadParam { src: url, dest: path, - filename: Some(filename), + filename: None, sha1: None, - }); + })); break; } Err(_) => continue, @@ -55,29 +52,9 @@ pub async fn build_download_param(app: &AppHandle) -> SJMCLResult SJMCLResult<()> { - let client = app.state::(); - let response = client.get(download_param.src.clone()).send().await?; - let bytes = response.error_for_status()?.bytes().await?; - - if let Some(parent) = download_param.dest.parent() { - tokio::fs::create_dir_all(parent).await?; - } - - tokio::fs::write(&download_param.dest, &bytes).await?; - Ok(()) -} - pub fn decompress(app: &AppHandle) -> SJMCLResult<()> { let dir = app.path().resolve("terracotta", BaseDirectory::AppData)?; - if !dir.exists() { - return Ok(()); - } - for entry in fs::read_dir(&dir)? { let entry = entry?; let path = entry.path(); diff --git a/src/components/modals/multiplayer-modal.tsx b/src/components/modals/multiplayer-modal.tsx index f705717a9..c471907a3 100644 --- a/src/components/modals/multiplayer-modal.tsx +++ b/src/components/modals/multiplayer-modal.tsx @@ -33,6 +33,7 @@ import { useTranslation } from "react-i18next"; import { IconType } from "react-icons"; import { LuCopy, LuDownload, LuHouse, LuUsers } from "react-icons/lu"; import { useLauncherConfig } from "@/contexts/config"; +import { useGlobalData } from "@/contexts/global-data"; import { useToast } from "@/contexts/toast"; import { MultiplayerService } from "@/services/multiplayer"; import { copyText } from "@/utils/copy"; @@ -120,7 +121,8 @@ const MultiplayerModal: React.FC> = ({ const toast = useToast(); const { config } = useLauncherConfig(); const primaryColor = config.appearance.theme.primaryColor; - + const { selectedPlayer } = useGlobalData(); + const [port, setPort] = useState(0); const [generatedInviteCode, setGeneratedInviteCode] = useState(""); const [hasTerracotta, setHasTerracotta] = useState(null); const [inviteCode, setInviteCode] = useState(""); @@ -155,10 +157,9 @@ const MultiplayerModal: React.FC> = ({ ); const optionIconBg = useColorModeValue("blackAlpha.100", "whiteAlpha.150"); const optionTextColor = useColorModeValue("gray.800", "whiteAlpha.900"); - const checkTerracottaSupport = useCallback(async () => { setIsChecking(true); - const response = await MultiplayerService.checkTerracottaSupport(); + const response = await MultiplayerService.checkTerracotta(); if (response.status === "success") { setHasTerracotta(response.data); @@ -189,25 +190,15 @@ const MultiplayerModal: React.FC> = ({ const handleCreateRoom = async () => { setIsCreatingRoom(true); - const response = await MultiplayerService.createRoom(); - - if (response.status === "success") { - setGeneratedInviteCode(response.data); - setInviteCode(response.data); - toast({ - title: response.message, - status: "success", - }); - } else { - const hasNoOpenInstance = response.raw_error === "NO_OPEN_INSTANCE"; - - toast({ - title: hasNoOpenInstance - ? t("MultiplayerModal.toast.noOpenInstance") - : response.message, - description: hasNoOpenInstance ? undefined : response.details, - status: hasNoOpenInstance ? "warning" : "error", - }); + await fetch(`/127.0.0.1:${port}/state/scanning?${selectedPlayer?.name}`, { + method: "GET", + }); + const response = await fetch(`/127.0.0.1:${port}/state`, { + method: "GET", + }); + if (response.ok) { + const data = await response.json(); + setGeneratedInviteCode(data.room); } setIsCreatingRoom(false); @@ -234,13 +225,33 @@ const MultiplayerModal: React.FC> = ({ setIsDownloading(false); }; + const handleInit = useCallback(async () => { + console.log("Initializing multiplayer modal..."); + await MultiplayerService.launchTerracotta() + .then(() => { + console.log("Launched Terracotta"); + }) + .catch((err) => { + console.error("Failed to launch Terracotta:", err); + }); + const response = await MultiplayerService.fetchPort(); + if (response.status === "success") { + setPort(response.data); + console.log("Fetched Terracotta port:", response.data); + } + }, []); + + useEffect(() => { + if (props.isOpen && hasTerracotta === true) { + handleInit(); + } + }, [props.isOpen, hasTerracotta, handleInit]); + const handleJoinRoom = async () => { const normalizedInviteCode = inviteCode.trim(); - if (normalizedInviteCode.length !== INVITE_CODE_LENGTH) return; - setIsJoiningRoom(true); const response = await MultiplayerService.joinRoom(normalizedInviteCode); - + /*/state/guesting?&*/ if (response.status === "success") { toast({ title: response.message, diff --git a/src/locales/en.json b/src/locales/en.json index 4e2f09edb..18d9b72d5 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1544,9 +1544,9 @@ }, "field": { "inviteCode": { - "helper": "6 digits", + "helper": "Invite code", "label": "Invite Code", - "placeholder": "Enter 6-digit code" + "placeholder": "Enter invite code" } }, "header": { @@ -1558,13 +1558,26 @@ "menu": { "githubReleasePage": "GitHub Releases" }, + "runtimeState": { + "guest-connecting": "Preparing to join room", + "guest-ok": "Ready to launch and join", + "guest-starting": "Room joined, waiting for launch info", + "host-ok": "Room created successfully", + "host-scanning": "Scanning to create room", + "host-starting": "Creating room", + "waiting": "Ready to create or join a room" + }, "status": { "checking": "Checking multiplayer core", "notReady": "Multiplayer core is not installed", "ready": "Multiplayer core is ready" }, "toast": { - "noOpenInstance": "No open instance" + "joinReady": "Ready to launch and join the room", + "joinTimeout": "Timed out while joining the room", + "launchTimeout": "Timed out while starting multiplayer core", + "roomReady": "Room created and invite code is ready", + "scanTimeout": "Timed out while creating the room" } }, "NoSuitableJavaDialog": { @@ -2287,29 +2300,45 @@ }, "success": "Multiplayer core check completed" }, - "createRoom": { - "error": { - "description": { - "NO_OPEN_INSTANCE": "No open instance" - }, - "title": "Failed to create multiplayer room" - }, - "success": "Multiplayer room created" - }, "downloadTerracotta": { "error": { "title": "Failed to download multiplayer core" }, "success": "Multiplayer core downloaded" }, - "joinRoom": { + "fetchPort": { + "error": { + "title": "Failed to get multiplayer core port" + }, + "success": "Multiplayer core port retrieved" + }, + "fetchState": { + "error": { + "title": "Failed to get multiplayer room status" + }, + "success": "Multiplayer room status retrieved" + }, + "launchTerracotta": { + "error": { + "title": "Failed to start multiplayer core" + }, + "success": "Multiplayer core started" + }, + "startGuesting": { "error": { "description": { + "HTTP_400": "Unable to join the room", "INVALID_INVITE_CODE": "Invite code must be 6 digits" }, "title": "Failed to join multiplayer room" }, - "success": "Joined multiplayer room" + "success": "Joined room request sent" + }, + "startScanning": { + "error": { + "title": "Failed to start room scanning" + }, + "success": "Room scanning started" } }, "resource": { diff --git a/src/locales/zh-Hans.json b/src/locales/zh-Hans.json index 215471751..6049a8c9a 100644 --- a/src/locales/zh-Hans.json +++ b/src/locales/zh-Hans.json @@ -1544,9 +1544,9 @@ }, "field": { "inviteCode": { - "helper": "6位数字", + "helper": "邀请码", "label": "邀请码", - "placeholder": "请输入6位邀请码" + "placeholder": "请输入邀请码" } }, "header": { @@ -1558,13 +1558,26 @@ "menu": { "githubReleasePage": "GitHub 发布页" }, + "runtimeState": { + "guest-connecting": "正在准备加入房间", + "guest-ok": "已准备好启动并加入房间", + "guest-starting": "已加入房间,正在等待启动信息", + "host-ok": "房间已创建成功", + "host-scanning": "正在扫描并创建房间", + "host-starting": "正在创建房间", + "waiting": "可以创建房间或加入房间" + }, "status": { "checking": "正在检查联机核心", "notReady": "未下载联机核心", "ready": "联机核心已就绪" }, "toast": { - "noOpenInstance": "没有打开的实例" + "joinReady": "已准备好启动游戏并加入房间", + "joinTimeout": "加入房间超时", + "launchTimeout": "启动联机核心超时", + "roomReady": "房间已创建,邀请码已生成", + "scanTimeout": "创建房间超时" } }, "NoSuitableJavaDialog": { @@ -2287,29 +2300,45 @@ }, "success": "联机核心检查完成" }, - "createRoom": { - "error": { - "description": { - "NO_OPEN_INSTANCE": "没有打开的实例" - }, - "title": "创建联机房间失败" - }, - "success": "联机房间已创建" - }, "downloadTerracotta": { "error": { "title": "下载联机核心失败" }, "success": "联机核心已下载" }, - "joinRoom": { + "fetchPort": { + "error": { + "title": "获取联机核心端口失败" + }, + "success": "联机核心端口已获取" + }, + "fetchState": { + "error": { + "title": "获取联机房间状态失败" + }, + "success": "联机房间状态已获取" + }, + "launchTerracotta": { + "error": { + "title": "启动联机核心失败" + }, + "success": "联机核心已启动" + }, + "startGuesting": { "error": { "description": { + "HTTP_400": "无法加入该房间", "INVALID_INVITE_CODE": "邀请码必须为6位数字" }, "title": "加入联机房间失败" }, - "success": "已加入联机房间" + "success": "已发送加入房间请求" + }, + "startScanning": { + "error": { + "title": "开启房间扫描失败" + }, + "success": "已开启房间扫描" } }, "resource": { diff --git a/src/services/multiplayer.ts b/src/services/multiplayer.ts index 3cd094386..77080b500 100644 --- a/src/services/multiplayer.ts +++ b/src/services/multiplayer.ts @@ -4,8 +4,13 @@ import { responseHandler } from "@/utils/response"; export class MultiplayerService { @responseHandler("multiplayer") - static async checkTerracottaSupport(): Promise> { - return await invoke("check_terracotta_support"); + static async checkTerracotta(): Promise> { + return await invoke("check_terracotta"); + } + + @responseHandler("multiplayer") + static async launchTerracotta(): Promise> { + return await invoke("launch_terracotta"); } @responseHandler("multiplayer") @@ -22,4 +27,8 @@ export class MultiplayerService { static async joinRoom(inviteCode: string): Promise> { return await invoke("join_room", { inviteCode }); } + @responseHandler("multiplayer") + static async fetchPort(): Promise> { + return await invoke("fetch_port"); + } } From 02b648246bf33c4ab6850b46b0b516454ae11f1d Mon Sep 17 00:00:00 2001 From: icgnos Date: Wed, 29 Apr 2026 23:16:15 +0800 Subject: [PATCH 07/13] feat(multiplayer): enhance terracotta commands with task monitoring and decompression improvements --- src-tauri/src/multiplayer/commands.rs | 17 +++++++--- .../src/multiplayer/helpers/terracotta.rs | 31 ++++++++++++------- src-tauri/src/resource/helpers/misc.rs | 4 +-- src-tauri/src/resource/models.rs | 2 +- src-tauri/src/tasks/monitor.rs | 23 +++++++++++++- 5 files changed, 57 insertions(+), 20 deletions(-) diff --git a/src-tauri/src/multiplayer/commands.rs b/src-tauri/src/multiplayer/commands.rs index 12ec083e2..d06ec585f 100644 --- a/src-tauri/src/multiplayer/commands.rs +++ b/src-tauri/src/multiplayer/commands.rs @@ -1,3 +1,4 @@ +use std::pin::Pin; use std::{ffi::OsStr, fs}; use crate::{ @@ -5,6 +6,7 @@ use crate::{ multiplayer::helpers::terracotta::{build_download_param, decompress}, resource::models::ResourceError, tasks::commands::schedule_progressive_task_group, + tasks::monitor::TaskMonitor, }; use serde_json::Value; use tauri::{path::BaseDirectory, AppHandle, Manager}; @@ -14,7 +16,7 @@ use tokio::process::Command; pub async fn check_terracotta(app: AppHandle) -> SJMCLResult { let dir = &app.path().resolve("terracotta", BaseDirectory::AppData)?; println!("Checking if Terracotta is installed at: {:?}", dir); - Ok(dir.exists()) + Ok(dir.exists() && fs::read_dir(dir)?.next().is_some()) } #[tauri::command] @@ -55,9 +57,16 @@ pub async fn download_terracotta(app: AppHandle) -> SJMCLResult<()> { if download_param.is_empty() { return Err(ResourceError::NoDownloadApi.into()); } - schedule_progressive_task_group(app.clone(), "terracotta".to_string(), download_param, false) - .await?; - decompress(&app)?; + let task_group = + schedule_progressive_task_group(app.clone(), "terracotta".to_string(), download_param, false) + .await?; + + let monitor = app.state::>>(); + monitor.wait_for_task_group(&task_group.task_group).await?; + + log::info!("Terracotta downloaded, starting decompression..."); + decompress(&app).await?; + log::info!("Terracotta decompressed successfully."); // TODO: 如果是 macOS 还需要安装 Ok(()) } diff --git a/src-tauri/src/multiplayer/helpers/terracotta.rs b/src-tauri/src/multiplayer/helpers/terracotta.rs index 9abe79de0..903b3bcf3 100644 --- a/src-tauri/src/multiplayer/helpers/terracotta.rs +++ b/src-tauri/src/multiplayer/helpers/terracotta.rs @@ -1,4 +1,4 @@ -use crate::error::SJMCLResult; +use crate::error::{SJMCLError, SJMCLResult}; use crate::launcher_config::models::LauncherConfig; use crate::resource::helpers::misc::{get_download_api, get_source_priority_list}; use crate::resource::models::ResourceType; @@ -29,14 +29,17 @@ pub async fn build_download_param(app: &AppHandle) -> SJMCLResult { - let url = api_url.join(&format!( - "/burningtnt/Terracotta/releases/download/v0.4.2/terracotta-0.4.2-{platform}-pkg.tar.gz" - ))?; - let path = app.path().resolve("terracotta", BaseDirectory::AppData)?; - fs::create_dir_all(&path)?; + let filename = format!("terracotta-0.4.2-{platform}-pkg.tar.gz"); + let url = api_url.join(&format!("download/v0.4.2/{filename}"))?; + let path = app + .path() + .resolve("terracotta", BaseDirectory::AppData)? + .join(filename); + // fs::create_dir_all(&path)?; + log::debug!("{}, {}", url, path.to_str().unwrap()); param.push(PTaskParam::Download(DownloadParam { src: url, dest: path, @@ -52,24 +55,28 @@ pub async fn build_download_param(app: &AppHandle) -> SJMCLResult SJMCLResult<()> { +pub async fn decompress(app: &AppHandle) -> SJMCLResult<()> { let dir = app.path().resolve("terracotta", BaseDirectory::AppData)?; - for entry in fs::read_dir(&dir)? { let entry = entry?; let path = entry.path(); if path.extension().and_then(|s| s.to_str()) == Some("gz") { + log::info!("Found compressed file: {:?}", path); let file = fs::File::open(&path)?; let decompressor = GzDecoder::new(file); let mut archive = Archive::new(decompressor); - archive.unpack(dir)?; + archive.unpack(dir).map_err(|e| { + log::error!("Failed to unpack archive: {}", e); + e + })?; fs::remove_file(path)?; return Ok(()); } } - - Ok(()) + Err(SJMCLError( + "No compressed file found in terracotta directory".into(), + )) } pub fn _install() { diff --git a/src-tauri/src/resource/helpers/misc.rs b/src-tauri/src/resource/helpers/misc.rs index 288ea7786..3e9668a3c 100644 --- a/src-tauri/src/resource/helpers/misc.rs +++ b/src-tauri/src/resource/helpers/misc.rs @@ -51,7 +51,7 @@ pub fn get_download_api(source: SourceType, resource_type: ResourceType) -> SJMC ResourceType::NeoforgeMaven | ResourceType::NeoforgeInstall => Ok(Url::parse("https://maven.neoforged.net/releases/")?), ResourceType::QuiltMaven => Ok(Url::parse("https://maven.quiltmc.org/repository/release/")?), ResourceType::QuiltMeta => Ok(Url::parse("https://meta.quiltmc.org/")?), - ResourceType::Terrocotta => Ok(Url::parse("https://github.com/burningtnt/Terracotta/releases")?), + ResourceType::Terracotta => Ok(Url::parse("https://github.com/burningtnt/Terracotta/releases/")?), }, SourceType::BMCLAPIMirror => match resource_type { ResourceType::VersionManifest => Ok(Url::parse("https://bmclapi2.bangbang93.com/mc/game/version_manifest.json")?), @@ -73,7 +73,7 @@ pub fn get_download_api(source: SourceType, resource_type: ResourceType) -> SJMC ResourceType::OptiFine => Ok(Url::parse("https://bmclapi2.bangbang93.com/optifine/")?), ResourceType::QuiltMaven => Ok(Url::parse("https://bmclapi2.bangbang93.com/maven/")?), ResourceType::QuiltMeta => Ok(Url::parse("https://bmclapi2.bangbang93.com/quilt-meta/")?),// seems 'not found' - ResourceType::Terrocotta => Ok(Url::parse("https://gitee.com/burningtnt/Terracotta/releases")?), + ResourceType::Terracotta => Ok(Url::parse("https://gitee.com/burningtnt/Terracotta/releases/")?), }, } } diff --git a/src-tauri/src/resource/models.rs b/src-tauri/src/resource/models.rs index f7055b911..c8fb201a6 100644 --- a/src-tauri/src/resource/models.rs +++ b/src-tauri/src/resource/models.rs @@ -28,7 +28,7 @@ pub enum ResourceType { NeoforgeMaven, QuiltMaven, QuiltMeta, - Terrocotta, + Terracotta, } #[derive(Eq, Hash, PartialEq, Clone, Copy, Debug, EnumIter)] diff --git a/src-tauri/src/tasks/monitor.rs b/src-tauri/src/tasks/monitor.rs index c746c2359..ca0d5eb50 100644 --- a/src-tauri/src/tasks/monitor.rs +++ b/src-tauri/src/tasks/monitor.rs @@ -1,4 +1,4 @@ -use crate::error::SJMCLResult; +use crate::error::{SJMCLError, SJMCLResult}; use crate::launcher_config::commands::retrieve_launcher_config; use crate::tasks::download::DownloadTask; use crate::tasks::events::{GEvent, GEventStatus, PEvent, TEvent}; @@ -479,6 +479,27 @@ impl TaskMonitor { } } + pub async fn wait_for_task_group(&self, task_group: &str) -> SJMCLResult<()> { + loop { + let status = { + let group_map = self.group_map.read().unwrap(); + group_map.get(task_group).map(|g| g.status.clone()) + }; + match status { + Some(GEventStatus::Completed) => return Ok(()), + Some(GEventStatus::Failed) => { + return Err(SJMCLError(format!("Task group '{task_group}' failed"))) + } + Some(GEventStatus::Cancelled) => { + return Err(SJMCLError(format!( + "Task group '{task_group}' was cancelled" + ))) + } + _ => tokio::time::sleep(std::time::Duration::from_millis(200)).await, + } + } + } + pub fn state_list(&self) -> Vec { self .group_map From 2919810da2080980cb4e084b23afa3b156b1e0ac Mon Sep 17 00:00:00 2001 From: icgnos Date: Thu, 30 Apr 2026 13:47:38 +0800 Subject: [PATCH 08/13] feat(multiplayer): add platform support check and update UI for multiplayer functionality --- src-tauri/capabilities/default.json | 5 +- src/components/modals/multiplayer-modal.tsx | 630 ++++++++++++-------- src/locales/en.json | 3 +- src/locales/zh-Hans.json | 3 +- src/pages/launch.tsx | 15 +- src/services/multiplayer.ts | 28 +- 6 files changed, 410 insertions(+), 274 deletions(-) diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 87cbc334f..58e75d45c 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -15,7 +15,10 @@ "deep-link:default", "dialog:default", "log:default", - "http:default", + { + "identifier": "http:default", + "allow": [{ "url": "http://127.0.0.1:*/*" }] + }, "os:default", "process:default" ] diff --git a/src/components/modals/multiplayer-modal.tsx b/src/components/modals/multiplayer-modal.tsx index c471907a3..5372fb7e8 100644 --- a/src/components/modals/multiplayer-modal.tsx +++ b/src/components/modals/multiplayer-modal.tsx @@ -1,10 +1,13 @@ import { + AlertDialog, + AlertDialogBody, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, Box, Button, ButtonProps, - FormControl, - FormHelperText, - FormLabel, Grid, HStack, Icon, @@ -27,8 +30,9 @@ import { VStack, useColorModeValue, } from "@chakra-ui/react"; +import { fetch } from "@tauri-apps/plugin-http"; import { openUrl } from "@tauri-apps/plugin-opener"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { IconType } from "react-icons"; import { LuCopy, LuDownload, LuHouse, LuUsers } from "react-icons/lu"; @@ -42,6 +46,13 @@ const TERRACOTTA_ICON_URL = "https://zh.minecraft.wiki/images/Red_Glazed_Terracotta_JE1_BE1.png?272a2"; const INVITE_CODE_LENGTH = 6; +type Phase = + | "checking" + | "notDownloaded" + | "ready" + | "scanning" + | "roomStarted"; + interface MultiplayerActionButtonProps extends ButtonProps { icon: IconType; imageSrc?: string; @@ -122,14 +133,22 @@ const MultiplayerModal: React.FC> = ({ const { config } = useLauncherConfig(); const primaryColor = config.appearance.theme.primaryColor; const { selectedPlayer } = useGlobalData(); + + const [phase, setPhase] = useState("checking"); const [port, setPort] = useState(0); const [generatedInviteCode, setGeneratedInviteCode] = useState(""); - const [hasTerracotta, setHasTerracotta] = useState(null); - const [inviteCode, setInviteCode] = useState(""); - const [isChecking, setIsChecking] = useState(false); - const [isCreatingRoom, setIsCreatingRoom] = useState(false); const [isDownloading, setIsDownloading] = useState(false); - const [isJoiningRoom, setIsJoiningRoom] = useState(false); + + const [isJoinDialogOpen, setIsJoinDialogOpen] = useState(false); + const [joinCode, setJoinCode] = useState(""); + const [isJoining, setIsJoining] = useState(false); + + const phaseRef = useRef("checking"); + const cancelRef = useRef(null); + + useEffect(() => { + phaseRef.current = phase; + }, [phase]); const panelBg = useColorModeValue( "rgba(255, 255, 255, 0.7)", @@ -157,62 +176,89 @@ const MultiplayerModal: React.FC> = ({ ); const optionIconBg = useColorModeValue("blackAlpha.100", "whiteAlpha.150"); const optionTextColor = useColorModeValue("gray.800", "whiteAlpha.900"); - const checkTerracottaSupport = useCallback(async () => { - setIsChecking(true); - const response = await MultiplayerService.checkTerracotta(); - if (response.status === "success") { - setHasTerracotta(response.data); - } else { + const handleInit = useCallback(async () => { + console.log("[multiplayer] launching terracotta..."); + await MultiplayerService.launchTerracotta().catch((err) => + console.error("[multiplayer] launch failed:", err) + ); + const response = await MultiplayerService.fetchPort(); + if (response.status === "success" && response.data) { + setPort(response.data); + console.log("[multiplayer] port:", response.data); + } else if (response.status === "error") { toast({ title: response.message, description: response.details, status: "error", }); - setHasTerracotta(false); } - - setIsChecking(false); }, [toast]); + const checkTerracottaSupport = useCallback(async () => { + const response = await MultiplayerService.checkTerracotta(); + if (response.status === "success" && response.data) { + await handleInit(); + setPhase("ready"); + } else { + setPhase("notDownloaded"); + } + }, [handleInit]); + useEffect(() => { if (!props.isOpen) return; + setPhase("checking"); + setPort(0); setGeneratedInviteCode(""); - setInviteCode(""); - setHasTerracotta(null); + setJoinCode(""); checkTerracottaSupport(); }, [checkTerracottaSupport, props.isOpen]); + useEffect(() => { + if (!props.isOpen || port === 0) return; + const intervalId = setInterval(async () => { + try { + const response = await fetch(`http://127.0.0.1:${port}/state`); + if (response.ok) { + const data = await response.json(); + console.log(`[multiplayer] state:`, data); + if (data.room) { + setGeneratedInviteCode(data.room); + if (phaseRef.current === "scanning") { + setPhase("roomStarted"); + } + } + } + } catch (e) { + console.error(`[multiplayer] poll error:`, e); + } + }, 500); + return () => clearInterval(intervalId); + }, [props.isOpen, port]); + const handleCopyInviteCode = async () => { if (!generatedInviteCode) return; await copyText(generatedInviteCode, { toast }); }; const handleCreateRoom = async () => { - setIsCreatingRoom(true); - await fetch(`/127.0.0.1:${port}/state/scanning?${selectedPlayer?.name}`, { - method: "GET", - }); - const response = await fetch(`/127.0.0.1:${port}/state`, { - method: "GET", - }); - if (response.ok) { - const data = await response.json(); - setGeneratedInviteCode(data.room); + const url = `http://127.0.0.1:${port}/state/scanning?${selectedPlayer?.name}`; + console.log(`[multiplayer] create room: ${url}`); + setPhase("scanning"); + try { + await fetch(url, { method: "GET" }); + } catch (e) { + console.error(`[multiplayer] create room error:`, e); + toast({ title: String(e), status: "error" }); + setPhase("ready"); } - - setIsCreatingRoom(false); }; const handleDownloadTerracotta = async () => { setIsDownloading(true); const response = await MultiplayerService.downloadTerracotta(); - if (response.status === "success") { - toast({ - title: response.message, - status: "success", - }); + toast({ title: response.message, status: "success" }); await checkTerracottaSupport(); } else { toast({ @@ -221,256 +267,318 @@ const MultiplayerModal: React.FC> = ({ status: "error", }); } - setIsDownloading(false); }; - const handleInit = useCallback(async () => { - console.log("Initializing multiplayer modal..."); - await MultiplayerService.launchTerracotta() - .then(() => { - console.log("Launched Terracotta"); - }) - .catch((err) => { - console.error("Failed to launch Terracotta:", err); - }); - const response = await MultiplayerService.fetchPort(); - if (response.status === "success") { - setPort(response.data); - console.log("Fetched Terracotta port:", response.data); + const handleJoinRoomConfirm = async () => { + setIsJoining(true); + const url = `http://127.0.0.1:${port}/state/guesting?${joinCode.trim()}&${selectedPlayer?.name}`; + console.log(`[multiplayer] join room: ${url}`); + try { + const response = await fetch(url, { method: "GET" }); + if (response.ok) { + toast({ + title: t("MultiplayerModal.toast.joinReady"), + status: "success", + }); + } else { + toast({ + title: t("MultiplayerModal.toast.joinTimeout"), + status: "error", + }); + } + } catch (e) { + toast({ title: String(e), status: "error" }); } - }, []); - - useEffect(() => { - if (props.isOpen && hasTerracotta === true) { - handleInit(); - } - }, [props.isOpen, hasTerracotta, handleInit]); - - const handleJoinRoom = async () => { - const normalizedInviteCode = inviteCode.trim(); - setIsJoiningRoom(true); - const response = await MultiplayerService.joinRoom(normalizedInviteCode); - /*/state/guesting?&*/ - if (response.status === "success") { - toast({ - title: response.message, - status: "success", - }); - props.onClose?.(); - } else { - toast({ - title: response.message, - description: response.details, - status: "error", - }); - } - - setIsJoiningRoom(false); + setIsJoining(false); + setIsJoinDialogOpen(false); }; return ( - - - - {t("MultiplayerModal.header.title")} - - - - - {isChecking || hasTerracotta === null ? ( - - - - {t("MultiplayerModal.status.checking")} - - - ) : ( - - {t( - `MultiplayerModal.status.${hasTerracotta ? "ready" : "notReady"}` - )} - + <> + + + + {t("MultiplayerModal.header.title")} + + + + {phase === "checking" && ( + + + + + {t("MultiplayerModal.status.checking")} + + + )} - - - {hasTerracotta ? ( - <> - - - {t("MultiplayerModal.field.inviteCode.label")} - - - setInviteCode( - event.target.value - .replace(/\D/g, "") - .slice(0, INVITE_CODE_LENGTH) - ) - } - placeholder={t( - "MultiplayerModal.field.inviteCode.placeholder" - )} - inputMode="numeric" - maxLength={INVITE_CODE_LENGTH} - bg={panelBg} - borderColor={modalBorderColor} - _hover={{ borderColor: `${primaryColor}.300` }} - _focusVisible={{ - borderColor: `${primaryColor}.400`, - boxShadow: `0 0 0 1px var(--chakra-colors-${primaryColor}-400)`, - }} - /> - - {t("MultiplayerModal.field.inviteCode.helper")} - - - {generatedInviteCode && ( + {phase === "notDownloaded" && ( + <> - - - - {t("MultiplayerModal.label.roomInviteCode")} - - - {generatedInviteCode} - - - - + + {t("MultiplayerModal.status.notReady")} + - )} + + + + + + + {t( + + + {t("MultiplayerModal.button.thirdPartyChannels")} + + + + + + openUrl("https://github.com/burningtnt/Terracotta") + } + > + {t("MultiplayerModal.menu.githubReleasePage")} + + + + + + )} - - - - - - ) : ( - - - - + + + {t("MultiplayerModal.status.ready")} + + + + + { + setJoinCode(""); + setIsJoinDialogOpen(true); + }} + /> + + + )} + + {phase === "scanning" && ( + <> + - - - {t("MultiplayerModal.button.thirdPartyChannels")} - - - {t("MultiplayerModal.button.thirdPartyChannels")} + + + + {t("MultiplayerModal.runtimeState.host-scanning")} - - - - openUrl("https://github.com/burningtnt/Terracotta") - } + + + + )} + + {phase === "roomStarted" && ( + <> + {generatedInviteCode && ( + - {t("MultiplayerModal.menu.githubReleasePage")} - - - - - )} - - - - - - - + + + + {t("MultiplayerModal.label.roomInviteCode")} + + + {generatedInviteCode} + + + + + + )} + + + )} + + + + + + + + + setIsJoinDialogOpen(false)} + isCentered + > + + + + {t("MultiplayerModal.button.joinRoom")} + + + + setJoinCode( + e.target.value + .replace(/\D/g, "") + .slice(0, INVITE_CODE_LENGTH) + ) + } + placeholder={t("MultiplayerModal.field.inviteCode.placeholder")} + inputMode="numeric" + maxLength={INVITE_CODE_LENGTH} + /> + + + + + + + + + ); }; diff --git a/src/locales/en.json b/src/locales/en.json index 18d9b72d5..b290bb3bb 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1449,7 +1449,8 @@ "button": { "multiplayer": "Multiplayer", "launch": "Launch Game", - "instanceSettings": "Instance Settings" + "instanceSettings": "Instance Settings", + "windowsNotSupported": "Multiplayer requires Windows 10 or above" }, "SwitchButton": { "tooltip": { diff --git a/src/locales/zh-Hans.json b/src/locales/zh-Hans.json index 6049a8c9a..ce9aeb867 100644 --- a/src/locales/zh-Hans.json +++ b/src/locales/zh-Hans.json @@ -1449,7 +1449,8 @@ "button": { "multiplayer": "联机", "launch": "启动游戏", - "instanceSettings": "实例设置" + "instanceSettings": "实例设置", + "windowsNotSupported": "联机需要 Windows 10 及以上版本" }, "SwitchButton": { "tooltip": { diff --git a/src/pages/launch.tsx b/src/pages/launch.tsx index d7f56c3d3..3847e9145 100644 --- a/src/pages/launch.tsx +++ b/src/pages/launch.tsx @@ -29,9 +29,11 @@ import PlayersView from "@/components/players-view"; import { useLauncherConfig } from "@/contexts/config"; import { useGlobalData } from "@/contexts/global-data"; import { useSharedModals } from "@/contexts/shared-modal"; +import { useToast } from "@/contexts/toast"; import { PlayerType } from "@/enums/account"; import { Player } from "@/models/account"; import { InstanceSummary } from "@/models/instance/misc"; +import { MultiplayerService } from "@/services/multiplayer"; import cardStyles from "@/styles/card.module.css"; import styles from "@/styles/launch.module.css"; @@ -103,6 +105,7 @@ const LaunchPage = () => { const { t } = useTranslation(); const router = useRouter(); const { openSharedModal } = useSharedModals(); + const toast = useToast(); const { selectedPlayer, getPlayerList, getInstanceList, selectedInstance } = useGlobalData(); @@ -129,7 +132,17 @@ const LaunchPage = () => { left={7} colorScheme="blackAlpha" className={styles["multiplayer-button"]} - onClick={() => openSharedModal("multiplayer")} + onClick={async () => { + const res = await MultiplayerService.checkPlatformSupport(); + if (res.status !== "success" || !res.data) { + toast({ + title: t("LaunchPage.button.windowsNotSupported"), + status: "error", + }); + return; + } + openSharedModal("multiplayer"); + }} > diff --git a/src/services/multiplayer.ts b/src/services/multiplayer.ts index 77080b500..5bd5c7e1a 100644 --- a/src/services/multiplayer.ts +++ b/src/services/multiplayer.ts @@ -1,8 +1,27 @@ import { invoke } from "@tauri-apps/api/core"; +import { type, version } from "@tauri-apps/plugin-os"; import { InvokeResponse } from "@/models/response"; import { responseHandler } from "@/utils/response"; export class MultiplayerService { + static async checkPlatformSupport(): Promise> { + try { + if (type() !== "windows") { + return { status: "success", message: "", data: false }; + } + const [major, _minor, build] = version().split(".").map(Number); + const supported = major >= 10 && build >= 10240; + return { status: "success", message: "", data: supported }; + } catch (e) { + return { + status: "error", + message: String(e), + details: "", + raw_error: String(e), + }; + } + } + @responseHandler("multiplayer") static async checkTerracotta(): Promise> { return await invoke("check_terracotta"); @@ -13,20 +32,11 @@ export class MultiplayerService { return await invoke("launch_terracotta"); } - @responseHandler("multiplayer") - static async createRoom(): Promise> { - return await invoke("create_room"); - } - @responseHandler("multiplayer") static async downloadTerracotta(): Promise> { return await invoke("download_terracotta"); } - @responseHandler("multiplayer") - static async joinRoom(inviteCode: string): Promise> { - return await invoke("join_room", { inviteCode }); - } @responseHandler("multiplayer") static async fetchPort(): Promise> { return await invoke("fetch_port"); From 3c0e298f8dbfff6dca867abf8519e1a7e31519f8 Mon Sep 17 00:00:00 2001 From: icgnos Date: Thu, 30 Apr 2026 17:01:41 +0800 Subject: [PATCH 09/13] feat(multiplayer): simplify invite code input handling in multiplayer modal --- src-tauri/src/utils/logging.rs | 2 +- src/components/modals/multiplayer-modal.tsx | 12 ++---------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/utils/logging.rs b/src-tauri/src/utils/logging.rs index c0a29daaa..c49127781 100644 --- a/src-tauri/src/utils/logging.rs +++ b/src-tauri/src/utils/logging.rs @@ -60,7 +60,7 @@ pub fn setup_with_app(app: AppHandle) -> SJMCLResult<()> { }; // filter out noisy debug logs - const FILTERED_TARGETS_DEBUG: &[&str] = &["h2::", "hyper_util"]; + const FILTERED_TARGETS_DEBUG: &[&str] = &["h2::", "hyper_util", "reqwest::connect"]; let p = tauri_plugin_log::Builder::default() .clear_targets() diff --git a/src/components/modals/multiplayer-modal.tsx b/src/components/modals/multiplayer-modal.tsx index 5372fb7e8..d666e0ae1 100644 --- a/src/components/modals/multiplayer-modal.tsx +++ b/src/components/modals/multiplayer-modal.tsx @@ -545,16 +545,8 @@ const MultiplayerModal: React.FC> = ({ - setJoinCode( - e.target.value - .replace(/\D/g, "") - .slice(0, INVITE_CODE_LENGTH) - ) - } + onChange={(e) => setJoinCode(e.target.value)} placeholder={t("MultiplayerModal.field.inviteCode.placeholder")} - inputMode="numeric" - maxLength={INVITE_CODE_LENGTH} /> @@ -568,7 +560,7 @@ const MultiplayerModal: React.FC> = ({ )} @@ -516,18 +612,163 @@ const MultiplayerModal: React.FC> = ({ )} - + + )} + + {phase === "error" && errorType !== null && ( + <> + + + {t( + "MultiplayerModal.error.description." + + ERROR_TYPE_TO_KEY[errorType] + )} + + + + + )} + + {phase === "disconnected" && ( + <> + + + {t("MultiplayerModal.status.disconnected")} + + + + + )} + + {phase === "guestStarting" && ( + <> + + + + {t("MultiplayerModal.guest.starting")} + + + {t("MultiplayerModal.guest.difficulty")}: {difficulty} + + + + + + )} + + {phase === "guestOk" && ( + <> + + + + {t("MultiplayerModal.guest.joined")} + + {profiles.map((p, i) => ( + + + + {p.name} + + + {" "} + ({p.kind}) + + + + ))} + + + )} - - - diff --git a/src/locales/en.json b/src/locales/en.json index b290bb3bb..f618d6df6 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1541,6 +1541,7 @@ "createRoom": "Start as Host", "downloadCore": "Download Core", "joinRoom": "Join as Guest", + "reconnect": "Restart Core", "thirdPartyChannels": "Third-Party Downloads" }, "field": { @@ -1570,15 +1571,34 @@ }, "status": { "checking": "Checking multiplayer core", + "disconnected": "Multiplayer core disconnected", "notReady": "Multiplayer core is not installed", "ready": "Multiplayer core is ready" }, "toast": { - "joinReady": "Ready to launch and join the room", "joinTimeout": "Timed out while joining the room", "launchTimeout": "Timed out while starting multiplayer core", "roomReady": "Room created and invite code is ready", "scanTimeout": "Timed out while creating the room" + }, + "error": { + "description": { + "PING_HOST_FAIL": "Failed to join room: Room is closed or network unstable", + "PING_HOST_RST": "Room connection lost: Room is closed or network unstable", + "GUEST_ET_CRASH": "Failed to join room: EasyTier crashed", + "HOST_ET_CRASH": "Failed to create room: EasyTier crashed", + "PING_SERVER_RST": "Room closed: You exited the game world, room closed automatically", + "SCAFFOLDING_INVALID_RESPONSE": "Invalid Protocol: Host has sent invalid response" + }, + "return": "Return" + }, + "guest": { + "starting": "Joining room...", + "difficulty": "Connection difficulty", + "joined": "Players in room", + "stop": "Stop", + "leave": "Leave Room", + "closeRoom": "Close Room" } }, "NoSuitableJavaDialog": { diff --git a/src/locales/zh-Hans.json b/src/locales/zh-Hans.json index ce9aeb867..c4a677289 100644 --- a/src/locales/zh-Hans.json +++ b/src/locales/zh-Hans.json @@ -1541,6 +1541,7 @@ "createRoom": "以房主身份开始游戏", "downloadCore": "下载联机核心", "joinRoom": "以房客身份加入游戏", + "reconnect": "重启陶瓦核心", "thirdPartyChannels": "第三方下载渠道" }, "field": { @@ -1570,15 +1571,34 @@ }, "status": { "checking": "正在检查联机核心", + "disconnected": "联机核心连接已断开", "notReady": "未下载联机核心", "ready": "联机核心已就绪" }, "toast": { - "joinReady": "已准备好启动游戏并加入房间", "joinTimeout": "加入房间超时", "launchTimeout": "启动联机核心超时", "roomReady": "房间已创建,邀请码已生成", "scanTimeout": "创建房间超时" + }, + "error": { + "description": { + "PING_HOST_FAIL": "加入房间失败:房间已关闭或网络不稳定", + "PING_HOST_RST": "房间连接丢失:房间已关闭或网络不稳定", + "GUEST_ET_CRASH": "加入房间失败:EasyTier 崩溃", + "HOST_ET_CRASH": "创建房间失败:EasyTier 崩溃", + "PING_SERVER_RST": "房间已关闭:你退出了游戏世界,房间自动关闭", + "SCAFFOLDING_INVALID_RESPONSE": "协议无效:房主发送了无效的响应数据" + }, + "return": "返回" + }, + "guest": { + "starting": "正在加入房间……", + "difficulty": "连接难度", + "joined": "房间内的玩家", + "stop": "停止", + "leave": "退出房间", + "closeRoom": "关闭房间" } }, "NoSuitableJavaDialog": { From 5d0fc0017a7e20e3f6fccf988dab32f6d0c40562 Mon Sep 17 00:00:00 2001 From: icgnos Date: Fri, 1 May 2026 17:06:19 +0800 Subject: [PATCH 11/13] feat(multiplayer): refactor error handling and add multiplayer error models --- src-tauri/src/multiplayer/commands.rs | 8 ++-- .../src/multiplayer/helpers/terracotta.rs | 12 ++---- src-tauri/src/multiplayer/mod.rs | 1 + src-tauri/src/multiplayer/models.rs | 11 ++++++ src/components/modals/multiplayer-modal.tsx | 37 +++++-------------- src/locales/en.json | 20 ++++++++-- src/locales/zh-Hans.json | 20 ++++++++-- 7 files changed, 61 insertions(+), 48 deletions(-) create mode 100644 src-tauri/src/multiplayer/models.rs diff --git a/src-tauri/src/multiplayer/commands.rs b/src-tauri/src/multiplayer/commands.rs index a087b4b3e..760bc6bff 100644 --- a/src-tauri/src/multiplayer/commands.rs +++ b/src-tauri/src/multiplayer/commands.rs @@ -3,8 +3,9 @@ use std::time::Duration; use std::{ffi::OsStr, fs}; use crate::{ - error::{SJMCLError, SJMCLResult}, + error::SJMCLResult, multiplayer::helpers::terracotta::{build_download_param, decompress}, + multiplayer::models::MultiplayerError, resource::models::ResourceError, tasks::commands::schedule_progressive_task_group, tasks::monitor::TaskMonitor, @@ -16,7 +17,6 @@ use tokio::process::Command; #[tauri::command] pub async fn check_terracotta(app: AppHandle) -> SJMCLResult { let dir = &app.path().resolve("terracotta", BaseDirectory::AppData)?; - println!("Checking if Terracotta is installed at: {:?}", dir); Ok(dir.exists() && fs::read_dir(dir)?.next().is_some()) } @@ -47,7 +47,7 @@ pub async fn launch_terracotta(app: AppHandle) -> SJMCLResult<()> { return Ok(()); } } - Err(SJMCLError("terracotta executable not found".into())) + Err(MultiplayerError::ExecutableNotFound.into()) } #[tauri::command] @@ -84,5 +84,5 @@ pub async fn fetch_port(app: AppHandle) -> SJMCLResult { tokio::time::sleep(Duration::from_millis(500)).await; } } - Err(SJMCLError("terracotta port file not found".into())) + Err(MultiplayerError::PortFileNotFound.into()) } diff --git a/src-tauri/src/multiplayer/helpers/terracotta.rs b/src-tauri/src/multiplayer/helpers/terracotta.rs index f0684f182..9ebc84aeb 100644 --- a/src-tauri/src/multiplayer/helpers/terracotta.rs +++ b/src-tauri/src/multiplayer/helpers/terracotta.rs @@ -1,5 +1,6 @@ -use crate::error::{SJMCLError, SJMCLResult}; +use crate::error::SJMCLResult; use crate::launcher_config::models::LauncherConfig; +use crate::multiplayer::models::MultiplayerError; use crate::resource::helpers::misc::{get_download_api, get_source_priority_list}; use crate::resource::models::ResourceType; use crate::tasks::{download::DownloadParam, PTaskParam}; @@ -40,7 +41,6 @@ pub async fn build_download_param(app: &AppHandle) -> SJMCLResult SJMCLResult<()> { return Ok(()); } } - Err(SJMCLError( - "No compressed file found in terracotta directory".into(), - )) -} - -pub fn _install() { - unimplemented!() + Err(MultiplayerError::CompressedFileNotFound.into()) } diff --git a/src-tauri/src/multiplayer/mod.rs b/src-tauri/src/multiplayer/mod.rs index 99e8cf067..c6177d903 100644 --- a/src-tauri/src/multiplayer/mod.rs +++ b/src-tauri/src/multiplayer/mod.rs @@ -1,2 +1,3 @@ pub mod commands; pub mod helpers; +pub mod models; diff --git a/src-tauri/src/multiplayer/models.rs b/src-tauri/src/multiplayer/models.rs new file mode 100644 index 000000000..96a11912e --- /dev/null +++ b/src-tauri/src/multiplayer/models.rs @@ -0,0 +1,11 @@ +use strum_macros::Display; + +#[derive(Debug, Display)] +#[strum(serialize_all = "SCREAMING_SNAKE_CASE")] +pub enum MultiplayerError { + ExecutableNotFound, + PortFileNotFound, + CompressedFileNotFound, +} + +impl std::error::Error for MultiplayerError {} diff --git a/src/components/modals/multiplayer-modal.tsx b/src/components/modals/multiplayer-modal.tsx index 3d90af66d..2403c58ea 100644 --- a/src/components/modals/multiplayer-modal.tsx +++ b/src/components/modals/multiplayer-modal.tsx @@ -43,7 +43,6 @@ import { copyText } from "@/utils/copy"; const TERRACOTTA_ICON_URL = "https://zh.minecraft.wiki/images/Red_Glazed_Terracotta_JE1_BE1.png?272a2"; -const INVITE_CODE_LENGTH = 6; const ERROR_TYPE_TO_KEY: Record = { 0: "PING_HOST_FAIL", @@ -161,14 +160,9 @@ const MultiplayerModal: React.FC> = ({ const [joinCode, setJoinCode] = useState(""); const [isJoining, setIsJoining] = useState(false); - const phaseRef = useRef("checking"); const pollErrorCountRef = useRef(0); const cancelRef = useRef(null); - useEffect(() => { - phaseRef.current = phase; - }, [phase]); - const panelBg = useColorModeValue( "rgba(255, 255, 255, 0.7)", "rgba(255, 255, 255, 0.08)" @@ -197,10 +191,7 @@ const MultiplayerModal: React.FC> = ({ const optionTextColor = useColorModeValue("gray.800", "whiteAlpha.900"); const handleInit = useCallback(async (): Promise => { - console.log("[multiplayer] launching terracotta..."); - await MultiplayerService.launchTerracotta().catch((err) => - console.error("[multiplayer] launch failed:", err) - ); + await MultiplayerService.launchTerracotta(); for (let i = 0; i < 10; i++) { const response = await MultiplayerService.fetchPort(); if (response.status === "success" && response.data) { @@ -243,9 +234,7 @@ const MultiplayerModal: React.FC> = ({ return; } } - } catch (e) { - console.error("[multiplayer] pre-fetch state error:", e); - } + } catch {} } setPhase("ready"); } else { @@ -273,7 +262,6 @@ const MultiplayerModal: React.FC> = ({ if (response.ok) { pollErrorCountRef.current = 0; const data = await response.json(); - console.log(`[multiplayer] state:`, data); if (data.state === "exception" && ERROR_TYPE_TO_KEY[data.type]) { setErrorType(data.type); setPhase("error"); @@ -289,8 +277,7 @@ const MultiplayerModal: React.FC> = ({ setPhase("roomStarted"); } } - } catch (e) { - console.error(`[multiplayer] poll error:`, e); + } catch { pollErrorCountRef.current++; if (pollErrorCountRef.current >= 3) { setPort(0); @@ -304,9 +291,7 @@ const MultiplayerModal: React.FC> = ({ const handleReturnToLobby = async () => { try { await fetch(`http://127.0.0.1:${port}/state/ide`, { method: "GET" }); - } catch (e) { - console.error("[multiplayer] return to lobby error:", e); - } + } catch {} setErrorType(null); setPhase("ready"); }; @@ -342,13 +327,14 @@ const MultiplayerModal: React.FC> = ({ const handleCreateRoom = async () => { const url = `http://127.0.0.1:${port}/state/scanning?player=${encodeURIComponent(selectedPlayer?.name ?? "")}`; - console.log(`[multiplayer] create room: ${url}`); setPhase("scanning"); try { await fetch(url, { method: "GET" }); - } catch (e) { - console.error(`[multiplayer] create room error:`, e); - toast({ title: String(e), status: "error" }); + } catch { + toast({ + title: t("MultiplayerModal.toast.launchTimeout"), + status: "error", + }); setPhase("ready"); } }; @@ -372,12 +358,9 @@ const MultiplayerModal: React.FC> = ({ const handleJoinRoomConfirm = async () => { setIsJoining(true); const url = `http://127.0.0.1:${port}/state/guesting?room=${encodeURIComponent(joinCode.trim())}&player=${encodeURIComponent(selectedPlayer?.name ?? "")}`; - console.log(`[multiplayer] join room: ${url}`); try { const response = await fetch(url, { method: "GET" }); - if (response.ok) { - // room joined successfully; polling will transition to guestStarting/guestOk - } else { + if (!response.ok) { toast({ title: t("MultiplayerModal.toast.joinTimeout"), status: "error", diff --git a/src/locales/en.json b/src/locales/en.json index f618d6df6..82f246054 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2317,19 +2317,28 @@ "multiplayer": { "checkTerracottaSupport": { "error": { - "title": "Failed to check multiplayer core" + "title": "Failed to check multiplayer core", + "description": { + "EXECUTABLE_NOT_FOUND": "Multiplayer core executable not found" + } }, "success": "Multiplayer core check completed" }, "downloadTerracotta": { "error": { - "title": "Failed to download multiplayer core" + "title": "Failed to download multiplayer core", + "description": { + "COMPRESSED_FILE_NOT_FOUND": "Multiplayer core archive not found" + } }, "success": "Multiplayer core downloaded" }, "fetchPort": { "error": { - "title": "Failed to get multiplayer core port" + "title": "Failed to get multiplayer core port", + "description": { + "PORT_FILE_NOT_FOUND": "Unable to detect multiplayer core port" + } }, "success": "Multiplayer core port retrieved" }, @@ -2341,7 +2350,10 @@ }, "launchTerracotta": { "error": { - "title": "Failed to start multiplayer core" + "title": "Failed to start multiplayer core", + "description": { + "EXECUTABLE_NOT_FOUND": "Multiplayer core executable not found" + } }, "success": "Multiplayer core started" }, diff --git a/src/locales/zh-Hans.json b/src/locales/zh-Hans.json index c4a677289..01cdc2ddf 100644 --- a/src/locales/zh-Hans.json +++ b/src/locales/zh-Hans.json @@ -2317,19 +2317,28 @@ "multiplayer": { "checkTerracottaSupport": { "error": { - "title": "检查联机核心失败" + "title": "检查联机核心失败", + "description": { + "EXECUTABLE_NOT_FOUND": "联机核心可执行文件未找到" + } }, "success": "联机核心检查完成" }, "downloadTerracotta": { "error": { - "title": "下载联机核心失败" + "title": "下载联机核心失败", + "description": { + "COMPRESSED_FILE_NOT_FOUND": "联机核心压缩包未找到" + } }, "success": "联机核心已下载" }, "fetchPort": { "error": { - "title": "获取联机核心端口失败" + "title": "获取联机核心端口失败", + "description": { + "PORT_FILE_NOT_FOUND": "无法检测到联机核心端口" + } }, "success": "联机核心端口已获取" }, @@ -2341,7 +2350,10 @@ }, "launchTerracotta": { "error": { - "title": "启动联机核心失败" + "title": "启动联机核心失败", + "description": { + "EXECUTABLE_NOT_FOUND": "联机核心可执行文件未找到" + } }, "success": "联机核心已启动" }, From 60310d3cc54416f5c203a0cc6e1ec6226493fd39 Mon Sep 17 00:00:00 2001 From: icgnos Date: Sat, 2 May 2026 11:46:48 +0800 Subject: [PATCH 12/13] feat(multiplayer): delete redundant code and files --- .../target-codex-checkkzyTw1/CACHEDIR.TAG | 3 - src/components/modals/multiplayer-modal.tsx | 267 +++++++----------- src/global.d.ts | 2 - src/pages/launch.tsx | 4 +- src/styles/launch.module.css | 6 +- target-codex-checkC3WPzf/CACHEDIR.TAG | 3 - 6 files changed, 104 insertions(+), 181 deletions(-) delete mode 100644 src-tauri/target-codex-checkkzyTw1/CACHEDIR.TAG delete mode 100644 target-codex-checkC3WPzf/CACHEDIR.TAG diff --git a/src-tauri/target-codex-checkkzyTw1/CACHEDIR.TAG b/src-tauri/target-codex-checkkzyTw1/CACHEDIR.TAG deleted file mode 100644 index 20d7c319c..000000000 --- a/src-tauri/target-codex-checkkzyTw1/CACHEDIR.TAG +++ /dev/null @@ -1,3 +0,0 @@ -Signature: 8a477f597d28d172789f06886806bc55 -# This file is a cache directory tag created by cargo. -# For information about cache directory tags see https://bford.info/cachedir/ diff --git a/src/components/modals/multiplayer-modal.tsx b/src/components/modals/multiplayer-modal.tsx index 2403c58ea..60bb0e4a4 100644 --- a/src/components/modals/multiplayer-modal.tsx +++ b/src/components/modals/multiplayer-modal.tsx @@ -163,6 +163,52 @@ const MultiplayerModal: React.FC> = ({ const pollErrorCountRef = useRef(0); const cancelRef = useRef(null); + const StatusPanel: React.FC<{ children: React.ReactNode }> = ({ + children, + }) => ( + + {children} + + ); + + const ProfileList: React.FC<{ + profiles: { kind: string; name: string }[]; + }> = ({ profiles }) => ( + + + {t("MultiplayerModal.guest.joined")} + + {profiles.map((p, i) => ( + + + + {p.name} + + + {" "} + ({p.kind}) + + + + ))} + + ); + const panelBg = useColorModeValue( "rgba(255, 255, 255, 0.7)", "rgba(255, 255, 255, 0.08)" @@ -214,36 +260,47 @@ const MultiplayerModal: React.FC> = ({ return 0; }, [toast, t]); + const restoreRoomState = useCallback( + async (port: number): Promise => { + try { + const stateRes = await fetch(`http://127.0.0.1:${port}/state`); + if (stateRes.ok) { + const stateData = await stateRes.json(); + if (stateData.room) { + setGeneratedInviteCode(stateData.room); + setProfiles(stateData.profiles ?? []); + setPhase("roomStarted"); + return true; + } + if (stateData.state === "host-scanning") { + setPhase("scanning"); + return true; + } + } + } catch {} + return false; + }, + [] + ); + const checkTerracottaSupport = useCallback(async () => { const response = await MultiplayerService.checkTerracotta(); if (response.status === "success" && response.data) { const port = await handleInit(); if (port > 0) { - try { - const stateRes = await fetch(`http://127.0.0.1:${port}/state`); - if (stateRes.ok) { - const stateData = await stateRes.json(); - if (stateData.room) { - setGeneratedInviteCode(stateData.room); - setProfiles(stateData.profiles ?? []); - setPhase("roomStarted"); - return; - } - if (stateData.state === "host-scanning") { - setPhase("scanning"); - return; - } - } - } catch {} + const restored = await restoreRoomState(port); + if (!restored) { + setPhase("ready"); + } } - setPhase("ready"); } else { setPhase("notDownloaded"); } - }, [handleInit]); + }, [handleInit, restoreRoomState]); useEffect(() => { if (!props.isOpen) return; + pollErrorCountRef.current = 0; setPhase("checking"); setPort(0); setGeneratedInviteCode(""); @@ -300,23 +357,10 @@ const MultiplayerModal: React.FC> = ({ setPhase("checking"); const newPort = await handleInit(); if (newPort > 0) { - try { - const stateRes = await fetch(`http://127.0.0.1:${newPort}/state`); - if (stateRes.ok) { - const stateData = await stateRes.json(); - if (stateData.room) { - setGeneratedInviteCode(stateData.room); - setProfiles(stateData.profiles ?? []); - setPhase("roomStarted"); - return; - } - if (stateData.state === "host-scanning") { - setPhase("scanning"); - return; - } - } - } catch {} - setPhase("ready"); + const restored = await restoreRoomState(newPort); + if (!restored) { + setPhase("ready"); + } } }; @@ -391,37 +435,23 @@ const MultiplayerModal: React.FC> = ({ {phase === "checking" && ( - + {t("MultiplayerModal.status.checking")} - + )} {phase === "notDownloaded" && ( <> - + {t("MultiplayerModal.status.notReady")} - + > = ({ {phase === "ready" && ( <> - + {t("MultiplayerModal.status.ready")} - + > = ({ {phase === "scanning" && ( <> - + {t("MultiplayerModal.runtimeState.host-scanning")} - + @@ -597,41 +613,9 @@ const MultiplayerModal: React.FC> = ({ )} {profiles.length > 0 && ( - - - - {t("MultiplayerModal.guest.joined")} - - {profiles.map((p, i) => ( - - - - {p.name} - - - {" "} - ({p.kind}) - - - - ))} - - + + + )} @@ -665,18 +642,11 @@ const MultiplayerModal: React.FC> = ({ {phase === "disconnected" && ( <> - + {t("MultiplayerModal.status.disconnected")} - + @@ -685,14 +655,7 @@ const MultiplayerModal: React.FC> = ({ {phase === "guestStarting" && ( <> - + {t("MultiplayerModal.guest.starting")} @@ -701,7 +664,7 @@ const MultiplayerModal: React.FC> = ({ {t("MultiplayerModal.guest.difficulty")}: {difficulty} - + @@ -710,41 +673,9 @@ const MultiplayerModal: React.FC> = ({ {phase === "guestOk" && ( <> - - - - {t("MultiplayerModal.guest.joined")} - - {profiles.map((p, i) => ( - - - - {p.name} - - - {" "} - ({p.kind}) - - - - ))} - - + + + diff --git a/src/global.d.ts b/src/global.d.ts index c5ab50190..fea93a6b1 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -1,7 +1,5 @@ export {}; -declare module "*.css"; - declare global { interface Window { logger: { diff --git a/src/pages/launch.tsx b/src/pages/launch.tsx index 3847e9145..b8e030099 100644 --- a/src/pages/launch.tsx +++ b/src/pages/launch.tsx @@ -255,9 +255,7 @@ const LaunchPage = () => { tooltipPlacement="top" onClick={() => router.push( - `/instances/details/${encodeURIComponent( - selectedInstance.id - )}/settings` + `/instances/details/${encodeURIComponent(selectedInstance.id)}/settings` ) } /> diff --git a/src/styles/launch.module.css b/src/styles/launch.module.css index ab624e4d3..93cb8b830 100644 --- a/src/styles/launch.module.css +++ b/src/styles/launch.module.css @@ -24,8 +24,6 @@ } .multiplayer-button { - height: 4.5rem !important; - width: 10.5rem !important; backdrop-filter: blur(12px); background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(59, 130, 246, 0.28)) !important; border: 1px solid rgba(255, 255, 255, 0.2) !important; @@ -52,9 +50,13 @@ 2px 0 12px 3px rgba(59, 130, 246, 0.4); } +.multiplayer-button, .selected-user-card { height: 4.5rem !important; width: 10.5rem !important; +} + +.selected-user-card { backdrop-filter: blur(7px); padding: var(--chakra-space-3); } diff --git a/target-codex-checkC3WPzf/CACHEDIR.TAG b/target-codex-checkC3WPzf/CACHEDIR.TAG deleted file mode 100644 index 20d7c319c..000000000 --- a/target-codex-checkC3WPzf/CACHEDIR.TAG +++ /dev/null @@ -1,3 +0,0 @@ -Signature: 8a477f597d28d172789f06886806bc55 -# This file is a cache directory tag created by cargo. -# For information about cache directory tags see https://bford.info/cachedir/ From c3e09ea8f2955a18c6ac2a05ca37c6b9d79ed57d Mon Sep 17 00:00:00 2001 From: icgnos Date: Sat, 2 May 2026 15:10:16 +0800 Subject: [PATCH 13/13] feat(multiplayer): revert unwanted changes --- src/pages/launch.tsx | 7 ++++++- src/styles/launch.module.css | 9 ++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/pages/launch.tsx b/src/pages/launch.tsx index b8e030099..27bbaf408 100644 --- a/src/pages/launch.tsx +++ b/src/pages/launch.tsx @@ -59,6 +59,9 @@ const ButtonWithPopover: React.FC = ({ const [tooltipDisabled, setTooltipDisabled] = useState(false); + // To use Popover and Tooltip together, refer to: https://github.com/chakra-ui/chakra-ui/issues/2843 + // However, when the Popover is closed, the Tooltip will wrongly show again. + // To prevent this, we temporarily disable the Tooltip using a timeout. const handleClose = () => { setTooltipDisabled(true); onClose(); @@ -70,10 +73,11 @@ const ButtonWithPopover: React.FC = ({ isOpen={showAdd ? false : isOpen} onClose={handleClose} placement="top-end" - gutter={12} + gutter={12} // add more gutter to show clear space from the launch button's shadow > + {/* anchor for Tooltip */} = ({ {cloneElement(popoverContent, { + // Delay close after selecting an item for better UX. onSelectCallback: () => setTimeout(handleClose, 100), })} diff --git a/src/styles/launch.module.css b/src/styles/launch.module.css index 93cb8b830..1465089c4 100644 --- a/src/styles/launch.module.css +++ b/src/styles/launch.module.css @@ -9,6 +9,7 @@ transition: box-shadow 0.2s ease; } + .launch-button:hover { box-shadow: -2px 0 5px 2px rgba(233, 121, 57, 0.5), @@ -24,6 +25,8 @@ } .multiplayer-button { + height: 4.5rem !important; + width: 10.5rem !important; backdrop-filter: blur(12px); background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(59, 130, 246, 0.28)) !important; border: 1px solid rgba(255, 255, 255, 0.2) !important; @@ -50,13 +53,9 @@ 2px 0 12px 3px rgba(59, 130, 246, 0.4); } -.multiplayer-button, .selected-user-card { height: 4.5rem !important; width: 10.5rem !important; -} - -.selected-user-card { backdrop-filter: blur(7px); padding: var(--chakra-space-3); -} +} \ No newline at end of file