From f19a5d3586d1be910e193c8f41b1c380e9027d31 Mon Sep 17 00:00:00 2001 From: Dmitrii Date: Sun, 12 Jul 2026 06:27:28 +0300 Subject: [PATCH] Add webhook support for card transactions and notifications. Some optimizations and fixes Signed-off-by: Dmitrii Took 2 hours 4 minutes --- .../Controllers/v1/AuthController.cs | 25 +- .../Controllers/v1/TelemetryController.cs | 5 +- .../Controllers/v1/TransactionsController.cs | 30 +- .../Controllers/v1/WebhookController.cs | 195 ++++++++++ .../Persistent/Database/AppDbContext.cs | 3 +- .../Models/Transactions/Notification.cs | 21 ++ .../Models/Transactions/Transaction.cs | 11 +- .../Persistent/Models/Users/Card.cs | 2 + .../Persistent/Models/Users/User.cs | 7 +- .../Persistent/Models/Users/UserSession.cs | 6 +- backend/SpMega.Backend/Program.cs | 28 +- .../Debug/net10.0/SpMega.Backend.deps.json | 317 ---------------- .../net10.0/SpMega.Backend.runtimeconfig.json | 20 - ...ega.Backend.staticwebassets.endpoints.json | 5 - gradle.properties | 2 +- .../spmega/client/ModMenuIntegration.java | 23 +- .../tech/spmega/client/SPMegaClient.java | 7 +- .../client/WebhookNotificationPoller.java | 72 ++++ .../spmega/client/api/SPWorldsApiClient.java | 201 +++++----- .../tech/spmega/client/qr/QRCodeScanner.java | 97 +++-- .../telemetry/InstrumentedHttpClient.java | 54 +-- .../client/telemetry/SystemInfoCollector.java | 34 +- .../tech/spmega/client/ui/CardScreen.java | 32 +- .../tech/spmega/client/ui/GpsHudRenderer.java | 63 ++-- .../tech/spmega/client/ui/MainBankScreen.java | 2 +- .../client/ui/TransactionHistoryScreen.java | 79 ++-- .../spmega/client/ui/UiNotifications.java | 34 +- .../ui/service/BackendAuthenticator.java | 298 +++++++++------ .../client/ui/service/BankDatabase.java | 42 ++- .../client/ui/service/BankUiService.java | 350 +++++++++--------- .../client/ui/service/CardCredentials.java | 5 +- .../client/ui/service/CardViewModel.java | 2 +- .../spmega/client/ui/service/StoredCard.java | 2 +- .../resources/assets/spmega/lang/en_us.json | 7 + .../resources/assets/spmega/lang/ru_ru.json | 8 +- .../yawaflua/tech/spmega/ConfigManager.java | 13 +- .../git/yawaflua/tech/spmega/ModConfig.java | 3 + 37 files changed, 1155 insertions(+), 950 deletions(-) create mode 100644 backend/SpMega.Backend/Controllers/v1/WebhookController.cs create mode 100644 backend/SpMega.Backend/Persistent/Models/Transactions/Notification.cs delete mode 100644 backend/SpMega.Backend/bin/Debug/net10.0/SpMega.Backend.deps.json delete mode 100644 backend/SpMega.Backend/bin/Debug/net10.0/SpMega.Backend.runtimeconfig.json delete mode 100644 backend/SpMega.Backend/bin/Debug/net10.0/SpMega.Backend.staticwebassets.endpoints.json create mode 100644 src/client/java/git/yawaflua/tech/spmega/client/WebhookNotificationPoller.java diff --git a/backend/SpMega.Backend/Controllers/v1/AuthController.cs b/backend/SpMega.Backend/Controllers/v1/AuthController.cs index 6d9b7b0..322ca02 100644 --- a/backend/SpMega.Backend/Controllers/v1/AuthController.cs +++ b/backend/SpMega.Backend/Controllers/v1/AuthController.cs @@ -71,13 +71,35 @@ public class AuthController(AppDbContext dbContext, TokenService tokenService, I var dto = await resp.Content.ReadFromJsonAsync(); if (dto == null || dto.Name != session.UserName || Guid.Parse(dto.Id) != session.UserId) throw new Exception("Session expired, or dto is not acceptable."); + + var token = tokenService.GenerateAccessToken(session.UserName, body.userUUID); var user = await dbContext.Users.FirstOrDefaultAsync(k => k.Id == session.UserId); + + var shortId = string.Empty; + + if (user?.ShortId == null) + { + shortId = Program.GenerateRandomString(2); + while (true) + { + var a = await dbContext.Users.FirstOrDefaultAsync(k => k.ShortId == shortId); + if (a != null) + { + shortId = Program.GenerateRandomString(2); + } + else + { + break; + } + } + } if (user != null) { user.Token = token; user.Username = session.UserName; user.UpdatedAt = DateTime.UtcNow; + user.ShortId ??= shortId; dbContext.Update(user); } @@ -88,7 +110,8 @@ public class AuthController(AppDbContext dbContext, TokenService tokenService, I Id = body.userUUID, Username = session.UserName, Token = token, - UpdatedAt = DateTime.UtcNow + UpdatedAt = DateTime.UtcNow, + ShortId = shortId, }; await dbContext.AddAsync(user); } diff --git a/backend/SpMega.Backend/Controllers/v1/TelemetryController.cs b/backend/SpMega.Backend/Controllers/v1/TelemetryController.cs index 74d4f0c..acf7273 100644 --- a/backend/SpMega.Backend/Controllers/v1/TelemetryController.cs +++ b/backend/SpMega.Backend/Controllers/v1/TelemetryController.cs @@ -14,7 +14,7 @@ public class TelemetryController : ControllerBase private static readonly ActivitySource Source = new("SpMega.ModTelemetry"); [HttpPost] - [Authorize] + [AllowAnonymous] public IActionResult Post([FromBody] ModTelemetryBatchDto batch) { if (batch?.Events == null || batch.Events.Count == 0) @@ -28,7 +28,7 @@ public class TelemetryController : ControllerBase foreach (var e in batch.Events) { - using var activity = Source.StartActivity("mod.telemetry.event", ActivityKind.Internal); + using var activity = Source.StartActivity("mod.telemetry.event", ActivityKind.Client); if (activity is null) continue; activity.SetTag("user.id", userId); @@ -38,6 +38,7 @@ public class TelemetryController : ControllerBase activity.SetTag("event.timestamp", e.Timestamp.ToString("O")); activity.SetTag("event.payload", JsonSerializer.Serialize(e.Payload)); activity.SetTag("batch.sent_at", batch.SentAt.ToString("O")); + } return Ok(new { received = batch.Events.Count }); diff --git a/backend/SpMega.Backend/Controllers/v1/TransactionsController.cs b/backend/SpMega.Backend/Controllers/v1/TransactionsController.cs index 8280036..06d0762 100644 --- a/backend/SpMega.Backend/Controllers/v1/TransactionsController.cs +++ b/backend/SpMega.Backend/Controllers/v1/TransactionsController.cs @@ -37,12 +37,7 @@ public class TransactionsController(AppDbContext context, ILogger k.ShortId == shortId); + var a = await context.Transactions.FirstOrDefaultAsync(k => k.ShortId == shortId && EF.Property(k, "SenderId") == user.Id); if (a != null) { - shortId = Program.GenerateRandomString(5); + shortId = Program.GenerateRandomString(2); } else { @@ -92,24 +87,23 @@ public class TransactionsController(AppDbContext context, ILogger { { "receiver", body.receiverCard }, { "amount", body.amount }, - { "comment", (body.comment)[8..] + "..;Чек:"+ uri } + { "comment", (body.comment)[10..] + "..;Чек:"+ uri } }; - Console.WriteLine(((body.comment)[8..] + "..;Чек:"+ uri).Length); - Console.WriteLine(((body.comment)[8..] + "..;Чек:"+ uri)); var resp = await SendRequest(endpoint: "transactions", body: transitionInfo, AuthHeader: new("Bearer", cardToUse.Token)); var balance = (int?)JsonNode.Parse(resp)?["balance"]; if (balance == null) @@ -131,6 +125,12 @@ public class TransactionsController(AppDbContext context, ILogger logger, IConfiguration config) : ControllerBase +{ + private const string BASE_URL = "https://spworlds.ru/api/public/"; + + + [HttpPut("{cardId}")] + [Authorize] + public async Task RegisterWebhookForCard(Guid cardId) + { + var user = HttpContext.Items["@me"] as User; + if (user == null) return Unauthorized(); + + var cardToUse = user.Cards.FirstOrDefault(c => c.Id == cardId); + if (cardToUse == null) + return BadRequest("Card not found"); + + var shortId = Program.GenerateRandomString(6); + while (true) + { + var a = user.Cards.FirstOrDefault(k => k.ShortId == shortId); + if (a != null) + { + shortId = Program.GenerateRandomString(6); + } + else + { + break; + } + } + cardToUse.ShortId = shortId; + cardToUse.WebhookConnected = true; + + await dbContext.SaveChangesAsync(); + + var webhookUrl = new UriBuilder(); + webhookUrl.Scheme = "https"; + webhookUrl.Host = config["Url"]; + webhookUrl.Path = $"api/v1/webhook/{user.ShortId}/{cardToUse.ShortId}/{cardId}"; + + var resp = await SendRequest($"card/webhook", new AuthenticationHeaderValue("Bearer", cardToUse.Token), HttpMethod.Put, new { url = webhookUrl.ToString() }); + var jsonResponse = JsonSerializer.Deserialize(resp); + if (jsonResponse != null && jsonResponse.TryGetPropertyValue("id", out var respId) + && respId?.GetValue() == cardId) return Ok(); + logger.LogError("Failed to register webhook for card {CardId}. Response: {Response}", cardId, resp); + cardToUse.WebhookConnected = false; + + await dbContext.SaveChangesAsync(); + return BadRequest("Failed to register webhook for card"); + } + + [HttpPost("{userShortId}/{cardShortId}/{cardId}")] + public async Task ReceiveWebhook(string userShortId, string cardShortId, Guid cardId) + { + var user = await dbContext.Users.FirstOrDefaultAsync(k => k.ShortId == userShortId); + if (user == null) + return BadRequest("User not found"); + var cardToUse = user.Cards.FirstOrDefault(c => c.ShortId == cardShortId && c.Id == cardId && c.WebhookConnected); + if (cardToUse == null) + return BadRequest("Card not found"); + + var bodyHash = Request.Headers["X-Body-Hash"].ToString(); + var rawBody = await new StreamReader(Request.Body).ReadToEndAsync(); + var keyBytes = Encoding.UTF8.GetBytes(cardToUse.Token); + var messageBytes = Encoding.UTF8.GetBytes(rawBody); + + using var hmac = new HMACSHA256(keyBytes); + var hashBytes = hmac.ComputeHash(messageBytes); + + byte[] receivedHash; + try + { + receivedHash = Convert.FromBase64String(bodyHash); + } + catch (FormatException) + { + return BadRequest("Invalid body hash"); + } + + if (!CryptographicOperations.FixedTimeEquals(hashBytes, receivedHash)) + { + logger.LogError("Invalid body hash for webhook. Expected: {ExpectedHash}, Received: {ReceivedHash}", Convert.ToBase64String(hashBytes), bodyHash); + return BadRequest("Invalid body hash"); + } + + var body = JsonSerializer.Deserialize(rawBody, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + if (body == null || !Guid.TryParse(body.Id, out var notificationId)) + return BadRequest("Invalid webhook body"); + + if (await dbContext.Notifications.FirstOrDefaultAsync(notification => notification.Id == notificationId) != null) + return Ok(); + + var notify = new Notification + { + Id = notificationId, + ReceiverId = user.Id, + ReceiverName = body.Receiver.Username, + ReceiverNumber = body.Receiver.Number, + SenderName = body.Sender.Username, + SenderNumber = body.Sender.Number, + Comment = body.Comment, + Amount = body.Amount, + Type = body.Type, + IsRead = false, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + await dbContext.Notifications.AddAsync(notify); + await dbContext.SaveChangesAsync(); + return Ok(); + } + + [HttpGet("read")] + [Authorize] + public async Task>> ReadAllNotifications() + { + var user = HttpContext.Items["@me"] as User; + if (user == null) return Unauthorized(); + + var notifications = await dbContext.Notifications + .Where(notification => notification.ReceiverId == user.Id && !notification.IsRead) + .ToListAsync(); + foreach (var notification in notifications) + { + notification.IsRead = true; + notification.UpdatedAt = DateTime.UtcNow; + } + if (notifications.Count > 0) await dbContext.SaveChangesAsync(); + return Ok(notifications); + } + + [HttpGet("all")] + [Authorize] + public async Task>> GetAllNotifications([FromQuery] int after, [FromQuery] int limit = 20) + { + var user = HttpContext.Items["@me"] as User; + if (user == null) return Unauthorized(); + var notification = await dbContext.Notifications.Where(k => k.ReceiverId == user.Id).Skip(after).Take(limit).ToListAsync(); + return Ok(notification); + } + + [NonAction] + internal async Task SendRequest(string endpoint, AuthenticationHeaderValue AuthHeader, HttpMethod method = null, object body = null) + { + method ??= body == null ? HttpMethod.Get : HttpMethod.Post; + + HttpResponseMessage message; + var client = new System.Net.Http.HttpClient(); + + using (var requestMessage = new HttpRequestMessage(method, BASE_URL + endpoint)) + { + requestMessage.Content = new StringContent( + JsonSerializer.Serialize(body), + Encoding.UTF8, "application/json" + ); + + requestMessage.Headers.Authorization = AuthHeader; + + message = await client.SendAsync(requestMessage); + + } + + client.Dispose(); + + return await message.Content.ReadAsStringAsync(); + } +} + +public record Webhook( + string Id, + int Amount, + string Type, + WebhookUser Sender, + WebhookUser Receiver, + string Comment, + string CreatedAt +); + +public record WebhookUser(string Username, string Number); diff --git a/backend/SpMega.Backend/Persistent/Database/AppDbContext.cs b/backend/SpMega.Backend/Persistent/Database/AppDbContext.cs index ba6c220..11d473f 100644 --- a/backend/SpMega.Backend/Persistent/Database/AppDbContext.cs +++ b/backend/SpMega.Backend/Persistent/Database/AppDbContext.cs @@ -12,8 +12,7 @@ public class AppDbContext(DbContextOptions options) : DbContext(op internal DbSet Transactions { get; set; } internal DbSet Users { get; set; } internal DbSet UserSessions { get; set; } - internal DbSet ModTelemetry { get; set; } - internal DbSet BackendRequestTelemetry { get; set; } + internal DbSet Notifications { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { diff --git a/backend/SpMega.Backend/Persistent/Models/Transactions/Notification.cs b/backend/SpMega.Backend/Persistent/Models/Transactions/Notification.cs new file mode 100644 index 0000000..f6a0f25 --- /dev/null +++ b/backend/SpMega.Backend/Persistent/Models/Transactions/Notification.cs @@ -0,0 +1,21 @@ +using SpMega.Backend.Persistent.Models.Users; + +namespace SpMega.Backend.Persistent.Models.Transactions; + +public class Notification +{ + public Guid Id { get; set; } + public Guid ReceiverId { get; set; } + public string ReceiverName { get; set; } + public string ReceiverNumber { get; set; } + public string SenderName { get; set; } + public string SenderNumber { get; set; } + + public string Comment { get; set; } + public int Amount { get; set; } + public string Type { get; set; } + + public bool IsRead { get; set; } = false; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} \ No newline at end of file diff --git a/backend/SpMega.Backend/Persistent/Models/Transactions/Transaction.cs b/backend/SpMega.Backend/Persistent/Models/Transactions/Transaction.cs index b58581d..994f7e6 100644 --- a/backend/SpMega.Backend/Persistent/Models/Transactions/Transaction.cs +++ b/backend/SpMega.Backend/Persistent/Models/Transactions/Transaction.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; using SpMega.Backend.Persistent.Models.Users; @@ -7,13 +8,15 @@ public class Transaction { public Guid Id { get; set; } public string ShortId { get; set; } = Program.GenerateRandomString(8); - public string ReceiverName { get; set; } - public string ReceiverCardNumber { get; set; } + [MaxLength(2048)] public string ReceiverName { get; set; } + [MaxLength(2048)] public string ReceiverCardNumber { get; set; } [JsonIgnore] public User Sender { get; set; } - public string SenderCardNumber { get; set; } + + [MaxLength(2048)] public string SenderMinecraftName { get; set; } + [MaxLength(2048)] public string SenderCardNumber { get; set; } public int Amount { get; set; } = 0; - public string Comment { get; set; } = ""; + [MaxLength(2048)] public string Comment { get; set; } = ""; public DateTime TransactionDate { get; set; } = DateTime.UtcNow; } \ No newline at end of file diff --git a/backend/SpMega.Backend/Persistent/Models/Users/Card.cs b/backend/SpMega.Backend/Persistent/Models/Users/Card.cs index c8fb275..19564f3 100644 --- a/backend/SpMega.Backend/Persistent/Models/Users/Card.cs +++ b/backend/SpMega.Backend/Persistent/Models/Users/Card.cs @@ -9,6 +9,8 @@ public class Card public string Name { get; set; } public string SpworldsID { get; set; } public string Token { get; set; } + public string ShortId { get; set; } = ""; + public bool WebhookConnected { get; set; } = false; public int Balance { get; set; } = 0; diff --git a/backend/SpMega.Backend/Persistent/Models/Users/User.cs b/backend/SpMega.Backend/Persistent/Models/Users/User.cs index 633f5a5..84f0d7d 100644 --- a/backend/SpMega.Backend/Persistent/Models/Users/User.cs +++ b/backend/SpMega.Backend/Persistent/Models/Users/User.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using SpMega.Backend.Persistent.Models.Transactions; namespace SpMega.Backend.Persistent.Models.Users; @@ -5,8 +6,10 @@ namespace SpMega.Backend.Persistent.Models.Users; public class User { public Guid Id { get; set; } - public string Username { get; set; } - public string Token { get; set; } + [MaxLength(2048)] public string Username { get; set; } + [MaxLength(2048)] public string Token { get; set; } + + [MaxLength(2048)] public string? ShortId { get; set; } public List Cards { get; set; } = []; public List Transactions { get; set; } = []; diff --git a/backend/SpMega.Backend/Persistent/Models/Users/UserSession.cs b/backend/SpMega.Backend/Persistent/Models/Users/UserSession.cs index e881008..6c9f0a7 100644 --- a/backend/SpMega.Backend/Persistent/Models/Users/UserSession.cs +++ b/backend/SpMega.Backend/Persistent/Models/Users/UserSession.cs @@ -1,12 +1,14 @@ +using System.ComponentModel.DataAnnotations; + namespace SpMega.Backend.Persistent.Models.Users; public class UserSession { public Guid Id { get; set; } - public string Token { get; set; } + [MaxLength(2048)] public string Token { get; set; } public User? User { get; set; } = null; public Guid UserId { get; set; } - public string UserName { get; set; } + [MaxLength(2048)] public string UserName { get; set; } public DateTime CreatedAt { get; init; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } diff --git a/backend/SpMega.Backend/Program.cs b/backend/SpMega.Backend/Program.cs index d292eb7..1571552 100644 --- a/backend/SpMega.Backend/Program.cs +++ b/backend/SpMega.Backend/Program.cs @@ -3,6 +3,8 @@ using System.Text; using Microsoft.AspNetCore.Authorization; using Microsoft.EntityFrameworkCore; using MongoDB.Driver; +using OpenTelemetry.Exporter; +using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; using SpMega.Backend.Authetication; @@ -68,7 +70,10 @@ public class Program options.UseMongoDB(mongoClient, "spmega"); }); - var otlpEndpoint = conf["Otlp:Endpoint"] ?? "http://localhost:4317"; + var otlpEndpoint = (conf["Otlp__Endpoint"] ?? "http://curiosity:5080/api/default").TrimEnd('/'); + var otlpHeaders = conf["Otlp__Headers"] ?? throw new InvalidOperationException("Otlp__Headers is not configured."); + var tracesEndpoint = new Uri($"{otlpEndpoint}/v1/traces"); + var metricsEndpoint = new Uri($"{otlpEndpoint}/v1/metrics"); builder.Services.AddOpenTelemetry() .ConfigureResource(res => res.AddService("SpMega.Backend")) .WithTracing(tracing => @@ -79,9 +84,24 @@ public class Program .AddAspNetCoreInstrumentation() .AddOtlpExporter(opts => { - opts.Endpoint = new Uri(otlpEndpoint); + opts.Protocol = OtlpExportProtocol.HttpProtobuf; + opts.Endpoint = tracesEndpoint; + opts.Headers = otlpHeaders; }); - }); + }) + .WithMetrics(metrics => metrics + .AddAspNetCoreInstrumentation() + .AddMeter( + "Microsoft.AspNetCore.Hosting", + "Microsoft.AspNetCore.Server.Kestrel", + "System.Net.Http", + "System.Runtime") + .AddOtlpExporter(opts => + { + opts.Protocol = OtlpExportProtocol.HttpProtobuf; + opts.Endpoint = metricsEndpoint; + opts.Headers = otlpHeaders; + })); var app = builder.Build(); @@ -113,4 +133,4 @@ public class Program return result.ToString(); } -} \ No newline at end of file +} diff --git a/backend/SpMega.Backend/bin/Debug/net10.0/SpMega.Backend.deps.json b/backend/SpMega.Backend/bin/Debug/net10.0/SpMega.Backend.deps.json deleted file mode 100644 index 5d32a6c..0000000 --- a/backend/SpMega.Backend/bin/Debug/net10.0/SpMega.Backend.deps.json +++ /dev/null @@ -1,317 +0,0 @@ -{ - "runtimeTarget": { - "name": ".NETCoreApp,Version=v10.0", - "signature": "" - }, - "compilationOptions": {}, - "targets": { - ".NETCoreApp,Version=v10.0": { - "SpMega.Backend/1.0.0": { - "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "10.0.9", - "Microsoft.EntityFrameworkCore.Relational": "10.0.9", - "Microsoft.IdentityModel.Tokens": "8.19.1", - "MongoDB.EntityFrameworkCore": "10.0.2", - "System.IdentityModel.Tokens.Jwt": "8.19.1", - "spworlds-api": "1.0.3" - }, - "runtime": { - "SpMega.Backend.dll": {} - } - }, - "DnsClient/1.6.1": { - "runtime": { - "lib/net5.0/DnsClient.dll": { - "assemblyVersion": "1.6.1.0", - "fileVersion": "1.6.1.0" - } - } - }, - "Microsoft.Bcl.Cryptography/10.0.2": { - "runtime": { - "lib/net10.0/Microsoft.Bcl.Cryptography.dll": { - "assemblyVersion": "10.0.0.2", - "fileVersion": "10.0.225.61305" - } - } - }, - "Microsoft.EntityFrameworkCore/10.0.9": { - "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "10.0.9" - }, - "runtime": { - "lib/net10.0/Microsoft.EntityFrameworkCore.dll": { - "assemblyVersion": "10.0.9.0", - "fileVersion": "10.0.926.27113" - } - } - }, - "Microsoft.EntityFrameworkCore.Abstractions/10.0.9": { - "runtime": { - "lib/net10.0/Microsoft.EntityFrameworkCore.Abstractions.dll": { - "assemblyVersion": "10.0.9.0", - "fileVersion": "10.0.926.27113" - } - } - }, - "Microsoft.EntityFrameworkCore.Relational/10.0.9": { - "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.9" - }, - "runtime": { - "lib/net10.0/Microsoft.EntityFrameworkCore.Relational.dll": { - "assemblyVersion": "10.0.9.0", - "fileVersion": "10.0.926.27113" - } - } - }, - "Microsoft.IdentityModel.Abstractions/8.19.1": { - "runtime": { - "lib/net10.0/Microsoft.IdentityModel.Abstractions.dll": { - "assemblyVersion": "8.19.1.0", - "fileVersion": "8.19.1.26153" - } - } - }, - "Microsoft.IdentityModel.JsonWebTokens/8.19.1": { - "dependencies": { - "Microsoft.IdentityModel.Tokens": "8.19.1" - }, - "runtime": { - "lib/net10.0/Microsoft.IdentityModel.JsonWebTokens.dll": { - "assemblyVersion": "8.19.1.0", - "fileVersion": "8.19.1.26153" - } - } - }, - "Microsoft.IdentityModel.Logging/8.19.1": { - "dependencies": { - "Microsoft.IdentityModel.Abstractions": "8.19.1" - }, - "runtime": { - "lib/net10.0/Microsoft.IdentityModel.Logging.dll": { - "assemblyVersion": "8.19.1.0", - "fileVersion": "8.19.1.26153" - } - } - }, - "Microsoft.IdentityModel.Tokens/8.19.1": { - "dependencies": { - "Microsoft.Bcl.Cryptography": "10.0.2", - "Microsoft.IdentityModel.Logging": "8.19.1" - }, - "runtime": { - "lib/net10.0/Microsoft.IdentityModel.Tokens.dll": { - "assemblyVersion": "8.19.1.0", - "fileVersion": "8.19.1.26153" - } - } - }, - "MongoDB.Bson/3.9.0": { - "runtime": { - "lib/net6.0/MongoDB.Bson.dll": { - "assemblyVersion": "3.9.0.0", - "fileVersion": "3.9.0.0" - } - } - }, - "MongoDB.Driver/3.9.0": { - "dependencies": { - "DnsClient": "1.6.1", - "MongoDB.Bson": "3.9.0", - "SharpCompress": "0.48.1", - "Snappier": "1.3.1", - "ZstdSharp.Port": "0.7.3" - }, - "runtime": { - "lib/net6.0/MongoDB.Driver.dll": { - "assemblyVersion": "3.9.0.0", - "fileVersion": "3.9.0.0" - } - } - }, - "MongoDB.EntityFrameworkCore/10.0.2": { - "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.9", - "MongoDB.Driver": "3.9.0" - }, - "runtime": { - "lib/net10.0/MongoDB.EntityFrameworkCore.dll": { - "assemblyVersion": "10.0.2.0", - "fileVersion": "10.0.2.0" - } - } - }, - "SharpCompress/0.48.1": { - "runtime": { - "lib/net10.0/SharpCompress.dll": { - "assemblyVersion": "0.48.1.0", - "fileVersion": "0.48.1.0" - } - } - }, - "Snappier/1.3.1": { - "runtime": { - "lib/net8.0/Snappier.dll": { - "assemblyVersion": "1.3.1.0", - "fileVersion": "1.3.1.0" - } - } - }, - "spworlds-api/1.0.3": { - "runtime": { - "lib/net8.0/spworlds-api.dll": { - "assemblyVersion": "1.0.3.0", - "fileVersion": "1.0.3.0" - } - } - }, - "System.IdentityModel.Tokens.Jwt/8.19.1": { - "dependencies": { - "Microsoft.IdentityModel.JsonWebTokens": "8.19.1", - "Microsoft.IdentityModel.Tokens": "8.19.1" - }, - "runtime": { - "lib/net10.0/System.IdentityModel.Tokens.Jwt.dll": { - "assemblyVersion": "8.19.1.0", - "fileVersion": "8.19.1.26153" - } - } - }, - "ZstdSharp.Port/0.7.3": { - "runtime": { - "lib/net7.0/ZstdSharp.dll": { - "assemblyVersion": "0.7.3.0", - "fileVersion": "0.7.3.0" - } - } - } - } - }, - "libraries": { - "SpMega.Backend/1.0.0": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "DnsClient/1.6.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-4H/f2uYJOZ+YObZjpY9ABrKZI+JNw3uizp6oMzTXwDw6F+2qIPhpRl/1t68O/6e98+vqNiYGu+lswmwdYUy3gg==", - "path": "dnsclient/1.6.1", - "hashPath": "dnsclient.1.6.1.nupkg.sha512" - }, - "Microsoft.Bcl.Cryptography/10.0.2": { - "type": "package", - "serviceable": true, - "sha512": "sha512-LG9Yll3B5aNpxv0+D47g6LiOiKBIlodhcHdQwcYzo8VeexFLGqx5ymetmA2aBRyo9cCcWsQWrFsdbsr8LvmWDw==", - "path": "microsoft.bcl.cryptography/10.0.2", - "hashPath": "microsoft.bcl.cryptography.10.0.2.nupkg.sha512" - }, - "Microsoft.EntityFrameworkCore/10.0.9": { - "type": "package", - "serviceable": true, - "sha512": "sha512-tu85SRzOT021V7EQlViCiAE7TqldVn469Y6lt5TEn/+XC4/MeNCHgMRSxqYuWqvF4zAQZUhCmtNEZuM3ss4LeA==", - "path": "microsoft.entityframeworkcore/10.0.9", - "hashPath": "microsoft.entityframeworkcore.10.0.9.nupkg.sha512" - }, - "Microsoft.EntityFrameworkCore.Abstractions/10.0.9": { - "type": "package", - "serviceable": true, - "sha512": "sha512-GRMaiPkqYna/gCsyDffYDWmefGPC3hDrdMw+2rrGcQwhs6uZOsaMQXMJnoXQ35tx9SkBV2ieRRU9N/jLOO6BZw==", - "path": "microsoft.entityframeworkcore.abstractions/10.0.9", - "hashPath": "microsoft.entityframeworkcore.abstractions.10.0.9.nupkg.sha512" - }, - "Microsoft.EntityFrameworkCore.Relational/10.0.9": { - "type": "package", - "serviceable": true, - "sha512": "sha512-dEoyYKgiaZHHgOFm1WMWm1sFEsEuhPWufX4L9PekKtqd/RaIcPjkCjvbrVvJtApErb5wPSJhYvnTlxhH+p9h2g==", - "path": "microsoft.entityframeworkcore.relational/10.0.9", - "hashPath": "microsoft.entityframeworkcore.relational.10.0.9.nupkg.sha512" - }, - "Microsoft.IdentityModel.Abstractions/8.19.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-gFA8THIk23uNF/vMdOHnjIdXD1LyA2g12cHzMJ+Xag6WpgWLw6E/6uCXxvA0gp9d2yAvkRt3xzFzMUiO/hofnQ==", - "path": "microsoft.identitymodel.abstractions/8.19.1", - "hashPath": "microsoft.identitymodel.abstractions.8.19.1.nupkg.sha512" - }, - "Microsoft.IdentityModel.JsonWebTokens/8.19.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-6eeY+y2QFyjj3XnCz/8gJdoP5smYHTS9ow1bw2nsZzDIPjPhBZlackYTIduSMipVpxnoT/B62LkrXX2jPggOXg==", - "path": "microsoft.identitymodel.jsonwebtokens/8.19.1", - "hashPath": "microsoft.identitymodel.jsonwebtokens.8.19.1.nupkg.sha512" - }, - "Microsoft.IdentityModel.Logging/8.19.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-H+sMrMpdbWnwkQnpb/ESkQovtOgdefmj0ecGCcP40mDKzE5i4dUYkH6599M9mWYFNGNJnTp92l/9wLubYXWimw==", - "path": "microsoft.identitymodel.logging/8.19.1", - "hashPath": "microsoft.identitymodel.logging.8.19.1.nupkg.sha512" - }, - "Microsoft.IdentityModel.Tokens/8.19.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-KDiuSLXud2AFVNAOottd8ztVysfPeHyr4r8gofU3/VKUXlI7oytzGTnPsNJ/B3nui17rgz8wAdWNJOtzPjkUxw==", - "path": "microsoft.identitymodel.tokens/8.19.1", - "hashPath": "microsoft.identitymodel.tokens.8.19.1.nupkg.sha512" - }, - "MongoDB.Bson/3.9.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-J6vB61zKwMSfxbkN+/amGvj1qVMDKrKjV3kmoOWttMcv8JJScLDCFh89FyL3f2lDUyJrnfgZCR6/KX+07e99eg==", - "path": "mongodb.bson/3.9.0", - "hashPath": "mongodb.bson.3.9.0.nupkg.sha512" - }, - "MongoDB.Driver/3.9.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-XKUa+y5RtNH1iInfxj3Y7c1FN1BQ16/7hFxoqU6fzc3+BKM1D3mGa+pB/yBbAk8jNxf7+JWEnCfuQOyCo7dQLg==", - "path": "mongodb.driver/3.9.0", - "hashPath": "mongodb.driver.3.9.0.nupkg.sha512" - }, - "MongoDB.EntityFrameworkCore/10.0.2": { - "type": "package", - "serviceable": true, - "sha512": "sha512-GBN9+OAoIyBEgVPlYTXW3tyIsnpGPCA5AWZPy+X7ca3zJu+2Lli9FODZWl//w0yZHqwGwsLUL6XhcPPEaBYvBQ==", - "path": "mongodb.entityframeworkcore/10.0.2", - "hashPath": "mongodb.entityframeworkcore.10.0.2.nupkg.sha512" - }, - "SharpCompress/0.48.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-SqGaVniGG943Gph/gHhUQUiZPyC7y0tXZyMf0/B2oGsMav9dqs7JJOuUA+xOkwKYaWM2TM7aZQjNK91f4bX71A==", - "path": "sharpcompress/0.48.1", - "hashPath": "sharpcompress.0.48.1.nupkg.sha512" - }, - "Snappier/1.3.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-DOdDQiO8YZ5rBtVLY+6CmR1yp9WYoJRgEEktPBrR0tEj9QO2djA/zv0O3DX0OZpEAfosbY8pytQ9tQUogwQsEA==", - "path": "snappier/1.3.1", - "hashPath": "snappier.1.3.1.nupkg.sha512" - }, - "spworlds-api/1.0.3": { - "type": "package", - "serviceable": true, - "sha512": "sha512-rLQsC3MiXBfPKf3v45Qcrj0yypfZktTPuT/eGUJpcAlSZix1FBSRUbQVtfXbA8BHGIqn7nhZMVetaP3dmk+n1g==", - "path": "spworlds-api/1.0.3", - "hashPath": "spworlds-api.1.0.3.nupkg.sha512" - }, - "System.IdentityModel.Tokens.Jwt/8.19.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-2VHcRtT95GAcW1E3aVBLvL2rAAMxKHXKMXKXFyWzwgkdFXZPMMvP8tVOfnRydL4vTr1RirNuGC6T8VSEF2YsPQ==", - "path": "system.identitymodel.tokens.jwt/8.19.1", - "hashPath": "system.identitymodel.tokens.jwt.8.19.1.nupkg.sha512" - }, - "ZstdSharp.Port/0.7.3": { - "type": "package", - "serviceable": true, - "sha512": "sha512-U9Ix4l4cl58Kzz1rJzj5hoVTjmbx1qGMwzAcbv1j/d3NzrFaESIurQyg+ow4mivCgkE3S413y+U9k4WdnEIkRA==", - "path": "zstdsharp.port/0.7.3", - "hashPath": "zstdsharp.port.0.7.3.nupkg.sha512" - } - } -} \ No newline at end of file diff --git a/backend/SpMega.Backend/bin/Debug/net10.0/SpMega.Backend.runtimeconfig.json b/backend/SpMega.Backend/bin/Debug/net10.0/SpMega.Backend.runtimeconfig.json deleted file mode 100644 index bf15a00..0000000 --- a/backend/SpMega.Backend/bin/Debug/net10.0/SpMega.Backend.runtimeconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "runtimeOptions": { - "tfm": "net10.0", - "frameworks": [ - { - "name": "Microsoft.NETCore.App", - "version": "10.0.0" - }, - { - "name": "Microsoft.AspNetCore.App", - "version": "10.0.0" - } - ], - "configProperties": { - "System.GC.Server": true, - "System.Reflection.NullabilityInfoContext.IsSupported": true, - "System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false - } - } -} \ No newline at end of file diff --git a/backend/SpMega.Backend/bin/Debug/net10.0/SpMega.Backend.staticwebassets.endpoints.json b/backend/SpMega.Backend/bin/Debug/net10.0/SpMega.Backend.staticwebassets.endpoints.json deleted file mode 100644 index 2b6c535..0000000 --- a/backend/SpMega.Backend/bin/Debug/net10.0/SpMega.Backend.staticwebassets.endpoints.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "Version": 1, - "ManifestType": "Build", - "Endpoints": [] -} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index d9ded40..28a3a62 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,7 @@ minecraft_version=1.21.11 yarn_mappings=1.21.11+build.4 loader_version=0.18.1 # Mod Properties -mod_version=0.4-pre-alpha +mod_version=0.5-beta maven_group=git.yawaflua.tech archives_base_name=SPMega # Dependencies diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ModMenuIntegration.java b/src/client/java/git/yawaflua/tech/spmega/client/ModMenuIntegration.java index ef6a6f1..b1ad026 100644 --- a/src/client/java/git/yawaflua/tech/spmega/client/ModMenuIntegration.java +++ b/src/client/java/git/yawaflua/tech/spmega/client/ModMenuIntegration.java @@ -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] diff --git a/src/client/java/git/yawaflua/tech/spmega/client/SPMegaClient.java b/src/client/java/git/yawaflua/tech/spmega/client/SPMegaClient.java index 9f7e68b..44ee160 100644 --- a/src/client/java/git/yawaflua/tech/spmega/client/SPMegaClient.java +++ b/src/client/java/git/yawaflua/tech/spmega/client/SPMegaClient.java @@ -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; diff --git a/src/client/java/git/yawaflua/tech/spmega/client/WebhookNotificationPoller.java b/src/client/java/git/yawaflua/tech/spmega/client/WebhookNotificationPoller.java new file mode 100644 index 0000000..732122d --- /dev/null +++ b/src/client/java/git/yawaflua/tech/spmega/client/WebhookNotificationPoller.java @@ -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))))); + }); + } +} diff --git a/src/client/java/git/yawaflua/tech/spmega/client/api/SPWorldsApiClient.java b/src/client/java/git/yawaflua/tech/spmega/client/api/SPWorldsApiClient.java index afd4f59..cf28b04 100644 --- a/src/client/java/git/yawaflua/tech/spmega/client/api/SPWorldsApiClient.java +++ b/src/client/java/git/yawaflua/tech/spmega/client/api/SPWorldsApiClient.java @@ -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 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 getPlayerCards(String username, CardAuth auth) throws IOException, InterruptedException { + public CompletableFuture> 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 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 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 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 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 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 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 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 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) { diff --git a/src/client/java/git/yawaflua/tech/spmega/client/qr/QRCodeScanner.java b/src/client/java/git/yawaflua/tech/spmega/client/qr/QRCodeScanner.java index 9f1d518..73145f2 100644 --- a/src/client/java/git/yawaflua/tech/spmega/client/qr/QRCodeScanner.java +++ b/src/client/java/git/yawaflua/tech/spmega/client/qr/QRCodeScanner.java @@ -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 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); diff --git a/src/client/java/git/yawaflua/tech/spmega/client/telemetry/InstrumentedHttpClient.java b/src/client/java/git/yawaflua/tech/spmega/client/telemetry/InstrumentedHttpClient.java index 966b48a..acff2a4 100644 --- a/src/client/java/git/yawaflua/tech/spmega/client/telemetry/InstrumentedHttpClient.java +++ b/src/client/java/git/yawaflua/tech/spmega/client/telemetry/InstrumentedHttpClient.java @@ -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 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 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> 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)); - } } diff --git a/src/client/java/git/yawaflua/tech/spmega/client/telemetry/SystemInfoCollector.java b/src/client/java/git/yawaflua/tech/spmega/client/telemetry/SystemInfoCollector.java index 3635bbe..b4198dd 100644 --- a/src/client/java/git/yawaflua/tech/spmega/client/telemetry/SystemInfoCollector.java +++ b/src/client/java/git/yawaflua/tech/spmega/client/telemetry/SystemInfoCollector.java @@ -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 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() { diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/CardScreen.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/CardScreen.java index c805537..2983570 100644 --- a/src/client/java/git/yawaflua/tech/spmega/client/ui/CardScreen.java +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/CardScreen.java @@ -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 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 ? "Вебхуки включены" : "Включить вебхуки")); + } } } diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/GpsHudRenderer.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/GpsHudRenderer.java index b748be6..bc748a9 100644 --- a/src/client/java/git/yawaflua/tech/spmega/client/ui/GpsHudRenderer.java +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/GpsHudRenderer.java @@ -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 cities = new ArrayList<>(); + private volatile List 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 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 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; } } diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/MainBankScreen.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/MainBankScreen.java index c4c5a3e..96492f7 100644 --- a/src/client/java/git/yawaflua/tech/spmega/client/ui/MainBankScreen.java +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/MainBankScreen.java @@ -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()); } diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/TransactionHistoryScreen.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/TransactionHistoryScreen.java index a32f091..211724d 100644 --- a/src/client/java/git/yawaflua/tech/spmega/client/ui/TransactionHistoryScreen.java +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/TransactionHistoryScreen.java @@ -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 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> 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 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 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 diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/UiNotifications.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/UiNotifications.java index ec98eae..906671f 100644 --- a/src/client/java/git/yawaflua/tech/spmega/client/ui/UiNotifications.java +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/UiNotifications.java @@ -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 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; } } diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BackendAuthenticator.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BackendAuthenticator.java index b153a6c..0742bd6 100644 --- a/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BackendAuthenticator.java +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BackendAuthenticator.java @@ -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 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 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 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 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 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 fetchCardsFromBackend() throws IOException, InterruptedException { + public static CompletableFuture> 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 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 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 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 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> 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 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 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 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 fetchTransactionsFromBackend(String cardId, int page) throws IOException, InterruptedException { + public static CompletableFuture> 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 response = httpClient.send(request); + return httpClient.sendAsync(request) + .thenApply(response -> parseTransactionsResponse(response, cardId)); + } + + private static List parseTransactionsResponse( + HttpResponse 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) { + } } diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BankDatabase.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BankDatabase.java index c325474..b8846b4 100644 --- a/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BankDatabase.java +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BankDatabase.java @@ -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 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 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; diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BankUiService.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BankUiService.java index 5ca49d0..2d2e6db 100644 --- a/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BankUiService.java +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BankUiService.java @@ -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 syncCardsWithBackendAsync(String playerUuid) { - return CompletableFuture.runAsync(() -> { - try { - List 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 refreshOnServerJoinAsync(String playerUuid) { - return CompletableFuture.runAsync(() -> { - try { - List 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 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> loadRecipientCardsAsync(String username) { @@ -156,9 +146,8 @@ public final class BankUiService { return CompletableFuture.completedFuture(List.of()); } - return CompletableFuture.supplyAsync(() -> { - try { - List apiCards = apiClient.getPlayerCards(username, toApiAuth(credentials)); + return apiClient.getPlayerCardsAsync(username, toApiAuth(credentials)) + .thenApply(apiCards -> { List 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 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 removeSelectedCardAsync() { @@ -227,101 +187,48 @@ public final class BankUiService { }); } + public CompletableFuture 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 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 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 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 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 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 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 syncMissingCardsAsync(List backendCards, String playerUuid) { + CompletableFuture 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 refreshStoredCardsAsync(String playerUuid) { + CompletableFuture 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()) { diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/service/CardCredentials.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/CardCredentials.java index 8950849..6223eee 100644 --- a/src/client/java/git/yawaflua/tech/spmega/client/ui/service/CardCredentials.java +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/CardCredentials.java @@ -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); + } } diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/service/CardViewModel.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/CardViewModel.java index f6a35f6..5f971fb 100644 --- a/src/client/java/git/yawaflua/tech/spmega/client/ui/service/CardViewModel.java +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/CardViewModel.java @@ -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) { } diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/service/StoredCard.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/StoredCard.java index aece313..1a1b4fd 100644 --- a/src/client/java/git/yawaflua/tech/spmega/client/ui/service/StoredCard.java +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/StoredCard.java @@ -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) { } diff --git a/src/client/resources/assets/spmega/lang/en_us.json b/src/client/resources/assets/spmega/lang/en_us.json index efdb460..3e30e96 100644 --- a/src/client/resources/assets/spmega/lang/en_us.json +++ b/src/client/resources/assets/spmega/lang/en_us.json @@ -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.", diff --git a/src/client/resources/assets/spmega/lang/ru_ru.json b/src/client/resources/assets/spmega/lang/ru_ru.json index f12c5c9..69e85db 100644 --- a/src/client/resources/assets/spmega/lang/ru_ru.json +++ b/src/client/resources/assets/spmega/lang/ru_ru.json @@ -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" } - diff --git a/src/main/java/git/yawaflua/tech/spmega/ConfigManager.java b/src/main/java/git/yawaflua/tech/spmega/ConfigManager.java index 86c081a..6ceee34 100644 --- a/src/main/java/git/yawaflua/tech/spmega/ConfigManager.java +++ b/src/main/java/git/yawaflua/tech/spmega/ConfigManager.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())); diff --git a/src/main/java/git/yawaflua/tech/spmega/ModConfig.java b/src/main/java/git/yawaflua/tech/spmega/ModConfig.java index a6fb7ed..faef153 100644 --- a/src/main/java/git/yawaflua/tech/spmega/ModConfig.java +++ b/src/main/java/git/yawaflua/tech/spmega/ModConfig.java @@ -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