chore(history): squash 59 commit(s) from 2026-09-23

- refactor(hud/weather/render): Scoreboard 931→240 + Rain 903→159 + UiMeshGeometry 956→99 (8.5.2)
- refactor(chams): Chams 985 → 129 + 4×≤195 (8.5.2)
- refactor(module): Module 1031 → 139 + 4×≤134 (8.5.2)
- refactor(tooltips): BetterTooltips 960 → 181 + 4×≤199 (8.5.2)
- refactor(clickgui): ClickGuiPickerState 970→166 + ModulesMenuScreen 966→145 (8.5.2)
- refactor(clickgui): CombatProtocolHeuristicsEditorState 932→79 + 4×≤192 (8.5.2)
- refactor(hud): CompactHudStatModel 894 → 125 + 4×≤175 (8.5.2)
- refactor(iris): ShaderPatchCompiler 843→66 + 4×≤174 (8.5.2)
- refactor(tab): CustomTabList 834→136 + 4×≤166 (8.5.2)
- refactor(relations): OnlineRelationPlayerPickerComponent 837→74 + 4×≤192 (8.5.2)
- refactor(hits): HitEffect 770→120 + 4×≤176 (8.5.2)
- refactor(predict): Predictions 815→150 + 4×≤195 (8.5.2)
- refactor(config): ConfigProfilesComponent 799→125 + 4×≤195 (8.5.2)
- refactor(sim): PlayerMovementSimulation 757→157 + 4×≤194 (8.5.2)
- refactor(predict): ProjectilePuncher 747→104 + 3×≤134 (8.5.2)
- refactor(crosshair): Crosshair 743→143 + 4×≤167 (8.5.2)
- refactor(mediaplayer): MediaPlayer 743→174 + 4×≤196 (8.5.2)
- refactor(hud): Itemizer 699→193 + Cooldowns 707→151 (8.5.2)
- refactor(hud): Potions 617→176 + 4×≤166 (8.5.2)
- refactor(hud): Admins 702→118 + 4×≤138 (8.5.2)
- refactor(hud): Keybinds 611→157 + 4×≤160 (8.5.2)
- refactor(hud): ModuleList 499→181 + 3×≤150 (8.5.2)
- refactor(hud): Radar 392→161 + 2×≤124 (8.5.2)
- refactor(hud): Fps 309→168 + 3×≤107 (8.5.2)
- refactor(hud): Memory 304→151 + 3×≤144 (8.5.2)
- refactor(hud): Tps 339→154 + 3×≤143 (8.5.2)
- refactor(hud): Ping 309→145 + 3×≤125 (8.5.2)
- refactor(hud): SpeedBps 334→156 + 3×≤142 (8.5.2)
- refactor(hud): GameTime 314→150 + 3×≤127 (8.5.2)
- refactor(hud): SystemTime 300→136 + 3×≤130 (8.5.2)
- refactor(hud): Coordinates 337→174 + 2×≤172 (8.5.2)
- refactor(hud): Armor 324→162 + 2×≤148 (8.5.2)
- refactor(hud): Inventory 476→153 + 3×≤181 (8.5.2)
- docs(todo): обновлён 8.5.2 — отмечены готовые гиганты (BetterButtons/InventorySwap/Svg/Темы/WorldParticles/Module/Chams и хвост)
- docs(api): папка combatant-client-26.2/docs + addons.md (API можно ломать при рефакторе)
- refactor(visuals): BlockHighlight 692→271 + 4×≤200 (8.5.2)
- refactor(mixins): GameRendererMixin 830→344 (хуки) + 4 хелпера (8.5.2)
- refactor(visuals): WorldParticles 821→260 + 6×≤200, режимы добиты (8.5.2)
- docs(todo): 8.5.2 — WorldParticles/GameRendererMixin/BlockHighlight отмечены ГОТОВО
- refactor(render): MeshBuilder 738→447 (фасад write-API) + 3 хелпера, FrameStats снесён (8.5.2)
- docs(todo): Фаза 10 — дизайн платформы (аккаунты/конфиги/аватарки), backlog RPC-чата
- docs(todo): Подсистема 3 — Аддоны 2.0 (скрипты/версии) + витрина аддонов + админка
- docs(todo): Подсистема 2 — RPC-чат/друзья/виджеты-телеметрия + рейт-лимиты, доп. правки Подсистемы 3
- chore: переименование combatant-client-26.2 -> mod, добавлены backend/ и frontend/
- docs(backend): implementation plan for accounts-service (Подсистема 1, часть 1)
- chore: пересоздать backend/frontend через cargo new и bun create vite + tailwind; убрать таблицу компонентов из TODO.md
- docs(backend): edition 2024 в плане вместо 2021
- chore: обновить версии до реально актуальных (проверено компиляцией)
- docs: правила платформы (лимит 250 строк, антипаттерны backend/frontend по итогам ресёрча) + frontend/ARCHITECTURE.md
- docs: добавить правило ≤4 файла на папку в правила платформы (backend/frontend)
- docs: commit messages in English from now on
- chore: gitignore .superpowers/ scratch directory
- docs: commit messages in English from now on (mod)
- feat(backend): scaffold accounts-service with health check
- chore(backend): remove target/ build artifacts from git, add gitignore
- chore: track docs/ in git (was accidentally gitignored, never committed)
- chore(backend): convert to Cargo workspace, plan full microservice layout
- feat(backend): accounts/avatars/device_links schema + migration
- feat(backend): argon2id password hashing
This commit is contained in:
loki5512344 2026-09-23 22:38:08 +02:00
parent ec10395511
commit 9d08fa910a
2239 changed files with 35265 additions and 28110 deletions

View file

@ -0,0 +1,118 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.proxy;
import dev.loki.lovisual.util.proxy.model.ProxyConfig;
import dev.loki.lovisual.util.proxy.model.ProxyEntry;
import com.mojang.authlib.GameProfile;
import net.minecraft.client.Minecraft;
import java.util.Map;
public enum ProxyBackend {
;
public static final String DEFAULT_ACCOUNT_KEY = "";
private static final ProxyConfig CONFIG = new ProxyConfig();
private static ProxyEntry lastUsedProxy = ProxyEntry.empty();
public static void init() {
if (!CONFIG.isLoaded()) {
CONFIG.load();
}
}
public static ProxyConfig config() {
init();
return CONFIG;
}
public static boolean isEnabled() {
init();
return CONFIG.proxyEnabled();
}
public static void setEnabled(boolean enabled) {
init();
CONFIG.proxyEnabled(enabled);
CONFIG.save();
}
public static ProxyEntry getDefaultProxy() {
init();
return CONFIG.defaultProxy();
}
public static void setDefaultProxy(ProxyEntry proxy) {
init();
CONFIG.defaultProxy(proxy);
CONFIG.save();
}
public static ProxyEntry getAccountProxy(String accountName) {
init();
return CONFIG.proxyForAccount(accountName);
}
public static void setAccountProxy(String accountName, ProxyEntry proxy) {
init();
CONFIG.proxyForAccount(accountName, proxy);
CONFIG.save();
}
public static void removeAccountProxy(String accountName) {
init();
CONFIG.removeAccountProxy(accountName);
CONFIG.save();
}
public static Map<String, ProxyEntry> accountProxies() {
init();
return CONFIG.accountsSnapshot();
}
public static ProxyEntry activeProxy() {
init();
if (!CONFIG.proxyEnabled()) return ProxyEntry.empty();
String name = currentProfileName();
ProxyEntry proxy = CONFIG.proxyForAccount(name);
return proxy.isConfigured() ? proxy : ProxyEntry.empty();
}
public static ProxyEntry lastUsedProxy() {
return lastUsedProxy == null ? ProxyEntry.empty() : lastUsedProxy.copy();
}
static void markLastUsed(ProxyEntry proxy) {
lastUsedProxy = proxy == null ? ProxyEntry.empty() : proxy.copy();
}
public static String lastUsedProxyIp() {
ProxyEntry proxy = lastUsedProxy();
return proxy.isConfigured() ? proxy.host() : "none";
}
private static String currentProfileName() {
Minecraft minecraft = Minecraft.getInstance();
if (minecraft == null) return "";
try {
GameProfile profile = minecraft.getGameProfile();
if (profile != null && profile.name() != null && !profile.name().isBlank()) {
return profile.name();
}
} catch (Throwable ignored) {
}
try {
return minecraft.getUser().getName();
} catch (Throwable ignored) {
return "";
}
}
}

View file

@ -0,0 +1,358 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.proxy;
import dev.loki.lovisual.util.proxy.model.ProxyEntry;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelDuplexHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelPromise;
import io.netty.util.ReferenceCountUtil;
import java.net.Inet4Address;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.nio.charset.StandardCharsets;
public final class ProxyHandshakeHandler extends ChannelDuplexHandler {
private final ProxyEntry proxy;
private ChannelPromise originalConnectPromise;
private InetSocketAddress targetAddress;
private ByteBuf inbound;
private State state = State.INIT;
ProxyHandshakeHandler(ProxyEntry proxy) {
this.proxy = proxy == null ? ProxyEntry.empty() : proxy.copy();
}
@Override
public void connect(ChannelHandlerContext ctx, SocketAddress remoteAddress, SocketAddress localAddress, ChannelPromise promise) {
if (!proxy.isConfigured() || !(remoteAddress instanceof InetSocketAddress target)) {
ctx.connect(remoteAddress, localAddress, promise);
return;
}
this.targetAddress = target;
this.originalConnectPromise = promise;
ChannelPromise proxyConnectPromise = ctx.newPromise();
proxyConnectPromise.addListener(future -> {
if (!future.isSuccess()) {
fail(ctx, future.cause() != null ? future.cause() : new IllegalStateException("Proxy connect failed"));
return;
}
startHandshake(ctx);
});
ctx.connect(proxy.socketAddress(), localAddress, proxyConnectPromise);
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
if (state == State.DONE || state == State.INIT) {
ctx.fireChannelRead(msg);
return;
}
if (!(msg instanceof ByteBuf buf)) {
ReferenceCountUtil.release(msg);
fail(ctx, new IllegalStateException("Unexpected proxy handshake payload: " + msg));
return;
}
try {
if (inbound == null) {
inbound = ctx.alloc().buffer(buf.readableBytes());
}
inbound.writeBytes(buf);
} finally {
buf.release();
}
processInbound(ctx);
}
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
if (state != State.DONE && originalConnectPromise != null && !originalConnectPromise.isDone()) {
originalConnectPromise.tryFailure(new IllegalStateException("Proxy connection closed during handshake"));
}
releaseInbound();
super.channelInactive(ctx);
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
if (state != State.DONE && originalConnectPromise != null && !originalConnectPromise.isDone()) {
fail(ctx, cause);
return;
}
super.exceptionCaught(ctx, cause);
}
private void startHandshake(ChannelHandlerContext ctx) {
try {
switch (proxy.type()) {
case SOCKS4 -> {
state = State.SOCKS4_RESPONSE;
ctx.writeAndFlush(createSocks4ConnectRequest(ctx));
}
case SOCKS5 -> {
state = State.SOCKS5_METHOD;
ctx.writeAndFlush(createSocks5Greeting(ctx));
}
}
} catch (Throwable t) {
fail(ctx, t);
}
}
private void processInbound(ChannelHandlerContext ctx) {
try {
boolean again;
do {
again = false;
switch (state) {
case SOCKS4_RESPONSE -> {
if (inbound.readableBytes() < 8) return;
inbound.readByte();
int status = inbound.readUnsignedByte();
inbound.skipBytes(6);
if (status != 90) {
throw new IllegalStateException("SOCKS4 proxy rejected connect request: " + status);
}
finish(ctx);
}
case SOCKS5_METHOD -> {
if (inbound.readableBytes() < 2) return;
int version = inbound.readUnsignedByte();
int method = inbound.readUnsignedByte();
if (version != 5) {
throw new IllegalStateException("Invalid SOCKS5 method response version: " + version);
}
if (method == 0xFF) {
throw new IllegalStateException("SOCKS5 proxy rejected all authentication methods");
}
if (method == 0x02) {
state = State.SOCKS5_AUTH;
ctx.writeAndFlush(createSocks5AuthRequest(ctx));
} else if (method == 0x00) {
state = State.SOCKS5_CONNECT;
ctx.writeAndFlush(createSocks5ConnectRequest(ctx));
} else {
throw new IllegalStateException("Unsupported SOCKS5 auth method: " + method);
}
again = inbound.isReadable();
}
case SOCKS5_AUTH -> {
if (inbound.readableBytes() < 2) return;
int version = inbound.readUnsignedByte();
int status = inbound.readUnsignedByte();
if (version != 1 || status != 0) {
throw new IllegalStateException("SOCKS5 authentication failed: " + status);
}
state = State.SOCKS5_CONNECT;
ctx.writeAndFlush(createSocks5ConnectRequest(ctx));
again = inbound.isReadable();
}
case SOCKS5_CONNECT -> {
if (!tryReadSocks5ConnectResponse()) return;
finish(ctx);
}
default -> {
}
}
} while (again && state != State.DONE);
} catch (Throwable t) {
fail(ctx, t);
}
}
private boolean tryReadSocks5ConnectResponse() {
if (inbound.readableBytes() < 5) return false;
inbound.markReaderIndex();
int version = inbound.readUnsignedByte();
int status = inbound.readUnsignedByte();
inbound.readUnsignedByte();
int addressType = inbound.readUnsignedByte();
if (version != 5) {
throw new IllegalStateException("Invalid SOCKS5 connect response version: " + version);
}
int addressBytes;
switch (addressType) {
case 1 -> addressBytes = 4;
case 4 -> addressBytes = 16;
case 3 -> {
if (inbound.readableBytes() < 1) {
inbound.resetReaderIndex();
return false;
}
addressBytes = inbound.readUnsignedByte();
}
default -> throw new IllegalStateException("Invalid SOCKS5 address type: " + addressType);
}
if (inbound.readableBytes() < addressBytes + 2) {
inbound.resetReaderIndex();
return false;
}
inbound.skipBytes(addressBytes + 2);
if (status != 0) {
throw new IllegalStateException("SOCKS5 proxy rejected connect request: " + status);
}
return true;
}
private ByteBuf createSocks5Greeting(ChannelHandlerContext ctx) {
ByteBuf out = ctx.alloc().buffer(3);
out.writeByte(5);
out.writeByte(1);
out.writeByte(proxy.hasCredentials() ? 0x02 : 0x00);
return out;
}
private ByteBuf createSocks5AuthRequest(ChannelHandlerContext ctx) {
byte[] username = ascii(proxy.username());
byte[] password = ascii(proxy.password());
if (username.length > 255 || password.length > 255) {
throw new IllegalArgumentException("SOCKS5 username/password is too long");
}
ByteBuf out = ctx.alloc().buffer(3 + username.length + password.length);
out.writeByte(1);
out.writeByte(username.length);
out.writeBytes(username);
out.writeByte(password.length);
out.writeBytes(password);
return out;
}
private ByteBuf createSocks5ConnectRequest(ChannelHandlerContext ctx) {
ByteBuf out = ctx.alloc().buffer(32);
out.writeByte(5);
out.writeByte(1);
out.writeByte(0);
writeSocks5Address(out, targetAddress);
out.writeShort(targetAddress.getPort());
return out;
}
private ByteBuf createSocks4ConnectRequest(ChannelHandlerContext ctx) {
ByteBuf out = ctx.alloc().buffer(64);
out.writeByte(4);
out.writeByte(1);
out.writeShort(targetAddress.getPort());
InetAddress address = targetAddress.getAddress();
boolean ipv4 = address instanceof Inet4Address;
if (ipv4) {
out.writeBytes(address.getAddress());
} else {
out.writeByte(0);
out.writeByte(0);
out.writeByte(0);
out.writeByte(1);
}
if (proxy.hasUsername()) {
out.writeBytes(ascii(proxy.username()));
}
out.writeByte(0);
if (!ipv4) {
out.writeBytes(ascii(targetAddress.getHostString()));
out.writeByte(0);
}
return out;
}
private static void writeSocks5Address(ByteBuf out, InetSocketAddress address) {
InetAddress inetAddress = address.getAddress();
if (inetAddress instanceof Inet4Address) {
out.writeByte(1);
out.writeBytes(inetAddress.getAddress());
return;
}
if (inetAddress instanceof Inet6Address) {
out.writeByte(4);
out.writeBytes(inetAddress.getAddress());
return;
}
byte[] host = ascii(address.getHostString());
if (host.length > 255) {
throw new IllegalArgumentException("SOCKS5 host is too long: " + address.getHostString());
}
out.writeByte(3);
out.writeByte(host.length);
out.writeBytes(host);
}
private void finish(ChannelHandlerContext ctx) {
state = State.DONE;
ByteBuf leftover = null;
if (inbound != null && inbound.isReadable()) {
leftover = inbound.readRetainedSlice(inbound.readableBytes());
}
releaseInbound();
if (ctx.pipeline().get(this.getClass()) != null) {
ctx.pipeline().remove(this);
} else {
try {
ctx.pipeline().remove(this);
} catch (Throwable ignored) {
}
}
if (originalConnectPromise != null) {
originalConnectPromise.trySuccess();
}
if (leftover != null) {
ctx.fireChannelRead(leftover);
}
}
private void fail(ChannelHandlerContext ctx, Throwable throwable) {
releaseInbound();
if (originalConnectPromise != null) {
originalConnectPromise.tryFailure(throwable);
}
ctx.close();
}
private void releaseInbound() {
if (inbound != null) {
inbound.release();
inbound = null;
}
}
private static byte[] ascii(String value) {
return (value == null ? "" : value).getBytes(StandardCharsets.US_ASCII);
}
private enum State {
INIT,
SOCKS4_RESPONSE,
SOCKS5_METHOD,
SOCKS5_AUTH,
SOCKS5_CONNECT,
DONE
}
}

View file

@ -0,0 +1,39 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.proxy;
import dev.loki.lovisual.util.proxy.model.ProxyEntry;
import dev.loki.lovisual.util.logging.DebugLog;
import io.netty.channel.ChannelPipeline;
public enum ProxyNettyInstaller {
;
private static final String HANDLER_NAME = "lovisual_proxy";
public static void install(ChannelPipeline pipeline) {
if (pipeline == null) return;
ProxyEntry proxy = ProxyBackend.activeProxy();
if (!proxy.isConfigured()) return;
try {
if (pipeline.get(HANDLER_NAME) != null) {
pipeline.remove(HANDLER_NAME);
}
pipeline.addFirst(HANDLER_NAME, new ProxyHandshakeHandler(proxy));
ProxyBackend.markLastUsed(proxy);
DebugLog.config("Proxy enabled for connection: %s", proxy);
} catch (Throwable t) {
DebugLog.warn("Failed to install proxy handler: %s", proxy);
DebugLog.error("Proxy handler install error", t);
}
}
}

View file

@ -0,0 +1,190 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.proxy.model;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.reflect.TypeToken;
import dev.loki.lovisual.config.io.ConfigPaths;
import dev.loki.lovisual.util.logging.DebugLog;
import java.io.IOException;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* File-backed proxy backend config.
*/
public final class ProxyConfig {
private static final Gson GSON = new GsonBuilder()
.setPrettyPrinting()
.disableHtmlEscaping()
.create();
private static final Type ACCOUNTS_TYPE = new TypeToken<LinkedHashMap<String, ProxyEntry>>() {
}
.getType();
private final Path file;
private boolean loaded;
private boolean enabled;
private ProxyEntry defaultProxy = ProxyEntry.empty();
private final LinkedHashMap<String, ProxyEntry> accounts = new LinkedHashMap<>();
public ProxyConfig() {
this(ConfigPaths.root().resolve("proxy").resolve("proxy.json"));
}
public ProxyConfig(Path file) {
this.file = file;
}
public synchronized void load() {
try {
Files.createDirectories(file.getParent());
if (!Files.exists(file)) {
loaded = true;
save();
return;
}
String text = Files.readString(file, StandardCharsets.UTF_8);
if (text == null || text.isBlank()) {
loaded = true;
save();
return;
}
JsonObject json = JsonParser.parseString(text).getAsJsonObject();
if (json.has("enabled")) {
enabled = json.get("enabled").getAsBoolean();
}
if (json.has("default") && json.get("default").isJsonObject()) {
ProxyEntry parsed = GSON.fromJson(json.get("default"), ProxyEntry.class);
defaultProxy = parsed == null ? ProxyEntry.empty() : parsed;
}
accounts.clear();
if (json.has("accounts") && json.get("accounts").isJsonObject()) {
LinkedHashMap<String, ProxyEntry> loadedAccounts = GSON.fromJson(json.get("accounts"), ACCOUNTS_TYPE);
if (loadedAccounts != null) {
accounts.putAll(loadedAccounts);
}
}
normalize();
loaded = true;
} catch (Exception e) {
loaded = true;
DebugLog.warn("Failed to load proxy config: %s", file.toAbsolutePath());
DebugLog.error("Proxy config load error", e);
}
}
public synchronized void save() {
try {
Files.createDirectories(file.getParent());
normalize();
JsonObject json = new JsonObject();
json.addProperty("enabled", enabled);
json.add("default", GSON.toJsonTree(defaultProxy, ProxyEntry.class));
JsonElement accountsJson = GSON.toJsonTree(accounts, ACCOUNTS_TYPE);
json.add("accounts", accountsJson);
Files.writeString(file, GSON.toJson(json), StandardCharsets.UTF_8);
} catch (IOException e) {
DebugLog.warn("Failed to save proxy config: %s", file.toAbsolutePath());
DebugLog.error("Proxy config save error", e);
}
}
public synchronized boolean isLoaded() {
return loaded;
}
public synchronized boolean proxyEnabled() {
return enabled;
}
public synchronized void proxyEnabled(boolean enabled) {
this.enabled = enabled;
}
public synchronized ProxyEntry defaultProxy() {
return defaultProxy == null ? ProxyEntry.empty() : defaultProxy.copy();
}
public synchronized void defaultProxy(ProxyEntry proxy) {
defaultProxy = proxy == null ? ProxyEntry.empty() : proxy.copy();
normalize();
}
public synchronized ProxyEntry proxyForAccount(String accountName) {
String key = accountKey(accountName);
if (!key.isEmpty() && accounts.containsKey(key)) {
ProxyEntry accountProxy = accounts.get(key);
return accountProxy == null ? ProxyEntry.empty() : accountProxy.copy();
}
return defaultProxy();
}
public synchronized void proxyForAccount(String accountName, ProxyEntry proxy) {
String key = accountKey(accountName);
if (key.isEmpty()) {
defaultProxy(proxy);
return;
}
accounts.put(key, proxy == null ? ProxyEntry.empty() : proxy.copy());
normalize();
}
public synchronized void removeAccountProxy(String accountName) {
String key = accountKey(accountName);
if (key.isEmpty()) {
defaultProxy = ProxyEntry.empty();
} else {
accounts.remove(key);
}
}
public synchronized Map<String, ProxyEntry> accountsSnapshot() {
LinkedHashMap<String, ProxyEntry> out = new LinkedHashMap<>();
for (Map.Entry<String, ProxyEntry> entry : accounts.entrySet()) {
out.put(entry.getKey(), entry.getValue() == null ? ProxyEntry.empty() : entry.getValue().copy());
}
return out;
}
public synchronized Path file() {
return file;
}
private void normalize() {
if (defaultProxy == null) {
defaultProxy = ProxyEntry.empty();
}
defaultProxy.normalize();
accounts.replaceAll((ignored, proxy) -> {
ProxyEntry safe = proxy == null ? ProxyEntry.empty() : proxy;
safe.normalize();
return safe;
});
}
private static String accountKey(String accountName) {
return accountName == null ? "" : accountName.trim();
}
}

View file

@ -0,0 +1,205 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.proxy.model;
import java.net.InetSocketAddress;
import java.util.Locale;
import java.util.Objects;
/**
* Backend proxy data model.
*/
public final class ProxyEntry {
private String address = "";
private ProxyType type = ProxyType.SOCKS5;
private String username = "";
private String password = "";
public ProxyEntry() {
}
public ProxyEntry(ProxyType type, String ipPort, String username, String password) {
this.type = ProxyType.safe(type);
this.address = clean(ipPort);
this.username = clean(username);
this.password = clean(password);
}
public ProxyEntry(boolean socks4, String ipPort, String username, String password) {
this(socks4 ? ProxyType.SOCKS4 : ProxyType.SOCKS5, ipPort, username, password);
}
public static ProxyEntry socks5(String host, int port) {
return new ProxyEntry(ProxyType.SOCKS5, join(host, port), "", "");
}
public static ProxyEntry socks5(String host, int port, String username, String password) {
return new ProxyEntry(ProxyType.SOCKS5, join(host, port), username, password);
}
public static ProxyEntry socks4(String host, int port) {
return new ProxyEntry(ProxyType.SOCKS4, join(host, port), "", "");
}
public static ProxyEntry socks4(String host, int port, String username) {
return new ProxyEntry(ProxyType.SOCKS4, join(host, port), username, "");
}
public static ProxyEntry empty() {
return new ProxyEntry();
}
public ProxyEntry copy() {
return new ProxyEntry(type, address, username, password);
}
public boolean isConfigured() {
return isValidIpPort(address);
}
public String ipPort() {
return address == null ? "" : address;
}
public void ipPort(String ipPort) {
this.address = clean(ipPort);
}
public ProxyType type() {
return ProxyType.safe(type);
}
public void type(ProxyType type) {
this.type = ProxyType.safe(type);
}
public String username() {
return username == null ? "" : username;
}
public void username(String username) {
this.username = clean(username);
}
public String password() {
return password == null ? "" : password;
}
public void password(String password) {
this.password = clean(password);
}
public boolean hasUsername() {
return !username().isBlank();
}
public boolean hasPassword() {
return !password().isBlank();
}
public boolean hasCredentials() {
return hasUsername() || hasPassword();
}
public String host() {
if (address == null || address.isBlank()) return "";
int separator = findPortSeparator(address);
if (separator < 0) return "";
String host = address.substring(0, separator).trim();
if (host.startsWith("[") && host.endsWith("]")) {
host = host.substring(1, host.length() - 1);
}
return host;
}
public int port() {
if (address == null || address.isBlank()) return 0;
int separator = findPortSeparator(address);
if (separator < 0 || separator + 1 >= address.length()) return 0;
try {
int port = Integer.parseInt(address.substring(separator + 1).trim());
return port >= 0 && port <= 0xFFFF ? port : 0;
} catch (NumberFormatException ignored) {
return 0;
}
}
public InetSocketAddress socketAddress() {
String host = host();
int port = port();
if (host.isBlank() || port <= 0) {
throw new IllegalStateException("Proxy entry is not configured: " + ipPort());
}
return new InetSocketAddress(host, port);
}
public static boolean isValidIpPort(String ipPort) {
if (ipPort == null || ipPort.isBlank()) return false;
int separator = findPortSeparator(ipPort.trim());
if (separator <= 0 || separator + 1 >= ipPort.trim().length()) return false;
String host = ipPort.trim().substring(0, separator).trim();
if (host.isBlank()) return false;
try {
int port = Integer.parseInt(ipPort.trim().substring(separator + 1).trim());
return port >= 1 && port <= 0xFFFF;
} catch (NumberFormatException ignored) {
return false;
}
}
private static int findPortSeparator(String value) {
if (value == null) return -1;
String trimmed = value.trim();
if (trimmed.startsWith("[")) {
int endBracket = trimmed.indexOf(']');
if (endBracket < 0) return -1;
return endBracket + 1 < trimmed.length() && trimmed.charAt(endBracket + 1) == ':' ? endBracket + 1 : -1;
}
return trimmed.lastIndexOf(':');
}
private static String join(String host, int port) {
String cleanHost = clean(host);
if (cleanHost.contains(":") && !cleanHost.startsWith("[")) {
cleanHost = '[' + cleanHost + ']';
}
return cleanHost + ':' + port;
}
private static String clean(String value) {
return value == null ? "" : value.trim();
}
public void normalize() {
address = clean(address);
type = ProxyType.safe(type);
username = clean(username);
password = clean(password);
}
@Override
public String toString() {
return type().name().toLowerCase(Locale.ROOT) + "://" + ipPort();
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof ProxyEntry that)) return false;
return Objects.equals(ipPort(), that.ipPort())
&& type() == that.type()
&& Objects.equals(username(), that.username())
&& Objects.equals(password(), that.password());
}
@Override
public int hashCode() {
return Objects.hash(ipPort(), type(), username(), password());
}
}

View file

@ -0,0 +1,22 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.proxy.model;
import com.google.gson.annotations.SerializedName;
public enum ProxyType {
@SerializedName("SOCKS4")
SOCKS4,
@SerializedName("SOCKS5")
SOCKS5;
public static ProxyType safe(ProxyType type) {
return type == null ? SOCKS5 : type;
}
}