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/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-tauri/src/lib.rs b/src-tauri/src/lib.rs index b6c344557..020f969f4 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, + multiplayer::commands::launch_terracotta, + multiplayer::commands::download_terracotta, + 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 new file mode 100644 index 000000000..760bc6bff --- /dev/null +++ b/src-tauri/src/multiplayer/commands.rs @@ -0,0 +1,88 @@ +use std::pin::Pin; +use std::time::Duration; +use std::{ffi::OsStr, fs}; + +use crate::{ + error::SJMCLResult, + multiplayer::helpers::terracotta::{build_download_param, decompress}, + multiplayer::models::MultiplayerError, + resource::models::ResourceError, + tasks::commands::schedule_progressive_task_group, + tasks::monitor::TaskMonitor, +}; +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() && fs::read_dir(dir)?.next().is_some()) +} + +#[tauri::command] +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(); + + if path.is_file() + && path.extension() + == if cfg!(target_os = "windows") { + Some(OsStr::new("exe")) + } else { + None + } + { + Command::new(path) + .arg("--hmcl") + .arg( + &app + .path() + .resolve("sjmcl-terracotta", BaseDirectory::Temp)?, + ) + .spawn()?; + return Ok(()); + } + } + Err(MultiplayerError::ExecutableNotFound.into()) +} + +#[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()); + } + 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."); + Ok(()) +} + +#[tauri::command] +pub async fn fetch_port(app: AppHandle) -> SJMCLResult { + let path = &app + .path() + .resolve("sjmcl-terracotta", BaseDirectory::Temp)?; + for _ in 0..6 { + if path.exists() { + let content = tokio::fs::read_to_string(path).await?; + 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(Duration::from_millis(500)).await; + } + } + Err(MultiplayerError::PortFileNotFound.into()) +} 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..9ebc84aeb --- /dev/null +++ b/src-tauri/src/multiplayer/helpers/terracotta.rs @@ -0,0 +1,95 @@ +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}; +use flate2::read::GzDecoder; +use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +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> { + 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::Terracotta)?; + 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!("download/v0.4.2/{filename}"))?; + let path = app + .path() + .resolve("terracotta", BaseDirectory::AppData)? + .join(filename); + log::debug!("{}, {}", url, path.to_str().unwrap()); + param.push(PTaskParam::Download(DownloadParam { + src: url, + dest: path, + filename: None, + sha1: None, + })); + break; + } + Err(_) => continue, + } + } + + Ok(param) +} + +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).map_err(|e| { + log::error!("Failed to unpack archive: {}", e); + e + })?; + fs::remove_file(path)?; + + #[cfg(unix)] + { + for entry in fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_file() { + let ext = path.extension().and_then(|s| s.to_str()); + if ext != Some("gz") && ext != Some("pkg") { + fs::set_permissions(&path, PermissionsExt::from_mode(0o755))?; + } + } + } + } + + return Ok(()); + } + } + Err(MultiplayerError::CompressedFileNotFound.into()) +} diff --git a/src-tauri/src/multiplayer/mod.rs b/src-tauri/src/multiplayer/mod.rs new file mode 100644 index 000000000..c6177d903 --- /dev/null +++ b/src-tauri/src/multiplayer/mod.rs @@ -0,0 +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-tauri/src/resource/helpers/misc.rs b/src-tauri/src/resource/helpers/misc.rs index dad134ba1..3e9668a3c 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::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")?), @@ -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::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 5aa4dc881..c8fb201a6 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, + 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 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 new file mode 100644 index 000000000..60bb0e4a4 --- /dev/null +++ b/src/components/modals/multiplayer-modal.tsx @@ -0,0 +1,732 @@ +import { + AlertDialog, + AlertDialogBody, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + Box, + Button, + ButtonProps, + Grid, + HStack, + Icon, + Image, + Input, + Menu, + MenuButton, + MenuItem, + MenuList, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, + ModalProps, + Spinner, + Text, + VStack, + useColorModeValue, +} from "@chakra-ui/react"; +import { fetch } from "@tauri-apps/plugin-http"; +import { openUrl } from "@tauri-apps/plugin-opener"; +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"; +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"; + +const TERRACOTTA_ICON_URL = + "https://zh.minecraft.wiki/images/Red_Glazed_Terracotta_JE1_BE1.png?272a2"; + +const ERROR_TYPE_TO_KEY: Record = { + 0: "PING_HOST_FAIL", + 1: "PING_HOST_RST", + 2: "GUEST_ET_CRASH", + 3: "HOST_ET_CRASH", + 4: "PING_SERVER_RST", + 5: "SCAFFOLDING_INVALID_RESPONSE", +}; + +type Phase = + | "checking" + | "notDownloaded" + | "ready" + | "scanning" + | "roomStarted" + | "guestStarting" + | "guestOk" + | "error" + | "disconnected"; + +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 { selectedPlayer } = useGlobalData(); + + const [phase, setPhase] = useState("checking"); + const [port, setPort] = useState(0); + const [generatedInviteCode, setGeneratedInviteCode] = useState(""); + const [isDownloading, setIsDownloading] = useState(false); + + const [errorType, setErrorType] = useState(null); + const [difficulty, setDifficulty] = useState(""); + const [profiles, setProfiles] = useState<{ kind: string; name: string }[]>( + [] + ); + + const [isJoinDialogOpen, setIsJoinDialogOpen] = useState(false); + const [joinCode, setJoinCode] = useState(""); + const [isJoining, setIsJoining] = useState(false); + + 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)" + ); + 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 handleInit = useCallback(async (): Promise => { + await MultiplayerService.launchTerracotta(); + for (let i = 0; i < 10; i++) { + const response = await MultiplayerService.fetchPort(); + if (response.status === "success" && response.data) { + try { + const test = await fetch(`http://127.0.0.1:${response.data}/state`, { + signal: AbortSignal.timeout(2000), + }); + if (test.ok) { + setPort(response.data); + return response.data; + } + } catch {} + } + await new Promise((r) => setTimeout(r, 1000)); + } + toast({ + title: t("MultiplayerModal.toast.launchTimeout"), + status: "error", + }); + 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) { + const restored = await restoreRoomState(port); + if (!restored) { + setPhase("ready"); + } + } + } else { + setPhase("notDownloaded"); + } + }, [handleInit, restoreRoomState]); + + useEffect(() => { + if (!props.isOpen) return; + pollErrorCountRef.current = 0; + setPhase("checking"); + setPort(0); + setGeneratedInviteCode(""); + setJoinCode(""); + setErrorType(null); + setDifficulty(""); + setProfiles([]); + 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) { + pollErrorCountRef.current = 0; + const data = await response.json(); + if (data.state === "exception" && ERROR_TYPE_TO_KEY[data.type]) { + setErrorType(data.type); + setPhase("error"); + } else if (data.state === "guest-starting") { + setDifficulty(data.difficulty ?? ""); + setPhase("guestStarting"); + } else if (data.state === "guest-ok") { + setProfiles(data.profiles ?? []); + setPhase("guestOk"); + } else if (data.room) { + setGeneratedInviteCode(data.room); + setProfiles(data.profiles ?? []); + setPhase("roomStarted"); + } + } + } catch { + pollErrorCountRef.current++; + if (pollErrorCountRef.current >= 3) { + setPort(0); + setPhase("disconnected"); + } + } + }, 500); + return () => clearInterval(intervalId); + }, [props.isOpen, port]); + + const handleReturnToLobby = async () => { + try { + await fetch(`http://127.0.0.1:${port}/state/ide`, { method: "GET" }); + } catch {} + setErrorType(null); + setPhase("ready"); + }; + + const handleReconnect = async () => { + setPhase("checking"); + const newPort = await handleInit(); + if (newPort > 0) { + const restored = await restoreRoomState(newPort); + if (!restored) { + setPhase("ready"); + } + } + }; + + const handleCopyInviteCode = async () => { + if (!generatedInviteCode) return; + await copyText(generatedInviteCode, { toast }); + }; + + const handleCreateRoom = async () => { + const url = `http://127.0.0.1:${port}/state/scanning?player=${encodeURIComponent(selectedPlayer?.name ?? "")}`; + setPhase("scanning"); + try { + await fetch(url, { method: "GET" }); + } catch { + toast({ + title: t("MultiplayerModal.toast.launchTimeout"), + status: "error", + }); + setPhase("ready"); + } + }; + + 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 handleJoinRoomConfirm = async () => { + setIsJoining(true); + const url = `http://127.0.0.1:${port}/state/guesting?room=${encodeURIComponent(joinCode.trim())}&player=${encodeURIComponent(selectedPlayer?.name ?? "")}`; + try { + const response = await fetch(url, { method: "GET" }); + if (!response.ok) { + toast({ + title: t("MultiplayerModal.toast.joinTimeout"), + status: "error", + }); + } + } catch (e) { + toast({ title: String(e), status: "error" }); + } + setIsJoining(false); + setIsJoinDialogOpen(false); + }; + + return ( + <> + + + + {t("MultiplayerModal.header.title")} + + + + {phase === "checking" && ( + + + + + {t("MultiplayerModal.status.checking")} + + + + )} + + {phase === "notDownloaded" && ( + <> + + + {t("MultiplayerModal.status.notReady")} + + + + + + + + + {t( + + + {t("MultiplayerModal.button.thirdPartyChannels")} + + + + + + openUrl("https://github.com/burningtnt/Terracotta") + } + > + {t("MultiplayerModal.menu.githubReleasePage")} + + + + + + )} + + {phase === "ready" && ( + <> + + + {t("MultiplayerModal.status.ready")} + + + + + { + setJoinCode(""); + setIsJoinDialogOpen(true); + }} + /> + + + )} + + {phase === "scanning" && ( + <> + + + + + {t("MultiplayerModal.runtimeState.host-scanning")} + + + + + + )} + + {phase === "roomStarted" && ( + <> + {generatedInviteCode && ( + + + + + {t("MultiplayerModal.label.roomInviteCode")} + + + {generatedInviteCode} + + + + + + )} + + {profiles.length > 0 && ( + + + + )} + + + + )} + + {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" && ( + <> + + + + + + )} + + + + + + setIsJoinDialogOpen(false)} + isCentered + > + + + + {t("MultiplayerModal.button.joinRoom")} + + + setJoinCode(e.target.value)} + placeholder={t("MultiplayerModal.field.inviteCode.placeholder")} + /> + + + + + + + + + + ); +}; + +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/locales/en.json b/src/locales/en.json index 116a76a84..82f246054 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1447,8 +1447,10 @@ }, "LaunchPage": { "button": { + "multiplayer": "Multiplayer", "launch": "Launch Game", - "instanceSettings": "Instance Settings" + "instanceSettings": "Instance Settings", + "windowsNotSupported": "Multiplayer requires Windows 10 or above" }, "SwitchButton": { "tooltip": { @@ -1533,6 +1535,72 @@ "MenuSelector": { "selectedCount": "{{count}} selected" }, + "MultiplayerModal": { + "button": { + "copyInviteCode": "Copy Invite Code", + "createRoom": "Start as Host", + "downloadCore": "Download Core", + "joinRoom": "Join as Guest", + "reconnect": "Restart Core", + "thirdPartyChannels": "Third-Party Downloads" + }, + "field": { + "inviteCode": { + "helper": "Invite code", + "label": "Invite Code", + "placeholder": "Enter invite code" + } + }, + "header": { + "title": "Multiplayer" + }, + "label": { + "roomInviteCode": "Room Invite Code" + }, + "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", + "disconnected": "Multiplayer core disconnected", + "notReady": "Multiplayer core is not installed", + "ready": "Multiplayer core is ready" + }, + "toast": { + "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": { "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 +2314,66 @@ } } }, + "multiplayer": { + "checkTerracottaSupport": { + "error": { + "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", + "description": { + "COMPRESSED_FILE_NOT_FOUND": "Multiplayer core archive not found" + } + }, + "success": "Multiplayer core downloaded" + }, + "fetchPort": { + "error": { + "title": "Failed to get multiplayer core port", + "description": { + "PORT_FILE_NOT_FOUND": "Unable to detect 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", + "description": { + "EXECUTABLE_NOT_FOUND": "Multiplayer core executable not found" + } + }, + "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 room request sent" + }, + "startScanning": { + "error": { + "title": "Failed to start room scanning" + }, + "success": "Room scanning started" + } + }, "resource": { "fetchGameVersionList": { "error": { diff --git a/src/locales/zh-Hans.json b/src/locales/zh-Hans.json index 5276378cc..01cdc2ddf 100644 --- a/src/locales/zh-Hans.json +++ b/src/locales/zh-Hans.json @@ -1447,8 +1447,10 @@ }, "LaunchPage": { "button": { + "multiplayer": "联机", "launch": "启动游戏", - "instanceSettings": "实例设置" + "instanceSettings": "实例设置", + "windowsNotSupported": "联机需要 Windows 10 及以上版本" }, "SwitchButton": { "tooltip": { @@ -1533,6 +1535,72 @@ "MenuSelector": { "selectedCount": "已选 {{count}} 项" }, + "MultiplayerModal": { + "button": { + "copyInviteCode": "复制邀请码", + "createRoom": "以房主身份开始游戏", + "downloadCore": "下载联机核心", + "joinRoom": "以房客身份加入游戏", + "reconnect": "重启陶瓦核心", + "thirdPartyChannels": "第三方下载渠道" + }, + "field": { + "inviteCode": { + "helper": "邀请码", + "label": "邀请码", + "placeholder": "请输入邀请码" + } + }, + "header": { + "title": "联机" + }, + "label": { + "roomInviteCode": "房间邀请码" + }, + "menu": { + "githubReleasePage": "GitHub 发布页" + }, + "runtimeState": { + "guest-connecting": "正在准备加入房间", + "guest-ok": "已准备好启动并加入房间", + "guest-starting": "已加入房间,正在等待启动信息", + "host-ok": "房间已创建成功", + "host-scanning": "正在扫描并创建房间", + "host-starting": "正在创建房间", + "waiting": "可以创建房间或加入房间" + }, + "status": { + "checking": "正在检查联机核心", + "disconnected": "联机核心连接已断开", + "notReady": "未下载联机核心", + "ready": "联机核心已就绪" + }, + "toast": { + "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": { "title": "未找到合适的 Java 运行时", "body": "对于当前实例,没有合适的 Java 运行时可供选择。点击 “确定” 以前往 Java 管理页面手动添加或下载 Java。" @@ -2246,6 +2314,66 @@ } } }, + "multiplayer": { + "checkTerracottaSupport": { + "error": { + "title": "检查联机核心失败", + "description": { + "EXECUTABLE_NOT_FOUND": "联机核心可执行文件未找到" + } + }, + "success": "联机核心检查完成" + }, + "downloadTerracotta": { + "error": { + "title": "下载联机核心失败", + "description": { + "COMPRESSED_FILE_NOT_FOUND": "联机核心压缩包未找到" + } + }, + "success": "联机核心已下载" + }, + "fetchPort": { + "error": { + "title": "获取联机核心端口失败", + "description": { + "PORT_FILE_NOT_FOUND": "无法检测到联机核心端口" + } + }, + "success": "联机核心端口已获取" + }, + "fetchState": { + "error": { + "title": "获取联机房间状态失败" + }, + "success": "联机房间状态已获取" + }, + "launchTerracotta": { + "error": { + "title": "启动联机核心失败", + "description": { + "EXECUTABLE_NOT_FOUND": "联机核心可执行文件未找到" + } + }, + "success": "联机核心已启动" + }, + "startGuesting": { + "error": { + "description": { + "HTTP_400": "无法加入该房间", + "INVALID_INVITE_CODE": "邀请码必须为6位数字" + }, + "title": "加入联机房间失败" + }, + "success": "已发送加入房间请求" + }, + "startScanning": { + "error": { + "title": "开启房间扫描失败" + }, + "success": "已开启房间扫描" + } + }, "resource": { "fetchGameVersionList": { "error": { diff --git a/src/pages/launch.tsx b/src/pages/launch.tsx index 123415126..27bbaf408 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"; @@ -28,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"; @@ -107,6 +110,7 @@ const LaunchPage = () => { const { t } = useTranslation(); const router = useRouter(); const { openSharedModal } = useSharedModals(); + const toast = useToast(); const { selectedPlayer, getPlayerList, getInstanceList, selectedInstance } = useGlobalData(); @@ -126,136 +130,164 @@ 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..5bd5c7e1a --- /dev/null +++ b/src/services/multiplayer.ts @@ -0,0 +1,44 @@ +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"); + } + + @responseHandler("multiplayer") + static async launchTerracotta(): Promise> { + return await invoke("launch_terracotta"); + } + + @responseHandler("multiplayer") + static async downloadTerracotta(): Promise> { + return await invoke("download_terracotta"); + } + + @responseHandler("multiplayer") + static async fetchPort(): Promise> { + return await invoke("fetch_port"); + } +} diff --git a/src/styles/launch.module.css b/src/styles/launch.module.css index 96ac9ce27..1465089c4 100644 --- a/src/styles/launch.module.css +++ b/src/styles/launch.module.css @@ -24,6 +24,35 @@ 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;