From 1f21120ead3bcd441efce7eaf80ce93fb398ac6b Mon Sep 17 00:00:00 2001 From: Pro0101-2b2fr Date: Tue, 23 Jun 2026 23:52:14 +0200 Subject: [PATCH 1/8] fix(multiplayer): add LAN server detection for custom theme - Add UDP multicast listener (224.0.2.60:4445) for LAN world broadcasts - REST endpoint GET /api/v1/client/servers/lan - LAN servers use negative IDs to avoid collision with regular servers - Auto-prune stale servers after 15 seconds (TTL) - Frontend polls every 3 seconds with error handling - Show 'LAN' tag on detected servers - Hide Remove/Edit buttons for LAN servers (not editable) - Disable sorting when LAN servers are present - Shutdown hook to cleanup socket on client close --- src-theme/src/integration/rest.ts | 7 + src-theme/src/integration/types.ts | 1 + .../menu/multiplayer/Multiplayer.svelte | 47 ++++-- .../rest/v1/game/ServerListFunctions.kt | 144 ++++++++++++++++++ 4 files changed, 189 insertions(+), 10 deletions(-) diff --git a/src-theme/src/integration/rest.ts b/src-theme/src/integration/rest.ts index 9dacb871993..1c467a5e872 100644 --- a/src-theme/src/integration/rest.ts +++ b/src-theme/src/integration/rest.ts @@ -268,6 +268,13 @@ export async function getServers(): Promise { return data; } +export async function getLanServers(): Promise { + const response = await fetch(`${API_BASE}/client/servers/lan`); + const data: Server[] = await response.json(); + + return data; +} + export async function connectToServer(address: string) { await fetch(`${API_BASE}/client/servers/connect`, { method: "POST", diff --git a/src-theme/src/integration/types.ts b/src-theme/src/integration/types.ts index 0b87b0b4b4c..a14c56bf63f 100644 --- a/src-theme/src/integration/types.ts +++ b/src-theme/src/integration/types.ts @@ -304,6 +304,7 @@ export interface Server { version: string; ping: number; resourcePackPolicy: string; + lan?: boolean; } export interface TextComponent { diff --git a/src-theme/src/routes/menu/multiplayer/Multiplayer.svelte b/src-theme/src/routes/menu/multiplayer/Multiplayer.svelte index f83a532edc5..e7dc7b19ebb 100644 --- a/src-theme/src/routes/menu/multiplayer/Multiplayer.svelte +++ b/src-theme/src/routes/menu/multiplayer/Multiplayer.svelte @@ -8,11 +8,12 @@ import Search from "../common/Search.svelte"; import MenuListItem from "../common/menulist/MenuListItem.svelte"; import MenuListItemButton from "../common/menulist/MenuListItemButton.svelte"; - import {onMount} from "svelte"; + import {onDestroy, onMount} from "svelte"; import { browse, connectToServer, getClientInfo, + getLanServers, getModule, getProtocols, getSelectedProtocol, @@ -49,20 +50,21 @@ let currentEditServer: Server | null = null; $: { - let filteredServers = servers; + let combined = [...servers, ...lanServers]; if (onlineOnly) { - filteredServers = filteredServers.filter(s => s.ping > 0); + combined = combined.filter(s => s.ping > 0); } if (searchQuery) { - filteredServers = filteredServers.filter(s => s.name.toLowerCase().includes(searchQuery.toLowerCase())); + combined = combined.filter(s => s.name.toLowerCase().includes(searchQuery.toLowerCase())); } - renderedServers = filteredServers; + renderedServers = combined; } let clientInfo: ClientInfo | null = null; let autoConfig = false; let spooferConfigurable: ConfigurableSetting | null = null; let servers: Server[] = []; + let lanServers: Server[] = []; let renderedServers: Server[] = []; let protocols: Protocol[] = []; let selectedProtocol: Protocol = { @@ -78,14 +80,20 @@ // This is a hack and there should be a better solution. let timesSorted = 0; + let lanPollInterval: ReturnType | null = null; + onMount(async () => { clientInfo = await getClientInfo(); spooferConfigurable = await getSpooferSettings(); autoConfig = (await getModule("AutoConfig")).enabled; await refreshServers(); - renderedServers = servers; + await refreshLanServers(); + renderedServers = [...servers, ...lanServers]; protocols = await getProtocols(); selectedProtocol = await getSelectedProtocol(); + + // Poll for LAN servers every 3 seconds + lanPollInterval = setInterval(refreshLanServers, 3000); }); listen("serverPinged", (pingedEvent: ServerPingedEvent) => { @@ -107,6 +115,20 @@ servers = await getServers(); } + async function refreshLanServers() { + try { + lanServers = await getLanServers(); + } catch { + // Silently ignore LAN server fetch errors to avoid spamming console + } + } + + onDestroy(() => { + if (lanPollInterval) { + clearInterval(lanPollInterval); + } + }); + async function removeServer(index: number) { await removeServerRest(index); await refreshServers(); @@ -139,7 +161,7 @@ async function handleServerSort(e: CustomEvent<{ newOrder: number[] }>) { await orderServers(e.detail.newOrder); await refreshServers(); - renderedServers = servers; + renderedServers = [...servers, ...lanServers]; timesSorted++; // See declaration } @@ -192,7 +214,7 @@ {/if} - {#key timesSorted} {#each renderedServers as server} @@ -208,6 +230,9 @@ textComponent={server.ping <= 0 ? "§CCan't connect to server" : server.label}/> + {#if server.lan} + + {/if} {#if server.ping > 0} @@ -215,8 +240,10 @@ - removeServer(server.id)}/> - editServer(server)}/> + {#if !server.lan} + removeServer(server.id)}/> + editServer(server)}/> + {/if} diff --git a/src/main/kotlin/net/ccbluex/liquidbounce/integration/interop/protocol/rest/v1/game/ServerListFunctions.kt b/src/main/kotlin/net/ccbluex/liquidbounce/integration/interop/protocol/rest/v1/game/ServerListFunctions.kt index 31df79f902f..cb5e970cf1a 100644 --- a/src/main/kotlin/net/ccbluex/liquidbounce/integration/interop/protocol/rest/v1/game/ServerListFunctions.kt +++ b/src/main/kotlin/net/ccbluex/liquidbounce/integration/interop/protocol/rest/v1/game/ServerListFunctions.kt @@ -20,6 +20,7 @@ package net.ccbluex.liquidbounce.integration.interop.protocol.rest.v1.game import com.google.gson.JsonArray +import com.google.gson.JsonObject import net.ccbluex.liquidbounce.config.gson.interopGson import net.ccbluex.liquidbounce.config.gson.serializer.minecraft.ResourcePolicy import net.ccbluex.liquidbounce.event.EventListener @@ -46,8 +47,12 @@ import net.minecraft.network.chat.Component import net.minecraft.server.network.EventLoopGroupHolder import net.minecraft.util.CommonColors import net.minecraft.util.Util +import java.net.DatagramPacket +import java.net.InetAddress +import java.net.MulticastSocket import java.net.UnknownHostException import java.util.concurrent.CompletableFuture +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Future // GET /api/v1/client/servers @@ -184,6 +189,135 @@ object ActiveServerList : EventListener { private val pingTasks = mutableListOf>() + // LAN server detection - listens for UDP multicast from LAN worlds + private val LAN_GROUP = InetAddress.getByName("224.0.2.60") + private const val LAN_PORT = 4445 + private const val LAN_SERVER_TTL_MS = 15_000L // Remove servers not seen for 15 seconds + + private data class LanServerEntry(val motd: String, val lastSeen: Long, val serverData: ServerData) + + // ConcurrentHashMap: address -> entry (thread-safe without explicit synchronization) + private val lanServers = ConcurrentHashMap() + + @Volatile + private var lanDetectorThread: Thread? = null + + @Volatile + private var lanSocket: MulticastSocket? = null + + init { + startLanDetection() + // Shutdown hook to clean up socket when client closes + Runtime.getRuntime().addShutdownHook(Thread { stopLanDetection() }) + } + + private fun startLanDetection() { + try { + val socket = MulticastSocket(LAN_PORT) + socket.joinGroup(LAN_GROUP) + socket.soTimeout = 5000 + lanSocket = socket + + lanDetectorThread = Thread({ + runLanDetectionLoop(socket) + }, "LanServerDetector").apply { isDaemon = true; start() } + } catch (e: Exception) { + logger.warn("Unable to start LAN server detection: {}", e.message) + } + } + + private fun runLanDetectionLoop(socket: MulticastSocket) { + try { + val buf = ByteArray(1024) + val packet = DatagramPacket(buf, buf.size) + + while (!Thread.currentThread().isInterrupted) { + try { + socket.receive(packet) + handleLanBroadcast(packet) + } catch (_: java.net.SocketTimeoutException) { + pruneStaleServers() + } + } + } catch (e: Exception) { + if (!Thread.currentThread().isInterrupted) { + logger.warn("LAN server detection error: {}", e.message) + } + } finally { + runCatching { socket.leaveGroup(LAN_GROUP) } + runCatching { socket.close() } + } + } + + private fun handleLanBroadcast(packet: DatagramPacket) { + val response = String(packet.data, packet.offset, packet.length) + // LAN broadcast format: "MOTD§port" (e.g., "My World§25565") + val parts = response.split("\u00A7".toRegex(), limit = 2) + if (parts.size != 2) return + + val motd = parts[0] + val addrPart = parts[1] + // addrPart can be "port" or "address:port" + val address = if (addrPart.contains(":")) { + addrPart + } else { + "${packet.address.hostAddress}:$addrPart" + } + + val existing = lanServers[address] + if (existing != null) { + // Update lastSeen, keep existing ServerData (preserves ping state) + lanServers[address] = existing.copy(lastSeen = System.currentTimeMillis()) + } else { + // New LAN server: create ServerData and trigger ping + val serverData = ServerData(motd, address, ServerData.Type.LAN) + lanServers[address] = LanServerEntry(motd, System.currentTimeMillis(), serverData) + ping(serverData) + } + } + + private fun stopLanDetection() { + lanDetectorThread?.interrupt() + lanSocket?.close() + lanServers.clear() + } + + private fun pruneStaleServers() { + val now = System.currentTimeMillis() + lanServers.entries.removeIf { now - it.value.lastSeen > LAN_SERVER_TTL_MS } + } + + /** + * Returns the list of currently detected LAN servers with full Server-compatible JSON fields. + * Uses negative IDs prefixed to avoid collision with regular server IDs. + */ + fun getLanServers(): List { + pruneStaleServers() + return lanServers.entries.mapIndexed { index, (address, entry) -> + val sd = entry.serverData + JsonObject().apply { + // Use negative IDs to distinguish from regular servers (e.g., -1, -2, ...) + addProperty("id", -(index + 1)) + addProperty("address", address) + addProperty("name", entry.motd) + addProperty("lan", true) + // Use pinged data from ServerData + addProperty("ping", sd.ping) + addProperty("icon", sd.iconBytes?.let { java.util.Base64.getEncoder().encodeToString(it) } ?: "") + addProperty("label", sd.motd?.string ?: entry.motd) + add("players", JsonObject().apply { + addProperty("max", sd.players?.max() ?: 0) + addProperty("online", sd.players?.online() ?: 0) + }) + addProperty("online", sd.ping > 0) + addProperty("playerCountLabel", sd.status?.string ?: "") + addProperty("protocolVersion", sd.protocol) + addProperty("version", sd.version?.string ?: "LAN") + addProperty("resourcePackPolicy", sd.resourcePackStatus?.name ?: "PROMPT") + } + } + } + private fun cancelTasks() { pingTasks.forEach { it.cancel(true) } pingTasks.clear() @@ -248,8 +382,18 @@ val ServerList.servers: List fun ServerList.getByAddress(address: String) = servers.firstOrNull { it.ip == address } +// GET /api/v1/client/servers/lan +private fun Routing.getLanServers() = get("/lan") { + runCatching { + val servers = JsonArray() + ActiveServerList.getLanServers().forEach { servers.add(it) } + call.respond(servers) + }.getOrElse { call.internalServerError("Failed to get LAN servers due to ${it.message}") } +} + internal fun Routing.serverListRoutes() = route("/servers") { getServers() + getLanServers() putAddServer() deleteServer() putEditServer() From 6c9b56e6b42f66da451deb0e5d79e73d1369f66b Mon Sep 17 00:00:00 2001 From: Pro0101-2b2fr Date: Wed, 24 Jun 2026 17:40:55 +0200 Subject: [PATCH 2/8] fix(lan): use vanilla LanServerDetection for LAN server discovery - Replace custom UDP multicast implementation with vanilla LanServerDetection.LanServerDetector and LanServerList - Replace Runtime.addShutdownHook with ClientShutdownEvent handler to follow LiquidBounce's event system pattern - Remove redundant imports (DatagramPacket, InetAddress, MulticastSocket) --- .../rest/v1/game/ServerListFunctions.kt | 122 +++++------------- 1 file changed, 31 insertions(+), 91 deletions(-) diff --git a/src/main/kotlin/net/ccbluex/liquidbounce/integration/interop/protocol/rest/v1/game/ServerListFunctions.kt b/src/main/kotlin/net/ccbluex/liquidbounce/integration/interop/protocol/rest/v1/game/ServerListFunctions.kt index cb5e970cf1a..5962d0df9f4 100644 --- a/src/main/kotlin/net/ccbluex/liquidbounce/integration/interop/protocol/rest/v1/game/ServerListFunctions.kt +++ b/src/main/kotlin/net/ccbluex/liquidbounce/integration/interop/protocol/rest/v1/game/ServerListFunctions.kt @@ -24,6 +24,7 @@ import com.google.gson.JsonObject import net.ccbluex.liquidbounce.config.gson.interopGson import net.ccbluex.liquidbounce.config.gson.serializer.minecraft.ResourcePolicy import net.ccbluex.liquidbounce.event.EventListener +import net.ccbluex.liquidbounce.event.events.ClientShutdownEvent import net.ccbluex.liquidbounce.event.events.GameTickEvent import net.ccbluex.liquidbounce.event.events.ScreenEvent import net.ccbluex.liquidbounce.event.handler @@ -42,14 +43,13 @@ import net.minecraft.client.multiplayer.ServerData.ServerPackStatus import net.minecraft.client.multiplayer.ServerList import net.minecraft.client.multiplayer.ServerStatusPinger import net.minecraft.client.multiplayer.resolver.ServerAddress +import net.minecraft.client.server.LanServer +import net.minecraft.client.server.LanServerDetection import net.minecraft.network.chat.CommonComponents import net.minecraft.network.chat.Component import net.minecraft.server.network.EventLoopGroupHolder import net.minecraft.util.CommonColors import net.minecraft.util.Util -import java.net.DatagramPacket -import java.net.InetAddress -import java.net.MulticastSocket import java.net.UnknownHostException import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentHashMap @@ -189,122 +189,62 @@ object ActiveServerList : EventListener { private val pingTasks = mutableListOf>() - // LAN server detection - listens for UDP multicast from LAN worlds - private val LAN_GROUP = InetAddress.getByName("224.0.2.60") - private const val LAN_PORT = 4445 - private const val LAN_SERVER_TTL_MS = 15_000L // Remove servers not seen for 15 seconds + // LAN server detection using vanilla Minecraft's LanServerDetection + private val lanServerList = LanServerDetection.LanServerList() + private var lanDetector: LanServerDetection.LanServerDetector? = null - private data class LanServerEntry(val motd: String, val lastSeen: Long, val serverData: ServerData) - - // ConcurrentHashMap: address -> entry (thread-safe without explicit synchronization) - private val lanServers = ConcurrentHashMap() - - @Volatile - private var lanDetectorThread: Thread? = null - - @Volatile - private var lanSocket: MulticastSocket? = null + // Track ServerData for each LAN server (for ping info) + private val lanServerData = ConcurrentHashMap() init { startLanDetection() - // Shutdown hook to clean up socket when client closes - Runtime.getRuntime().addShutdownHook(Thread { stopLanDetection() }) } - private fun startLanDetection() { - try { - val socket = MulticastSocket(LAN_PORT) - socket.joinGroup(LAN_GROUP) - socket.soTimeout = 5000 - lanSocket = socket - - lanDetectorThread = Thread({ - runLanDetectionLoop(socket) - }, "LanServerDetector").apply { isDaemon = true; start() } - } catch (e: Exception) { - logger.warn("Unable to start LAN server detection: {}", e.message) - } + @Suppress("unused") + private val shutdownHandler = handler { + stopLanDetection() } - private fun runLanDetectionLoop(socket: MulticastSocket) { + private fun startLanDetection() { try { - val buf = ByteArray(1024) - val packet = DatagramPacket(buf, buf.size) - - while (!Thread.currentThread().isInterrupted) { - try { - socket.receive(packet) - handleLanBroadcast(packet) - } catch (_: java.net.SocketTimeoutException) { - pruneStaleServers() - } - } + lanDetector = LanServerDetection.LanServerDetector(lanServerList).apply { start() } } catch (e: Exception) { - if (!Thread.currentThread().isInterrupted) { - logger.warn("LAN server detection error: {}", e.message) - } - } finally { - runCatching { socket.leaveGroup(LAN_GROUP) } - runCatching { socket.close() } - } - } - - private fun handleLanBroadcast(packet: DatagramPacket) { - val response = String(packet.data, packet.offset, packet.length) - // LAN broadcast format: "MOTD§port" (e.g., "My World§25565") - val parts = response.split("\u00A7".toRegex(), limit = 2) - if (parts.size != 2) return - - val motd = parts[0] - val addrPart = parts[1] - // addrPart can be "port" or "address:port" - val address = if (addrPart.contains(":")) { - addrPart - } else { - "${packet.address.hostAddress}:$addrPart" - } - - val existing = lanServers[address] - if (existing != null) { - // Update lastSeen, keep existing ServerData (preserves ping state) - lanServers[address] = existing.copy(lastSeen = System.currentTimeMillis()) - } else { - // New LAN server: create ServerData and trigger ping - val serverData = ServerData(motd, address, ServerData.Type.LAN) - lanServers[address] = LanServerEntry(motd, System.currentTimeMillis(), serverData) - ping(serverData) + logger.warn("Unable to start LAN server detection: {}", e.message) } } private fun stopLanDetection() { - lanDetectorThread?.interrupt() - lanSocket?.close() - lanServers.clear() - } - - private fun pruneStaleServers() { - val now = System.currentTimeMillis() - lanServers.entries.removeIf { now - it.value.lastSeen > LAN_SERVER_TTL_MS } + lanDetector?.interrupt() + lanDetector = null + lanServerData.clear() } /** * Returns the list of currently detected LAN servers with full Server-compatible JSON fields. - * Uses negative IDs prefixed to avoid collision with regular server IDs. + * Uses negative IDs to avoid collision with regular server IDs. */ fun getLanServers(): List { - pruneStaleServers() - return lanServers.entries.mapIndexed { index, (address, entry) -> - val sd = entry.serverData + // Check for new servers from vanilla detector + lanServerList.takeDirtyServers()?.forEach { lanServer -> + val address = lanServer.address + if (!lanServerData.containsKey(address)) { + val serverData = ServerData(lanServer.motd, address, ServerData.Type.LAN) + lanServerData[address] = serverData + ping(serverData) + } + } + + return lanServerData.entries.mapIndexed { index, (address, sd) -> JsonObject().apply { // Use negative IDs to distinguish from regular servers (e.g., -1, -2, ...) addProperty("id", -(index + 1)) addProperty("address", address) - addProperty("name", entry.motd) + addProperty("name", sd.name) addProperty("lan", true) // Use pinged data from ServerData addProperty("ping", sd.ping) addProperty("icon", sd.iconBytes?.let { java.util.Base64.getEncoder().encodeToString(it) } ?: "") - addProperty("label", sd.motd?.string ?: entry.motd) + addProperty("label", sd.motd?.string ?: sd.name) add("players", JsonObject().apply { addProperty("max", sd.players?.max() ?: 0) addProperty("online", sd.players?.online() ?: 0) From 3e306029af5859f9978beed50487de4d09658f5c Mon Sep 17 00:00:00 2001 From: Pro0101-2b2fr Date: Thu, 25 Jun 2026 09:24:32 +0200 Subject: [PATCH 3/8] refactor(lan): use ServerInfoSerializer for LAN server JSON Replace manual JsonObject construction with interopGson.toJsonTree(serverData) to use the existing ServerInfoSerializer, as suggested in code review. --- .../rest/v1/game/ServerListFunctions.kt | 31 ++++++++----------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/src/main/kotlin/net/ccbluex/liquidbounce/integration/interop/protocol/rest/v1/game/ServerListFunctions.kt b/src/main/kotlin/net/ccbluex/liquidbounce/integration/interop/protocol/rest/v1/game/ServerListFunctions.kt index 5962d0df9f4..70f8568dff6 100644 --- a/src/main/kotlin/net/ccbluex/liquidbounce/integration/interop/protocol/rest/v1/game/ServerListFunctions.kt +++ b/src/main/kotlin/net/ccbluex/liquidbounce/integration/interop/protocol/rest/v1/game/ServerListFunctions.kt @@ -234,28 +234,23 @@ object ActiveServerList : EventListener { } } - return lanServerData.entries.mapIndexed { index, (address, sd) -> - JsonObject().apply { + return lanServerData.entries.mapIndexed { index, (_, serverData) -> + val json = interopGson.toJsonTree(serverData) + + if (!json.isJsonObject) { + logger.warn("Failed to convert LAN serverData to json") + return@mapIndexed null + } + + json.asJsonObject.apply { // Use negative IDs to distinguish from regular servers (e.g., -1, -2, ...) addProperty("id", -(index + 1)) - addProperty("address", address) - addProperty("name", sd.name) addProperty("lan", true) - // Use pinged data from ServerData - addProperty("ping", sd.ping) - addProperty("icon", sd.iconBytes?.let { java.util.Base64.getEncoder().encodeToString(it) } ?: "") - addProperty("label", sd.motd?.string ?: sd.name) - add("players", JsonObject().apply { - addProperty("max", sd.players?.max() ?: 0) - addProperty("online", sd.players?.online() ?: 0) - }) - addProperty("online", sd.ping > 0) - addProperty("playerCountLabel", sd.status?.string ?: "") - addProperty("protocolVersion", sd.protocol) - addProperty("version", sd.version?.string ?: "LAN") - addProperty("resourcePackPolicy", sd.resourcePackStatus?.name ?: "PROMPT") + // Add online status (ServerInfoSerializer uses status enum, frontend expects boolean) + addProperty("online", serverData.state() == ServerData.State.SUCCESSFUL || + serverData.state() == ServerData.State.INCOMPATIBLE) } - } + }.filterNotNull() } private fun cancelTasks() { From 7da5e16f6a6dddfdf8a58bcce916df591b2700f8 Mon Sep 17 00:00:00 2001 From: Pro0101-2b2fr Date: Tue, 30 Jun 2026 22:30:59 +0200 Subject: [PATCH 4/8] refactor(lan): simplify getLanServers and reorder before ActiveServerList - Use call.respond(ActiveServerList.getLanServers()) directly - Move getLanServers() endpoint before ActiveServerList object, after other endpoints as requested in code review --- .../protocol/rest/v1/game/ServerListFunctions.kt | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/net/ccbluex/liquidbounce/integration/interop/protocol/rest/v1/game/ServerListFunctions.kt b/src/main/kotlin/net/ccbluex/liquidbounce/integration/interop/protocol/rest/v1/game/ServerListFunctions.kt index 70f8568dff6..886e67ed770 100644 --- a/src/main/kotlin/net/ccbluex/liquidbounce/integration/interop/protocol/rest/v1/game/ServerListFunctions.kt +++ b/src/main/kotlin/net/ccbluex/liquidbounce/integration/interop/protocol/rest/v1/game/ServerListFunctions.kt @@ -177,6 +177,13 @@ private fun Routing.postOrderServers() = post("/order") { call.respondNoContent() } +// GET /api/v1/client/servers/lan +private fun Routing.getLanServers() = get("/lan") { + runCatching { + call.respond(ActiveServerList.getLanServers()) + }.getOrElse { call.internalServerError("Failed to get LAN servers due to ${it.message}") } +} + object ActiveServerList : EventListener { internal val serverList = ServerList(mc).apply { load() } @@ -317,15 +324,6 @@ val ServerList.servers: List fun ServerList.getByAddress(address: String) = servers.firstOrNull { it.ip == address } -// GET /api/v1/client/servers/lan -private fun Routing.getLanServers() = get("/lan") { - runCatching { - val servers = JsonArray() - ActiveServerList.getLanServers().forEach { servers.add(it) } - call.respond(servers) - }.getOrElse { call.internalServerError("Failed to get LAN servers due to ${it.message}") } -} - internal fun Routing.serverListRoutes() = route("/servers") { getServers() getLanServers() From c6affb293c1dfc97a48ca4e686921c7eaa2121c7 Mon Sep 17 00:00:00 2001 From: Senk Ju <18741573+SenkJu@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:34:52 +0200 Subject: [PATCH 5/8] some refactorings --- src-theme/src/integration/rest.ts | 5 + .../menu/multiplayer/Multiplayer.svelte | 148 +++++++++--------- 2 files changed, 77 insertions(+), 76 deletions(-) diff --git a/src-theme/src/integration/rest.ts b/src-theme/src/integration/rest.ts index 39dd9c4aba8..f79f686d6a4 100644 --- a/src-theme/src/integration/rest.ts +++ b/src-theme/src/integration/rest.ts @@ -272,6 +272,11 @@ export async function getServers(): Promise { export async function getLanServers(): Promise { const response = await fetch(`${API_BASE}/client/servers/lan`); + + if (!response.ok) { + return []; + } + const data: Server[] = await response.json(); return data; diff --git a/src-theme/src/routes/menu/multiplayer/Multiplayer.svelte b/src-theme/src/routes/menu/multiplayer/Multiplayer.svelte index e7dc7b19ebb..674f104bdec 100644 --- a/src-theme/src/routes/menu/multiplayer/Multiplayer.svelte +++ b/src-theme/src/routes/menu/multiplayer/Multiplayer.svelte @@ -4,7 +4,6 @@ import BottomButtonWrapper from "../common/buttons/BottomButtonWrapper.svelte"; import ButtonContainer from "../common/buttons/ButtonContainer.svelte"; import IconTextButton from "../common/buttons/IconTextButton.svelte"; - import Menu from "../common/Menu.svelte"; import Search from "../common/Search.svelte"; import MenuListItem from "../common/menulist/MenuListItem.svelte"; import MenuListItemButton from "../common/menulist/MenuListItemButton.svelte"; @@ -116,15 +115,11 @@ } async function refreshLanServers() { - try { - lanServers = await getLanServers(); - } catch { - // Silently ignore LAN server fetch errors to avoid spamming console - } + lanServers = await getLanServers(); } onDestroy(() => { - if (lanPollInterval) { + if (lanPollInterval !== null) { clearInterval(lanPollInterval); } }); @@ -189,80 +184,81 @@ + {#if currentEditServer} {/if} + - - - - - - - - {#if spooferConfigurable} - - {/if} - {#if clientInfo && clientInfo.viaFabricPlus} - p.name)} - on:change={changeProtocolVersion}/> - openScreen("viafabricplus_protocol_selection")}/> - {:else} - browse("VIAFABRICPLUS")}/> - {/if} - - - - {#key timesSorted} - {#each renderedServers as server} - 0 ? `${server.ping}ms` : null} - imageTextBackgroundColor={getPingColor(server.ping)} - image={server.ping < 0 || !server.icon + + + + + + + + {#if spooferConfigurable} + + {/if} + {#if clientInfo && clientInfo.viaFabricPlus} + p.name)} + on:change={changeProtocolVersion}/> + openScreen("viafabricplus_protocol_selection")}/> + {:else} + browse("VIAFABRICPLUS")}/> + {/if} + + + + {#key timesSorted} + {#each renderedServers as server} + 0 ? `${server.ping}ms` : null} + imageTextBackgroundColor={getPingColor(server.ping)} + image={server.ping < 0 || !server.icon ? `${REST_BASE}/api/v1/client/resource?id=minecraft:textures/misc/unknown_server.png` :`data:image/png;base64,${server.icon}`} - title={server.name} - on:dblclick={() => connectToServer(server.address)}> - - - - {#if server.lan} - - {/if} - {#if server.ping > 0} - - - {/if} - - - - {#if !server.lan} - removeServer(server.id)}/> - editServer(server)}/> - {/if} - - - - connectToServer(server.address)}/> - - - {/each} - {/key} - - - - - addServerModalVisible = true}/> - directConnectModalVisible = true}/> - - - - - openScreen("title")}/> - - - + title={server.name} + on:dblclick={() => connectToServer(server.address)}> + + + + {#if server.lan} + + {/if} + {#if server.ping > 0} + + + {/if} + + + + {#if !server.lan} + removeServer(server.id)}/> + editServer(server)}/> + {/if} + + + + connectToServer(server.address)}/> + + + {/each} + {/key} + + + + + addServerModalVisible = true}/> + directConnectModalVisible = true}/> + + + + + openScreen("title")}/> + + From 9c86bcb13c8bdf1b6285ce3073cfaaa5d9879a59 Mon Sep 17 00:00:00 2001 From: Senk Ju <18741573+SenkJu@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:38:05 +0200 Subject: [PATCH 6/8] make refresh button refresh lan servers --- src-theme/src/routes/menu/multiplayer/Multiplayer.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/src-theme/src/routes/menu/multiplayer/Multiplayer.svelte b/src-theme/src/routes/menu/multiplayer/Multiplayer.svelte index 674f104bdec..92caf1eb309 100644 --- a/src-theme/src/routes/menu/multiplayer/Multiplayer.svelte +++ b/src-theme/src/routes/menu/multiplayer/Multiplayer.svelte @@ -112,6 +112,7 @@ async function refreshServers() { servers = await getServers(); + refreshLanServers(); } async function refreshLanServers() { From 34004af7c1ab97a3383f3a71302d490468a95d38 Mon Sep 17 00:00:00 2001 From: Senk Ju <18741573+SenkJu@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:52:55 +0200 Subject: [PATCH 7/8] await refreshLanServers --- src-theme/src/routes/menu/multiplayer/Multiplayer.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-theme/src/routes/menu/multiplayer/Multiplayer.svelte b/src-theme/src/routes/menu/multiplayer/Multiplayer.svelte index 92caf1eb309..f8910f4f2ab 100644 --- a/src-theme/src/routes/menu/multiplayer/Multiplayer.svelte +++ b/src-theme/src/routes/menu/multiplayer/Multiplayer.svelte @@ -112,7 +112,7 @@ async function refreshServers() { servers = await getServers(); - refreshLanServers(); + await refreshLanServers(); } async function refreshLanServers() { From ac6718ae8740da4aea754324e6456090d46b8e77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=A8=E8=91=89=20Scarlet?= <93977077+mukjepscarlet@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:30:26 +0800 Subject: [PATCH 8/8] backend refactors --- .../rest/v1/game/ServerListFunctions.kt | 85 +++++++++++++------ 1 file changed, 57 insertions(+), 28 deletions(-) diff --git a/src/main/kotlin/net/ccbluex/liquidbounce/integration/interop/protocol/rest/v1/game/ServerListFunctions.kt b/src/main/kotlin/net/ccbluex/liquidbounce/integration/interop/protocol/rest/v1/game/ServerListFunctions.kt index 886e67ed770..e775ca2285a 100644 --- a/src/main/kotlin/net/ccbluex/liquidbounce/integration/interop/protocol/rest/v1/game/ServerListFunctions.kt +++ b/src/main/kotlin/net/ccbluex/liquidbounce/integration/interop/protocol/rest/v1/game/ServerListFunctions.kt @@ -21,6 +21,8 @@ package net.ccbluex.liquidbounce.integration.interop.protocol.rest.v1.game import com.google.gson.JsonArray import com.google.gson.JsonObject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import net.ccbluex.liquidbounce.config.gson.interopGson import net.ccbluex.liquidbounce.config.gson.serializer.minecraft.ResourcePolicy import net.ccbluex.liquidbounce.event.EventListener @@ -33,6 +35,7 @@ import net.ccbluex.liquidbounce.integration.interop.protocol.rest.v1.game.Active import net.ccbluex.liquidbounce.integration.interop.protocol.rest.v1.game.ActiveServerList.serverList import net.ccbluex.liquidbounce.utils.client.logger import net.ccbluex.liquidbounce.utils.client.mc +import net.ccbluex.liquidbounce.utils.kotlin.Minecraft import net.ccbluex.netty.http.routing.Routing import net.minecraft.SharedConstants import net.minecraft.client.gui.screens.ConnectScreen @@ -43,7 +46,6 @@ import net.minecraft.client.multiplayer.ServerData.ServerPackStatus import net.minecraft.client.multiplayer.ServerList import net.minecraft.client.multiplayer.ServerStatusPinger import net.minecraft.client.multiplayer.resolver.ServerAddress -import net.minecraft.client.server.LanServer import net.minecraft.client.server.LanServerDetection import net.minecraft.network.chat.CommonComponents import net.minecraft.network.chat.Component @@ -52,7 +54,6 @@ import net.minecraft.util.CommonColors import net.minecraft.util.Util import java.net.UnknownHostException import java.util.concurrent.CompletableFuture -import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Future // GET /api/v1/client/servers @@ -198,10 +199,14 @@ object ActiveServerList : EventListener { // LAN server detection using vanilla Minecraft's LanServerDetection private val lanServerList = LanServerDetection.LanServerList() + @Volatile private var lanDetector: LanServerDetection.LanServerDetector? = null - // Track ServerData for each LAN server (for ping info) - private val lanServerData = ConcurrentHashMap() + /** + * Tracks ServerData for each LAN server (for ping info), keyed by address + * Should be accessed from main thread + */ + private val lanServers = hashMapOf() init { startLanDetection() @@ -213,51 +218,53 @@ object ActiveServerList : EventListener { } private fun startLanDetection() { - try { - lanDetector = LanServerDetection.LanServerDetector(lanServerList).apply { start() } - } catch (e: Exception) { - logger.warn("Unable to start LAN server detection: {}", e.message) - } + lanDetector = LanServerDetection.LanServerDetector(lanServerList).apply { start() } } private fun stopLanDetection() { lanDetector?.interrupt() lanDetector = null - lanServerData.clear() + lanServerList.takeDirtyServers() + lanServers.clear() } /** * Returns the list of currently detected LAN servers with full Server-compatible JSON fields. - * Uses negative IDs to avoid collision with regular server IDs. + * Mirrors vanilla's updateNetworkServers pattern: takeDirtyServers returns full list → full replacement. + * Uses negative IDs (sorted by address) to avoid collision with regular server IDs. */ - fun getLanServers(): List { - // Check for new servers from vanilla detector - lanServerList.takeDirtyServers()?.forEach { lanServer -> - val address = lanServer.address - if (!lanServerData.containsKey(address)) { - val serverData = ServerData(lanServer.motd, address, ServerData.Type.LAN) - lanServerData[address] = serverData - ping(serverData) + suspend fun getLanServers(): List { + // Check for new/updated servers from vanilla detector — returns full list when dirty + val serverDatas = withContext(Dispatchers.Minecraft) { + lanServerList.takeDirtyServers()?.let { allServers -> + // Full replacement: stale servers are naturally removed when takeDirtyServers drops them + lanServers.clear() + for (lan in allServers) { + lanServers.computeIfAbsent(lan.address) { + ServerData(lan.motd, it, ServerData.Type.LAN) + } + } } + lanServers.values.toTypedArray() } - return lanServerData.entries.mapIndexed { index, (_, serverData) -> - val json = interopGson.toJsonTree(serverData) + serverDatas.sortBy { it.ip } - if (!json.isJsonObject) { - logger.warn("Failed to convert LAN serverData to json") - return@mapIndexed null + // Ping newly added LAN servers + serverDatas.forEach { + if (it.state() == ServerData.State.INITIAL) { + this.ping(it) } + } - json.asJsonObject.apply { - // Use negative IDs to distinguish from regular servers (e.g., -1, -2, ...) + return serverDatas.mapIndexed { index, serverData -> + interopGson.toJsonTree(serverData).asJsonObject.apply { addProperty("id", -(index + 1)) addProperty("lan", true) - // Add online status (ServerInfoSerializer uses status enum, frontend expects boolean) addProperty("online", serverData.state() == ServerData.State.SUCCESSFUL || serverData.state() == ServerData.State.INCOMPATIBLE) } - }.filterNotNull() + } } private fun cancelTasks() { @@ -313,6 +320,28 @@ object ActiveServerList : EventListener { @Suppress("unused") private val tickHandler = handler { serverListPinger.tick() + maybeRePingLanServers() + } + + // Periodic re-ping interval for LAN servers + private var lastLanPingTime = 0L + + private fun maybeRePingLanServers() { + val now = System.currentTimeMillis() + if (now - lastLanPingTime < 30_000L) return + lastLanPingTime = now + + for (entry in lanServers.values) { + when (entry.state()) { + ServerData.State.SUCCESSFUL, + ServerData.State.INCOMPATIBLE, + ServerData.State.UNREACHABLE -> { + entry.setState(ServerData.State.INITIAL) + ping(entry) + } + else -> {} + } + } } override val running = true