Skip to content
Merged
7 changes: 7 additions & 0 deletions src-theme/src/integration/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,13 @@ export async function getServers(): Promise<Server[]> {
return data;
}

export async function getLanServers(): Promise<Server[]> {
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",
Expand Down
1 change: 1 addition & 0 deletions src-theme/src/integration/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ export interface Server {
version: string;
ping: number;
resourcePackPolicy: string;
lan?: boolean;
}

export interface TextComponent {
Expand Down
47 changes: 37 additions & 10 deletions src-theme/src/routes/menu/multiplayer/Multiplayer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = {
Expand All @@ -78,14 +80,20 @@
// This is a hack and there should be a better solution.
let timesSorted = 0;

let lanPollInterval: ReturnType<typeof setInterval> | 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) => {
Expand All @@ -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();
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -192,7 +214,7 @@
{/if}
</OptionBar>

<MenuList sortable={renderedServers.length === servers.length} elementCount={servers.length}
<MenuList sortable={renderedServers.length === servers.length && lanServers.length === 0} elementCount={servers.length}
on:sort={handleServerSort}>
{#key timesSorted}
{#each renderedServers as server}
Expand All @@ -208,15 +230,20 @@
textComponent={server.ping <= 0 ? "§CCan't connect to server" : server.label}/>

<svelte:fragment slot="tag">
{#if server.lan}
<MenuListItemTag text="LAN"/>
{/if}
{#if server.ping > 0}
<MenuListItemTag text="{server.players.online}/{server.players.max} Players"/>
<MenuListItemTag text={server.version}/>
{/if}
</svelte:fragment>

<svelte:fragment slot="active-visible">
<MenuListItemButton title="Remove" icon="trash" on:click={() => removeServer(server.id)}/>
<MenuListItemButton title="Edit" icon="pen-2" on:click={() => editServer(server)}/>
{#if !server.lan}
<MenuListItemButton title="Remove" icon="trash" on:click={() => removeServer(server.id)}/>
<MenuListItemButton title="Edit" icon="pen-2" on:click={() => editServer(server)}/>
{/if}
</svelte:fragment>

<svelte:fragment slot="always-visible">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@
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
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
Expand All @@ -41,13 +43,16 @@ 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.UnknownHostException
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Future

// GET /api/v1/client/servers
Expand Down Expand Up @@ -184,6 +189,70 @@ object ActiveServerList : EventListener {

private val pingTasks = mutableListOf<Future<*>>()

// LAN server detection using vanilla Minecraft's LanServerDetection
private val lanServerList = LanServerDetection.LanServerList()
private var lanDetector: LanServerDetection.LanServerDetector? = null

// Track ServerData for each LAN server (for ping info)
private val lanServerData = ConcurrentHashMap<String, ServerData>()

init {
startLanDetection()
}

@Suppress("unused")
private val shutdownHandler = handler<ClientShutdownEvent> {
stopLanDetection()
}

private fun startLanDetection() {
try {
lanDetector = LanServerDetection.LanServerDetector(lanServerList).apply { start() }
} catch (e: Exception) {
logger.warn("Unable to start LAN server detection: {}", e.message)
}
}

private fun stopLanDetection() {
lanDetector?.interrupt()
lanDetector = null
lanServerData.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.
*/
fun getLanServers(): List<JsonObject> {
// 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, (_, 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("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() {
pingTasks.forEach { it.cancel(true) }
pingTasks.clear()
Expand Down Expand Up @@ -248,8 +317,18 @@ val ServerList.servers: List<ServerData>

fun ServerList.getByAddress(address: String) = servers.firstOrNull { it.ip == address }

// GET /api/v1/client/servers/lan
private fun Routing.getLanServers() = get("/lan") {
Comment thread
Pro0101-2b2fr marked this conversation as resolved.
Outdated
runCatching {
val servers = JsonArray()
ActiveServerList.getLanServers().forEach { servers.add(it) }
call.respond(servers)
Comment thread
Pro0101-2b2fr marked this conversation as resolved.
Outdated
}.getOrElse { call.internalServerError("Failed to get LAN servers due to ${it.message}") }
}

internal fun Routing.serverListRoutes() = route("/servers") {
getServers()
getLanServers()
putAddServer()
deleteServer()
putEditServer()
Expand Down