diff --git a/src-tauri/src/instance/commands.rs b/src-tauri/src/instance/commands.rs index 4838ae782..a363c385d 100644 --- a/src-tauri/src/instance/commands.rs +++ b/src-tauri/src/instance/commands.rs @@ -24,7 +24,8 @@ use crate::instance::helpers::resourcepack::{ load_resourcepack_from_dir, load_resourcepack_from_zip, }; use crate::instance::helpers::server::{ - load_servers_info_from_path, query_servers_online, GameServerInfo, + get_servers_nbt_path_by_instance_id, load_servers_info_from_nbt, query_servers_online, + save_servers_to_nbt, GameServerInfo, }; use crate::instance::helpers::world::{load_level_data_from_nbt, load_world_info_from_dir}; use crate::instance::models::misc::{ @@ -419,14 +420,11 @@ pub async fn retrieve_game_server_list( query_online: bool, ) -> SJMCLResult> { // query_online is false, return local data from nbt (servers.dat) - let game_root_dir = - match get_instance_subdir_path_by_id(&app, &instance_id, &InstanceSubdirType::Root) { - Some(path) => path, - None => return Ok(Vec::new()), - }; - - let nbt_path = game_root_dir.join("servers.dat"); - let mut game_servers = match load_servers_info_from_path(&nbt_path).await { + let nbt_path = match get_servers_nbt_path_by_instance_id(&app, &instance_id) { + Some(path) => path, + None => return Ok(Vec::new()), + }; + let mut game_servers = match load_servers_info_from_nbt(&nbt_path).await { Ok(servers) => servers, Err(_) => return Err(InstanceError::ServerNbtReadError.into()), }; @@ -442,6 +440,58 @@ pub async fn retrieve_game_server_list( Ok(game_servers) } +#[tauri::command] +pub async fn delete_game_server( + app: AppHandle, + instance_id: String, + server_addr: String, +) -> SJMCLResult<()> { + let nbt_path = match get_servers_nbt_path_by_instance_id(&app, &instance_id) { + Some(path) => path, + None => return Err(InstanceError::InstanceNotFoundByID.into()), + }; + let mut existing_servers = load_servers_info_from_nbt(&nbt_path).await?; + + existing_servers.retain(|server| server.ip != server_addr); + save_servers_to_nbt(&nbt_path, &existing_servers) + .await + .map_err(|_| InstanceError::FileOperationError)?; + + Ok(()) +} + +#[tauri::command] +pub async fn add_game_server( + app: AppHandle, + instance_id: String, + server_addr: String, + server_name: String, +) -> SJMCLResult<()> { + let nbt_path = match get_servers_nbt_path_by_instance_id(&app, &instance_id) { + Some(path) => path, + None => return Err(InstanceError::InstanceNotFoundByID.into()), + }; + let mut existing_servers = load_servers_info_from_nbt(&nbt_path).await?; + + if existing_servers + .iter() + .any(|server| server.ip == server_addr) + { + return Err(InstanceError::DuplicateServer.into()); + } + + existing_servers.push(GameServerInfo { + ip: server_addr, + name: server_name, + ..Default::default() + }); + save_servers_to_nbt(&nbt_path, &existing_servers) + .await + .map_err(|_| InstanceError::FileOperationError)?; + + Ok(()) +} + #[tauri::command] pub async fn retrieve_local_mod_list( app: AppHandle, diff --git a/src-tauri/src/instance/helpers/server.rs b/src-tauri/src/instance/helpers/server.rs index 1b7c9d0e7..009c21777 100644 --- a/src-tauri/src/instance/helpers/server.rs +++ b/src-tauri/src/instance/helpers/server.rs @@ -1,10 +1,15 @@ use crate::error::SJMCLResult; +use crate::instance::helpers::misc::get_instance_subdir_path_by_id; +use crate::instance::models::misc::InstanceSubdirType; use mc_server_status::{McClient, McError, ServerData, ServerEdition, ServerInfo, ServerStatus}; use quartz_nbt::io::Flavor; use serde::{self, Deserialize, Serialize}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::time::Duration; use tauri::async_runtime; +use tauri::AppHandle; + +pub const SERVERS_DAT_FILENAME: &str = "servers.dat"; #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize, Default)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -46,7 +51,37 @@ impl From for GameServerInfo { } } -pub async fn load_servers_info_from_path(path: &Path) -> SJMCLResult> { +impl From for NbtServerInfo { + fn from(server: GameServerInfo) -> Self { + Self { + ip: server.ip, + icon: (!server.icon_src.is_empty()).then_some(server.icon_src), + name: server.name, + hidden: server.hidden, + } + } +} + +impl From<&GameServerInfo> for NbtServerInfo { + fn from(server: &GameServerInfo) -> Self { + Self { + ip: server.ip.clone(), + icon: (!server.icon_src.is_empty()).then_some(server.icon_src.clone()), + name: server.name.clone(), + hidden: server.hidden, + } + } +} + +pub fn get_servers_nbt_path_by_instance_id( + app: &AppHandle, + instance_id: &String, +) -> Option { + let game_root_dir = get_instance_subdir_path_by_id(app, instance_id, &InstanceSubdirType::Root)?; + Some(game_root_dir.join(SERVERS_DAT_FILENAME)) +} + +pub async fn load_servers_info_from_nbt(path: &Path) -> SJMCLResult> { if !path.exists() { return Ok(Vec::new()); } @@ -62,6 +97,16 @@ pub async fn load_servers_info_from_path(path: &Path) -> SJMCLResult SJMCLResult<()> { + let servers_info = NbtServersInfo { + servers: servers.iter().map(NbtServerInfo::from).collect(), + }; + let bytes = quartz_nbt::serde::serialize(&servers_info, None, Flavor::Uncompressed)?; + tokio::fs::write(path, bytes).await?; + + Ok(()) +} + /// Query multiple servers online status in parallel. pub async fn query_servers_online( mut servers: Vec, diff --git a/src-tauri/src/instance/models/misc.rs b/src-tauri/src/instance/models/misc.rs index 6ab48a0ec..8a3ebe0a4 100644 --- a/src-tauri/src/instance/models/misc.rs +++ b/src-tauri/src/instance/models/misc.rs @@ -246,11 +246,13 @@ pub struct ScreenshotInfo { pub enum InstanceError { InstanceNotFoundByID, ServerNbtReadError, + DuplicateServer, FileNotFoundError, InvalidSourcePath, FileCreationFailed, FileCopyFailed, FileMoveFailed, + FileOperationError, FolderCreationFailed, ShortcutCreationFailed, ZipFileProcessFailed, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 95aa4055b..87254d9b1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -108,6 +108,8 @@ pub async fn run() { instance::commands::retrieve_world_list, instance::commands::retrieve_world_details, instance::commands::retrieve_game_server_list, + instance::commands::add_game_server, + instance::commands::delete_game_server, instance::commands::retrieve_local_mod_list, instance::commands::retrieve_resource_pack_list, instance::commands::retrieve_server_resource_pack_list, diff --git a/src/components/modals/add-game-server-modal.tsx b/src/components/modals/add-game-server-modal.tsx new file mode 100644 index 000000000..bb19d8148 --- /dev/null +++ b/src/components/modals/add-game-server-modal.tsx @@ -0,0 +1,156 @@ +import { + Button, + FormControl, + FormErrorMessage, + FormLabel, + Input, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + ModalProps, + VStack, +} from "@chakra-ui/react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useLauncherConfig } from "@/contexts/config"; +import { useToast } from "@/contexts/toast"; +import { InstanceService } from "@/services/instance"; + +interface AddGameServerModalProps extends Omit { + presetUrl?: string; + instanceId: string; +} + +const AddGameServerModal: React.FC = ({ + presetUrl = "", + instanceId, + ...modalProps +}) => { + const { t } = useTranslation(); + const toast = useToast(); + const { config } = useLauncherConfig(); + const primaryColor = config.appearance.theme.primaryColor; + const { isOpen, onClose } = modalProps; + const initialRef = useRef(null); + const hasAutoPresetRef = useRef(false); + + const [serverUrl, setServerUrl] = useState(""); + const [serverName, setServerName] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const [isServerUrlTouched, setIsServerUrlTouched] = useState(false); + const isServerUrlInvalid = isServerUrlTouched && !serverUrl; + const serverNamePlaceholder = t("AddGameServerModal.placeholder.serverName"); + + useEffect(() => { + if (isOpen) { + hasAutoPresetRef.current = false; + setServerUrl(presetUrl); + setServerName(""); + setIsServerUrlTouched(false); + } + }, [isOpen, presetUrl]); + + const handleAddGameServer = useCallback( + (instanceId: string) => { + const finalServerName = serverName.trim() || serverNamePlaceholder; + setIsLoading(true); + InstanceService.addGameServer(instanceId, serverUrl, finalServerName) + .then((response) => { + if (response.status === "success") { + toast({ + title: response.message, + status: "success", + }); + onClose?.(); + } else { + toast({ + title: response.message, + description: response.details, + status: "error", + }); + } + }) + .finally(() => { + setIsLoading(false); + }); + }, + [serverUrl, serverName, serverNamePlaceholder, toast, onClose] + ); + + return ( + + + + {t("AddGameServerModal.header.title")} + + + + + + {t("AddGameServerModal.label.serverAddress")} + + setServerUrl(e.target.value)} + onBlur={() => setIsServerUrlTouched(true)} + ref={initialRef} + focusBorderColor={`${primaryColor}.500`} + /> + {isServerUrlInvalid && ( + + {t("AddGameServerModal.serverAddressRequired")} + + )} + + + + + {t("AddGameServerModal.label.serverName")} + + setServerName(e.target.value)} + focusBorderColor={`${primaryColor}.500`} + /> + + + + + + + + + + + ); +}; + +export default AddGameServerModal; diff --git a/src/enums/service-error.ts b/src/enums/service-error.ts index d43f7d454..4b9b9ac1b 100644 --- a/src/enums/service-error.ts +++ b/src/enums/service-error.ts @@ -15,11 +15,13 @@ export enum AccountServiceError { export enum InstanceError { InstanceNotFoundById = "INSTANCE_NOT_FOUND_BY_ID", ServerNbtReadError = "SERVER_NBT_READ_ERROR", + DuplicateServer = "DUPLICATE_SERVER", FileNotFoundError = "FILE_NOT_FOUND_ERROR", InvalidSourcePath = "INVALID_SOURCE_PATH", FileCreationFailed = "FILE_CREATION_FAILED", FileCopyFailed = "FILE_COPY_FAILED", FileMoveFailed = "FILE_MOVE_FAILED", + FileOperationError = "FILE_OPERATION_ERROR", FolderCreationFailed = "FOLDER_CREATION_FAILED", ShortcutCreationFailed = "SHORTCUT_CREATION_FAILED", ZipFileProcessFailed = "ZIP_FILE_PROCESS_FAILED", diff --git a/src/locales/en.json b/src/locales/en.json index f6c86b909..01dcf7d5b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -141,6 +141,20 @@ "endpointUrl": "Endpoint URL" } }, + "AddGameServerModal": { + "header": { + "title": "Add Game Server" + }, + "serverAddressRequired": "Server address is required", + "label": { + "serverAddress": "Server Address", + "serverName": "Server Name" + }, + "placeholder": { + "serverAddress": "Enter server address", + "serverName": "Minecraft Server" + } + }, "AddPlayerModal": { "modal": { "header": "Add Player" @@ -412,6 +426,16 @@ "CreateRenamedInstShortcutAlertDialog": { "content": "The name of this instance has recently been changed. Please refresh the instance list or restart the launcher before trying to add a launch shortcut again." }, + "DeleteAuthServerAlertDialog": { + "dialog": { + "title": "Delete Authentication Server", + "content": "Confirm delete the authentication server \"{{name}}\" ? This will remove it from your server list." + } + }, + "DeleteGameServerAlertDialog": { + "title": "Delete Game Server", + "content": "Confirm delete the game server \"{{name}}\" ({{addr}}) ? It will be lost forever! (A long time!)" + }, "DeleteInstanceAlertDialog": { "dialog": { "title": "Delete Game Instance", @@ -434,12 +458,6 @@ "content": "Confirm delete the player \"{{name}}\" ? This cannot be undone!" } }, - "DeleteAuthServerAlertDialog": { - "dialog": { - "title": "Delete Authentication Server", - "content": "Confirm delete the authentication server \"{{name}}\" ? This will remove it from your server list." - } - }, "DevToolbar": { "tooltip": "Dev Toolbar" }, @@ -2319,6 +2337,28 @@ } } }, + "addGameServer": { + "success": "Game server added successfully", + "error": { + "title": "Failed to add game server", + "description": { + "INSTANCE_NOT_FOUND_BY_ID": "Instance ID does not exist", + "FILE_OPERATION_ERROR": "Failed to write server info", + "DUPLICATE_SERVER": "Server already exists" + } + } + }, + "deleteGameServer": { + "success": "Game server deleted successfully", + "error": { + "title": "Failed to delete game server", + "description": { + "INSTANCE_NOT_FOUND_BY_ID": "Instance ID does not exist", + "FILE_OPERATION_ERROR": "Failed to write server info", + "SERVER_NOT_FOUND": "Server not found" + } + } + }, "retrieveLocalModList": { "error": { "title": "Failed to retrieve local mod list", diff --git a/src/locales/zh-Hans.json b/src/locales/zh-Hans.json index c6371e3ba..0bfa9fb0c 100644 --- a/src/locales/zh-Hans.json +++ b/src/locales/zh-Hans.json @@ -141,6 +141,20 @@ "endpointUrl": "源地址" } }, + "AddGameServerModal": { + "header": { + "title": "添加游戏服务器" + }, + "serverAddressRequired": "服务器地址不能为空", + "label": { + "serverAddress": "服务器地址", + "serverName": "服务器名称" + }, + "placeholder": { + "serverAddress": "请输入服务器地址", + "serverName": "Minecraft 服务器" + } + }, "AddPlayerModal": { "modal": { "header": "添加角色" @@ -412,6 +426,16 @@ "CreateRenamedInstShortcutAlertDialog": { "content": "您最近更改了此实例的名称,请刷新实例列表或重启启动器后,再次尝试添加启动快捷方式。" }, + "DeleteAuthServerAlertDialog": { + "dialog": { + "title": "删除认证服务器", + "content": "确定要删除认证服务器 “{{name}}” 吗?它将不再出现在你的服务器列表中。" + } + }, + "DeleteGameServerAlertDialog": { + "title": "删除游戏服务器", + "content": "确定要删除游戏服务器 “{{name}}”({{addr}}) 吗?它将会永久消失!(真的很久!)" + }, "DeleteInstanceAlertDialog": { "dialog": { "title": "删除游戏实例", @@ -434,12 +458,6 @@ "content": "确定要删除角色 “{{name}}” 吗?这一操作不可逆!" } }, - "DeleteAuthServerAlertDialog": { - "dialog": { - "title": "删除认证服务器", - "content": "确定要删除认证服务器 “{{name}}” 吗?它将不再出现在你的服务器列表中。" - } - }, "DevToolbar": { "tooltip": "开发工具栏" }, @@ -2319,6 +2337,28 @@ } } }, + "addGameServer": { + "success": "游戏服务器添加成功", + "error": { + "title": "添加游戏服务器失败", + "description": { + "INSTANCE_NOT_FOUND_BY_ID": "实例ID不存在", + "FILE_OPERATION_ERROR": "写入服务器信息失败", + "DUPLICATE_SERVER": "服务器已存在" + } + } + }, + "deleteGameServer": { + "success": "游戏服务器删除成功", + "error": { + "title": "删除游戏服务器失败", + "description": { + "INSTANCE_NOT_FOUND_BY_ID": "实例ID不存在", + "FILE_OPERATION_ERROR": "写入服务器信息失败", + "SERVER_NOT_FOUND": "服务器不存在" + } + } + }, "retrieveLocalModList": { "error": { "title": "获取模组列表失败", diff --git a/src/pages/instances/details/[id]/worlds.tsx b/src/pages/instances/details/[id]/worlds.tsx index 58a2597b6..1199af126 100644 --- a/src/pages/instances/details/[id]/worlds.tsx +++ b/src/pages/instances/details/[id]/worlds.tsx @@ -18,6 +18,7 @@ import CountTag from "@/components/common/count-tag"; import Empty from "@/components/common/empty"; import { OptionItem, OptionItemGroup } from "@/components/common/option-item"; import { Section } from "@/components/common/section"; +import AddGameServerModal from "@/components/modals/add-game-server-modal"; import WorldLevelDataModal from "@/components/modals/world-level-data-modal"; import { useLauncherConfig } from "@/contexts/config"; import { useInstanceSharedData } from "@/contexts/instance"; @@ -45,12 +46,17 @@ const InstanceWorldsPage = () => { } = useInstanceSharedData(); const accordionStates = config.states.instanceWorldsPage.accordionStates; const toast = useToast(); - const { openSharedModal } = useSharedModals(); - + const { openSharedModal, openGenericConfirmDialog } = useSharedModals(); const [worlds, setWorlds] = useState([]); const [selectedWorldName, setSelectedWorldName] = useState(); const [gameServers, setGameServers] = useState([]); + const { + isOpen: isAddGameServerModalOpen, + onOpen: onAddGameServerModalOpen, + onClose: onAddGameServerModalClose, + } = useDisclosure(); + const { isOpen: isWorldLevelDataModalOpen, onOpen: onWorldLevelDataModallOpen, @@ -94,16 +100,45 @@ const InstanceWorldsPage = () => { [toast, instanceId] ); - useEffect(() => { + // First fetch from local nbt (queryOnline=false) for instant feedback, + // then query online status to avoid long wait harming UX. + const refreshGameServerList = useCallback(() => { handleRetrieveGameServerList(false); handleRetrieveGameServerList(true); + }, [handleRetrieveGameServerList]); + useEffect(() => { + refreshGameServerList(); // refresh every minute to query server info const intervalId = setInterval(async () => { handleRetrieveGameServerList(true); }, 60000); return () => clearInterval(intervalId); - }, [instanceId, handleRetrieveGameServerList]); + }, [instanceId, handleRetrieveGameServerList, refreshGameServerList]); + + const handleDeleteServer = useCallback( + (server: GameServerInfo) => { + if (!instanceId) return; + InstanceService.deleteGameServer(instanceId, server.ip).then( + (response) => { + if (response.status === "success") { + toast({ + title: response.message, + status: "success", + }); + refreshGameServerList(); + } else { + toast({ + title: response.message, + description: response.details, + status: "error", + }); + } + } + ); + }, + [instanceId, toast, refreshGameServerList] + ); const worldSecMenuOperations = [ { @@ -141,6 +176,21 @@ const InstanceWorldsPage = () => { }, ]; + const serverSecMenuOperations = [ + { + icon: "add", + onClick: () => { + onAddGameServerModalOpen(); + }, + }, + { + icon: "refresh", + onClick: () => { + refreshGameServerList(); + }, + }, + ]; + const worldItemMenuOperations = (save: WorldInfo) => [ { label: "", @@ -181,6 +231,40 @@ const InstanceWorldsPage = () => { : []), ]; + const serverItemMenuOperations = (server: GameServerInfo) => [ + { + icon: "delete", + danger: true, + onClick: () => { + openGenericConfirmDialog({ + title: t("DeleteGameServerAlertDialog.title"), + body: t("DeleteGameServerAlertDialog.content", { + name: server.name, + addr: server.ip, + }), + btnOK: t("General.delete"), + isAlert: true, + onOKCallback: () => { + handleDeleteServer(server); + }, + showSuppressBtn: true, + suppressKey: "deleteGameServerAlert", + }); + }, + }, + { + icon: "launch", + label: t("InstanceWorldsPage.serverList.launch"), + danger: false, + onClick: () => { + openSharedModal("launch", { + instanceId: instanceId, + quickPlayMultiplayer: server.ip, + }); + }, + }, + ]; + return ( <>
{ ); }} headExtra={ - { - handleRetrieveGameServerList(false); - handleRetrieveGameServerList(true); - }} - size="xs" - fontSize="sm" - h={21} - /> + + {serverSecMenuOperations.map((btn, index) => ( + + ))} + } > {gameServers.length > 0 ? ( @@ -344,16 +430,17 @@ const InstanceWorldsPage = () => { ))} - { - openSharedModal("launch", { - instanceId: instanceId, - quickPlayMultiplayer: server.ip, - }); - }} - /> + + {serverItemMenuOperations(server).map((item, index) => ( + + ))} + ))} @@ -362,6 +449,16 @@ const InstanceWorldsPage = () => { )}
+ {instanceId && ( + { + onAddGameServerModalClose(); + refreshGameServerList(); + }} + /> + )} ); }; diff --git a/src/services/instance.ts b/src/services/instance.ts index bee23fab1..67044a625 100644 --- a/src/services/instance.ts +++ b/src/services/instance.ts @@ -272,6 +272,44 @@ export class InstanceService { }); } + /** + * ADD a game server entry into the instance's `servers.dat`. + * The command rejects duplicate `serverAddr` values in the same instance. + * @param {string} instanceId - The target instance ID. + * @param {string} serverAddr - The server address (for example: `example.com` or `example.com:25565`). + * @param {string} serverName - The display name stored in `servers.dat`. + * @returns {Promise>} + */ + @responseHandler("instance") + static async addGameServer( + instanceId: string, + serverAddr: string, + serverName: string + ): Promise> { + return await invoke("add_game_server", { + instanceId, + serverAddr, + serverName, + }); + } + + /** + * DELETE a game server from the instance's servers.dat. + * @param {string} instanceId - The ID of the instance. + * @param {string} serverAddr - The server address (IP) to delete. + * @returns {Promise>} + */ + @responseHandler("instance") + static async deleteGameServer( + instanceId: string, + serverAddr: string + ): Promise> { + return await invoke("delete_game_server", { + instanceId, + serverAddr, + }); + } + /** * RETRIEVE the list of resource packs. * @param {string} instanceId - The instance ID to retrieve the resource packs for.