diff --git a/src-theme/src/integration/rest.ts b/src-theme/src/integration/rest.ts index a9259193804..f79f686d6a4 100644 --- a/src-theme/src/integration/rest.ts +++ b/src-theme/src/integration/rest.ts @@ -270,6 +270,18 @@ export async function getServers(): Promise { return data; } +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; +} + 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 e6dc7553d47..be828da92e3 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 92e308f42d6..f8910f4f2ab 100644 --- a/src-theme/src/routes/menu/multiplayer/Multiplayer.svelte +++ b/src-theme/src/routes/menu/multiplayer/Multiplayer.svelte @@ -7,11 +7,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, @@ -48,20 +49,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 = { @@ -77,14 +79,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) => { @@ -104,8 +112,19 @@ async function refreshServers() { servers = await getServers(); + await refreshLanServers(); } + async function refreshLanServers() { + lanServers = await getLanServers(); + } + + onDestroy(() => { + if (lanPollInterval !== null) { + clearInterval(lanPollInterval); + } + }); + async function removeServer(index: number) { await removeServerRest(index); await refreshServers(); @@ -138,7 +157,7 @@ async function handleServerSort(e: CustomEvent<{ newOrder: number[] }>) { await orderServers(e.detail.newOrder); await refreshServers(); - renderedServers = servers; + renderedServers = [...servers, ...lanServers]; timesSorted++; // See declaration } @@ -193,7 +212,7 @@ {/if} - {#key timesSorted} {#each renderedServers as server} @@ -209,6 +228,9 @@ textComponent={server.ping <= 0 ? "§CCan't connect to server" : server.label}/> + {#if server.lan} + + {/if} {#if server.ping > 0} @@ -216,8 +238,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..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 @@ -20,9 +20,13 @@ 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 +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 @@ -31,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 @@ -41,6 +46,7 @@ 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.LanServerDetection import net.minecraft.network.chat.CommonComponents import net.minecraft.network.chat.Component import net.minecraft.server.network.EventLoopGroupHolder @@ -172,6 +178,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() } @@ -184,6 +197,76 @@ object ActiveServerList : EventListener { private val pingTasks = mutableListOf>() + // LAN server detection using vanilla Minecraft's LanServerDetection + private val lanServerList = LanServerDetection.LanServerList() + @Volatile + private var lanDetector: LanServerDetection.LanServerDetector? = null + + /** + * Tracks ServerData for each LAN server (for ping info), keyed by address + * Should be accessed from main thread + */ + private val lanServers = hashMapOf() + + init { + startLanDetection() + } + + @Suppress("unused") + private val shutdownHandler = handler { + stopLanDetection() + } + + private fun startLanDetection() { + lanDetector = LanServerDetection.LanServerDetector(lanServerList).apply { start() } + } + + private fun stopLanDetection() { + lanDetector?.interrupt() + lanDetector = null + lanServerList.takeDirtyServers() + lanServers.clear() + } + + /** + * Returns the list of currently detected LAN servers with full Server-compatible JSON fields. + * Mirrors vanilla's updateNetworkServers pattern: takeDirtyServers returns full list → full replacement. + * Uses negative IDs (sorted by address) to avoid collision with regular server IDs. + */ + 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() + } + + serverDatas.sortBy { it.ip } + + // Ping newly added LAN servers + serverDatas.forEach { + if (it.state() == ServerData.State.INITIAL) { + this.ping(it) + } + } + + return serverDatas.mapIndexed { index, serverData -> + interopGson.toJsonTree(serverData).asJsonObject.apply { + addProperty("id", -(index + 1)) + addProperty("lan", true) + addProperty("online", serverData.state() == ServerData.State.SUCCESSFUL || + serverData.state() == ServerData.State.INCOMPATIBLE) + } + } + } + private fun cancelTasks() { pingTasks.forEach { it.cancel(true) } pingTasks.clear() @@ -237,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 @@ -250,6 +355,7 @@ fun ServerList.getByAddress(address: String) = servers.firstOrNull { it.ip == ad internal fun Routing.serverListRoutes() = route("/servers") { getServers() + getLanServers() putAddServer() deleteServer() putEditServer()