Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
156 changes: 148 additions & 8 deletions src-tauri/src/instance/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ use tokio;
use tokio::sync::Semaphore;
use url::Url;
use zip::read::ZipArchive;
// [new]
//use base64::{Engine as _, engine::general_purpose};
Copy link
Owner

Choose a reason for hiding this comment

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

请删除 vibe 痕迹

Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

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

This commented-out import statement should be removed. If the base64 encoding functionality is not needed, the comment should be deleted to keep the code clean. If it will be needed in the future, it should either be implemented now or tracked in a TODO comment with a GitHub issue reference.

Suggested change
//use base64::{Engine as _, engine::general_purpose};

Copilot uses AI. Check for mistakes.
use serde::Serialize;

#[tauri::command]
pub async fn retrieve_instance_list(app: AppHandle) -> SJMCLResult<Vec<InstanceSummary>> {
Expand Down Expand Up @@ -97,16 +100,10 @@ pub async fn retrieve_instance_list(app: AppHandle) -> SJMCLResult<Vec<InstanceS
let version_cmp_fn = build_game_version_cmp_fn(&app);
match config_state.states.all_instances_page.sort_by.as_str() {
"versionAsc" => {
summary_list.sort_by(|a, b| {
version_cmp_fn(&a.version, &b.version)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
Copy link
Owner

@UNIkeEN UNIkeEN Jan 28, 2026

Choose a reason for hiding this comment

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

此处删除了其他 PR 的修复逻辑

});
summary_list.sort_by(|a, b| version_cmp_fn(&a.version, &b.version));
}
"versionDesc" => {
summary_list.sort_by(|a, b| {
version_cmp_fn(&b.version, &a.version)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
summary_list.sort_by(|a, b| version_cmp_fn(&b.version, &a.version));
}
Comment on lines 102 to 107
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

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

The removal of the secondary sort by name (.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))) changes the existing behavior. When instances have the same version, they will no longer be sorted alphabetically by name. This appears to be an unrelated change to the PR's stated purpose of adding server management functionality. If this is intentional, it should be documented in the PR description or made in a separate commit. If unintentional, the secondary sort should be restored.

Copilot uses AI. Check for mistakes.
_ => {
summary_list.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
Expand Down Expand Up @@ -1323,3 +1320,146 @@ pub fn add_custom_instance_icon(

Ok(())
}

// [New] add server to instance's servers.dat
#[tauri::command]
pub async fn add_server_to_instance(
instance_path: String,
name: String,
address: String,
) -> Result<String, String> {
use quartz_nbt::io::{read_nbt, write_nbt, Flavor};
use quartz_nbt::{NbtCompound, NbtList};
use std::fs::File;
use std::path::PathBuf;

let mut dat_path = PathBuf::from(&instance_path);
dat_path.push("servers.dat");

let mut server_entry = NbtCompound::new();
server_entry.insert("name", name);
server_entry.insert("ip", address);
server_entry.insert("hiddenAddress", false);

let mut root = if dat_path.exists() {
let mut file = File::open(&dat_path).map_err(|e| e.to_string())?;

match read_nbt(&mut file, Flavor::Uncompressed) {
Ok((compound, _)) => compound,
Err(_) => {
let mut c = NbtCompound::new();
c.insert("servers", NbtList::new());
c
}
}
} else {
let mut c = NbtCompound::new();
c.insert("servers", NbtList::new());
c
};

if let Ok(servers_list) = root.get_mut::<_, &mut NbtList>("servers") {
servers_list.push(server_entry);
} else {
let mut new_list = NbtList::new();
new_list.push(server_entry);
root.insert("servers", new_list);
}

let mut file = File::create(&dat_path).map_err(|e| e.to_string())?;

write_nbt(&mut file, None, &root, Flavor::Uncompressed).map_err(|e| e.to_string())?;

Ok("OK".into())
}
Comment on lines +1325 to +1374
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

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

The new Tauri command add_server_to_instance is not registered in the invoke_handler in src-tauri/src/lib.rs. This command will not be accessible from the frontend and will cause runtime errors when invoked. You need to add instance::commands::add_server_to_instance to the tauri::generate_handler![] macro in lib.rs.

Copilot uses AI. Check for mistakes.

// [New] delete server from instance's servers.dat
#[tauri::command]
pub async fn delete_server_from_instance(
instance_path: String,
address: String,
) -> Result<(), String> {
use quartz_nbt::io::{read_nbt, write_nbt, Flavor};
use quartz_nbt::{NbtCompound, NbtList, NbtTag};
use std::fs::File;
use std::path::PathBuf;

let mut dat_path = PathBuf::from(&instance_path);
dat_path.push("servers.dat");

if !dat_path.exists() {
return Ok(());
}

let mut file = File::open(&dat_path).map_err(|e| e.to_string())?;
let (mut root, _) = read_nbt(&mut file, Flavor::Uncompressed).map_err(|e| e.to_string())?;

if let Ok(servers) = root.get_mut::<_, &mut NbtList>("servers") {
let mut new_servers = Vec::new();

for i in 0..servers.len() {
let mut should_keep = true;
if let Ok(entry) = servers.get::<&NbtCompound>(i) {
if let Ok(ip) = entry.get::<_, &str>("ip") {
if ip == address {
should_keep = false;
}
}
}

if should_keep {
// clone new entry
new_servers.push(servers[i].clone());
}
}
*servers = NbtList::from(new_servers);
}

let mut file = File::create(&dat_path).map_err(|e| e.to_string())?;
write_nbt(&mut file, None, &root, Flavor::Uncompressed).map_err(|e| e.to_string())?;

Ok(())
}
Comment on lines +1377 to +1422
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

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

The new Tauri command delete_server_from_instance is not registered in the invoke_handler in src-tauri/src/lib.rs. This command will not be accessible from the frontend and will cause runtime errors when invoked. You need to add instance::commands::delete_server_from_instance to the tauri::generate_handler![] macro in lib.rs.

Copilot uses AI. Check for mistakes.

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PingResult {
pub motd: String,
pub version: String,
pub players: u32,
pub max_players: u32,
pub favicon: String,
}

#[tauri::command]
pub async fn ping_server(address: String) -> Result<PingResult, String> {
let server_infos = vec![GameServerInfo {
name: "Preview".to_string(),
ip: address.clone(),
icon_src: String::new(),
hidden: false,
description: String::new(),
is_queried: false,
online: false,
players_max: 0,
players_online: 0,
}];

match query_servers_online(server_infos).await {
Ok(results) => {
if let Some(res) = results.first() {
Ok(PingResult {
// 映射结果
Copy link
Owner

@UNIkeEN UNIkeEN Jan 28, 2026

Choose a reason for hiding this comment

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

请与项目其他代码统一,使用英文注释

motd: res.description.clone(),
version: "Unknown".to_string(),
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

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

The version field is hardcoded as "Unknown" instead of retrieving the actual server version. The GameServerInfo structure appears to have version information from the query_servers_online function, but it's not being mapped to the PingResult. Consider adding a version field to GameServerInfo or extracting it from the server response.

Suggested change
version: "Unknown".to_string(),
version: res.version.clone(),

Copilot uses AI. Check for mistakes.
players: res.players_online as u32,
max_players: res.players_max as u32,
favicon: res.icon_src.clone(),
})
} else {
Err("服务器未响应".to_string())
Copy link
Owner

Choose a reason for hiding this comment

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

请使用 Enums 返回错误

}
}
Err(e) => Err(format!("查询失败: {:?}", e)),
Comment on lines +1460 to +1463
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

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

These error messages are hardcoded in Chinese ("服务器未响应" and "查询失败"). For consistency with the rest of the codebase, error messages should be in English since they will be displayed in the frontend's error handling which can then apply proper i18n. Change these to English messages like "Server did not respond" and "Query failed".

Suggested change
Err("服务器未响应".to_string())
}
}
Err(e) => Err(format!("查询失败: {:?}", e)),
Err("Server did not respond".to_string())
}
}
Err(e) => Err(format!("Query failed: {:?}", e)),

Copilot uses AI. Check for mistakes.
}
}
Comment on lines +1434 to +1465
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

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

The new Tauri command ping_server is not registered in the invoke_handler in src-tauri/src/lib.rs. This command will not be accessible from the frontend and will cause runtime errors when invoked. You need to add instance::commands::ping_server to the tauri::generate_handler![] macro in lib.rs.

Copilot uses AI. Check for mistakes.
212 changes: 212 additions & 0 deletions src/components/modals/add-server-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import {
Badge,
Box,
Button,
FormControl,
FormLabel,
HStack,
Image,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Skeleton,
Text,
VStack,
} from "@chakra-ui/react";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";

interface AddServerModalProps {
isOpen: boolean;
onClose: () => void;
onAdd: (name: string, address: string) => void;
}

export const AddServerModal = ({
isOpen,
onClose,
onAdd,
}: AddServerModalProps) => {
const [name, setName] = useState("");
const [address, setAddress] = useState("");
const [queryResult, setQueryResult] = useState<any>(null);
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

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

The queryResult state is typed as any, which bypasses TypeScript's type safety. You should define a proper interface for the server query result that matches the PingResult structure returned from the backend (with fields: motd, version, players, maxPlayers, favicon) or handle the error case with a discriminated union type.

Copilot uses AI. Check for mistakes.
const [isQuerying, setIsQuerying] = useState(false);

useEffect(() => {
if (!address || !address.includes(".") || address.length < 3) {
setQueryResult(null);
setIsQuerying(false);
return;
}
Comment on lines +41 to +45
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

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

The validation logic !address || !address.includes(".") || address.length < 3 is overly simplistic and may reject valid server addresses or accept invalid ones. For example, it would reject "localhost" (a valid address) but accept "a.b" (too short to be useful). Consider implementing proper validation that:

  1. Accepts both domain names and IP addresses (IPv4/IPv6)
  2. Validates domain format using a regex or URL parser
  3. Optionally validates port numbers if included (e.g., "server.com:25565")
  4. Allows "localhost" as a valid address

Copilot uses AI. Check for mistakes.

const delayTimer = setTimeout(async () => {
setIsQuerying(true);
try {
const result = await invoke("ping_server", { address });
Copy link
Owner

Choose a reason for hiding this comment

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

不符合前端 service-handleService 格式规范

setQueryResult(result);
} catch (err) {
setQueryResult({ error: true });
} finally {
setIsQuerying(false);
}
}, 600);
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

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

The debounce timeout of 600ms is relatively short for a server ping operation that involves network requests. Consider increasing this to at least 1000-1500ms to reduce unnecessary server queries as users type, especially for slower network connections. This will improve performance and reduce server load.

Suggested change
}, 600);
}, 1200);

Copilot uses AI. Check for mistakes.

return () => clearTimeout(delayTimer);
}, [address]);

const handleConfirm = () => {
if (address.trim()) {
onAdd(name || "Minecraft Server", address.trim());
Comment on lines +63 to +64
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

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

The default server name "Minecraft Server" is hardcoded in English. For consistency with the i18n system, this should use a translation key or be derived from the server's MOTD (which is already available in queryResult if the ping succeeds).

Suggested change
if (address.trim()) {
onAdd(name || "Minecraft Server", address.trim());
const trimmedAddress = address.trim();
if (trimmedAddress) {
const trimmedName = name.trim();
const motd =
queryResult && typeof queryResult === "object"
? // Prefer a "clean" MOTD text if available, otherwise fall back to a direct MOTD field
(queryResult.motd &&
(queryResult.motd.clean || queryResult.motd.text || queryResult.motd)) ||
queryResult.motd
: undefined;
const resolvedName =
(trimmedName as string) ||
(typeof motd === "string" ? motd : "") ||
trimmedAddress;
onAdd(resolvedName, trimmedAddress);

Copilot uses AI. Check for mistakes.
handleClose();
}
};

const handleClose = () => {
setName("");
setAddress("");
setQueryResult(null);
onClose();
};

return (
<Modal isOpen={isOpen} onClose={handleClose} isCentered size="sm">
<ModalOverlay backdropFilter="blur(5px)" />
<ModalContent
bg="rgba(25, 25, 25, 0.9)"
Copy link
Owner

Choose a reason for hiding this comment

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

本项目其他 modal 从未单独定义 overlay、content、modal 内 input 的 css 格式

组件单独定义的 style 参数过多

color="white"
borderRadius="xl"
border="1px solid rgba(255,255,255,0.1)"
>
<ModalHeader fontSize="md">添加服务器</ModalHeader>
Copy link
Owner

Choose a reason for hiding this comment

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

未使用 i18n

<ModalCloseButton />
<ModalBody>
<VStack spacing={4} align="stretch">
<FormControl>
<FormLabel fontSize="xs" opacity={0.6}>
名称 (可选)
</FormLabel>
<Input
variant="filled"
bg="rgba(0,0,0,0.3)"
_hover={{ bg: "rgba(0,0,0,0.4)" }}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</FormControl>

<FormControl isRequired>
<FormLabel fontSize="xs" opacity={0.6}>
地址 (IP / 域名)
</FormLabel>
<Input
variant="filled"
bg="rgba(0,0,0,0.3)"
_hover={{ bg: "rgba(0,0,0,0.4)" }}
placeholder="play.example.com"
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
</FormControl>

{/*overview */}
<Box
minH="70px"
p={3}
borderRadius="md"
bg="whiteAlpha.50"
border="1px solid"
borderColor="whiteAlpha.100"
>
{isQuerying ? (
<HStack spacing={3}>
<Skeleton
startColor="whiteAlpha.200"
endColor="whiteAlpha.300"
borderRadius="sm"
boxSize="40px"
/>

<VStack align="start" spacing={2} flex={1}>
<Skeleton
startColor="whiteAlpha.200"
endColor="whiteAlpha.300"
h="12px"
w="80%"
/>
<Skeleton
startColor="whiteAlpha.200"
endColor="whiteAlpha.300"
h="10px"
w="40%"
/>
</VStack>
</HStack>
) : queryResult?.error ? (
<Text fontSize="xs" color="red.300" textAlign="center" py={4}>
无法解析或连接服务器
Copy link
Owner

Choose a reason for hiding this comment

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

未使用 i18n

</Text>
) : queryResult ? (
<HStack spacing={3}>
<Image
src={queryResult.favicon}
alt="Server Favicon"
fallbackSrc="/images/icons/UnknownWorld.webp"
boxSize="40px"
borderRadius="sm"
/>
<VStack align="start" spacing={0} flex={1}>
<Text
fontSize="xs"
fontWeight="bold"
noOfLines={1}
color="blue.200"
>
{queryResult.motd || "Minecraft Server"}
</Text>
<Text fontSize="2xs" opacity={0.6}>
{queryResult.version} • {queryResult.players}/
{queryResult.maxPlayers}
</Text>
</VStack>
<Badge colorScheme="green" variant="subtle" fontSize="2xs">
在线
</Badge>
</HStack>
) : (
<Text
fontSize="xs"
color="whiteAlpha.400"
textAlign="center"
py={4}
>
输入地址后自动预览
</Text>
)}
</Box>
</VStack>
</ModalBody>

<ModalFooter>
<Button size="sm" variant="ghost" mr={3} onClick={handleClose}>
取消
Copy link
Owner

Choose a reason for hiding this comment

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

未使用 i18n

</Button>
<Button
size="sm"
colorScheme="blue"
onClick={handleConfirm}
isDisabled={!address.trim() || isQuerying}
>
确定添加
Copy link
Owner

Choose a reason for hiding this comment

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

未使用 i18n

</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};

export default AddServerModal;
Comment on lines +1 to +212
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

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

This component contains hardcoded Chinese text strings instead of using the i18n translation system. All user-facing text should be internationalized using the t() function with locale keys. The following strings need to be moved to locale files: "添加服务器" (line 85), "名称 (可选)" (line 91), "地址 (IP / 域名)" (line 104), "无法解析或连接服务器" (line 151), "Minecraft Server" (line 169), "在线" (line 177), "输入地址后自动预览" (line 187), "取消" (line 196), "确定添加" (line 204).

Copilot uses AI. Check for mistakes.
Loading
Loading