Skip to content
Open
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
140 changes: 124 additions & 16 deletions src-tauri/src/instance/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ use crate::instance::helpers::resourcepack::{
};
use crate::instance::helpers::server::{
GameServerInfo, get_servers_nbt_path_by_instance_id, load_servers_info_from_nbt,
query_servers_online, save_servers_to_nbt,
persist_server_icons_to_nbt, query_servers_online, save_servers_to_nbt,
to_visible_servers_with_index,
};
use crate::instance::helpers::world::{load_level_data_from_nbt, load_world_info_from_dir};
use crate::instance::models::misc::{
Expand Down Expand Up @@ -519,17 +520,16 @@ pub async fn retrieve_game_server_list(
Some(path) => path,
None => return Ok(Vec::new()),
};
let mut game_servers = match load_servers_info_from_nbt(&nbt_path).await {
let nbt_servers = match load_servers_info_from_nbt(&nbt_path).await {
Ok(servers) => servers,
Err(_) => return Err(InstanceError::ServerNbtReadError.into()),
};

// skip hidden servers
game_servers.retain(|server| !server.hidden);
let mut game_servers = to_visible_servers_with_index(nbt_servers);

// query_online is true, amend query and return player count and online status
if query_online {
game_servers = query_servers_online(game_servers).await?;
game_servers = query_servers_online(game_servers, None).await?;
let _ = persist_server_icons_to_nbt(&nbt_path, &game_servers).await;
}

Ok(game_servers)
Expand All @@ -539,47 +539,155 @@ pub async fn retrieve_game_server_list(
pub async fn delete_game_server(
app: AppHandle,
instance_id: String,
server_addr: String,
index: usize,
) -> 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);
if index >= existing_servers.len() {
return Err(InstanceError::ServerNotFound.into());
}

existing_servers.remove(index);
save_servers_to_nbt(&nbt_path, &existing_servers)
.await
.map_err(|_| InstanceError::FileOperationError)?;

Ok(())
}

/// Append a server entry like vanilla multiplayer "Add Server" (no IP dedup).
/// Returns the servers.dat index of the new entry.
#[tauri::command]
pub async fn add_game_server(
app: AppHandle,
instance_id: String,
server_addr: String,
server_name: String,
) -> SJMCLResult<()> {
) -> SJMCLResult<usize> {
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()
});
let index = existing_servers.len() - 1;
save_servers_to_nbt(&nbt_path, &existing_servers)
.await
.map_err(|_| InstanceError::FileOperationError)?;

Ok(index)
}

/// Ping selected visible servers. `indexes` of `None` means all visible entries.
/// Returns only the queried entries (with status filled); frontend merges by `index`.
#[tauri::command]
pub async fn query_game_server_online_status(
app: AppHandle,
instance_id: String,
indexes: Option<Vec<usize>>,
) -> SJMCLResult<Vec<GameServerInfo>> {
let nbt_path = match get_servers_nbt_path_by_instance_id(&app, &instance_id) {
Some(path) => path,
None => return Ok(Vec::new()),
};
let nbt_servers = match load_servers_info_from_nbt(&nbt_path).await {
Ok(servers) => servers,
Err(_) => return Err(InstanceError::ServerNbtReadError.into()),
};

let visible = to_visible_servers_with_index(nbt_servers);
let targets: Vec<GameServerInfo> = match indexes {
Some(indexes) => visible
.into_iter()
.filter(|server| indexes.contains(&server.index))
.collect(),
None => visible,
};

let queried = query_servers_online(targets, Some(app.clone())).await?;
let _ = persist_server_icons_to_nbt(&nbt_path, &queried).await;

Ok(queried)
}

/// Update name/address of an existing server entry (by servers.dat index).
#[tauri::command]
pub async fn update_game_server(
app: AppHandle,
instance_id: String,
index: usize,
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?;

let Some(server) = existing_servers.get_mut(index) else {
return Err(InstanceError::ServerNotFound.into());
};

server.ip = server_addr;
if !server_name.trim().is_empty() {
server.name = server_name;
}

save_servers_to_nbt(&nbt_path, &existing_servers)
.await
.map_err(|_| InstanceError::FileOperationError)?;

Ok(())
}

/// Reorder one visible server entry, swapping with the adjacent visible one (vanilla arrows).
#[tauri::command]
pub async fn move_game_server(
app: AppHandle,
instance_id: String,
index: usize,
move_up: bool,
) -> 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 index >= existing_servers.len() {
return Err(InstanceError::ServerNotFound.into());
}

let visible_indices: Vec<usize> = existing_servers
.iter()
.enumerate()
.filter(|(_, server)| !server.hidden)
.map(|(i, _)| i)
.collect();
let pos = visible_indices
.iter()
.position(|&i| i == index)
.ok_or(InstanceError::ServerNotFound)?;

let Some(next_pos) = (if move_up {
pos.checked_sub(1)
} else {
(pos + 1 < visible_indices.len()).then_some(pos + 1)
}) else {
return Ok(());
};

existing_servers.swap(index, visible_indices[next_pos]);
save_servers_to_nbt(&nbt_path, &existing_servers)
.await
.map_err(|_| InstanceError::FileOperationError)?;
Expand Down
123 changes: 110 additions & 13 deletions src-tauri/src/instance/helpers/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@ use serde::{self, Deserialize, Serialize};
use sjmcl_types::error::{SJMCLError, SJMCLResult};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tauri::AppHandle;
use tauri::{AppHandle, Emitter};
use tokio::sync::Semaphore;
use tokio::task::JoinSet;

use crate::instance::helpers::misc::get_instance_subdir_path_by_id;
use crate::instance::models::misc::InstanceSubdirType;

pub const SERVERS_DAT_FILENAME: &str = "servers.dat";
/// Emitted for each finished ping so the UI can render results progressively.
pub const GAME_SERVER_STATUS_EVENT: &str = "instance:game-server-status";

#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize, Default)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
Expand All @@ -26,6 +28,8 @@ pub struct GameServerInfo {
pub players_max: usize,
pub online: bool, // if false, it may be offline in the query result or failed in the query.
pub latency: Option<u64>, // ping latency in milliseconds
// position in servers.dat; used for delete/reorder (vanilla allows duplicate IPs)
pub index: usize,
}

#[derive(Debug, Serialize, Deserialize, Default)]
Expand Down Expand Up @@ -56,9 +60,10 @@ impl From<NbtServerInfo> for GameServerInfo {

impl From<GameServerInfo> for NbtServerInfo {
fn from(server: GameServerInfo) -> Self {
let icon = normalize_icon_for_nbt(&server.icon_src);
Self {
ip: server.ip,
icon: (!server.icon_src.is_empty()).then_some(server.icon_src),
icon: (!icon.is_empty()).then_some(icon),
name: server.name,
hidden: server.hidden,
}
Expand All @@ -67,9 +72,10 @@ impl From<GameServerInfo> for NbtServerInfo {

impl From<&GameServerInfo> for NbtServerInfo {
fn from(server: &GameServerInfo) -> Self {
let icon = normalize_icon_for_nbt(&server.icon_src);
Self {
ip: server.ip.clone(),
icon: (!server.icon_src.is_empty()).then_some(server.icon_src.clone()),
icon: (!icon.is_empty()).then_some(icon),
name: server.name.clone(),
hidden: server.hidden,
}
Expand Down Expand Up @@ -110,8 +116,24 @@ pub async fn save_servers_to_nbt(path: &Path, servers: &[GameServerInfo]) -> SJM
Ok(())
}

/// Attach servers.dat indexes and drop hidden rows (same filter as vanilla multiplayer UI).
pub fn to_visible_servers_with_index(servers: Vec<GameServerInfo>) -> Vec<GameServerInfo> {
servers
.into_iter()
.enumerate()
.map(|(i, mut server)| {
server.index = i;
server
})
.filter(|server| !server.hidden)
.collect()
}

/// Ping Java servers in parallel via `lite-mc-ping`.
/// Emits `GAME_SERVER_STATUS_EVENT` as each server finishes when `app` is set.
pub async fn query_servers_online(
mut servers: Vec<GameServerInfo>,
app: Option<AppHandle>,
) -> SJMCLResult<Vec<GameServerInfo>> {
if servers.is_empty() {
return Ok(servers);
Expand All @@ -126,11 +148,15 @@ pub async fn query_servers_online(
});

let mut set: JoinSet<(usize, ServerAddress, SJMCLResult<PingResult>)> = JoinSet::new();
let mut parse_failed: Vec<usize> = Vec::new();

for (idx, sv) in servers.iter().enumerate() {
let address = match sv.ip.parse::<ServerAddress>() {
Ok(a) => a,
Err(_) => continue,
Err(_) => {
parse_failed.push(idx);
continue;
}
};
let sem = sem.clone();
let options = options.clone();
Expand All @@ -156,21 +182,92 @@ pub async fn query_servers_online(
let Ok((idx, _addr, result)) = joined else {
continue;
};
if let Ok(info) = result {
servers[idx].online = true;
servers[idx].latency = info.latency.map(|x| x.as_millis() as u64);
servers[idx].players_online = info.status.players.online as usize;
servers[idx].players_max = info.status.players.max as usize;
servers[idx].description = info.status.description;
if let Some(ico) = info.status.favicon {
servers[idx].icon_src = ico;
match result {
Ok(info) => {
servers[idx].online = true;
servers[idx].latency = info.latency.map(|x| x.as_millis() as u64);
servers[idx].players_online = info.status.players.online as usize;
servers[idx].players_max = info.status.players.max as usize;
servers[idx].description = info.status.description;
if let Some(ico) = info.status.favicon {
servers[idx].icon_src = ico;
}
}
Err(_) => {
mark_offline(&mut servers[idx]);
}
}
servers[idx].is_queried = true;
if let Some(app) = &app {
let _ = app.emit(GAME_SERVER_STATUS_EVENT, &servers[idx]);
}
}

for idx in parse_failed {
mark_offline(&mut servers[idx]);
servers[idx].is_queried = true;
if let Some(app) = &app {
let _ = app.emit(GAME_SERVER_STATUS_EVENT, &servers[idx]);
}
}

for server in &mut servers {
server.is_queried = true;
if !server.is_queried {
mark_offline(server);
server.is_queried = true;
}
}

Ok(servers)
}

/// Strip `data:image/png;base64,` (or similar) so vanilla-compatible NBT stores raw base64.
pub fn normalize_icon_for_nbt(icon: &str) -> String {
let icon = icon.trim();
if icon.is_empty() {
return String::new();
}
if let Some(idx) = icon.find(";base64,") {
return icon[idx + ";base64,".len()..].to_string();
}
icon.to_string()
}

/// Persist ping-fetched icons into servers.dat (vanilla multiplayer also caches icons there).
pub async fn persist_server_icons_to_nbt(
path: &Path,
queried: &[GameServerInfo],
) -> SJMCLResult<()> {
if queried.is_empty() {
return Ok(());
}
let mut existing = load_servers_info_from_nbt(path).await?;
let mut changed = false;
for server in queried {
if server.icon_src.is_empty() {
continue;
}
let Some(entry) = existing.get_mut(server.index) else {
continue;
};
let normalized = normalize_icon_for_nbt(&server.icon_src);
if normalized.is_empty() {
continue;
}
if normalize_icon_for_nbt(&entry.icon_src) != normalized {
entry.icon_src = server.icon_src.clone();
changed = true;
}
}
if changed {
save_servers_to_nbt(path, &existing).await?;
}
Ok(())
}

fn mark_offline(server: &mut GameServerInfo) {
server.online = false;
server.latency = None;
server.players_online = 0;
server.players_max = 0;
}
Loading
Loading