Add webhook support for card transactions and notifications.
Java CI / build (push) Successful in 50s
.NET+Docker CI/CD / Unit and Integration tests (push) Successful in 33s
.NET+Docker CI/CD / Push Docker image to ghcr.io (push) Failing after 30s

Some optimizations and fixes

Signed-off-by: Dmitrii <computer@yawaflua.tech>

Took 2 hours 4 minutes
This commit is contained in:
Dmitrii
2026-07-12 06:27:28 +03:00
parent 0e53d7dd8c
commit f19a5d3586
37 changed files with 1155 additions and 950 deletions
@@ -34,6 +34,7 @@ public class ModMenuIntegration implements ModMenuApi {
final boolean[] allowBackend = {current.allowBackend()};
final boolean[] signQuickPayEnabled = {current.signQuickPayEnabled()};
final GpsHudPosition[] gpsPosition = {current.gpsPosition()};
final GpsHudPosition[] notificationPosition = {current.notificationPosition()};
final boolean[] telemetryEnabled = {current.telemetryEnabled()};
final int[] telemetryIntervalSeconds = {current.telemetryIntervalSeconds()};
@@ -59,10 +60,12 @@ public class ModMenuIntegration implements ModMenuApi {
.setTooltip(Text.literal("Запустить процесс авторизации через Mojang и ваш бэкенд"))
.setSaveConsumer(newValue -> {
if (newValue) {
BackendAuthenticator.authenticate(MinecraftClient.getInstance());
if (SPMega.getConfig() != null) {
apiToken[0] = SPMega.getConfig().apiToken();
}
BackendAuthenticator.authenticateAsync(MinecraftClient.getInstance())
.thenAccept(authenticated -> {
if (authenticated && SPMega.getConfig() != null) {
apiToken[0] = SPMega.getConfig().apiToken();
}
});
} else {
apiToken[0] = ModConfig.DEFAULT_API_TOKEN;
}
@@ -84,6 +87,17 @@ public class ModMenuIntegration implements ModMenuApi {
.setSaveConsumer(newValue -> gpsPosition[0] = newValue)
.build());
general.addEntry(entryBuilder.startEnumSelector(
Text.translatable("option.spmega.notification_position"),
GpsHudPosition.class,
current.notificationPosition()
)
.setDefaultValue(ModConfig.DEFAULT_NOTIFICATION_POSITION)
.setEnumNameProvider(position -> Text.translatable(
"option.spmega.notification_position." + position.name().toLowerCase()))
.setSaveConsumer(newValue -> notificationPosition[0] = newValue)
.build());
// Telemetry settings
ConfigCategory telemetry = builder.getOrCreateCategory(Text.translatable("category.spmega.telemetry"));
@@ -119,6 +133,7 @@ public class ModMenuIntegration implements ModMenuApi {
signQuickPayEnabled[0],
gpsEnabledVal,
gpsPosition[0],
notificationPosition[0],
telemetryEnabled[0],
telemetryIntervalSeconds[0],
telemetryCollectSystemInfo[0]
@@ -27,7 +27,6 @@ import net.minecraft.text.Text;
import net.minecraft.util.ActionResult;
import org.lwjgl.glfw.GLFW;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -107,7 +106,7 @@ public class SPMegaClient implements ClientModInitializer {
UiOpeners.openMainMenu(client);
}
while (scanQrKeyBinding.wasPressed()) {
QRCodeScanner.ScanQrCode(client);
QRCodeScanner.scanQrCode(client);
}
while (toggleGpsKeyBinding.wasPressed()) {
GpsHudRenderer.instance().toggle();
@@ -120,6 +119,7 @@ public class SPMegaClient implements ClientModInitializer {
current.signQuickPayEnabled(),
GpsHudRenderer.instance().isEnabled(),
current.gpsPosition(),
current.notificationPosition(),
current.telemetryEnabled(),
current.telemetryIntervalSeconds(),
current.telemetryCollectSystemInfo()
@@ -232,7 +232,8 @@ public class SPMegaClient implements ClientModInitializer {
});
new ChatListener().register();
CompletableFuture.runAsync(() -> BackendAuthenticator.authenticate(MinecraftClient.getInstance()))
WebhookNotificationPoller.instance().start();
BackendAuthenticator.authenticateAsync(MinecraftClient.getInstance())
.exceptionally(throwable -> {
System.err.println("[SPMEGA] Error during async authenticate: " + throwable.getMessage());
return null;
@@ -0,0 +1,72 @@
package git.yawaflua.tech.spmega.client;
import git.yawaflua.tech.spmega.client.ui.UiNotifications;
import git.yawaflua.tech.spmega.client.ui.service.BackendAuthenticator;
import git.yawaflua.tech.spmega.client.ui.service.BankUiService;
import net.minecraft.client.MinecraftClient;
import net.minecraft.text.Text;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
public final class WebhookNotificationPoller {
private static final WebhookNotificationPoller INSTANCE = new WebhookNotificationPoller();
private static final long POLL_INTERVAL_SECONDS = 15;
private final AtomicBoolean started = new AtomicBoolean();
private final AtomicBoolean requestInFlight = new AtomicBoolean();
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(runnable, "SPMega-Webhook-Poller");
thread.setDaemon(true);
return thread;
});
private WebhookNotificationPoller() {
}
public static WebhookNotificationPoller instance() {
return INSTANCE;
}
private static String format(BackendAuthenticator.PaymentNotification notification) {
String sender = notification.senderName().isBlank()
? notification.senderNumber()
: notification.senderName();
String comment = notification.comment().isBlank() ? "" : "" + notification.comment();
return "Получено " + notification.amount() + " АР от " + sender + comment;
}
public void start() {
if (started.compareAndSet(false, true)) {
scheduler.scheduleAtFixedRate(this::poll, 0, POLL_INTERVAL_SECONDS, TimeUnit.SECONDS);
}
}
private void poll() {
try {
if (!BankUiService.instance().hasWebhookEnabledCards()
|| !requestInFlight.compareAndSet(false, true)) {
return;
}
} catch (Exception exception) {
System.err.println("[SPMEGA] Failed to read local webhook state: " + exception.getMessage());
return;
}
BackendAuthenticator.readNotificationsAsync().whenComplete((notifications, exception) -> {
requestInFlight.set(false);
if (exception != null) {
System.err.println("[SPMEGA] Failed to poll webhook notifications: " + exception.getMessage());
return;
}
MinecraftClient client = MinecraftClient.getInstance();
if (client == null || notifications.isEmpty()) {
return;
}
client.execute(() -> notifications.forEach(notification ->
UiNotifications.instance().showQueued(Text.literal(format(notification)))));
});
}
}
@@ -6,11 +6,12 @@ import git.yawaflua.tech.spmega.client.telemetry.InstrumentedHttpClient;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
public final class SPWorldsApiClient {
private final InstrumentedHttpClient httpClient;
@@ -48,88 +49,88 @@ public final class SPWorldsApiClient {
return json.get(key).getAsString();
}
public CardInfo getCardInfo(CardAuth auth) throws IOException, InterruptedException {
public CompletableFuture<CardInfo> getCardInfoAsync(CardAuth auth) {
HttpRequest request = requestBuilder("/api/public/card", auth).GET().build();
String body = send(request);
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
if (json.has("statusCode")) {
switch (json.get("statusCode").getAsInt()) {
case 403:
throw new IOException("Апи вернула ошибку: " + json.get("message").getAsString());
default:
System.out.println("Unhandled status code in card info response: " + json.get("statusCode").getAsInt());
break;
return sendAsync(request).thenApply(body -> {
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
if (json.has("statusCode")) {
switch (json.get("statusCode").getAsInt()) {
case 403:
throw new IOException("Апи вернула ошибку: " + json.get("message").getAsString());
default:
System.out.println("Unhandled status code in card info response: " + json.get("statusCode").getAsInt());
break;
}
}
long balance = json.has("balance") ? json.get("balance").getAsLong() : 0L;
String webhook = json.has("webhook") && !json.get("webhook").isJsonNull()
? json.get("webhook").getAsString()
: "";
return new CardInfo(balance, webhook);
} catch (Exception exception) {
System.out.println("Failed to parse card info response: " + exception.getMessage());
System.out.println(body);
throw new CompletionException(new IOException("Failed to parse card info response", exception));
}
long balance = json.has("balance") ? json.get("balance").getAsLong() : 0L;
String webhook = json.has("webhook") && !json.get("webhook").isJsonNull()
? json.get("webhook").getAsString()
: "";
return new CardInfo(balance, webhook);
} catch (Exception e) {
System.out.println("Failed to parse card info response: " + e.getMessage());
System.out.println(body);
throw new IOException("Failed to parse card info response", e);
}
});
}
public List<PlayerCard> getPlayerCards(String username, CardAuth auth) throws IOException, InterruptedException {
public CompletableFuture<List<PlayerCard>> getPlayerCardsAsync(String username, CardAuth auth) {
HttpRequest request = requestBuilder("/api/public/accounts/" + username + "/cards", auth).GET().build();
String body = send(request);
try {
JsonArray json = JsonParser.parseString(body).getAsJsonArray();
List<PlayerCard> cards = new ArrayList<>();
for (JsonElement element : json) {
JsonObject card = element.getAsJsonObject();
String name = card.has("name") ? card.get("name").getAsString() : "";
String number = card.has("number") ? card.get("number").getAsString() : "";
cards.add(new PlayerCard(name, number));
}
return cards;
} catch (Exception e) {
System.out.println("Failed to parse player cards response: " + e.getMessage());
System.out.println(body);
throw new IOException("Failed to parse player cards response", e);
}
}
public AccountMe getAccountMe(CardAuth auth) throws IOException, InterruptedException {
HttpRequest request = requestBuilder("/api/public/accounts/me", auth).GET().build();
String body = send(request);
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
String id = json.has("id") ? json.get("id").getAsString() : "";
String username = json.has("username") ? json.get("username").getAsString() : "";
String minecraftUuid = json.has("minecraftUUID") ? json.get("minecraftUUID").getAsString() : "";
List<AccountCard> cards = new ArrayList<>();
if (json.has("cards") && json.get("cards").isJsonArray()) {
for (JsonElement element : json.getAsJsonArray("cards")) {
return sendAsync(request).thenApply(body -> {
try {
JsonArray json = JsonParser.parseString(body).getAsJsonArray();
List<PlayerCard> cards = new ArrayList<>();
for (JsonElement element : json) {
JsonObject card = element.getAsJsonObject();
cards.add(new AccountCard(
getString(card, "id"),
getString(card, "name"),
getString(card, "number"),
card.has("color") && !card.get("color").isJsonNull() ? card.get("color").getAsInt() : 0
));
String name = card.has("name") ? card.get("name").getAsString() : "";
String number = card.has("number") ? card.get("number").getAsString() : "";
cards.add(new PlayerCard(name, number));
}
return cards;
} catch (Exception exception) {
System.out.println("Failed to parse player cards response: " + exception.getMessage());
System.out.println(body);
throw new CompletionException(new IOException("Failed to parse player cards response", exception));
}
return new AccountMe(id, username, minecraftUuid, cards);
} catch (Exception e) {
System.out.println("Failed to parse account info response: " + e.getMessage());
System.out.println(body);
throw new IOException("Failed to parse account info response", e);
}
});
}
public TransactionResult createTransaction(CardAuth auth, String receiver, long amount, String comment)
throws IOException, InterruptedException {
public CompletableFuture<AccountMe> getAccountMeAsync(CardAuth auth) {
HttpRequest request = requestBuilder("/api/public/accounts/me", auth).GET().build();
return sendAsync(request).thenApply(body -> {
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
String id = json.has("id") ? json.get("id").getAsString() : "";
String username = json.has("username") ? json.get("username").getAsString() : "";
String minecraftUuid = json.has("minecraftUUID") ? json.get("minecraftUUID").getAsString() : "";
List<AccountCard> cards = new ArrayList<>();
if (json.has("cards") && json.get("cards").isJsonArray()) {
for (JsonElement element : json.getAsJsonArray("cards")) {
JsonObject card = element.getAsJsonObject();
cards.add(new AccountCard(
getString(card, "id"),
getString(card, "name"),
getString(card, "number"),
card.has("color") && !card.get("color").isJsonNull() ? card.get("color").getAsInt() : 0
));
}
}
return new AccountMe(id, username, minecraftUuid, cards);
} catch (Exception exception) {
System.out.println("Failed to parse account info response: " + exception.getMessage());
System.out.println(body);
throw new CompletionException(new IOException("Failed to parse account info response", exception));
}
});
}
public CompletableFuture<TransactionResult> createTransactionAsync(
CardAuth auth, String receiver, long amount, String comment) {
JsonObject payload = new JsonObject();
payload.addProperty("receiver", receiver);
payload.addProperty("amount", amount);
@@ -139,18 +140,17 @@ public final class SPWorldsApiClient {
.POST(HttpRequest.BodyPublishers.ofString(gson.toJson(payload)))
.build();
String body = send(request);
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
long balance = json.has("balance") ? json.get("balance").getAsLong() : 0L;
return new TransactionResult(balance);
} catch (Exception exception) {
System.out.println("Failed to parse transaction response: " + exception.getMessage());
System.out.println(body);
throw new IOException("Failed to parse transaction response", exception);
}
return sendAsync(request).thenApply(body -> {
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
long balance = json.has("balance") ? json.get("balance").getAsLong() : 0L;
return new TransactionResult(balance);
} catch (Exception exception) {
System.out.println("Failed to parse transaction response: " + exception.getMessage());
System.out.println(body);
throw new CompletionException(new IOException("Failed to parse transaction response", exception));
}
});
}
private HttpRequest.Builder requestBuilder(String path, CardAuth auth) {
@@ -160,22 +160,25 @@ public final class SPWorldsApiClient {
.header("Accept", "application/json");
}
private String send(HttpRequest request) throws IOException, InterruptedException {
HttpResponse<String> response = httpClient.send(request);
int statusCode = response.statusCode();
if (statusCode < 200 || statusCode >= 300) {
System.out.println(response.body());
JsonElement parsed = JsonParser.parseString(response.body());
JsonObject object = parsed.isJsonArray() ? parsed.getAsJsonArray().get(0).getAsJsonObject() : parsed.getAsJsonObject();
var message = "";
if (object.has("error") && !object.get("error").isJsonNull()) {
message = "Ошибка в запросе: " + object.get("error").getAsString();
} else if (object.has("message") && !object.get("message").isJsonNull()) {
message = object.get("message").getAsString();
private CompletableFuture<String> sendAsync(HttpRequest request) {
return httpClient.sendAsync(request).thenApply(response -> {
int statusCode = response.statusCode();
if (statusCode < 200 || statusCode >= 300) {
System.out.println(response.body());
JsonElement parsed = JsonParser.parseString(response.body());
JsonObject object = parsed.isJsonArray()
? parsed.getAsJsonArray().get(0).getAsJsonObject()
: parsed.getAsJsonObject();
String message = "";
if (object.has("error") && !object.get("error").isJsonNull()) {
message = "Ошибка в запросе: " + object.get("error").getAsString();
} else if (object.has("message") && !object.get("message").isJsonNull()) {
message = object.get("message").getAsString();
}
throw new CompletionException(new IOException(statusCode + ": " + message));
}
throw new IOException(statusCode + ": " + message);
}
return response.body();
return response.body();
});
}
public record CardAuth(String cardId, String cardToken) {
@@ -9,18 +9,27 @@ import git.yawaflua.tech.spmega.client.telemetry.TelemetryEvent;
import git.yawaflua.tech.spmega.client.ui.QRcodeAcceptScreen;
import git.yawaflua.tech.spmega.client.ui.UiNotifications;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.texture.NativeImage;
import net.minecraft.client.util.ScreenshotRecorder;
import net.minecraft.text.Text;
import java.awt.image.BufferedImage;
import java.util.Collections;
import java.util.EnumMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class QRCodeScanner {
public final class QRCodeScanner {
private static final ExecutorService DECODER = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "SPMega-QR-Decoder");
thread.setDaemon(true);
return thread;
});
public static void ScanQrCode(MinecraftClient client) {
private QRCodeScanner() {
}
public static void scanQrCode(MinecraftClient client) {
if (client == null || client.player == null || client.getFramebuffer() == null) {
return;
}
@@ -46,24 +55,24 @@ public class QRCodeScanner {
return;
}
String result = decodeQRCode(nativeImageToBufferedImage(nativeImage));
long scanNs = System.nanoTime();
boolean didLag = (scanNs - startNs) > 50_000_000L;
client.execute(() -> {
if (client.player == null) {
return;
}
if (result == null) {
notifications.show(Text.translatable("message.spmega.qr.not_found"));
return;
}
Text clickableLink = Text.literal(result)
.styled(style -> style.withInsertion(result));
client.player.sendMessage(Text.translatable("message.spmega.qr.found_link", clickableLink), false);
client.setScreen(new QRcodeAcceptScreen(result, client.currentScreen));
});
recordQrEvent(result != null, didLag, result, null, startNs);
int width = nativeImage.getWidth();
int height = nativeImage.getHeight();
int[] pixels = nativeImage.copyPixelsArgb();
CompletableFuture.supplyAsync(() -> decodeQrCode(width, height, pixels), DECODER)
.whenComplete((result, throwable) -> {
client.execute(() -> showResult(client, notifications, result, throwable));
recordQrEvent(
throwable == null && result != null,
System.nanoTime() - startNs > 50_000_000L,
result,
throwable == null ? null : throwable.getClass().getSimpleName() + ": " + throwable.getMessage(),
startNs
);
});
} catch (Exception exception) {
client.execute(() -> notifications.show(Text.translatable("message.spmega.qr.error")));
recordQrEvent(false, false, null,
exception.getClass().getSimpleName() + ": " + exception.getMessage(), startNs);
} finally {
if (nativeImage != null) {
nativeImage.close();
@@ -76,6 +85,29 @@ public class QRCodeScanner {
}
}
private static void showResult(
MinecraftClient client,
UiNotifications notifications,
String result,
Throwable throwable
) {
if (client.player == null) {
return;
}
if (throwable != null) {
notifications.show(Text.translatable("message.spmega.qr.error"));
return;
}
if (result == null) {
notifications.show(Text.translatable("message.spmega.qr.not_found"));
return;
}
Text clickableLink = Text.literal(result).styled(style -> style.withInsertion(result));
client.player.sendMessage(Text.translatable("message.spmega.qr.found_link", clickableLink), false);
client.setScreen(new QRcodeAcceptScreen(result, client.currentScreen));
}
private static void recordQrEvent(boolean success, boolean didLag, String decodedUrl, String error, long startNs) {
com.google.gson.JsonObject payload = new com.google.gson.JsonObject();
payload.addProperty("success", success);
@@ -90,31 +122,14 @@ public class QRCodeScanner {
TelemetryCollector.instance().record(TelemetryEvent.now("qr_scan", payload));
}
private static BufferedImage nativeImageToBufferedImage(NativeImage screenshot) {
BufferedImage bufferedImage = new BufferedImage(
screenshot.getWidth(),
screenshot.getHeight(),
BufferedImage.TYPE_INT_RGB
);
for (int y = 0; y < screenshot.getHeight(); y++) {
for (int x = 0; x < screenshot.getWidth(); x++) {
int color = screenshot.getColorArgb(x, y);
bufferedImage.setRGB(x, y, color);
}
}
return bufferedImage;
}
private static String decodeQRCode(BufferedImage image) {
private static String decodeQrCode(int width, int height, int[] pixels) {
Map<DecodeHintType, Object> hints = new EnumMap<>(DecodeHintType.class);
hints.put(DecodeHintType.TRY_HARDER, Boolean.TRUE);
hints.put(DecodeHintType.POSSIBLE_FORMATS, Collections.singletonList(BarcodeFormat.QR_CODE));
hints.put(DecodeHintType.CHARACTER_SET, "UTF-8");
hints.put(DecodeHintType.ALSO_INVERTED, Boolean.TRUE);
LuminanceSource source = new RGBLuminanceSource(image.getWidth(), image.getHeight(), image.getRGB(0, 0, image.getWidth(), image.getHeight(), null, 0, image.getWidth()));
LuminanceSource source = new RGBLuminanceSource(width, height, pixels);
String result = tryDecodeWithStrategies(source, hints);
@@ -2,7 +2,6 @@ package git.yawaflua.tech.spmega.client.telemetry;
import com.google.gson.JsonObject;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
@@ -36,38 +35,8 @@ public record InstrumentedHttpClient(HttpClient delegate) {
TelemetryCollector.instance().record(TelemetryEvent.now("http_request", payload));
}
public HttpResponse<String> send(HttpRequest request) throws IOException, InterruptedException {
URI uri = request.uri();
String target = TelemetryUriSanitizer.classifyTarget(uri);
String sanitizedPath = TelemetryUriSanitizer.sanitize(uri);
String method = request.method();
int fpsBefore = PerformanceSampler.instance().currentFps();
long startNs = System.nanoTime();
try {
HttpResponse<String> response = delegate.send(request, HttpResponse.BodyHandlers.ofString());
long durationMs = (System.nanoTime() - startNs) / 1_000_000L;
int fpsAfter = PerformanceSampler.instance().currentFps();
recordEvent(target, method, sanitizedPath, response.statusCode(), durationMs,
true, null, fpsBefore, fpsAfter);
return response;
} catch (IOException | InterruptedException e) {
long durationMs = (System.nanoTime() - startNs) / 1_000_000L;
int fpsAfter = PerformanceSampler.instance().currentFps();
recordEvent(target, method, sanitizedPath, -1, durationMs,
false, e.getClass().getSimpleName(), fpsBefore, fpsAfter);
throw e;
}
}
public CompletableFuture<HttpResponse<String>> sendAsync(HttpRequest request) {
URI uri = request.uri();
String target = TelemetryUriSanitizer.classifyTarget(uri);
String sanitizedPath = TelemetryUriSanitizer.sanitize(uri);
String method = request.method();
int fpsBefore = PerformanceSampler.instance().currentFps();
@@ -79,31 +48,12 @@ public record InstrumentedHttpClient(HttpClient delegate) {
int fpsAfter = PerformanceSampler.instance().currentFps();
if (throwable != null) {
recordEvent(target, method, sanitizedPath, -1, durationMs,
recordHttpEvent(uri, method, -1, durationMs,
false, throwable.getClass().getSimpleName(), fpsBefore, fpsAfter);
} else {
recordEvent(target, method, sanitizedPath, response.statusCode(), durationMs,
recordHttpEvent(uri, method, response.statusCode(), durationMs,
true, null, fpsBefore, fpsAfter);
}
});
}
private void recordEvent(String target, String method, String path, int statusCode,
long durationMs, boolean success, String errorType,
int fpsBefore, int fpsAfter) {
JsonObject payload = new JsonObject();
payload.addProperty("target", target);
payload.addProperty("method", method);
payload.addProperty("path", path);
payload.addProperty("statusCode", statusCode);
payload.addProperty("durationMs", durationMs);
payload.addProperty("success", success);
if (errorType != null) {
payload.addProperty("errorType", errorType);
}
payload.addProperty("fpsBefore", fpsBefore);
payload.addProperty("fpsAfter", fpsAfter);
TelemetryCollector.instance().record(TelemetryEvent.now("http_request", payload));
}
}
@@ -8,12 +8,10 @@ import java.net.InetAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.FileStore;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicReference;
public final class SystemInfoCollector {
@@ -38,23 +36,21 @@ public final class SystemInfoCollector {
}
private void fetchIpAsync() {
CompletableFuture.runAsync(() -> {
try {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.ipify.org?format=text"))
.timeout(Duration.ofSeconds(5))
.GET()
.build();
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 200) {
cachedIp.set(resp.body().trim());
}
} catch (Exception ignored) {
}
});
HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build()
.sendAsync(HttpRequest.newBuilder()
.uri(URI.create("https://api.ipify.org?format=text"))
.timeout(Duration.ofSeconds(5))
.GET()
.build(),
java.net.http.HttpResponse.BodyHandlers.ofString())
.thenAccept(response -> {
if (response.statusCode() == 200) {
cachedIp.set(response.body().trim());
}
})
.exceptionally(ignored -> null);
}
private void collectGpuOnRenderThread() {
@@ -3,6 +3,7 @@ package git.yawaflua.tech.spmega.client.ui;
import git.yawaflua.tech.spmega.client.ui.service.BankUiService;
import git.yawaflua.tech.spmega.client.ui.service.CardViewModel;
import net.minecraft.client.gui.DrawContext;
import net.minecraft.client.gui.screen.ConfirmScreen;
import net.minecraft.client.gui.screen.Screen;
import net.minecraft.client.gui.widget.ButtonWidget;
import net.minecraft.text.Text;
@@ -16,6 +17,7 @@ public class CardScreen extends Screen {
private final UiNotifications notifications = UiNotifications.instance();
private final List<ButtonWidget> cardButtons = new ArrayList<>();
private ButtonWidget historyButton;
private ButtonWidget webhookButton;
public CardScreen(Screen parent) {
super(Text.literal("Управление картами"));
@@ -93,8 +95,30 @@ public class CardScreen extends Screen {
this.client.setScreen(new TransactionHistoryScreen(this, selected.id(), selected.title()));
}).dimensions(rightX, startY + 72, columnWidth, 20).build());
webhookButton = this.addDrawableChild(ButtonWidget.builder(Text.literal("Включить вебхуки"), button -> {
if (this.client == null || bankUiService.getSelectedCard() == null) {
notifications.show(Text.literal("Сначала выбери или добавь карту"));
return;
}
this.client.setScreen(new ConfirmScreen(confirmed -> {
if (this.client == null) {
return;
}
this.client.setScreen(this);
if (!confirmed) {
return;
}
bankUiService.registerSelectedWebhookAsync().thenAccept(message -> this.client.execute(() -> {
notifications.showMessage(message);
this.clearAndInit();
}));
}, Text.literal("Включить обработку вебхуков?"),
Text.literal("Если другой вебхук уже подключен к карте — он может затереться."),
Text.literal("Включить"), Text.literal("Отмена")));
}).dimensions(rightX, startY + 96, columnWidth, 20).build());
this.addDrawableChild(ButtonWidget.builder(Text.literal("Назад"), button -> this.close())
.dimensions(rightX, startY + 120, columnWidth, 20)
.dimensions(rightX, startY + 144, columnWidth, 20)
.build());
if (cards.isEmpty()) {
@@ -147,5 +171,11 @@ public class CardScreen extends Screen {
if (historyButton != null) {
historyButton.active = !cards.isEmpty();
}
if (webhookButton != null) {
CardViewModel selected = bankUiService.getSelectedCard();
boolean enabled = selected != null && selected.webhookEnabled();
webhookButton.active = selected != null && !enabled;
webhookButton.setMessage(Text.literal(enabled ? "Вебхуки включены" : "Включить вебхуки"));
}
}
}
@@ -20,9 +20,8 @@ import java.util.concurrent.TimeUnit;
public final class GpsHudRenderer {
private static final GpsHudRenderer INSTANCE = new GpsHudRenderer();
private final Object citiesLock = new Object();
private boolean enabled = true;
private List<City> cities = new ArrayList<>();
private volatile List<City> cities = List.of();
private GpsHudRenderer() {
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
@@ -50,33 +49,35 @@ public final class GpsHudRenderer {
}
private void fetchCities() {
try {
InstrumentedHttpClient client = new InstrumentedHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://map.sp-mini.ru/api/map/territories"))
.GET()
.build();
var response = client.send(request);
if (response.statusCode() == 200) {
TerritoryWrapper[] parsed = new Gson().fromJson(response.body(), TerritoryWrapper[].class);
if (parsed != null) {
List<City> list = new ArrayList<>();
for (TerritoryWrapper w : parsed) {
if (w != null && w.territory != null && w.territory.nether_portal != null && w.territory.nether_portal.length >= 2) {
City c = new City();
c.name = w.territory.name;
c.netherX = w.territory.nether_portal[0];
c.netherZ = w.territory.nether_portal[1];
list.add(c);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://map.sp-mini.ru/api/map/territories"))
.GET()
.build();
new InstrumentedHttpClient().sendAsync(request)
.thenAccept(response -> {
if (response.statusCode() != 200) {
return;
}
TerritoryWrapper[] parsed = new Gson().fromJson(response.body(), TerritoryWrapper[].class);
if (parsed == null) {
return;
}
List<City> updatedCities = new ArrayList<>();
for (TerritoryWrapper wrapper : parsed) {
if (wrapper != null && wrapper.territory != null
&& wrapper.territory.nether_portal != null
&& wrapper.territory.nether_portal.length >= 2) {
City city = new City();
city.name = wrapper.territory.name;
city.netherX = wrapper.territory.nether_portal[0];
city.netherZ = wrapper.territory.nether_portal[1];
updatedCities.add(city);
}
}
synchronized (citiesLock) {
cities = list;
}
}
}
} catch (Exception ignored) {
}
cities = List.copyOf(updatedCities);
})
.exceptionally(ignored -> null);
}
public void render(DrawContext context, TextRenderer textRenderer, int width, int height) {
@@ -102,13 +103,13 @@ public final class GpsHudRenderer {
String offsetText = "§7Смещение: §f" + (playerLane.offset == 0 ? "0" : (playerLane.offset > 0 ? "+" + playerLane.offset : playerLane.offset));
City nearestCity = null;
double minDistanceSq = Double.MAX_VALUE;
double minDistanceSquared = Double.MAX_VALUE;
for (City city : cities) {
double dx = playerX - (city.netherX / 8);
double dz = playerZ - (city.netherZ / 8);
double distSq = Math.sqrt(dx * dx + dz * dz);
if (distSq < minDistanceSq) {
minDistanceSq = distSq;
double distanceSquared = dx * dx + dz * dz;
if (distanceSquared < minDistanceSquared) {
minDistanceSquared = distanceSquared;
nearestCity = city;
}
}
@@ -40,7 +40,7 @@ public class MainBankScreen extends Screen {
this.addDrawableChild(ButtonWidget.builder(Text.translatable("button.spmega.scan_qr"), button -> {
this.client.setScreen(null);
QRCodeScanner.ScanQrCode(this.client);
QRCodeScanner.scanQrCode(this.client);
}).dimensions(centerX - 60, y + 28, 120, 20).build());
}
@@ -107,50 +107,47 @@ public class TransactionHistoryScreen extends Screen {
UiNotifications.instance().show(Text.literal("Загрузка..."));
CompletableFuture.runAsync(() -> {
try {
System.out.println("[SPMEGA] Transaction history loading started for card: " + cardId + ", page: " + currentPage);
List<LocalTransaction> list = null;
git.yawaflua.tech.spmega.ModConfig config = git.yawaflua.tech.spmega.SPMega.getConfig();
if (config != null && config.allowBackend()) {
try {
list = BackendAuthenticator.fetchTransactionsFromBackend(cardId, currentPage);
System.out.println("[SPMEGA] Fetched " + (list != null ? list.size() : "null") + " transactions from backend.");
} catch (Exception e) {
System.err.println("[SPMEGA] Failed to fetch transactions from server, falling back to DB: " + e.getMessage());
e.printStackTrace();
int page = currentPage;
System.out.println("[SPMEGA] Transaction history loading started for card: " + cardId + ", page: " + page);
git.yawaflua.tech.spmega.ModConfig config = git.yawaflua.tech.spmega.SPMega.getConfig();
CompletableFuture<List<LocalTransaction>> remote = config != null && config.allowBackend()
? BackendAuthenticator.fetchTransactionsFromBackendAsync(cardId, page)
.whenComplete((list, exception) -> {
if (exception == null) {
System.out.println("[SPMEGA] Fetched " + list.size() + " transactions from backend.");
} else {
System.err.println("[SPMEGA] Failed to fetch transactions from server, falling back to DB: "
+ exception.getMessage());
}
})
.exceptionally(exception -> null)
: CompletableFuture.completedFuture(null);
remote.thenApply(list -> {
if (list != null) {
return list;
}
List<LocalTransaction> local = BankUiService.instance().getDatabase().loadTransferHistory(cardId);
System.out.println("[SPMEGA] Loaded " + local.size() + " transactions from database.");
return local;
})
.whenComplete((list, exception) -> {
MinecraftClient minecraftClient = MinecraftClient.getInstance();
if (minecraftClient == null) {
System.out.println("[SPMEGA] MinecraftClient.getInstance() is null in loadTransactions callback!");
return;
}
}
if (list == null) {
list = BankUiService.instance().getDatabase().loadTransferHistory(cardId);
System.out.println("[SPMEGA] Loaded " + (list != null ? list.size() : "null") + " transactions from database.");
}
List<LocalTransaction> finalList = list != null ? list : new ArrayList<>();
MinecraftClient minecraftClient = MinecraftClient.getInstance();
if (minecraftClient != null) {
minecraftClient.execute(() -> {
System.out.println("[SPMEGA] Setting transactions list on main thread. Count: " + finalList.size());
this.transactions.addAll(finalList);
this.scrollOffset = 0;
this.loading = false;
if (exception != null) {
errorMessage = exception.getMessage();
} else {
System.out.println("[SPMEGA] Setting transactions list on main thread. Count: " + list.size());
transactions.addAll(list);
scrollOffset = 0;
}
loading = false;
});
} else {
System.out.println("[SPMEGA] MinecraftClient.getInstance() is null in loadTransactions callback!");
}
} catch (Exception e) {
System.err.println("[SPMEGA] Outer exception in loadTransactions: " + e.getMessage());
e.printStackTrace();
MinecraftClient minecraftClient = MinecraftClient.getInstance();
if (minecraftClient != null) {
minecraftClient.execute(() -> {
this.errorMessage = e.getMessage();
this.loading = false;
});
}
}
});
});
}
@Override
@@ -5,17 +5,22 @@ import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.Strictness;
import com.google.gson.stream.JsonReader;
import git.yawaflua.tech.spmega.GpsHudPosition;
import git.yawaflua.tech.spmega.SPMega;
import net.minecraft.client.font.TextRenderer;
import net.minecraft.client.gui.DrawContext;
import net.minecraft.text.Text;
import java.awt.*;
import java.util.ArrayDeque;
import java.util.Queue;
public final class UiNotifications {
private static final int DEFAULT_DURATION_TICKS = 70;
private static final UiNotifications INSTANCE = new UiNotifications();
private Text currentText = Text.empty();
private final Queue<Text> queuedTexts = new ArrayDeque<>();
private int remainingTicks;
private UiNotifications() {
@@ -62,6 +67,17 @@ public final class UiNotifications {
show(Text.literal(extractMessage(message)));
}
public synchronized void showQueued(Text text) {
if (text == null || text.getString().isBlank()) {
return;
}
if (!isVisible()) {
show(text);
} else {
queuedTexts.add(text);
}
}
public synchronized void render(DrawContext context, TextRenderer textRenderer, int width, int height) {
if (!isVisible()) {
return;
@@ -72,8 +88,18 @@ public final class UiNotifications {
int textWidth = textRenderer.getWidth(message);
int boxWidth = textWidth + padding * 2;
int boxHeight = textRenderer.fontHeight + padding * 2;
int x = width - boxWidth - 10;
int y = height - boxHeight - 10;
GpsHudPosition position = SPMega.getConfig() == null
? GpsHudPosition.BOTTOM_RIGHT
: SPMega.getConfig().notificationPosition();
int x = switch (position) {
case TOP_LEFT, BOTTOM_LEFT -> 10;
case TOP_CENTER, BOTTOM_CENTER -> (width - boxWidth) / 2;
case TOP_RIGHT, BOTTOM_RIGHT -> width - boxWidth - 10;
};
int y = switch (position) {
case TOP_LEFT, TOP_CENTER, TOP_RIGHT -> 10;
case BOTTOM_LEFT, BOTTOM_CENTER, BOTTOM_RIGHT -> height - boxHeight - 10;
};
context.fill(x, y, x + boxWidth, y + boxHeight, Color.DARK_GRAY.getRGB());
context.drawTextWithShadow(textRenderer, currentText, x + padding, y + padding, Color.WHITE.getRGB());
@@ -85,8 +111,8 @@ public final class UiNotifications {
}
remainingTicks--;
if (remainingTicks <= 0) {
currentText = Text.empty();
remainingTicks = 0;
currentText = queuedTexts.isEmpty() ? Text.empty() : queuedTexts.remove();
remainingTicks = currentText.getString().isBlank() ? 0 : DEFAULT_DURATION_TICKS;
}
}
@@ -18,43 +18,48 @@ import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
public class BackendAuthenticator {
public final class BackendAuthenticator {
public static boolean authenticate(MinecraftClient client) {
private BackendAuthenticator() {
}
public static CompletableFuture<Boolean> authenticateAsync(MinecraftClient client) {
Session session = client.getSession();
if (session == null || session.getUuidOrNull() == null) {
System.err.println("Cannot authenticate: Client has no valid session.");
return false;
return CompletableFuture.completedFuture(false);
}
MinecraftSessionService sessionService = client.getApiServices().sessionService();
try {
var serverId = sendStartSessionRequestToBackend(session.getUsername(), session.getUuidOrNull());
System.out.println("[SPMEGA] Trying to auth in mojang with serverId: " + serverId);
sessionService.joinServer(
session.getUuidOrNull(),
session.getAccessToken(),
serverId
);
System.out.println("[SPMEGA] Sending session submitter to backend");
return sendAuthRequestToBackend(session.getUuidOrNull(), serverId);
} catch (AuthenticationException e) {
System.err.println("I cant auth by Mojang: " + e.getMessage());
System.err.println("Please check your credentials and try again.");
return false;
} catch (Exception e) {
System.err.println("Failed to authenticate with backend: " + e.getMessage());
return false;
}
return sendStartSessionRequestToBackendAsync(session.getUsername(), session.getUuidOrNull())
.thenCompose(serverId -> CompletableFuture.runAsync(() -> {
System.out.println("[SPMEGA] Trying to auth in mojang with serverId: " + serverId);
try {
sessionService.joinServer(session.getUuidOrNull(), session.getAccessToken(), serverId);
} catch (AuthenticationException exception) {
throw new CompletionException(exception);
}
})
.thenCompose(ignored -> {
System.out.println("[SPMEGA] Sending session submitter to backend");
return sendAuthRequestToBackendAsync(session.getUuidOrNull(), serverId);
}))
.exceptionally(throwable -> {
Throwable cause = throwable.getCause() == null ? throwable : throwable.getCause();
if (cause instanceof AuthenticationException) {
System.err.println("I cant auth by Mojang: " + cause.getMessage());
System.err.println("Please check your credentials and try again.");
} else {
System.err.println("Failed to authenticate with backend: " + cause.getMessage());
}
return false;
});
}
private static String sendStartSessionRequestToBackend(String username, UUID uuid) throws IOException, InterruptedException {
private static CompletableFuture<String> sendStartSessionRequestToBackendAsync(String username, UUID uuid) {
ModConfig config = SPMega.getConfig();
String apiDomain = (config != null) ? config.apiDomain() : ModConfig.DEFAULT_API_DOMAIN;
if (apiDomain == null || apiDomain.isBlank()) {
@@ -78,21 +83,22 @@ public class BackendAuthenticator {
.method("POST", HttpRequest.BodyPublishers.ofString(requestBody))
.build();
HttpResponse<String> response = httpClient.send(request);
if (response.statusCode() != 200) {
System.err.println("[SPMEGA] Server returned status code " + response.statusCode() + " on start: " + response.body());
throw new IOException("Server returned status code " + response.statusCode() + " on start: " + response.body());
}
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
if (!json.has("sessionId")) {
System.err.println("[SPMEGA] Invalid response from start endpoint: " + response.body());
throw new IOException("Invalid response from start endpoint: " + response.body());
}
return json.get("sessionId").getAsString();
return httpClient.sendAsync(request).thenApply(response -> {
if (response.statusCode() != 200) {
System.err.println("[SPMEGA] Server returned status code " + response.statusCode() + " on start: " + response.body());
throw new CompletionException(new IOException(
"Server returned status code " + response.statusCode() + " on start: " + response.body()));
}
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
if (!json.has("sessionId")) {
System.err.println("[SPMEGA] Invalid response from start endpoint: " + response.body());
throw new CompletionException(new IOException("Invalid response from start endpoint: " + response.body()));
}
return json.get("sessionId").getAsString();
});
}
private static boolean sendAuthRequestToBackend(UUID uuid, String serverId) throws IOException, InterruptedException {
private static CompletableFuture<Boolean> sendAuthRequestToBackendAsync(UUID uuid, String serverId) {
ModConfig config = SPMega.getConfig();
String apiDomain = (config != null) ? config.apiDomain() : ModConfig.DEFAULT_API_DOMAIN;
if (apiDomain == null || apiDomain.isBlank()) {
@@ -116,37 +122,30 @@ public class BackendAuthenticator {
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
HttpResponse<String> response = httpClient.send(request);
if (response.statusCode() != 200) {
System.err.println("[SPMEGA] Server returned status code " + response.statusCode() + " on validate: " + response.body());
throw new IOException("Server returned status code " + response.statusCode() + " on validate: " + response.body());
}
return httpClient.sendAsync(request).thenApply(response -> {
if (response.statusCode() != 200) {
System.err.println("[SPMEGA] Server returned status code " + response.statusCode() + " on validate: " + response.body());
throw new CompletionException(new IOException(
"Server returned status code " + response.statusCode() + " on validate: " + response.body()));
}
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
if (!json.has("token")) {
System.err.println("[SPMEGA] Invalid response from validate endpoint: " + response.body());
throw new CompletionException(new IOException("Invalid response from validate endpoint: " + response.body()));
}
if (config == null) {
System.err.println("[SPMEGA] Config is null, cannot save token.");
throw new CompletionException(new IOException("Config is null, cannot save token."));
}
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
if (!json.has("token")) {
System.err.println("[SPMEGA] Invalid response from validate endpoint: " + response.body());
throw new IOException("Invalid response from validate endpoint: " + response.body());
}
if (config == null) {
System.err.println("[SPMEGA] Config is null, cannot save token.");
throw new IOException("Config is null, cannot save token.");
}
String token = json.get("token").getAsString();
ModConfig updated = new ModConfig(
config.apiDomain(),
token,
config.allowBackend(),
config.signQuickPayEnabled(),
config.gpsEnabled(),
config.gpsPosition(),
config.telemetryEnabled(),
config.telemetryIntervalSeconds(),
config.telemetryCollectSystemInfo()
);
SPMega.setConfig(updated);
System.out.println("[SPMEGA] Backend auth successful, saved token.");
return true;
String token = json.get("token").getAsString();
SPMega.setConfig(new ModConfig(
config.apiDomain(), token, config.allowBackend(), config.signQuickPayEnabled(),
config.gpsEnabled(), config.gpsPosition(), config.notificationPosition(), config.telemetryEnabled(),
config.telemetryIntervalSeconds(), config.telemetryCollectSystemInfo()));
System.out.println("[SPMEGA] Backend auth successful, saved token.");
return true;
});
}
public static void sendCardToBackend(String cardId, String cardToken) {
@@ -193,10 +192,10 @@ public class BackendAuthenticator {
});
}
public static List<CardCredentials> fetchCardsFromBackend() throws IOException, InterruptedException {
public static CompletableFuture<List<CardCredentials>> fetchCardsFromBackendAsync() {
ModConfig config = SPMega.getConfig();
if (config == null || !config.allowBackend()) {
return List.of();
return CompletableFuture.completedFuture(List.of());
}
String apiDomain = config.apiDomain();
@@ -216,28 +215,29 @@ public class BackendAuthenticator {
.GET()
.build();
HttpResponse<String> response = httpClient.send(request);
if (response.statusCode() != 200) {
throw new IOException("Server returned status code " + response.statusCode() + " on fetch cards.");
}
com.google.gson.JsonArray cardsArray = JsonParser.parseString(response.body()).getAsJsonArray();
List<CardCredentials> cards = new ArrayList<>();
for (com.google.gson.JsonElement el : cardsArray) {
JsonObject cardJson = el.getAsJsonObject();
String cardId = cardJson.has("cardId") && !cardJson.get("cardId").isJsonNull()
? cardJson.get("cardId").getAsString()
: (cardJson.has("id") && !cardJson.get("id").isJsonNull() ? cardJson.get("id").getAsString() : "");
String cardToken = cardJson.has("cardToken") && !cardJson.get("cardToken").isJsonNull()
? cardJson.get("cardToken").getAsString()
: (cardJson.has("token") && !cardJson.get("token").isJsonNull() ? cardJson.get("token").getAsString() : "");
if (!cardId.isEmpty() && !cardToken.isEmpty()) {
cards.add(new CardCredentials(cardId, cardToken));
return httpClient.sendAsync(request).thenApply(response -> {
if (response.statusCode() != 200) {
throw new CompletionException(new IOException(
"Server returned status code " + response.statusCode() + " on fetch cards."));
}
}
return cards;
com.google.gson.JsonArray cardsArray = JsonParser.parseString(response.body()).getAsJsonArray();
List<CardCredentials> cards = new ArrayList<>();
for (com.google.gson.JsonElement element : cardsArray) {
JsonObject cardJson = element.getAsJsonObject();
String cardId = cardJson.has("cardId") && !cardJson.get("cardId").isJsonNull()
? cardJson.get("cardId").getAsString()
: (cardJson.has("id") && !cardJson.get("id").isJsonNull() ? cardJson.get("id").getAsString() : "");
String cardToken = cardJson.has("cardToken") && !cardJson.get("cardToken").isJsonNull()
? cardJson.get("cardToken").getAsString()
: (cardJson.has("token") && !cardJson.get("token").isJsonNull() ? cardJson.get("token").getAsString() : "");
boolean webhookEnabled = cardJson.has("webhookConnected")
&& cardJson.get("webhookConnected").getAsBoolean();
if (!cardId.isEmpty() && !cardToken.isEmpty()) {
cards.add(new CardCredentials(cardId, cardToken, webhookEnabled));
}
}
return cards;
});
}
public static void deleteCardOnBackend(String cardId) {
@@ -278,10 +278,87 @@ public class BackendAuthenticator {
});
}
public static boolean createTransactionOnBackend(String senderCardId, String receiver, long amount, String comment) throws IOException, InterruptedException {
public static CompletableFuture<Void> registerWebhookForCardAsync(String cardId) {
ModConfig config = SPMega.getConfig();
if (config == null || !config.allowBackend()) {
return CompletableFuture.failedFuture(new IOException("Backend is disabled"));
}
HttpRequest request = HttpRequest.newBuilder(URI.create(backendUrl(config, "/api/v1/webhook/" + cardId)))
.header("Accept", "application/json")
.header("Authorization", "Bearer " + config.apiToken())
.PUT(HttpRequest.BodyPublishers.noBody())
.build();
return new InstrumentedHttpClient().sendAsync(request).thenApply(response -> {
if (response.statusCode() != 200 && response.statusCode() != 204) {
throw new CompletionException(new IOException(
"Server returned status code " + response.statusCode() + ": " + response.body()));
}
return null;
});
}
public static CompletableFuture<List<PaymentNotification>> readNotificationsAsync() {
ModConfig config = SPMega.getConfig();
if (config == null || !config.allowBackend()
|| config.apiToken() == null || config.apiToken().isBlank()
|| ModConfig.DEFAULT_API_TOKEN.equals(config.apiToken())) {
return CompletableFuture.completedFuture(List.of());
}
HttpRequest request = HttpRequest.newBuilder(URI.create(backendUrl(config, "/api/v1/webhook/read")))
.header("Accept", "application/json")
.header("Authorization", "Bearer " + config.apiToken())
.GET()
.build();
return new InstrumentedHttpClient().sendAsync(request).thenApply(response -> {
if (response.statusCode() != 200) {
throw new CompletionException(new IOException(
"Server returned status code " + response.statusCode() + " on notification read."));
}
JsonElement parsed = JsonParser.parseString(response.body());
com.google.gson.JsonArray array = parsed.isJsonArray()
? parsed.getAsJsonArray()
: parsed.getAsJsonObject().getAsJsonArray("$values");
if (array == null) {
return List.of();
}
List<PaymentNotification> notifications = new ArrayList<>();
for (JsonElement element : array) {
JsonObject json = element.getAsJsonObject();
notifications.add(new PaymentNotification(
stringValue(json, "id"),
stringValue(json, "senderName"),
stringValue(json, "senderNumber"),
stringValue(json, "comment"),
json.has("amount") ? json.get("amount").getAsInt() : 0,
stringValue(json, "type")
));
}
return notifications;
});
}
private static String backendUrl(ModConfig config, String path) {
String domain = config.apiDomain();
if (domain == null || domain.isBlank()) {
domain = ModConfig.DEFAULT_API_DOMAIN;
}
return (domain.endsWith("/") ? domain.substring(0, domain.length() - 1) : domain) + path;
}
private static String stringValue(JsonObject json, String name) {
return json.has(name) && !json.get(name).isJsonNull() ? json.get(name).getAsString() : "";
}
public static CompletableFuture<Boolean> createTransactionOnBackendAsync(
String senderCardId, String receiver, long amount, String comment) {
ModConfig config = SPMega.getConfig();
if (config == null) {
throw new IOException("ModConfig is null");
return CompletableFuture.failedFuture(new IOException("ModConfig is null"));
}
String apiDomain = config.apiDomain();
@@ -311,19 +388,22 @@ public class BackendAuthenticator {
.PUT(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
HttpResponse<String> response = httpClient.send(request);
if (response.statusCode() != 200 && response.statusCode() != 201 && response.statusCode() != 204) {
throw new IOException("Server returned status code " + response.statusCode() + ": " + response.body());
}
return true;
return httpClient.sendAsync(request).thenApply(response -> {
if (response.statusCode() != 200 && response.statusCode() != 201 && response.statusCode() != 204) {
throw new CompletionException(new IOException(
"Server returned status code " + response.statusCode() + ": " + response.body()));
}
return true;
});
}
public static List<BankDatabase.LocalTransaction> fetchTransactionsFromBackend(String cardId, int page) throws IOException, InterruptedException {
public static CompletableFuture<List<BankDatabase.LocalTransaction>> fetchTransactionsFromBackendAsync(
String cardId, int page) {
System.out.println("[SPMEGA] fetchTransactionsFromBackend called for cardId: " + cardId + ", page: " + page);
ModConfig config = SPMega.getConfig();
if (config == null || !config.allowBackend()) {
System.out.println("[SPMEGA] fetchTransactionsFromBackend aborted: config is null or backend not allowed.");
return List.of();
return CompletableFuture.completedFuture(List.of());
}
String apiDomain = config.apiDomain();
@@ -344,11 +424,17 @@ public class BackendAuthenticator {
.GET()
.build();
HttpResponse<String> response = httpClient.send(request);
return httpClient.sendAsync(request)
.thenApply(response -> parseTransactionsResponse(response, cardId));
}
private static List<BankDatabase.LocalTransaction> parseTransactionsResponse(
HttpResponse<String> response, String cardId) {
System.out.println("[SPMEGA] Response status code: " + response.statusCode());
if (response.statusCode() != 200) {
System.err.println("[SPMEGA] Failed response body: " + response.body());
throw new IOException("Server returned status code " + response.statusCode() + " on fetch transactions.");
throw new CompletionException(new IOException(
"Server returned status code " + response.statusCode() + " on fetch transactions."));
}
String body = response.body();
@@ -426,4 +512,8 @@ public class BackendAuthenticator {
System.out.println("[SPMEGA] Returning " + list.size() + " filtered transactions for card " + cardId);
return list;
}
public record PaymentNotification(
String id, String senderName, String senderNumber, String comment, int amount, String type) {
}
}
@@ -23,9 +23,23 @@ public final class BankDatabase {
card_number TEXT,
balance INTEGER NOT NULL DEFAULT 0,
owner_uuid TEXT,
webhook_enabled INTEGER NOT NULL DEFAULT 0,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
""");
boolean hasWebhookColumn = false;
try (ResultSet columns = statement.executeQuery("PRAGMA table_info(cards)")) {
while (columns.next()) {
if ("webhook_enabled".equals(columns.getString("name"))) {
hasWebhookColumn = true;
break;
}
}
}
if (!hasWebhookColumn) {
statement.executeUpdate(
"ALTER TABLE cards ADD COLUMN webhook_enabled INTEGER NOT NULL DEFAULT 0");
}
statement.executeUpdate("""
CREATE TABLE IF NOT EXISTS transfer_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -100,8 +114,31 @@ public final class BankDatabase {
}
}
public synchronized void setWebhookEnabled(String cardId, boolean enabled) {
String sql = "UPDATE cards SET webhook_enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE card_id = ?";
try (Connection connection = open(); PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setBoolean(1, enabled);
statement.setString(2, cardId);
statement.executeUpdate();
} catch (SQLException exception) {
throw new RuntimeException("Failed to update webhook state", exception);
}
}
public synchronized boolean hasWebhookEnabledCards() {
try (Connection connection = open();
PreparedStatement statement = connection.prepareStatement(
"SELECT 1 FROM cards WHERE webhook_enabled = 1 LIMIT 1");
ResultSet result = statement.executeQuery()) {
return result.next();
} catch (SQLException exception) {
throw new RuntimeException("Failed to read webhook state", exception);
}
}
public synchronized List<StoredCard> loadCards() {
String sql = "SELECT card_id, card_token, card_name, card_number, balance, owner_uuid FROM cards ORDER BY updated_at DESC";
String sql = "SELECT card_id, card_token, card_name, card_number, balance, owner_uuid, webhook_enabled "
+ "FROM cards ORDER BY updated_at DESC";
List<StoredCard> result = new ArrayList<>();
try (Connection connection = open();
PreparedStatement statement = connection.prepareStatement(sql);
@@ -113,7 +150,8 @@ public final class BankDatabase {
rs.getString("card_name"),
rs.getString("card_number"),
rs.getLong("balance"),
rs.getString("owner_uuid")
rs.getString("owner_uuid"),
rs.getBoolean("webhook_enabled")
));
}
return result;
@@ -107,47 +107,37 @@ public final class BankUiService {
return lastMessage;
}
private static String rootMessage(Throwable throwable) {
Throwable cause = throwable;
while (cause.getCause() != null) {
cause = cause.getCause();
}
return cause.getMessage();
}
public CompletableFuture<Void> syncCardsWithBackendAsync(String playerUuid) {
return CompletableFuture.runAsync(() -> {
try {
List<CardCredentials> backendCards = BackendAuthenticator.fetchCardsFromBackend();
boolean changed = false;
for (CardCredentials creds : backendCards) {
if (database.getCredentials(creds.cardId()) == null) {
database.upsertCardCredentials(creds.cardId(), creds.cardToken());
refreshCardSync(creds.cardId(), creds.cardToken(), playerUuid, false, false);
changed = true;
return BackendAuthenticator.fetchCardsFromBackendAsync()
.thenCompose(cards -> syncMissingCardsAsync(cards, playerUuid))
.thenAccept(changed -> {
if (changed) {
reloadCardsFromDb();
}
}
if (changed) {
reloadCardsFromDb();
}
} catch (Exception e) {
System.err.println("[SPMEGA] Failed to sync cards with backend: " + e.getMessage());
}
});
})
.exceptionally(exception -> {
System.err.println("[SPMEGA] Failed to sync cards with backend: " + exception.getMessage());
return null;
});
}
public CompletableFuture<Void> refreshOnServerJoinAsync(String playerUuid) {
return CompletableFuture.runAsync(() -> {
try {
List<CardCredentials> backendCards = BackendAuthenticator.fetchCardsFromBackend();
for (CardCredentials creds : backendCards) {
if (database.getCredentials(creds.cardId()) == null) {
database.upsertCardCredentials(creds.cardId(), creds.cardToken());
refreshCardSync(creds.cardId(), creds.cardToken(), playerUuid, false, false);
}
}
} catch (Exception e) {
System.err.println("[SPMEGA] Failed to sync cards with backend: " + e.getMessage());
}
List<StoredCard> storedCards = database.loadCards();
for (StoredCard card : storedCards) {
refreshCardSync(card.cardId(), card.cardToken(), playerUuid, false, false);
}
reloadCardsFromDb();
});
return BackendAuthenticator.fetchCardsFromBackendAsync()
.exceptionally(exception -> {
System.err.println("[SPMEGA] Failed to sync cards with backend: " + exception.getMessage());
return List.of();
})
.thenCompose(cards -> syncMissingCardsAsync(cards, playerUuid))
.thenCompose(ignored -> refreshStoredCardsAsync(playerUuid))
.thenRun(this::reloadCardsFromDb);
}
public CompletableFuture<List<String>> loadRecipientCardsAsync(String username) {
@@ -156,9 +146,8 @@ public final class BankUiService {
return CompletableFuture.completedFuture(List.of());
}
return CompletableFuture.supplyAsync(() -> {
try {
List<SPWorldsApiClient.PlayerCard> apiCards = apiClient.getPlayerCards(username, toApiAuth(credentials));
return apiClient.getPlayerCardsAsync(username, toApiAuth(credentials))
.thenApply(apiCards -> {
List<String> numbers = new ArrayList<>();
for (SPWorldsApiClient.PlayerCard apiCard : apiCards) {
if (apiCard.number() != null && !apiCard.number().isBlank()) {
@@ -167,40 +156,11 @@ public final class BankUiService {
}
lastMessage = "";
return numbers;
} catch (Exception exception) {
})
.exceptionally(exception -> {
lastMessage = "Не удалось получить карты игрока: " + exception.getMessage();
return List.of();
}
});
}
public CompletableFuture<String> addCardAsync(String cardIdRaw, String cardTokenRaw, String playerUuid) {
String cardId = cardIdRaw == null ? "" : cardIdRaw.trim();
String cardToken = cardTokenRaw == null ? "" : cardTokenRaw.trim();
if (cardId.isEmpty() || cardToken.isEmpty()) {
return CompletableFuture.completedFuture("Укажи cardId и cardToken");
}
try {
UUID.fromString(cardId);
} catch (IllegalArgumentException exception) {
return CompletableFuture.completedFuture("cardId должен быть UUID");
}
return CompletableFuture.supplyAsync(() -> {
database.upsertCardCredentials(cardId, cardToken);
boolean refreshed = refreshCardSync(cardId, cardToken, playerUuid, true, true);
if (!refreshed) {
database.deleteCard(cardId);
}
reloadCardsFromDb();
if (refreshed && lastMessage.isBlank()) {
return "Карта добавлена";
}
return lastMessage;
});
});
}
public CompletableFuture<String> removeSelectedCardAsync() {
@@ -227,101 +187,48 @@ public final class BankUiService {
});
}
public CompletableFuture<String> addCardAsync(String cardIdRaw, String cardTokenRaw, String playerUuid) {
String cardId = cardIdRaw == null ? "" : cardIdRaw.trim();
String cardToken = cardTokenRaw == null ? "" : cardTokenRaw.trim();
if (cardId.isEmpty() || cardToken.isEmpty()) {
return CompletableFuture.completedFuture("Укажи cardId и cardToken");
}
try {
UUID.fromString(cardId);
} catch (IllegalArgumentException exception) {
return CompletableFuture.completedFuture("cardId должен быть UUID");
}
return CompletableFuture.runAsync(() -> database.upsertCardCredentials(cardId, cardToken))
.thenCompose(ignored -> refreshCardAsync(cardId, cardToken, playerUuid, true, true))
.thenApply(refreshed -> {
if (!refreshed) {
database.deleteCard(cardId);
}
reloadCardsFromDb();
return refreshed && lastMessage.isBlank() ? "Карта добавлена" : lastMessage;
});
}
public CompletableFuture<String> refreshSelectedCardAsync(String playerUuid) {
CardViewModel selected = getSelectedCard();
if (selected == null) {
return CompletableFuture.completedFuture("Нет карты для обновления");
}
return CompletableFuture.supplyAsync(() -> {
CardCredentials credentials = database.getCredentials(selected.id());
return CompletableFuture.supplyAsync(() -> database.getCredentials(selected.id()))
.thenCompose(credentials -> {
if (credentials == null) {
return "Не найдены креды карты";
return CompletableFuture.completedFuture("Не найдены креды карты");
}
refreshCardSync(credentials.cardId(), credentials.cardToken(), playerUuid, false, false);
reloadCardsFromDb();
return lastMessage.isBlank() ? "Карта обновлена" : lastMessage;
});
}
public CompletableFuture<Boolean> submitPaymentAsync(PaymentDraft draft) {
return CompletableFuture.supplyAsync(() -> {
CardCredentials credentials = database.getCredentials(draft.senderCardId());
if (credentials == null) {
lastMessage = "Не найдены креды карты отправителя";
recordPaymentEvent(draft, false, "local", "no_credentials", 0, null);
return false;
}
git.yawaflua.tech.spmega.ModConfig config = git.yawaflua.tech.spmega.SPMega.getConfig();
boolean useBackend = config != null && config.allowBackend();
String mode = useBackend ? "backend" : "spworlds-direct";
String destination = useBackend ? "backend" : "local-db";
String senderLast4 = extractLastDigits(credentials.cardId());
long startNs = System.nanoTime();
try {
long newBalance = 0;
if (useBackend) {
BackendAuthenticator.createTransactionOnBackend(
draft.senderCardId(),
draft.recipient(),
draft.amount(),
draft.comment()
);
try {
refreshCardSync(credentials.cardId(), credentials.cardToken(), "", false, false);
StoredCard updatedCard = database.loadCards().stream()
.filter(c -> c.cardId().equals(credentials.cardId()))
.findFirst()
.orElse(null);
if (updatedCard != null) {
newBalance = updatedCard.balance();
}
} catch (Exception e) {
System.err.println("[SPMEGA] Failed to refresh card balance after transaction: " + e.getMessage());
}
} else {
SPWorldsApiClient.TransactionResult result = apiClient.createTransaction(
toApiAuth(credentials),
draft.recipient(),
draft.amount(),
draft.comment()
);
newBalance = result.balance();
database.updateCardBalance(credentials.cardId(), newBalance);
}
database.insertTransferHistory(
credentials.cardId(),
draft.recipient(),
draft.amount(),
draft.comment(),
newBalance,
"SUCCESS"
);
reloadCardsFromDb();
lastMessage = "Перевод выполнен";
long durationMs = (System.nanoTime() - startNs) / 1_000_000L;
recordPaymentEvent(draft, true, mode, destination, durationMs, senderLast4);
return true;
} catch (Exception exception) {
database.insertTransferHistory(
credentials.cardId(),
draft.recipient(),
draft.amount(),
draft.comment(),
null,
"FAILED: " + trimMessage(exception.getMessage())
);
lastMessage = "Ошибка перевода: " + exception.getMessage();
long durationMs = (System.nanoTime() - startNs) / 1_000_000L;
recordPaymentEvent(draft, false, mode, destination, durationMs, senderLast4, exception.getMessage());
return false;
}
});
return refreshCardAsync(credentials.cardId(), credentials.cardToken(), playerUuid, false, false)
.thenApply(ignored -> {
reloadCardsFromDb();
return lastMessage.isBlank() ? "Карта обновлена" : lastMessage;
});
});
}
private void recordPaymentEvent(PaymentDraft draft, boolean success, String mode, String destination, long durationMs, String senderLast4) {
@@ -343,17 +250,73 @@ public final class BankUiService {
TelemetryCollector.instance().record(TelemetryEvent.now("payment", payload));
}
private boolean refreshCardSync(
public CompletableFuture<Boolean> submitPaymentAsync(PaymentDraft draft) {
return CompletableFuture.supplyAsync(() -> database.getCredentials(draft.senderCardId()))
.thenCompose(credentials -> {
if (credentials == null) {
lastMessage = "Не найдены креды карты отправителя";
recordPaymentEvent(draft, false, "local", "no_credentials", 0, null);
return CompletableFuture.completedFuture(false);
}
ModConfig config = SPMega.getConfig();
boolean useBackend = config != null && config.allowBackend();
String mode = useBackend ? "backend" : "spworlds-direct";
String destination = useBackend ? "backend" : "local-db";
String senderLast4 = extractLastDigits(credentials.cardId());
long startNs = System.nanoTime();
CompletableFuture<Long> transaction = useBackend
? BackendAuthenticator.createTransactionOnBackendAsync(
draft.senderCardId(), draft.recipient(), draft.amount(), draft.comment())
.thenCompose(ignored -> refreshCardAsync(
credentials.cardId(), credentials.cardToken(), "", false, false))
.thenApply(ignored -> database.loadCards().stream()
.filter(card -> card.cardId().equals(credentials.cardId()))
.findFirst()
.map(StoredCard::balance)
.orElse(0L))
: apiClient.createTransactionAsync(
toApiAuth(credentials), draft.recipient(), draft.amount(), draft.comment())
.thenApply(result -> {
database.updateCardBalance(credentials.cardId(), result.balance());
return result.balance();
});
return transaction
.thenApply(newBalance -> {
database.insertTransferHistory(
credentials.cardId(), draft.recipient(), draft.amount(), draft.comment(),
newBalance, "SUCCESS");
reloadCardsFromDb();
lastMessage = "Перевод выполнен";
recordPaymentEvent(draft, true, mode, destination,
(System.nanoTime() - startNs) / 1_000_000L, senderLast4);
return true;
})
.exceptionally(exception -> {
String error = rootMessage(exception);
database.insertTransferHistory(
credentials.cardId(), draft.recipient(), draft.amount(), draft.comment(),
null, "FAILED: " + trimMessage(error));
lastMessage = "Ошибка перевода: " + error;
recordPaymentEvent(draft, false, mode, destination,
(System.nanoTime() - startNs) / 1_000_000L, senderLast4, error);
return false;
});
});
}
private CompletableFuture<Boolean> refreshCardAsync(
String cardId,
String cardToken,
String playerUuid,
boolean reportOwnerWarning,
boolean requireCardInAccount
) {
try {
SPWorldsApiClient.CardAuth auth = new SPWorldsApiClient.CardAuth(cardId, cardToken);
SPWorldsApiClient.AccountMe me = apiClient.getAccountMe(auth);
SPWorldsApiClient.CardInfo cardInfo = apiClient.getCardInfo(auth);
SPWorldsApiClient.CardAuth auth = new SPWorldsApiClient.CardAuth(cardId, cardToken);
return apiClient.getAccountMeAsync(auth)
.thenCompose(me -> apiClient.getCardInfoAsync(auth).thenApply(cardInfo -> {
SPWorldsApiClient.AccountCard currentCard = me.cards().stream()
.filter(card -> Objects.equals(card.id(), cardId))
@@ -378,17 +341,67 @@ public final class BankUiService {
return true;
}
}
git.yawaflua.tech.spmega.ModConfig config = git.yawaflua.tech.spmega.SPMega.getConfig();
ModConfig config = SPMega.getConfig();
if (config != null && config.allowBackend()) {
BackendAuthenticator.sendCardToBackend(cardId, cardToken);
}
lastMessage = "";
return true;
} catch (Exception exception) {
lastMessage = "Не удалось обновить карту: " + exception.getMessage();
return false;
}))
.exceptionally(exception -> {
lastMessage = "Не удалось обновить карту: " + rootMessage(exception);
return false;
});
}
public CompletableFuture<String> registerSelectedWebhookAsync() {
CardViewModel selected = getSelectedCard();
if (selected == null) {
return CompletableFuture.completedFuture("Сначала выбери или добавь карту");
}
if (selected.webhookEnabled()) {
return CompletableFuture.completedFuture("Вебхук для этой карты уже включён");
}
return BackendAuthenticator.registerWebhookForCardAsync(selected.id())
.thenApply(ignored -> {
database.setWebhookEnabled(selected.id(), true);
reloadCardsFromDb();
return "Обработка вебхуков включена";
})
.exceptionally(exception -> "Не удалось включить вебхук: " + rootMessage(exception));
}
public boolean hasWebhookEnabledCards() {
return database.hasWebhookEnabledCards();
}
private CompletableFuture<Boolean> syncMissingCardsAsync(List<CardCredentials> backendCards, String playerUuid) {
CompletableFuture<Boolean> result = CompletableFuture.completedFuture(false);
for (CardCredentials credentials : backendCards) {
result = result.thenCompose(changed -> {
if (database.getCredentials(credentials.cardId()) != null) {
database.setWebhookEnabled(credentials.cardId(), credentials.webhookEnabled());
return CompletableFuture.completedFuture(changed);
}
database.upsertCardCredentials(credentials.cardId(), credentials.cardToken());
database.setWebhookEnabled(credentials.cardId(), credentials.webhookEnabled());
return refreshCardAsync(
credentials.cardId(), credentials.cardToken(), playerUuid, false, false)
.thenApply(ignored -> true);
});
}
return result;
}
private CompletableFuture<Void> refreshStoredCardsAsync(String playerUuid) {
CompletableFuture<Void> result = CompletableFuture.completedFuture(null);
for (StoredCard card : database.loadCards()) {
result = result.thenCompose(ignored -> refreshCardAsync(
card.cardId(), card.cardToken(), playerUuid, false, false).thenApply(refreshed -> null));
}
return result;
}
private void reloadCardsFromDb() {
@@ -401,7 +414,8 @@ public final class BankUiService {
: stored.cardNumber();
String cardName = stored.cardName() == null || stored.cardName().isBlank() ? "Карта" : stored.cardName();
String title = cardNumber + ": " + cardName;
cards.add(new CardViewModel(stored.cardId(), title, stored.balance()));
cards.add(new CardViewModel(
stored.cardId(), title, stored.balance(), stored.webhookEnabled()));
}
if (cards.isEmpty()) {
@@ -1,5 +1,8 @@
package git.yawaflua.tech.spmega.client.ui.service;
public record CardCredentials(String cardId, String cardToken) {
public record CardCredentials(String cardId, String cardToken, boolean webhookEnabled) {
public CardCredentials(String cardId, String cardToken) {
this(cardId, cardToken, false);
}
}
@@ -1,5 +1,5 @@
package git.yawaflua.tech.spmega.client.ui.service;
public record CardViewModel(String id, String title, long balance) {
public record CardViewModel(String id, String title, long balance, boolean webhookEnabled) {
}
@@ -1,6 +1,6 @@
package git.yawaflua.tech.spmega.client.ui.service;
public record StoredCard(String cardId, String cardToken, String cardName, String cardNumber, long balance,
String ownerUuid) {
String ownerUuid, boolean webhookEnabled) {
}
@@ -29,6 +29,13 @@
"option.spmega.gps_position.bottom_right": "Bottom-Right",
"option.spmega.gps_position.bottom_center": "Bottom-center",
"option.spmega.gps_position.top_center": "Top-center",
"option.spmega.notification_position": "Notification position",
"option.spmega.notification_position.top_left": "Top-left",
"option.spmega.notification_position.top_right": "Top-right",
"option.spmega.notification_position.bottom_left": "Bottom-left",
"option.spmega.notification_position.bottom_right": "Bottom-right",
"option.spmega.notification_position.bottom_center": "Bottom-center",
"option.spmega.notification_position.top_center": "Top-center",
"category.spmega.telemetry": "Telemetry",
"option.spmega.telemetry_enabled": "Collect system information",
"tooltip.spmega.telemetry_enabled": "Disabling hides hardware data (CPU, GPU, RAM, IP). Performance and request stats keep sending.",
@@ -29,6 +29,13 @@
"option.spmega.gps_position.bottom_center": "Снизу по-центру",
"option.spmega.gps_position.top_center": "Сверху по-центру",
"option.spmega.gps_position.bottom_right": "Снизу справа",
"option.spmega.notification_position": "Положение уведомлений",
"option.spmega.notification_position.top_left": "Слева вверху",
"option.spmega.notification_position.top_right": "Справа вверху",
"option.spmega.notification_position.bottom_left": "Слева внизу",
"option.spmega.notification_position.bottom_center": "Снизу по центру",
"option.spmega.notification_position.top_center": "Сверху по центру",
"option.spmega.notification_position.bottom_right": "Снизу справа",
"category.spmega.telemetry": "Телеметрия",
"option.spmega.telemetry_enabled": "Сбор системной информации",
"tooltip.spmega.telemetry_enabled": "Отключение скрывает данные компьютера (CPU, GPU, RAM, IP). Статистика производительности и запросов продолжает отправляться.",
@@ -37,4 +44,3 @@
"option.spmega.telemetry_system_info": "Отправлять информацию о системе",
"tooltip.spmega.telemetry_system_info": "CPU, GPU, RAM, диск, IP, имя устройства, версия Java"
}
@@ -43,7 +43,7 @@ public final class ConfigManager {
}
boolean allowAccess = readBoolean(properties, "allow.backend", defaults.allowBackend());
String rawAllowAccess = properties.getProperty("sign.quickPay.enabled");
String rawAllowAccess = properties.getProperty("allow.backend");
if (rawAllowAccess == null || !Boolean.toString(allowAccess).equalsIgnoreCase(rawAllowAccess.trim())) {
shouldSave = true;
}
@@ -66,6 +66,14 @@ public final class ConfigManager {
shouldSave = true;
}
GpsHudPosition notificationPosition = readEnum(
properties, "notifications.position", GpsHudPosition.class, defaults.notificationPosition());
String rawNotificationPosition = properties.getProperty("notifications.position");
if (rawNotificationPosition == null
|| !notificationPosition.name().equalsIgnoreCase(rawNotificationPosition.trim())) {
shouldSave = true;
}
boolean telemetryEnabled = readBoolean(properties, "telemetry.enabled", defaults.telemetryEnabled());
String rawTelemetry = properties.getProperty("telemetry.enabled");
if (rawTelemetry == null || !Boolean.toString(telemetryEnabled).equalsIgnoreCase(rawTelemetry.trim())) {
@@ -85,6 +93,7 @@ public final class ConfigManager {
}
ModConfig config = new ModConfig(apiDomain, apiToken, allowAccess, signQuickPayEnabled, gpsEnabled, gpsPosition,
notificationPosition,
telemetryEnabled, telemetryIntervalSeconds, telemetryCollectSystemInfo);
@@ -145,9 +154,11 @@ public final class ConfigManager {
Properties properties = new Properties();
properties.setProperty("api.domain", config.apiDomain());
properties.setProperty("api.token", config.apiToken());
properties.setProperty("allow.backend", Boolean.toString(config.allowBackend()));
properties.setProperty("sign.quickPay.enabled", Boolean.toString(config.signQuickPayEnabled()));
properties.setProperty("gps.enabled", Boolean.toString(config.gpsEnabled()));
properties.setProperty("gps.position", config.gpsPosition().name());
properties.setProperty("notifications.position", config.notificationPosition().name());
properties.setProperty("telemetry.enabled", Boolean.toString(config.telemetryEnabled()));
properties.setProperty("telemetry.intervalSeconds", Integer.toString(config.telemetryIntervalSeconds()));
properties.setProperty("telemetry.collectSystemInfo", Boolean.toString(config.telemetryCollectSystemInfo()));
@@ -2,6 +2,7 @@ package git.yawaflua.tech.spmega;
public record ModConfig(String apiDomain, String apiToken, boolean allowBackend, boolean signQuickPayEnabled,
boolean gpsEnabled, GpsHudPosition gpsPosition,
GpsHudPosition notificationPosition,
boolean telemetryEnabled, int telemetryIntervalSeconds, boolean telemetryCollectSystemInfo) {
public static final String DEFAULT_API_DOMAIN = "https://spmega.yawaflua.tech";
public static final boolean ALLOW_BACKEND = true;
@@ -9,6 +10,7 @@ public record ModConfig(String apiDomain, String apiToken, boolean allowBackend,
public static final boolean DEFAULT_SIGN_QUICK_PAY_ENABLED = true;
public static final boolean DEFAULT_GPS_ENABLED = true;
public static final GpsHudPosition DEFAULT_GPS_POSITION = GpsHudPosition.TOP_CENTER;
public static final GpsHudPosition DEFAULT_NOTIFICATION_POSITION = GpsHudPosition.BOTTOM_RIGHT;
public static final boolean DEFAULT_TELEMETRY_ENABLED = true;
public static final int DEFAULT_TELEMETRY_INTERVAL_SECONDS = 60;
public static final boolean DEFAULT_TELEMETRY_COLLECT_SYSTEM_INFO = true;
@@ -21,6 +23,7 @@ public record ModConfig(String apiDomain, String apiToken, boolean allowBackend,
DEFAULT_SIGN_QUICK_PAY_ENABLED,
DEFAULT_GPS_ENABLED,
DEFAULT_GPS_POSITION,
DEFAULT_NOTIFICATION_POSITION,
DEFAULT_TELEMETRY_ENABLED,
DEFAULT_TELEMETRY_INTERVAL_SECONDS,
DEFAULT_TELEMETRY_COLLECT_SYSTEM_INFO