Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 59 additions & 9 deletions src-tauri/src/instance/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -419,14 +420,11 @@ pub async fn retrieve_game_server_list(
query_online: bool,
) -> SJMCLResult<Vec<GameServerInfo>> {
// 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()),
};
Expand All @@ -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(())
}
Comment on lines +444 to +461

This comment was marked as resolved.


#[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,
Expand Down
49 changes: 47 additions & 2 deletions src-tauri/src/instance/helpers/server.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand Down Expand Up @@ -46,7 +51,37 @@ impl From<NbtServerInfo> for GameServerInfo {
}
}

pub async fn load_servers_info_from_path(path: &Path) -> SJMCLResult<Vec<GameServerInfo>> {
impl From<GameServerInfo> 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<PathBuf> {
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<Vec<GameServerInfo>> {
if !path.exists() {
return Ok(Vec::new());
}
Expand All @@ -62,6 +97,16 @@ pub async fn load_servers_info_from_path(path: &Path) -> SJMCLResult<Vec<GameSer
Ok(game_server_list)
}

pub async fn save_servers_to_nbt(path: &Path, servers: &[GameServerInfo]) -> 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<GameServerInfo>,
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/instance/models/misc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,11 +246,13 @@ pub struct ScreenshotInfo {
pub enum InstanceError {
InstanceNotFoundByID,
ServerNbtReadError,
DuplicateServer,
FileNotFoundError,
InvalidSourcePath,
FileCreationFailed,
FileCopyFailed,
FileMoveFailed,
FileOperationError,
Comment on lines +249 to +255
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The localization files define a SERVER_NOT_FOUND error message but the corresponding error variant is missing from the InstanceError enum. Either add the ServerNotFound variant to the enum (and use it in the delete_game_server command), or remove the unused error message from the localization files. For consistency with the error messages defined, adding the variant is recommended.

Copilot uses AI. Check for mistakes.
FolderCreationFailed,
ShortcutCreationFailed,
ZipFileProcessFailed,
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
172 changes: 172 additions & 0 deletions src/components/modals/add-game-server-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
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<ModalProps, "children"> {
presetUrl?: string;
instanceId: string;
}

const AddGameServerModal: React.FC<AddGameServerModalProps> = ({
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<string>("");
const [serverName, setServerName] = useState<string>("");
const [isLoading, setIsLoading] = useState<boolean>(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)
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The server address is not trimmed before being sent to the backend. If users accidentally include leading or trailing whitespace in the address field, it will be saved as-is, which could cause connection issues and duplicate entries (since "example.com" and " example.com" would be treated as different servers).

Consider trimming the serverUrl before sending it: InstanceService.addGameServer(instanceId, serverUrl.trim(), finalServerName)

Suggested change
InstanceService.addGameServer(instanceId, serverUrl, finalServerName)
InstanceService.addGameServer(instanceId, serverUrl.trim(), finalServerName)

Copilot uses AI. Check for mistakes.
.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]
);

useEffect(() => {
if (
isOpen &&
presetUrl &&
serverUrl === presetUrl &&
!hasAutoPresetRef.current
) {
if (!serverName) {
const urlObj = new URL(presetUrl);
const extractedName = urlObj.hostname || presetUrl;

This comment was marked as off-topic.

setServerName(extractedName);
}
hasAutoPresetRef.current = true;
}
}, [isOpen, presetUrl, serverUrl, serverName]);

return (
<Modal
size={{ base: "md", lg: "lg", xl: "xl" }}
initialFocusRef={initialRef}
{...modalProps}
>
<ModalOverlay />
<ModalContent>
<ModalHeader>{t("AddGameServerModal.header.title")}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4} align="stretch">
<FormControl isInvalid={isServerUrlInvalid} isRequired>
<FormLabel htmlFor="serverUrl">
{t("AddGameServerModal.label.serverAddress")}
</FormLabel>
<Input
id="serverUrl"
type="url"
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The input type "url" is semantically incorrect for Minecraft server addresses. These addresses are typically in the format "example.com" or "example.com:25565", which are not valid URLs (they lack a protocol like http:// or https://). Using type="url" may cause browser validation issues and confuse users.

Change the input type to "text" to properly accept Minecraft server address formats.

Suggested change
type="url"
type="text"

Copilot uses AI. Check for mistakes.
placeholder={t("AddGameServerModal.placeholder.serverAddress")}
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
onBlur={() => setIsServerUrlTouched(true)}
ref={initialRef}
focusBorderColor={`${primaryColor}.500`}
/>
{isServerUrlInvalid && (
<FormErrorMessage>
{t("AddGameServerModal.serverAddressRequired")}
</FormErrorMessage>
)}
</FormControl>

<FormControl>
<FormLabel htmlFor="serverName">
{t("AddGameServerModal.label.serverName")}
</FormLabel>
<Input
id="serverName"
type="text"
placeholder={serverNamePlaceholder}
value={serverName}
onChange={(e) => setServerName(e.target.value)}
focusBorderColor={`${primaryColor}.500`}
/>
</FormControl>
</VStack>
</ModalBody>

<ModalFooter>
<Button variant="ghost" onClick={onClose}>
{t("General.cancel")}
</Button>
<Button
colorScheme={primaryColor}
onClick={() => {
if (instanceId) {
handleAddGameServer(instanceId);
}
}}
isLoading={isLoading}
isDisabled={!serverUrl}
>
{t("General.confirm")}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};

export default AddGameServerModal;
2 changes: 2 additions & 0 deletions src/enums/service-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading