feat(mod): apply server policy from the proxy

This commit is contained in:
loki5512344 2026-09-30 12:24:42 +02:00
parent 711fcafc3a
commit 33cff90c82
Signed by: boba
GPG key ID: 253067914055423B
10 changed files with 455 additions and 1 deletions

View file

@ -25,6 +25,7 @@ import dev.loki.lovisual.features.module.phase.Notifier;
import dev.loki.lovisual.features.module.lifecycle.ModuleAutoLoader;
import dev.loki.lovisual.features.module.core.ModuleManager;
import dev.loki.lovisual.features.module.phase.WorldPhase;
import dev.loki.lovisual.features.platform.serverpolicy.ServerPolicyClient;
import dev.loki.lovisual.render.effects.NetherPortalRiftPass;
import dev.loki.lovisual.render.effects.SleepOverlayPass;
import dev.loki.lovisual.render.engine.LoVisualRenderEngineBootstrap;
@ -60,6 +61,7 @@ public class LoVisual implements ClientModInitializer {
AccountConfig.get();
ProxyBackend.init();
CommandManager.init();
ServerPolicyClient.init();
MediaSessionService.get().init();
ClientLifecycleEvents.CLIENT_STOPPING.register(client -> MediaSessionService.get().shutdown());

View file

@ -4,9 +4,12 @@ import dev.loki.lovisual.Lifecycle;
import dev.loki.lovisual.addon.manager.ModuleExtensionManager;
import dev.loki.lovisual.config.values.mode.EnumValue;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.features.command.util.CommandOutput;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleManager;
import dev.loki.lovisual.features.module.lifecycle.ModuleActivationSource;
import dev.loki.lovisual.features.platform.serverpolicy.ServerPolicyState;
import net.minecraft.client.resources.language.I18n;
final class ModuleLifecycleHelper {
private final Module owner;
@ -28,6 +31,7 @@ final class ModuleLifecycleHelper {
void setEnabled(boolean state, ModuleActivationSource src, BooleanValue ev, EnumValue<ModuleActivationSource> av) {
if (state && !Lifecycle.isActive()) return;
if (state && !serverPolicyAllows()) return;
ModuleActivationSource norm = normalize(state, src);
if (enabled==state) {
if (source==norm && ev.get()==enabled && av.get()==source) return;
@ -47,7 +51,23 @@ final class ModuleLifecycleHelper {
dev.loki.lovisual.features.module.module.ModuleConfigValues.requestSaveQuietly(owner);
}
/** Server policy gate: deny in lock mode, warn-but-allow in suggest mode. */
private boolean serverPolicyAllows() {
String id = owner.name();
if (ServerPolicyState.allows(id)) {
if (ServerPolicyState.suggestWarn(id)) {
CommandOutput.warning(I18n.get("lovisual.policy.suggest",
owner.getDisplayName(), ServerPolicyState.reason()));
}
return true;
}
CommandOutput.warning(I18n.get("lovisual.policy.denied",
owner.getDisplayName(), ServerPolicyState.reason()));
return false;
}
void setTransient(boolean state, BooleanValue ev, EnumValue<ModuleActivationSource> av) {
if (state && !ServerPolicyState.allows(owner.name())) return;
if (enabled==state) return; enabled=state;
if (enabled) {
if (ModuleExtensionManager.beforeEnable(owner)) { owner.onEnable(); ModuleExtensionManager.afterEnable(owner);} else enabled=false;
@ -56,7 +76,7 @@ final class ModuleLifecycleHelper {
}
void loadAndApply(BooleanValue ev, EnumValue<ModuleActivationSource> av) {
enabled=ev.get(); source=normalize(enabled, av.get()); av.set(source);
enabled=ev.get() && ServerPolicyState.allows(owner.name()); source=normalize(enabled, av.get()); av.set(source);
if (enabled) {
if (ModuleExtensionManager.beforeEnable(owner)) { owner.onEnable(); ModuleExtensionManager.afterEnable(owner);}
else { enabled=false; source=ModuleActivationSource.NONE; ev.set(false); av.set(source); }

View file

@ -0,0 +1,31 @@
package dev.loki.lovisual.features.platform.serverpolicy;
import java.util.List;
/**
* A parsed server policy from the {@code lovisual:policy} channel: which
* modules the proxy denies ({@link Mode#LOCK} = refuse to enable and lock the
* ClickGui card, {@link Mode#SUGGEST} = warn only).
*/
public record ServerPolicy(Mode mode, String reason, List<String> deny, String server) {
public enum Mode {
LOCK,
SUGGEST
}
public ServerPolicy {
mode = mode == null ? Mode.LOCK : mode;
reason = reason == null ? "" : reason;
deny = deny == null ? List.of() : List.copyOf(deny);
server = server == null ? "" : server;
}
public boolean isLocked() {
return mode == Mode.LOCK;
}
public boolean denies(String moduleId) {
return moduleId != null && deny.contains(moduleId);
}
}

View file

@ -0,0 +1,79 @@
package dev.loki.lovisual.features.platform.serverpolicy;
import dev.loki.lovisual.features.command.util.CommandOutput;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleManager;
import io.netty.buffer.ByteBuf;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry;
import net.minecraft.client.resources.language.I18n;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
/**
* Fabric side of the {@code lovisual:policy} channel: registers the payload,
* asks the proxy for a policy on every join, applies incoming policies (force
* disabling denied modules) and forgets everything on disconnect so
* singleplayer has no policy.
*/
public final class ServerPolicyClient {
private ServerPolicyClient() {
}
/** The one-field JSON payload travelling on {@code lovisual:policy}. */
public record Payload(String json) implements CustomPacketPayload {
public static final CustomPacketPayload.Type<Payload> TYPE =
new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath("lovisual", "policy"));
public static final StreamCodec<ByteBuf, Payload> CODEC =
StreamCodec.composite(ByteBufCodecs.STRING_UTF8, Payload::json, Payload::new);
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}
/** Called once from {@code LoVisual#onInitializeClient}. */
public static void init() {
PayloadTypeRegistry.serverboundPlay().register(Payload.TYPE, Payload.CODEC);
PayloadTypeRegistry.clientboundPlay().register(Payload.TYPE, Payload.CODEC);
ClientPlayNetworking.registerReceiver(Payload.TYPE,
(payload, context) -> onPolicy(payload.json()));
ClientPlayConnectionEvents.JOIN.register((handler, sender, client) ->
ClientPlayNetworking.send(new Payload(ServerPolicyCodec.REQUEST_JSON)));
ClientPlayConnectionEvents.DISCONNECT.register((handler, client) ->
ServerPolicyState.clear());
}
private static void onPolicy(String json) {
ServerPolicy policy = ServerPolicyCodec.parse(json);
if (policy == null) {
return;
}
ServerPolicyState.apply(policy);
forceDisableDenied(policy);
}
/** A lock policy must stop modules that are already running. */
private static void forceDisableDenied(ServerPolicy policy) {
if (!policy.isLocked()) {
return;
}
boolean stoppedAny = false;
for (String id : policy.deny()) {
Module module = ModuleManager.get(id);
if (module != null && module.isEnabled()) {
module.setEnabledInternal(false);
stoppedAny = true;
}
}
if (stoppedAny) {
CommandOutput.warning(I18n.get("lovisual.policy.applied", policy.reason()));
}
}
}

View file

@ -0,0 +1,76 @@
package dev.loki.lovisual.features.platform.serverpolicy;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.util.ArrayList;
import java.util.List;
/**
* JSON half of the {@code lovisual:policy} channel. Parsing is deliberately
* forgiving: anything that is not a well-formed {@code "type":"policy"}
* object returns {@code null}, and an unknown mode degrades to the
* conservative {@link ServerPolicy.Mode#LOCK} — a broken payload must never
* silently unlock the client.
*/
public final class ServerPolicyCodec {
/** The C→S answer the proxy matches on to push the policy again. */
public static final String REQUEST_JSON = "{\"type\":\"request\"}";
private ServerPolicyCodec() {
}
public static ServerPolicy parse(String json) {
if (json == null || json.isBlank()) {
return null;
}
try {
JsonElement element = JsonParser.parseString(json);
if (!element.isJsonObject()) {
return null;
}
JsonObject object = element.getAsJsonObject();
JsonElement type = object.get("type");
if (type == null || !type.isJsonPrimitive() || !"policy".equals(type.getAsString())) {
return null;
}
return new ServerPolicy(
parseMode(object.get("mode")),
string(object.get("reason")),
parseDeny(object.get("deny")),
string(object.get("server")));
} catch (RuntimeException e) {
return null;
}
}
private static ServerPolicy.Mode parseMode(JsonElement raw) {
if (raw == null || !raw.isJsonPrimitive()) {
return ServerPolicy.Mode.LOCK;
}
String value = raw.getAsString();
if ("suggest".equalsIgnoreCase(value)) {
return ServerPolicy.Mode.SUGGEST;
}
return ServerPolicy.Mode.LOCK;
}
private static List<String> parseDeny(JsonElement raw) {
if (raw == null || !raw.isJsonArray()) {
return List.of();
}
List<String> out = new ArrayList<>();
for (JsonElement item : raw.getAsJsonArray()) {
if (item != null && item.isJsonPrimitive()) {
out.add(item.getAsString());
}
}
return out;
}
private static String string(JsonElement raw) {
return raw != null && raw.isJsonPrimitive() ? raw.getAsString() : "";
}
}

View file

@ -0,0 +1,66 @@
package dev.loki.lovisual.features.platform.serverpolicy;
/**
* Static holder for the active server policy. {@link #allows} is the single
* gate the module lifecycle consults; ClickGui reads {@link #isLocked} for the
* card lock and {@link #suggestWarn} for the warn-but-allow path.
*/
public final class ServerPolicyState {
private static volatile ServerPolicy current;
private static volatile long receivedAtMs;
private ServerPolicyState() {
}
/** Installs a parsed policy (replacing any previous one). */
public static void apply(ServerPolicy policy) {
if (policy == null) {
return;
}
current = policy;
receivedAtMs = System.currentTimeMillis();
}
/** Drops the policy — called on disconnect so singleplayer is unaffected. */
public static void clear() {
current = null;
receivedAtMs = 0L;
}
public static ServerPolicy current() {
return current;
}
public static long receivedAtMs() {
return receivedAtMs;
}
public static boolean active() {
return current != null;
}
/** True when the module may be enabled: no policy, no lock, or not denied. */
public static boolean allows(String moduleId) {
ServerPolicy policy = current;
return policy == null || !policy.isLocked() || !policy.denies(moduleId);
}
/** True when the module is listed by a {@code lock} policy. */
public static boolean isLocked(String moduleId) {
ServerPolicy policy = current;
return policy != null && policy.isLocked() && policy.denies(moduleId);
}
/** True when the module is listed by a {@code suggest} policy. */
public static boolean suggestWarn(String moduleId) {
ServerPolicy policy = current;
return policy != null && !policy.isLocked() && policy.denies(moduleId);
}
/** The proxy-supplied reason text, for messages; empty when no policy. */
public static String reason() {
ServerPolicy policy = current;
return policy == null ? "" : policy.reason();
}
}

View file

@ -3259,6 +3259,7 @@
"command.iris.description": "Shows Iris and shader compatibility diagnostics.",
"command.modules.description": "Lists modules by state or category.",
"command.ping.description": "Shows the current server latency.",
"command.policy.description": "Shows the policy the proxy sends to this client (mode, denied modules, reason).",
"command.profiler.description": "Controls rendering and runtime profilers.",
"command.serverinfo.description": "Shows information about the current world or server.",
"command.staff.description": "Manages the client staff list.",
@ -3266,6 +3267,10 @@
"command.tps.description": "Shows estimated server ticks per second.",
"command.username.description": "Shows the current Minecraft username and UUID.",
"command.xaero.description": "Adds, lists, or removes Xaero waypoints.",
"lovisual.policy.applied": "The server disabled modules by its rules: %s",
"lovisual.policy.denied": "Module %s is disabled by server policy: %s",
"lovisual.policy.locked": "Disabled by server: %s",
"lovisual.policy.suggest": "The server does not recommend %s: %s",
"module.animations.description": "Animates interface transitions.",
"setting.animations.hotbar_raise": "Hotbar raise",
"setting.animations.inventory": "Inventory open",

View file

@ -3054,6 +3054,7 @@
"command.iris.description": "Показывает диагностику совместимости Iris и шейдеров.",
"command.modules.description": "Показывает модули по состоянию или категории.",
"command.ping.description": "Показывает текущую задержку до сервера.",
"command.policy.description": "Показывает политику, которую прокси отправляет клиенту (режим, запрещённые модули, причина).",
"command.profiler.description": "Управляет профилировщиками рендера и среды выполнения.",
"command.serverinfo.description": "Показывает информацию о текущем мире или сервере.",
"command.staff.description": "Управляет списком персонала клиента.",
@ -3061,6 +3062,10 @@
"command.tps.description": "Показывает расчётное количество серверных тиков в секунду.",
"command.username.description": "Показывает текущее имя Minecraft и UUID.",
"command.xaero.description": "Добавляет, показывает или удаляет точки Xaero.",
"lovisual.policy.applied": "Сервер отключил модули по своим правилам: %s",
"lovisual.policy.denied": "Модуль %s отключён политикой сервера: %s",
"lovisual.policy.locked": "Отключено сервером: %s",
"lovisual.policy.suggest": "Сервер не рекомендует %s: %s",
"module.animations.description": "Плавные анимации интерфейса.",
"setting.animations.hotbar_raise": "Подъём хотбара",
"setting.animations.inventory": "Открытие инвентаря",

View file

@ -0,0 +1,85 @@
package dev.loki.lovisual.features.platform.serverpolicy;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Contract of the S→C half of the {@code lovisual:policy} channel: the proxy
* pushes one JSON object, everything else (garbage, non-policy messages) must
* degrade to {@code null} so a broken payload never locks the client.
*/
public final class ServerPolicyCodecTest {
@Test
void parsesFullPolicy() {
ServerPolicy policy = ServerPolicyCodec.parse("""
{"type":"policy","mode":"lock","reason":"Правила сервера",
"deny":["esp","freecam"],"server":"lobby-1"}""");
assertNotNull(policy);
assertEquals(ServerPolicy.Mode.LOCK, policy.mode());
assertTrue(policy.isLocked());
assertEquals("Правила сервера", policy.reason());
assertEquals(List.of("esp", "freecam"), policy.deny());
assertEquals("lobby-1", policy.server());
assertTrue(policy.denies("esp"));
assertFalse(policy.denies("hitboxes"));
}
@Test
void suggestModeParses() {
ServerPolicy policy = ServerPolicyCodec.parse(
"{\"type\":\"policy\",\"mode\":\"suggest\",\"reason\":\"только подсказка\",\"deny\":[\"esp\"]}");
assertNotNull(policy);
assertEquals(ServerPolicy.Mode.SUGGEST, policy.mode());
assertFalse(policy.isLocked());
}
@Test
void unknownOrMissingModeFallsBackToLock() {
ServerPolicy unknown = ServerPolicyCodec.parse(
"{\"type\":\"policy\",\"mode\":\"banana\",\"deny\":[\"esp\"]}");
assertNotNull(unknown);
assertEquals(ServerPolicy.Mode.LOCK, unknown.mode());
ServerPolicy missing = ServerPolicyCodec.parse("{\"type\":\"policy\",\"deny\":[\"esp\"]}");
assertNotNull(missing);
assertEquals(ServerPolicy.Mode.LOCK, missing.mode());
}
@Test
void missingDenyOrServerBecomesEmpty() {
ServerPolicy policy = ServerPolicyCodec.parse("{\"type\":\"policy\",\"mode\":\"lock\"}");
assertNotNull(policy);
assertTrue(policy.deny().isEmpty());
assertEquals("", policy.server());
assertEquals("", policy.reason());
assertFalse(policy.denies("esp"));
}
@Test
void nonPolicyPayloadsReturnNull() {
assertNull(ServerPolicyCodec.parse(null));
assertNull(ServerPolicyCodec.parse(""));
assertNull(ServerPolicyCodec.parse(" "));
assertNull(ServerPolicyCodec.parse("garbage"));
assertNull(ServerPolicyCodec.parse("[1,2,3]"));
assertNull(ServerPolicyCodec.parse("\"policy\""));
assertNull(ServerPolicyCodec.parse("{\"type\":\"request\"}"));
assertNull(ServerPolicyCodec.parse("{\"deny\":[\"esp\"]}"));
assertNull(ServerPolicyCodec.parse("{\"type\":42}"));
}
@Test
void requestJsonCarriesTheTypeTheProxyMatchesOn() {
com.google.gson.JsonObject request = com.google.gson.JsonParser
.parseString(ServerPolicyCodec.REQUEST_JSON).getAsJsonObject();
assertEquals("request", request.get("type").getAsString());
}
}

View file

@ -0,0 +1,85 @@
package dev.loki.lovisual.features.platform.serverpolicy;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* The gate the whole mod obeys: {@link ServerPolicyState#allows} is consulted
* by module enablement, ClickGui locks and the suggest-warning path. State is
* static, so every test resets it in {@code @BeforeEach}.
*/
public final class ServerPolicyStateTest {
private static ServerPolicy lockPolicy() {
return new ServerPolicy(ServerPolicy.Mode.LOCK, "Правила сервера",
List.of("esp", "freecam"), "lobby-1");
}
@BeforeEach
void reset() {
ServerPolicyState.clear();
}
@Test
void startsUnlockedWithNoPolicy() {
assertNull(ServerPolicyState.current());
assertTrue(ServerPolicyState.allows("esp"));
assertFalse(ServerPolicyState.isLocked("esp"));
assertFalse(ServerPolicyState.suggestWarn("esp"));
assertEquals("", ServerPolicyState.reason());
assertEquals(0L, ServerPolicyState.receivedAtMs());
}
@Test
void lockPolicyBlocksOnlyDeniedModules() {
ServerPolicyState.apply(lockPolicy());
assertFalse(ServerPolicyState.allows("esp"));
assertFalse(ServerPolicyState.allows("freecam"));
assertTrue(ServerPolicyState.isLocked("esp"));
assertTrue(ServerPolicyState.allows("hitboxes"));
assertFalse(ServerPolicyState.isLocked("hitboxes"));
assertEquals("Правила сервера", ServerPolicyState.reason());
assertEquals("lobby-1", ServerPolicyState.current().server());
assertTrue(ServerPolicyState.receivedAtMs() > 0L);
}
@Test
void suggestPolicyWarnsButStillAllows() {
ServerPolicyState.apply(new ServerPolicy(ServerPolicy.Mode.SUGGEST, "рекомендация",
List.of("esp"), ""));
assertTrue(ServerPolicyState.allows("esp"));
assertFalse(ServerPolicyState.isLocked("esp"));
assertTrue(ServerPolicyState.suggestWarn("esp"));
assertFalse(ServerPolicyState.suggestWarn("freecam"));
}
@Test
void applyingNewPolicyReplacesTheOldOne() {
ServerPolicyState.apply(lockPolicy());
ServerPolicyState.apply(new ServerPolicy(ServerPolicy.Mode.SUGGEST, "новая", List.of(), "bedwars"));
assertTrue(ServerPolicyState.allows("esp"));
assertEquals("новая", ServerPolicyState.reason());
assertEquals("bedwars", ServerPolicyState.current().server());
}
@Test
void clearRestoresFullAccess() {
ServerPolicyState.apply(lockPolicy());
ServerPolicyState.clear();
assertTrue(ServerPolicyState.allows("esp"));
assertFalse(ServerPolicyState.isLocked("esp"));
assertNull(ServerPolicyState.current());
assertEquals(0L, ServerPolicyState.receivedAtMs());
}
}