Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d41cd8f079 | ||
|
|
b1bc688d83 | ||
|
|
efe19edaf4 | ||
|
|
d12890f8eb | ||
|
|
69244ef832 | ||
|
|
d04afe2968 | ||
|
|
f19a5d3586 | ||
|
|
0e53d7dd8c | ||
|
|
f3a2a5d463 | ||
|
|
d065c6897e | ||
|
|
9238069d0e | ||
|
|
8516dce2ee | ||
|
|
7475a4ff32 | ||
|
|
254499b49a | ||
|
|
98bf51eb4c | ||
|
|
5858cc5db0 | ||
|
|
23291a1932 | ||
|
|
9f0209af6c | ||
|
|
0841f51a10 | ||
|
|
77e525b890 | ||
|
|
25929286c6 | ||
|
|
eae9f04b96 |
@@ -0,0 +1,69 @@
|
|||||||
|
name: .NET+Docker CI/CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "master", "develop", "main" ]
|
||||||
|
paths-ignore:
|
||||||
|
- 'README.md'
|
||||||
|
- '*.md'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Unit and Integration tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: 10.0.x
|
||||||
|
|
||||||
|
- name: Restore dependencies
|
||||||
|
run: dotnet restore backend/SpMega.Backend/SpMega.Backend.csproj
|
||||||
|
- name: Build
|
||||||
|
run: dotnet build --no-restore backend/SpMega.Backend/SpMega.Backend.csproj
|
||||||
|
- name: Test
|
||||||
|
run: dotnet test --no-build --verbosity normal backend/SpMega.Backend/SpMega.Backend.csproj
|
||||||
|
|
||||||
|
compose:
|
||||||
|
name: Push Docker image to ghcr.io
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Check out the repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: yawaflua
|
||||||
|
password: ${{ env.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Log in to Gitea Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.yawaflua.tech
|
||||||
|
username: yawaflua
|
||||||
|
password: ${{ env.GITEA_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
ghcr.io/yawaflua/spmega
|
||||||
|
git.yawaflua.tech/yawaflua/spmega
|
||||||
|
|
||||||
|
- name: Build and push API
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: backend/
|
||||||
|
file: backend/SpMega.Backend/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
@@ -19,3 +19,9 @@ hs_err_pid*
|
|||||||
|
|
||||||
# Common working directory
|
# Common working directory
|
||||||
run
|
run
|
||||||
|
|
||||||
|
backend/SpMega.Backend/bin/*
|
||||||
|
backend/SpMega.Backend/obj/*
|
||||||
|
backend/SpMega.Backend/appsettings.json
|
||||||
|
backend/SpMega.Backend/appsettings.Development.json
|
||||||
|
backend/SpMega.Backend/SpMega.Backend.http
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|

|
||||||
# SPMega
|
# SPMega
|
||||||
|
|
||||||
SPMega - клиентский Fabric-мод с банковым UI для работы с картами и переводами через API `spworlds.ru`.
|
SPMega - клиентский Fabric-мод с банковым UI для работы с картами и переводами через API `spworlds.ru`.
|
||||||
@@ -31,12 +32,63 @@ SPMega - клиентский Fabric-мод с банковым UI для раб
|
|||||||
`Вы не владелец карты. Часть функций может быть ограничена.`
|
`Вы не владелец карты. Часть функций может быть ограничена.`
|
||||||
###### позже это станет ограничением для синхронизации карт/истории с облаком
|
###### позже это станет ограничением для синхронизации карт/истории с облаком
|
||||||
|
|
||||||
## Сборка (PowerShell)
|
## Поддерживаемые версии
|
||||||
|
|
||||||
|
- Minecraft 1.21.1 (Java 21)
|
||||||
|
- Minecraft 1.21.11 (Java 21)
|
||||||
|
- Minecraft 26.1.x (сборка на 26.1.2, Java 25)
|
||||||
|
- Minecraft 26.2.x (сборка на 26.2, Java 25)
|
||||||
|
|
||||||
|
Версии собираются из общего дерева исходников через Stonecutter. Зависимости
|
||||||
|
для каждой версии находятся в `stonecutter.properties.toml`, а условные участки
|
||||||
|
исходников помечены директивами `/*? ... */`.
|
||||||
|
|
||||||
|
## Сборка
|
||||||
|
|
||||||
|
Все поддерживаемые версии:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew buildAndCollect
|
||||||
|
```
|
||||||
|
|
||||||
|
Готовые JAR-файлы появятся в `build/libs/<версия-мода>/`.
|
||||||
|
|
||||||
|
Одна версия:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :1.21.1:build
|
||||||
|
./gradlew :26.2.x:build
|
||||||
|
```
|
||||||
|
|
||||||
|
PowerShell:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\gradlew.bat build
|
.\gradlew.bat buildAndCollect
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Публикация на Modrinth
|
||||||
|
|
||||||
|
Один универсальный JAR для этих версий использовать нельзя: сборки 1.21.x и
|
||||||
|
26.x имеют несовместимые Minecraft/Fabric API. Вместо ручной загрузки четырёх
|
||||||
|
файлов Gradle создаёт отдельную запись Modrinth для каждой версии:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export MODRINTH_TOKEN="ваш-токен"
|
||||||
|
export MODRINTH_PROJECT_ID="id-или-slug-проекта"
|
||||||
|
./gradlew modrinth
|
||||||
|
```
|
||||||
|
|
||||||
|
В PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:MODRINTH_TOKEN="ваш-токен"
|
||||||
|
$env:MODRINTH_PROJECT_ID="id-или-slug-проекта"
|
||||||
|
.\gradlew.bat modrinth
|
||||||
|
```
|
||||||
|
|
||||||
|
Тип релиза задаётся свойством `modrinth_version_type` в `gradle.properties`:
|
||||||
|
`release`, `beta` или `alpha`.
|
||||||
|
|
||||||
## Дорожная карта
|
## Дорожная карта
|
||||||
|
|
||||||
- [ ] Синхронизация карт/истории с облаком
|
- [ ] Синхронизация карт/истории с облаком
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
SpMega.Backend/src/*
|
|
||||||
SpMega.Backend/obj/*
|
|
||||||
SpMega.Backend/appsettings.json
|
|
||||||
SpMega.Backend/SpMega.Backend.http
|
|
||||||
@@ -3,7 +3,9 @@ using System.Text.Encodings.Web;
|
|||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using SpMega.Backend.Persistent.Database;
|
using SpMega.Backend.Persistent.Database;
|
||||||
|
using SpMega.Backend.Services;
|
||||||
|
|
||||||
namespace SpMega.Backend.Authetication;
|
namespace SpMega.Backend.Authetication;
|
||||||
|
|
||||||
@@ -11,28 +13,39 @@ public class JwtAuthHandler(
|
|||||||
IOptionsMonitor<JwtAuthenticationSchemeOptions> options,
|
IOptionsMonitor<JwtAuthenticationSchemeOptions> options,
|
||||||
ILoggerFactory logger,
|
ILoggerFactory logger,
|
||||||
UrlEncoder encoder,
|
UrlEncoder encoder,
|
||||||
AppDbContext dbContext) : AuthenticationHandler<JwtAuthenticationSchemeOptions>(options, logger, encoder)
|
AppDbContext dbContext,
|
||||||
|
TokenService tokenService) : AuthenticationHandler<JwtAuthenticationSchemeOptions>(options, logger, encoder)
|
||||||
{
|
{
|
||||||
|
|
||||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
{
|
{
|
||||||
if (!Request.Headers.ContainsKey("Authorization") || !Request.Headers.Authorization.ToString().StartsWith("Bearer "))
|
var authorization = Request.Headers.Authorization.ToString();
|
||||||
return AuthenticateResult.NoResult();
|
if (string.IsNullOrWhiteSpace(authorization))
|
||||||
|
|
||||||
var token = Request.Headers.Authorization.ToString().Replace("Bearer ", "");
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(token))
|
|
||||||
{
|
{
|
||||||
return AuthenticateResult.Fail("Invalid authorization header format");
|
return AuthenticateResult.NoResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
const string bearerPrefix = "Bearer ";
|
||||||
|
if (!authorization.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return AuthenticateResult.Fail("Invalid authorization header format.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = authorization[bearerPrefix.Length..].Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
{
|
||||||
|
return AuthenticateResult.Fail("Invalid authorization header format.");
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var user = await dbContext.Users.FirstOrDefaultAsync(k => k.Token == token);
|
var validatedToken = tokenService.ValidateAccessToken(token);
|
||||||
|
var user = await dbContext.Users.FirstOrDefaultAsync(
|
||||||
|
candidate => candidate.Id == validatedToken.UserId && !candidate.IsDeleted,
|
||||||
|
Context.RequestAborted);
|
||||||
|
|
||||||
if (user == null)
|
if (user == null || !string.Equals(user.Username, validatedToken.UserName, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
return AuthenticateResult.Fail("Invalid or expired token");
|
return AuthenticateResult.Fail("Invalid token subject.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var claims = new List<Claim>
|
var claims = new List<Claim>
|
||||||
@@ -40,25 +53,39 @@ public class JwtAuthHandler(
|
|||||||
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||||
new(ClaimTypes.Name, user.Username),
|
new(ClaimTypes.Name, user.Username),
|
||||||
new("UserId", user.Id.ToString()),
|
new("UserId", user.Id.ToString()),
|
||||||
new("Token", token),
|
|
||||||
new(ClaimTypes.Role, "default")
|
new(ClaimTypes.Role, "default")
|
||||||
};
|
};
|
||||||
|
|
||||||
var claimsIdentity = new ClaimsIdentity(claims, Scheme.Name);
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, Scheme.Name));
|
||||||
var claimsPrincipal = new ClaimsPrincipal(claimsIdentity);
|
|
||||||
|
|
||||||
Context.Items["User"] = user;
|
Context.Items["User"] = user;
|
||||||
Context.Items["@me"] = user;
|
Context.Items["@me"] = user;
|
||||||
Context.Items["user"] = user;
|
Context.Items["user"] = user;
|
||||||
|
Context.Items[TokenService.HttpContextItemKey] = validatedToken;
|
||||||
|
|
||||||
var ticket = new AuthenticationTicket(claimsPrincipal, Scheme.Name);
|
return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name));
|
||||||
|
|
||||||
return AuthenticateResult.Success(ticket);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (SecurityTokenException exception)
|
||||||
{
|
{
|
||||||
Logger.LogError(ex, "Error during JWT authentication");
|
Logger.LogWarning("JWT authentication rejected: {Reason}", exception.Message);
|
||||||
return AuthenticateResult.Fail("Authentication error occurred");
|
return AuthenticateResult.Fail("Invalid or expired token.");
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Logger.LogError(exception, "Error during JWT authentication");
|
||||||
|
return AuthenticateResult.Fail("Authentication error occurred.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
|
||||||
|
{
|
||||||
|
Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
|
Response.ContentType = "application/json";
|
||||||
|
Response.Headers.WWWAuthenticate = $"{Scheme.Name} error=\"invalid_token\"";
|
||||||
|
Response.Headers["X-SPMega-Reauthenticate"] = "true";
|
||||||
|
return Response.WriteAsJsonAsync(new
|
||||||
|
{
|
||||||
|
code = "reauthentication_required",
|
||||||
|
message = "The access token is missing, invalid, expired, or older than two days."
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Net;
|
|||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -19,6 +20,7 @@ namespace SpMega.Backend.Controllers.v1;
|
|||||||
|
|
||||||
public record GetSessionIdBody(string userName, Guid userUUID);
|
public record GetSessionIdBody(string userName, Guid userUUID);
|
||||||
public record ValidateSessionBody(string sessionId, Guid userUUID);
|
public record ValidateSessionBody(string sessionId, Guid userUUID);
|
||||||
|
|
||||||
[Route("api/v1/auth")]
|
[Route("api/v1/auth")]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
public class AuthController(AppDbContext dbContext, TokenService tokenService, ILogger<AuthController> logger) : ControllerBase
|
public class AuthController(AppDbContext dbContext, TokenService tokenService, ILogger<AuthController> logger) : ControllerBase
|
||||||
@@ -66,14 +68,39 @@ public class AuthController(AppDbContext dbContext, TokenService tokenService, I
|
|||||||
|
|
||||||
var resp = await httpClient.SendAsync(request);
|
var resp = await httpClient.SendAsync(request);
|
||||||
if (resp.StatusCode != HttpStatusCode.OK) throw new Exception("Mojang response is not OK");
|
if (resp.StatusCode != HttpStatusCode.OK) throw new Exception("Mojang response is not OK");
|
||||||
Console.WriteLine(await resp.Content.ReadAsStringAsync());
|
|
||||||
var dto = await resp.Content.ReadFromJsonAsync<MojangDto>();
|
var dto = await resp.Content.ReadFromJsonAsync<MojangDto>();
|
||||||
if (dto == null || dto.Name != session.UserName || Guid.Parse(dto.Id) != session.UserId) throw new Exception("Session expired, or dto is not acceptable.");
|
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 token = tokenService.GenerateAccessToken(session.UserName, body.userUUID);
|
||||||
var user = await dbContext.Users.FirstOrDefaultAsync(k => k.Id == session.UserId);
|
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)
|
if (user != null)
|
||||||
{
|
{
|
||||||
user.Token = token;
|
user.Token = token;
|
||||||
|
user.Username = session.UserName;
|
||||||
|
user.UpdatedAt = DateTime.UtcNow;
|
||||||
|
user.ShortId ??= shortId;
|
||||||
|
|
||||||
dbContext.Update(user);
|
dbContext.Update(user);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -83,6 +110,8 @@ public class AuthController(AppDbContext dbContext, TokenService tokenService, I
|
|||||||
Id = body.userUUID,
|
Id = body.userUUID,
|
||||||
Username = session.UserName,
|
Username = session.UserName,
|
||||||
Token = token,
|
Token = token,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
ShortId = shortId,
|
||||||
};
|
};
|
||||||
await dbContext.AddAsync(user);
|
await dbContext.AddAsync(user);
|
||||||
}
|
}
|
||||||
@@ -99,6 +128,35 @@ public class AuthController(AppDbContext dbContext, TokenService tokenService, I
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("refresh")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> RefreshAccessTokenAsync()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items[TokenService.HttpContextItemKey] is not ValidatedAccessToken accessToken ||
|
||||||
|
HttpContext.Items["@me"] is not User user)
|
||||||
|
{
|
||||||
|
return Unauthorized(new { code = "reauthentication_required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
if (accessToken.ExpiresAt - now > TokenService.RefreshThreshold)
|
||||||
|
{
|
||||||
|
return Ok(new { refreshed = false, expiresAt = accessToken.ExpiresAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accessToken.AuthenticatedAt.Add(TokenService.MaximumAuthenticationAge) - now <= TokenService.RefreshThreshold)
|
||||||
|
{
|
||||||
|
return Unauthorized(new { code = "reauthentication_required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = tokenService.GenerateAccessToken(user.Username, user.Id, accessToken.AuthenticatedAt);
|
||||||
|
user.Token = token;
|
||||||
|
user.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(new { refreshed = true, token });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPut("cards")]
|
[HttpPut("cards")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<IActionResult> AddCardAsync([FromBody] AddCardBody body)
|
public async Task<IActionResult> AddCardAsync([FromBody] AddCardBody body)
|
||||||
@@ -109,11 +167,9 @@ public class AuthController(AppDbContext dbContext, TokenService tokenService, I
|
|||||||
string Base64BearerToken = Convert.ToBase64String(Encoding.UTF8.GetBytes(BearerToken));
|
string Base64BearerToken = Convert.ToBase64String(Encoding.UTF8.GetBytes(BearerToken));
|
||||||
var resp = await SendRequest("/accounts/me", new("Bearer", Base64BearerToken));
|
var resp = await SendRequest("/accounts/me", new("Bearer", Base64BearerToken));
|
||||||
var me = JsonSerializer.Deserialize<UserAccountDTO>(resp);
|
var me = JsonSerializer.Deserialize<UserAccountDTO>(resp);
|
||||||
Console.WriteLine(resp);
|
|
||||||
var user = ((User)HttpContext.Items["@me"]);
|
var user = ((User)HttpContext.Items["@me"]);
|
||||||
Console.WriteLine(me.id);
|
|
||||||
Console.WriteLine(user.Id.ToString());
|
if (user == null || user.Id != Guid.Parse(me.minecraftUUID))
|
||||||
if (user == null || user.Id.ToString() != me.minecraftUUID)
|
|
||||||
{
|
{
|
||||||
throw new Exception("Its not ur card");
|
throw new Exception("Its not ur card");
|
||||||
}
|
}
|
||||||
@@ -202,4 +258,3 @@ public class AuthController(AppDbContext dbContext, TokenService tokenService, I
|
|||||||
}
|
}
|
||||||
|
|
||||||
public record AddCardBody(string id, string token);
|
public record AddCardBody(string id, string token);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SpMega.Backend.Persistent.Models.DTO;
|
||||||
|
using SpMega.Backend.Persistent.Models.Users;
|
||||||
|
|
||||||
|
namespace SpMega.Backend.Controllers.v1;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v1/telemetry")]
|
||||||
|
[Authorize]
|
||||||
|
public class TelemetryController : ControllerBase
|
||||||
|
{
|
||||||
|
private static readonly ActivitySource Source = new("SpMega.ModTelemetry");
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public IActionResult Post([FromBody] ModTelemetryBatchDto batch)
|
||||||
|
{
|
||||||
|
if (batch?.Events == null || batch.Events.Count == 0)
|
||||||
|
{
|
||||||
|
return Ok(new { received = 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = HttpContext.Items["@me"] as User;
|
||||||
|
var userId = user?.Id.ToString() ?? User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "anonymous";
|
||||||
|
var sessionId = !string.IsNullOrEmpty(batch.SessionId) ? batch.SessionId : Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
|
foreach (var e in batch.Events)
|
||||||
|
{
|
||||||
|
using var activity = Source.StartActivity("mod.telemetry.event", ActivityKind.Client);
|
||||||
|
if (activity is null) continue;
|
||||||
|
|
||||||
|
activity.SetTag("user.id", userId);
|
||||||
|
activity.SetTag("session.id", sessionId);
|
||||||
|
activity.SetTag("client.version", batch.ClientVersion ?? string.Empty);
|
||||||
|
activity.SetTag("event.type", e.EventType);
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using MongoDB.Bson;
|
||||||
|
using MongoDB.Driver;
|
||||||
using SpMega.Backend.Persistent.Database;
|
using SpMega.Backend.Persistent.Database;
|
||||||
using SpMega.Backend.Persistent.Models.Transactions;
|
using SpMega.Backend.Persistent.Models.Transactions;
|
||||||
using SpMega.Backend.Persistent.Models.Users;
|
using SpMega.Backend.Persistent.Models.Users;
|
||||||
@@ -15,7 +18,8 @@ namespace SpMega.Backend.Controllers.v1;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
public class TransactionsController(AppDbContext context, ILogger<TransactionsController> logger, IConfiguration config) : ControllerBase
|
public class TransactionsController(AppDbContext context, ILogger<TransactionsController> logger, IConfiguration config) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string BASE_URL = "https://spworlds.ru/api/public";
|
private const int LIMIT = 10;
|
||||||
|
private const string BASE_URL = "https://spworlds.ru/api/public/";
|
||||||
|
|
||||||
[HttpGet("{billId}")]
|
[HttpGet("{billId}")]
|
||||||
public async Task<IActionResult> GetBill(string billId)
|
public async Task<IActionResult> GetBill(string billId)
|
||||||
@@ -33,12 +37,7 @@ public class TransactionsController(AppDbContext context, ILogger<TransactionsCo
|
|||||||
ShortId = "00000000",
|
ShortId = "00000000",
|
||||||
ReceiverName = "yawaflua",
|
ReceiverName = "yawaflua",
|
||||||
ReceiverCardNumber = "00000",
|
ReceiverCardNumber = "00000",
|
||||||
Sender = new User
|
SenderMinecraftName = "yawaflua",
|
||||||
{
|
|
||||||
Id = default,
|
|
||||||
Username = "yawaflua",
|
|
||||||
Token = "123",
|
|
||||||
},
|
|
||||||
SenderCardNumber = "010101",
|
SenderCardNumber = "010101",
|
||||||
Amount = 42,
|
Amount = 42,
|
||||||
Comment = "API FIRST",
|
Comment = "API FIRST",
|
||||||
@@ -55,32 +54,58 @@ public class TransactionsController(AppDbContext context, ILogger<TransactionsCo
|
|||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<Transaction>> CreateTransaction([FromBody] CreateTransactionBody body)
|
public async Task<ActionResult<Transaction>> CreateTransaction([FromBody] CreateTransactionBody body)
|
||||||
{
|
{
|
||||||
var BearerToken = $"{body.cardToUse.Id}:{body.cardToUse.Token}";
|
var user = HttpContext.Items["@me"] as User;
|
||||||
string Base64BearerToken = Convert.ToBase64String(Encoding.UTF8.GetBytes(BearerToken));
|
if (user == null)
|
||||||
|
{
|
||||||
|
return Unauthorized(new { error = "User not authenticated" });
|
||||||
|
}
|
||||||
|
var cardToUse = user.Cards.FirstOrDefault(l => l.Id == Guid.Parse(body.cardId));
|
||||||
|
if (cardToUse == null)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Card not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
var shortId = Program.GenerateRandomString(8);
|
|
||||||
|
var shortId = Program.GenerateRandomString(2);
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var a = await context.Transactions.FirstOrDefaultAsync(k => k.ShortId == shortId && EF.Property<Guid>(k, "SenderId") == user.Id);
|
||||||
|
if (a != null)
|
||||||
|
{
|
||||||
|
shortId = Program.GenerateRandomString(2);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
var transaction = new Transaction
|
var transaction = new Transaction
|
||||||
{
|
{
|
||||||
ReceiverName = body.receiverName,
|
ReceiverName = body.receiverName,
|
||||||
ShortId = shortId,
|
ShortId = $"{user.ShortId}{shortId}",
|
||||||
ReceiverCardNumber = body.receiverCard,
|
ReceiverCardNumber = body.receiverCard,
|
||||||
Sender = HttpContext.Items["@me"] as User,
|
Sender = user,
|
||||||
SenderCardNumber = body.cardToUse.SpworldsID,
|
SenderCardNumber = cardToUse.SpworldsID,
|
||||||
|
SenderMinecraftName = user.Username,
|
||||||
Amount = body.amount,
|
Amount = body.amount,
|
||||||
Comment = body.comment,
|
Comment = body.comment
|
||||||
};
|
};
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var uri = new Uri(new Uri(config["Url"] ?? "https://spmega.yawaflua.tech"), "/" + shortId);
|
var uri = "ywfl.dev" + "/s" + shortId;
|
||||||
var transitionInfo = new Dictionary<string, object>
|
var transitionInfo = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
{ "receiver", body.receiverCard },
|
{ "receiver", body.receiverCard },
|
||||||
{ "amount", body.amount },
|
{ "amount", body.amount },
|
||||||
{ "comment", "Чек: "+ uri }
|
{ "comment", (body.comment)[10..] + "..;Чек:"+ uri }
|
||||||
};
|
};
|
||||||
|
var resp = await SendRequest(endpoint: "transactions", body: transitionInfo, AuthHeader: new("Bearer", cardToUse.Token));
|
||||||
await SendRequest(endpoint: "transactions", body: transitionInfo, AuthHeader:new("Bearer", Base64BearerToken));
|
var balance = (int?)JsonNode.Parse(resp)?["balance"];
|
||||||
|
if (balance == null)
|
||||||
|
{
|
||||||
|
throw new Exception("Failed to create transaction: " + resp);
|
||||||
|
}
|
||||||
|
|
||||||
} catch (Exception exception)
|
} catch (Exception exception)
|
||||||
{
|
{
|
||||||
@@ -94,16 +119,27 @@ public class TransactionsController(AppDbContext context, ILogger<TransactionsCo
|
|||||||
{
|
{
|
||||||
Id = transaction.Id,
|
Id = transaction.Id,
|
||||||
ReceiverName = transaction.ReceiverName,
|
ReceiverName = transaction.ReceiverName,
|
||||||
|
ReceiverCardNumber = transaction.ReceiverCardNumber,
|
||||||
|
SenderMinecraftName = transaction.SenderMinecraftName,
|
||||||
|
SenderCardNumber = transaction.SenderCardNumber,
|
||||||
|
Amount = transaction.Amount,
|
||||||
|
Comment = transaction.Comment,
|
||||||
|
TransactionDate = transaction.TransactionDate
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet()]
|
[HttpGet()]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<List<Transaction>>> GetAllTransactions()
|
public async Task<ActionResult<List<Transaction>>> GetAllTransactions([FromQuery] int p = 1)
|
||||||
{
|
{
|
||||||
return Ok(await context.Transactions.Where(k => k.Sender.Id == ((User)HttpContext.Items["@me"]).Id).ToListAsync());
|
return Ok(await context.Transactions
|
||||||
|
.Where(k => EF.Property<Guid>(k, "SenderId") == ((User)HttpContext.Items["@me"]).Id)
|
||||||
|
.Skip(p - 1 * LIMIT)
|
||||||
|
.Take(p * LIMIT)
|
||||||
|
.ToListAsync());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[NonAction]
|
||||||
internal async Task<string> SendRequest(string endpoint, AuthenticationHeaderValue AuthHeader, HttpMethod method = null, object body = null)
|
internal async Task<string> SendRequest(string endpoint, AuthenticationHeaderValue AuthHeader, HttpMethod method = null, object body = null)
|
||||||
{
|
{
|
||||||
method ??= body == null ? HttpMethod.Get : HttpMethod.Post;
|
method ??= body == null ? HttpMethod.Get : HttpMethod.Post;
|
||||||
@@ -129,4 +165,4 @@ public class TransactionsController(AppDbContext context, ILogger<TransactionsCo
|
|||||||
return await message.Content.ReadAsStringAsync();
|
return await message.Content.ReadAsStringAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
public record CreateTransactionBody(Card cardToUse, string receiverCard, string receiverName, string comment, int amount);
|
public record CreateTransactionBody(string cardId, string receiverCard, string receiverName, string comment, int amount);
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using MongoDB.Driver.Linq;
|
||||||
|
using SpMega.Backend.Persistent.Database;
|
||||||
|
using SpMega.Backend.Persistent.Models.Transactions;
|
||||||
|
using SpMega.Backend.Persistent.Models.Users;
|
||||||
|
|
||||||
|
namespace SpMega.Backend.Controllers.v1;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("/api/v1/webhook")]
|
||||||
|
public class WebhookController( AppDbContext dbContext, ILogger<WebhookController> logger, IConfiguration config) : ControllerBase
|
||||||
|
{
|
||||||
|
private const string BASE_URL = "https://spworlds.ru/api/public/";
|
||||||
|
|
||||||
|
|
||||||
|
[HttpPut("{cardId}")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> 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<JsonObject>(resp);
|
||||||
|
if (jsonResponse != null && jsonResponse.TryGetPropertyValue("id", out var respId)
|
||||||
|
&& respId?.GetValue<Guid>() == 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<IActionResult> 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 == true);
|
||||||
|
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<Webhook>(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<ActionResult<List<Notification>>> 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<ActionResult<List<Notification>>> 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<string> 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);
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace SpMega.Backend.Middleware;
|
||||||
|
|
||||||
|
public class RequestTelemetryMiddleware(RequestDelegate next, ILogger<RequestTelemetryMiddleware> logger)
|
||||||
|
{
|
||||||
|
private static readonly ActivitySource Source = new("SpMega.RequestTelemetry");
|
||||||
|
private const string RequestIdHeader = "X-Request-Id";
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
var path = context.Request.Path.Value ?? string.Empty;
|
||||||
|
if (path.Equals("/api/v1/telemetry", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& context.Request.Method.Equals("POST", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var requestId = context.Request.Headers[RequestIdHeader].FirstOrDefault() ?? Guid.NewGuid().ToString("N");
|
||||||
|
context.Response.Headers[RequestIdHeader] = requestId;
|
||||||
|
|
||||||
|
using var activity = Source.StartActivity("http.request", ActivityKind.Server);
|
||||||
|
activity?.SetTag("request.id", requestId);
|
||||||
|
activity?.SetTag("http.method", context.Request.Method);
|
||||||
|
activity?.SetTag("http.path", path);
|
||||||
|
activity?.SetTag("http.scheme", context.Request.Scheme);
|
||||||
|
activity?.SetTag("http.host", context.Request.Host.Value ?? string.Empty);
|
||||||
|
activity?.SetTag("client.ip", context.Connection.RemoteIpAddress?.ToString());
|
||||||
|
activity?.SetTag("http.user_agent", context.Request.Headers.UserAgent.ToString());
|
||||||
|
activity?.SetTag("http.headers", string.Join(", ", context.Request.Headers.Select(h => $"{h.Key}: {h.Value}")));
|
||||||
|
|
||||||
|
var stopwatch = Stopwatch.StartNew();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await next(context);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
stopwatch.Stop();
|
||||||
|
activity?.SetTag("http.status_code", context.Response.StatusCode);
|
||||||
|
activity?.SetTag("http.duration_ms", stopwatch.ElapsedMilliseconds);
|
||||||
|
|
||||||
|
var userId = context.User?.Identity?.IsAuthenticated == true
|
||||||
|
? context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
|
||||||
|
: null;
|
||||||
|
if (userId is not null)
|
||||||
|
activity?.SetTag("user.id", userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -418,8 +418,8 @@
|
|||||||
<div class="logo-text">SPMega</div>
|
<div class="logo-text">SPMega</div>
|
||||||
</a>
|
</a>
|
||||||
<div class="meta-info">
|
<div class="meta-info">
|
||||||
<div>Версия: <span class="badge">1.0.0-2026</span></div>
|
<div>Версия: <span class="badge">1.0.1-2026</span></div>
|
||||||
<div style="margin-top: 4px;">Обновлено: 28 июня 2026</div>
|
<div style="margin-top: 4px;">Обновлено: 12 июля 2026</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -487,9 +487,18 @@
|
|||||||
<p>Мы собираем и обрабатываем:</p>
|
<p>Мы собираем и обрабатываем:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Ваш уникальный идентификатор Minecraft (<span class="badge">UUID</span>) и текущий никнейм.</li>
|
<li>Ваш уникальный идентификатор Minecraft (<span class="badge">UUID</span>) и текущий никнейм.</li>
|
||||||
<li>Сведения о версии игры, версии Java и используемой версии модификации.</li>
|
<li>Время первой и последующих авторизаций.</li>
|
||||||
<li>Ваш текущий IP-адрес для предотвращения мультиаккаунтинга и выявления попыток взлома API.</li>
|
<li>Ваш текущий IP-адрес для предотвращения и выявления попыток взлома API.</li>
|
||||||
<li>Техническую информацию о транзакциях (суммы, комментарии, номера виртуальных счетов/карт и идентификаторы).</li>
|
<li>Техническую информацию о транзакциях (суммы, комментарии, номера виртуальных счетов/карт и идентификаторы).</li>
|
||||||
|
<li>Технические характеристики устройства: процессор, видеокарту, объем оперативной памяти, операционную систему и название компьютера.</li>
|
||||||
|
<li>Показатели производительности мода, включая FPS и тайминги запросов.</li>
|
||||||
|
<li>Трассировочную информацию о запросах к серверной части SPMega для диагностики ошибок и улучшения качества мода.</li>
|
||||||
|
</ul>
|
||||||
|
<p>Количество передаваемой телеметрии можно уменьшить в настройках модификации.</p>
|
||||||
|
<p>Мы не сохраняем:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Токен карты «КАК ЕСТЬ», данные шифруются по протоколу AES-256-CBC, и только после этого сохраняются в базе данных.</li>
|
||||||
|
<li>Любые личные данные, кроме указанных выше</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -499,10 +508,11 @@
|
|||||||
<p>Мы высоко ценим доверие наших пользователей и гарантируем сохранность собираемой информации. Все личные данные передаются и обрабатываются по защищенным зашифрованным каналам связи (HTTPS/SSL).</p>
|
<p>Мы высоко ценим доверие наших пользователей и гарантируем сохранность собираемой информации. Все личные данные передаются и обрабатываются по защищенным зашифрованным каналам связи (HTTPS/SSL).</p>
|
||||||
<p>Администрация сайта строго обязуется:</p>
|
<p>Администрация сайта строго обязуется:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Не передавать, не продавать и не распространять</strong> ваши личные данные, IP-адреса и статистику третьим лицам.</li>
|
<li><strong>Не передавать, не продавать и не распространять</strong> ваши личные данные, IP-адреса, телеметрию и статистику третьим лицам.</li>
|
||||||
<li>Использовать собранную анонимизированную статистику исключительно во внутренних аналитических целях для улучшения производительности мода.</li>
|
<li>Использовать собранную анонимизированную статистику исключительно во внутренних аналитических целях для улучшения производительности мода.</li>
|
||||||
<li>Раскрывать информацию только в случаях, прямо предусмотренных действующим законодательством при наличии официальных судебных или правоохранительных запросов.</li>
|
<li>Раскрывать информацию только в случаях, прямо предусмотренных действующим законодательством государства Израиль при наличии официальных судебных или правоохранительных запросов.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<p>По юридическим вопросам свяжитесь с нами по электронной почте <a href="mailto:yawaflua@outlook.co.il">yawaflua@outlook.co.il</a> или в Telegram: <a href="https://t.me/meharmen">@meharmen</a>.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Section 6 -->
|
<!-- Section 6 -->
|
||||||
@@ -517,11 +527,11 @@
|
|||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<div class="footer-links">
|
<div class="footer-links">
|
||||||
<a href="/v1/BillPage/00000000-0000-0000-0000-000000000000" class="footer-link">Проверить чек</a>
|
<a href="/00000000" class="footer-link">Проверить чек</a>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<a href="#" class="footer-link">Поддержка</a>
|
<a href="https://t.me/meharmen" class="footer-link">Поддержка</a>
|
||||||
</div>
|
</div>
|
||||||
<div>© 2026 SPMega. Все права защищены. Minecraft является торговой маркой Mojang Synergies AB.</div>
|
<div>© 2026 Dmitri Shimanski. Все права защищены. NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT..</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<!-- Scroll to Top Button -->
|
<!-- Scroll to Top Button -->
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
|
namespace SpMega.Backend;
|
||||||
|
|
||||||
|
public class AgreementPage : PageModel
|
||||||
|
{
|
||||||
|
public void OnGet()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -587,7 +587,7 @@
|
|||||||
|
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<span class="meta-label">Отправитель:</span>
|
<span class="meta-label">Отправитель:</span>
|
||||||
<span class="meta-value bold">${escapeHtml(transaction.sender?.username || 'Клиент SPMega')}</span>
|
<span class="meta-value bold">${escapeHtml(transaction.senderMinecraftName || 'Клиент SPMega')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using MongoDB.EntityFrameworkCore.Extensions;
|
using MongoDB.EntityFrameworkCore.Extensions;
|
||||||
|
using SpMega.Backend.Persistent.Models.Telemetry;
|
||||||
using SpMega.Backend.Persistent.Models.Transactions;
|
using SpMega.Backend.Persistent.Models.Transactions;
|
||||||
using SpMega.Backend.Persistent.Models.Users;
|
using SpMega.Backend.Persistent.Models.Users;
|
||||||
|
using SpMega.Backend.Services;
|
||||||
|
|
||||||
namespace SpMega.Backend.Persistent.Database;
|
namespace SpMega.Backend.Persistent.Database;
|
||||||
|
|
||||||
@@ -10,6 +12,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
|||||||
internal DbSet<Transaction> Transactions { get; set; }
|
internal DbSet<Transaction> Transactions { get; set; }
|
||||||
internal DbSet<User> Users { get; set; }
|
internal DbSet<User> Users { get; set; }
|
||||||
internal DbSet<UserSession> UserSessions { get; set; }
|
internal DbSet<UserSession> UserSessions { get; set; }
|
||||||
|
internal DbSet<Notification> Notifications { get; set; }
|
||||||
|
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
@@ -20,6 +23,14 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
|||||||
{
|
{
|
||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity<User>()
|
||||||
|
.OwnsMany(u => u.Cards, card =>
|
||||||
|
{
|
||||||
|
card.Property(c => c.Token)
|
||||||
|
.HasConversion(
|
||||||
|
v => EncryptionHelper.Encrypt(v),
|
||||||
|
v => EncryptionHelper.Decrypt(v)
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace SpMega.Backend.Persistent.Models.DTO;
|
||||||
|
|
||||||
|
public record ModTelemetryEventDto(
|
||||||
|
string EventType,
|
||||||
|
DateTimeOffset Timestamp,
|
||||||
|
JsonElement Payload
|
||||||
|
);
|
||||||
|
|
||||||
|
public record ModTelemetryBatchDto(
|
||||||
|
string ClientVersion,
|
||||||
|
string SessionId,
|
||||||
|
DateTimeOffset SentAt,
|
||||||
|
List<ModTelemetryEventDto> Events
|
||||||
|
);
|
||||||
@@ -2,14 +2,32 @@ using SPWorldsApi.Types.Interfaces;
|
|||||||
|
|
||||||
namespace SpMega.Backend.Persistent.Models.DTO;
|
namespace SpMega.Backend.Persistent.Models.DTO;
|
||||||
|
|
||||||
public class UserAccountDTO : IUserAccount
|
public class UserAccountDTO
|
||||||
{
|
{
|
||||||
public string id { get; set; }
|
public string id { get; set; }
|
||||||
public string username { get; set; }
|
public string username { get; set; }
|
||||||
public string minecraftUUID { get; set; }
|
public string minecraftUUID { get; set; }
|
||||||
public string status { get; set; }
|
public string status { get; set; }
|
||||||
public List<string> roles { get; set; }
|
public List<string> roles { get; set; }
|
||||||
public ICity city { get; set; }
|
public CityDTO city { get; set; }
|
||||||
public List<IUserCard> cards { get; set; }
|
public List<UserCardDTO> cards { get; set; }
|
||||||
public DateTime createdAt { get; set; }
|
public DateTime createdAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class UserCardDTO
|
||||||
|
{
|
||||||
|
public string id { get; set; }
|
||||||
|
public string name { get; set; }
|
||||||
|
public string number { get; set; }
|
||||||
|
public int color { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CityDTO
|
||||||
|
{
|
||||||
|
public string id { get; set; }
|
||||||
|
public string name { get; set; }
|
||||||
|
public string description { get; set; }
|
||||||
|
public int x { get; set; }
|
||||||
|
public int z { get; set; }
|
||||||
|
public bool isMayor { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using MongoDB.Bson;
|
||||||
|
|
||||||
|
namespace SpMega.Backend.Persistent.Models.Telemetry;
|
||||||
|
|
||||||
|
public class BackendRequestTelemetryDocument
|
||||||
|
{
|
||||||
|
public ObjectId Id { get; set; }
|
||||||
|
public string RequestId { get; set; } = string.Empty;
|
||||||
|
public string Method { get; set; } = string.Empty;
|
||||||
|
public string Path { get; set; } = string.Empty;
|
||||||
|
public string Scheme { get; set; } = string.Empty;
|
||||||
|
public string Host { get; set; } = string.Empty;
|
||||||
|
public string? ClientIp { get; set; }
|
||||||
|
public string? UserAgent { get; set; }
|
||||||
|
public int StatusCode { get; set; }
|
||||||
|
public long DurationMs { get; set; }
|
||||||
|
public string? UserId { get; set; }
|
||||||
|
public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using MongoDB.Bson;
|
||||||
|
|
||||||
|
namespace SpMega.Backend.Persistent.Models.Telemetry;
|
||||||
|
|
||||||
|
public class ModTelemetryDocument
|
||||||
|
{
|
||||||
|
public ObjectId Id { get; set; }
|
||||||
|
public string UserId { get; set; } = string.Empty;
|
||||||
|
public string SessionId { get; set; } = string.Empty;
|
||||||
|
public string ClientVersion { get; set; } = string.Empty;
|
||||||
|
public DateTimeOffset SentAt { get; set; }
|
||||||
|
public DateTimeOffset ReceivedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
public List<ModTelemetryEventDocument> Events { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ModTelemetryEventDocument
|
||||||
|
{
|
||||||
|
public string EventType { get; set; } = string.Empty;
|
||||||
|
public DateTimeOffset Timestamp { get; set; }
|
||||||
|
public string PayloadJson { get; set; } = "{}";
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using SpMega.Backend.Persistent.Models.Users;
|
using SpMega.Backend.Persistent.Models.Users;
|
||||||
|
|
||||||
@@ -7,13 +8,15 @@ public class Transaction
|
|||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public string ShortId { get; set; } = Program.GenerateRandomString(8);
|
public string ShortId { get; set; } = Program.GenerateRandomString(8);
|
||||||
public string ReceiverName { get; set; }
|
[MaxLength(2048)] public string ReceiverName { get; set; }
|
||||||
public string ReceiverCardNumber { get; set; }
|
[MaxLength(2048)] public string ReceiverCardNumber { get; set; }
|
||||||
[JsonIgnore] public User Sender { 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 int Amount { get; set; } = 0;
|
||||||
public string Comment { get; set; } = "";
|
[MaxLength(2048)] public string Comment { get; set; } = "";
|
||||||
|
|
||||||
public DateTime TransactionDate { get; set; } = DateTime.UtcNow;
|
public DateTime TransactionDate { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,8 @@ public class Card
|
|||||||
public string Name { get; set; }
|
public string Name { get; set; }
|
||||||
public string SpworldsID { get; set; }
|
public string SpworldsID { get; set; }
|
||||||
public string Token { get; set; }
|
public string Token { get; set; }
|
||||||
|
public string? ShortId { get; set; } = "";
|
||||||
|
public bool? WebhookConnected { get; set; } = false;
|
||||||
|
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime UpdatedAt { get; set; }
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
using SpMega.Backend.Persistent.Models.Transactions;
|
using SpMega.Backend.Persistent.Models.Transactions;
|
||||||
|
|
||||||
namespace SpMega.Backend.Persistent.Models.Users;
|
namespace SpMega.Backend.Persistent.Models.Users;
|
||||||
@@ -5,8 +6,10 @@ namespace SpMega.Backend.Persistent.Models.Users;
|
|||||||
public class User
|
public class User
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public string Username { get; set; }
|
[MaxLength(2048)] public string Username { get; set; }
|
||||||
public string Token { get; set; }
|
[MaxLength(2048)] public string Token { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(2048)] public string? ShortId { get; set; }
|
||||||
|
|
||||||
public List<Card> Cards { get; set; } = [];
|
public List<Card> Cards { get; set; } = [];
|
||||||
public List<Transaction> Transactions { get; set; } = [];
|
public List<Transaction> Transactions { get; set; } = [];
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace SpMega.Backend.Persistent.Models.Users;
|
namespace SpMega.Backend.Persistent.Models.Users;
|
||||||
|
|
||||||
public class UserSession
|
public class UserSession
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public string Token { get; set; }
|
[MaxLength(2048)] public string Token { get; set; }
|
||||||
public User? User { get; set; } = null;
|
public User? User { get; set; } = null;
|
||||||
public Guid UserId { get; set; }
|
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 CreatedAt { get; init; } = DateTime.UtcNow;
|
||||||
public DateTime UpdatedAt { get; set; }
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using MongoDB.Driver;
|
using MongoDB.Driver;
|
||||||
|
using OpenTelemetry.Exporter;
|
||||||
|
using OpenTelemetry.Metrics;
|
||||||
|
using OpenTelemetry.Resources;
|
||||||
|
using OpenTelemetry.Trace;
|
||||||
using SpMega.Backend.Authetication;
|
using SpMega.Backend.Authetication;
|
||||||
|
using SpMega.Backend.Middleware;
|
||||||
using SpMega.Backend.Persistent.Database;
|
using SpMega.Backend.Persistent.Database;
|
||||||
using SpMega.Backend.Services;
|
using SpMega.Backend.Services;
|
||||||
|
|
||||||
@@ -14,10 +20,15 @@ public class Program
|
|||||||
{
|
{
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
builder.Configuration
|
builder.Configuration
|
||||||
.AddJsonFile("appsettings.json", false)
|
.AddJsonFile("appsettings.json", true)
|
||||||
.AddJsonFile("appsettings.Development.json", false)
|
.AddJsonFile("appsettings.Development.json", true)
|
||||||
.AddEnvironmentVariables();
|
.AddEnvironmentVariables();
|
||||||
|
|
||||||
|
var conf = builder.Configuration;
|
||||||
|
var encryptionKey = conf["Encryption:Key"] ?? "a-default-fallback-only-for-dev-key-change-this!";
|
||||||
|
EncryptionHelper.Initialize(encryptionKey);
|
||||||
|
_ = conf["JWT:Secret"] ?? conf["JWT__Secret"] ?? throw new InvalidOperationException("JWT__Secret is not configured.");
|
||||||
|
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization();
|
||||||
builder.Services.AddLogging(logging =>
|
builder.Services.AddLogging(logging =>
|
||||||
{
|
{
|
||||||
@@ -50,18 +61,51 @@ public class Program
|
|||||||
.RequireAuthenticatedUser()
|
.RequireAuthenticatedUser()
|
||||||
.Build());
|
.Build());
|
||||||
|
|
||||||
var serviceCollection = new ServiceCollection();
|
|
||||||
var mongoClient =
|
var mongoClient =
|
||||||
new MongoClient(builder.Configuration.GetValue<string>("Mongo") ?? "mongodb://curiosity:27018");
|
new MongoClient(builder.Configuration.GetValue<string>("Mongo") ?? "mongodb://curiosity:27018");
|
||||||
serviceCollection.AddSingleton<IMongoClient>(mongoClient);
|
builder.Services.AddSingleton<IMongoClient>(mongoClient);
|
||||||
|
|
||||||
builder.Services.AddDbContext<AppDbContext>((_, options) =>
|
builder.Services.AddDbContext<AppDbContext>((_, options) =>
|
||||||
{
|
{
|
||||||
options.UseMongoDB(mongoClient, "spmega");
|
options.UseMongoDB(mongoClient, "spmega");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var otlpEndpoint = (conf["Otlp:Endpoint"] ?? conf["Otlp__Endpoint"] ?? "http://curiosity:5080/api/default").TrimEnd('/');
|
||||||
|
var otlpHeaders = conf["Otlp:Headers"] ?? 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 =>
|
||||||
|
{
|
||||||
|
tracing
|
||||||
|
.AddSource("SpMega.ModTelemetry")
|
||||||
|
.AddSource("SpMega.RequestTelemetry")
|
||||||
|
.AddAspNetCoreInstrumentation()
|
||||||
|
.AddOtlpExporter(opts =>
|
||||||
|
{
|
||||||
|
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();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.UseMiddleware<RequestTelemetryMiddleware>();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.UseWhen(ctx => ctx.Request.Path.StartsWithSegments("/api"), appBuilder =>
|
app.UseWhen(ctx => ctx.Request.Path.StartsWithSegments("/api"), appBuilder =>
|
||||||
@@ -79,12 +123,11 @@ public class Program
|
|||||||
{
|
{
|
||||||
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
|
||||||
var random = new Random();
|
|
||||||
var result = new StringBuilder(length);
|
var result = new StringBuilder(length);
|
||||||
|
|
||||||
for (var i = 0; i < length; i++)
|
for (var i = 0; i < length; i++)
|
||||||
{
|
{
|
||||||
var index = random.Next(chars.Length);
|
var index = RandomNumberGenerator.GetInt32(chars.Length);
|
||||||
result.Append(chars[index]);
|
result.Append(chars[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace SpMega.Backend.Services;
|
||||||
|
|
||||||
|
public static class EncryptionHelper
|
||||||
|
{
|
||||||
|
private static byte[]? _key;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes the encryption key. The key will be derived using SHA-256 from the provided secret string
|
||||||
|
/// to ensure it is exactly 32 bytes (256 bits) long.
|
||||||
|
/// </summary>
|
||||||
|
public static void Initialize(string secretKey)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(secretKey))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Encryption key cannot be null or empty.", nameof(secretKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
_key = SHA256.HashData(Encoding.UTF8.GetBytes(secretKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encrypts the plain text using AES-256-CBC.
|
||||||
|
/// A unique Initialization Vector (IV) is generated for each encryption and stored alongside the ciphertext.
|
||||||
|
/// </summary>
|
||||||
|
public static string Encrypt(string plainText)
|
||||||
|
{
|
||||||
|
if (_key == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("EncryptionHelper has not been initialized. Call Initialize() first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(plainText))
|
||||||
|
{
|
||||||
|
return plainText;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var aes = Aes.Create();
|
||||||
|
aes.Key = _key;
|
||||||
|
aes.GenerateIV();
|
||||||
|
var iv = aes.IV;
|
||||||
|
|
||||||
|
using var encryptor = aes.CreateEncryptor(aes.Key, iv);
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
|
||||||
|
ms.Write(iv, 0, iv.Length);
|
||||||
|
|
||||||
|
using (var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write))
|
||||||
|
using (var writer = new StreamWriter(cs, Encoding.UTF8))
|
||||||
|
{
|
||||||
|
writer.Write(plainText);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Convert.ToBase64String(ms.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decrypts the cipher text using AES-256-CBC.
|
||||||
|
/// Reconstructs the Initialization Vector (IV) from the beginning of the ciphertext.
|
||||||
|
/// Falls back to returning the input string if decryption fails (e.g., for legacy plaintext tokens).
|
||||||
|
/// </summary>
|
||||||
|
public static string Decrypt(string cipherText)
|
||||||
|
{
|
||||||
|
if (_key == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("EncryptionHelper has not been initialized. Call Initialize() first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(cipherText))
|
||||||
|
{
|
||||||
|
return cipherText;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fullCipher = Convert.FromBase64String(cipherText);
|
||||||
|
|
||||||
|
using var aes = Aes.Create();
|
||||||
|
aes.Key = _key;
|
||||||
|
|
||||||
|
var ivLength = aes.BlockSize / 8;
|
||||||
|
if (fullCipher.Length < ivLength)
|
||||||
|
{
|
||||||
|
// Too short to be an encrypted payload, return original
|
||||||
|
return cipherText;
|
||||||
|
}
|
||||||
|
|
||||||
|
var iv = new byte[ivLength];
|
||||||
|
var cipher = new byte[fullCipher.Length - ivLength];
|
||||||
|
|
||||||
|
Buffer.BlockCopy(fullCipher, 0, iv, 0, ivLength);
|
||||||
|
Buffer.BlockCopy(fullCipher, ivLength, cipher, 0, cipher.Length);
|
||||||
|
|
||||||
|
aes.IV = iv;
|
||||||
|
|
||||||
|
using var decryptor = aes.CreateDecryptor(aes.Key, aes.IV);
|
||||||
|
using var ms = new MemoryStream(cipher);
|
||||||
|
using var cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read);
|
||||||
|
using var reader = new StreamReader(cs, Encoding.UTF8);
|
||||||
|
|
||||||
|
return reader.ReadToEnd();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is CryptographicException or FormatException)
|
||||||
|
{
|
||||||
|
// Fallback for legacy unencrypted tokens
|
||||||
|
return cipherText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Globalization;
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
@@ -5,36 +6,120 @@ using Microsoft.IdentityModel.Tokens;
|
|||||||
|
|
||||||
namespace SpMega.Backend.Services;
|
namespace SpMega.Backend.Services;
|
||||||
|
|
||||||
|
public sealed record ValidatedAccessToken(
|
||||||
|
Guid UserId,
|
||||||
|
string UserName,
|
||||||
|
DateTimeOffset AuthenticatedAt,
|
||||||
|
DateTimeOffset ExpiresAt);
|
||||||
|
|
||||||
public class TokenService(IConfiguration conf)
|
public class TokenService(IConfiguration conf)
|
||||||
{
|
{
|
||||||
private const int AccessTokenExpirationMinutes = 24;
|
public const string HttpContextItemKey = "ValidatedAccessToken";
|
||||||
private readonly string _issuer = conf["JWT__Issuer"] ?? "spmega.il.yawaflua.tech";
|
public static readonly TimeSpan AccessTokenLifetime = TimeSpan.FromHours(24);
|
||||||
private readonly string _secret = conf["JWT__Secret"] ?? throw new InvalidOperationException("JWT__Secret is not configured.");
|
public static readonly TimeSpan MaximumAuthenticationAge = TimeSpan.FromDays(2);
|
||||||
|
public static readonly TimeSpan RefreshThreshold = TimeSpan.FromHours(1);
|
||||||
|
|
||||||
|
private static readonly TimeSpan ClockSkew = TimeSpan.FromMinutes(1);
|
||||||
|
private readonly string _issuer = conf["JWT:Issuer"] ?? conf["JWT__Issuer"] ?? "spmega.il.yawaflua.tech";
|
||||||
|
private readonly string _secret = conf["JWT:Secret"] ?? conf["JWT__Secret"] ?? throw new InvalidOperationException("JWT__Secret is not configured.");
|
||||||
|
|
||||||
public string GenerateAccessToken(string userName, Guid uuid)
|
public string GenerateAccessToken(string userName, Guid uuid)
|
||||||
{
|
{
|
||||||
|
return GenerateAccessToken(userName, uuid, DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GenerateAccessToken(string userName, Guid uuid, DateTimeOffset authenticatedAt)
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var maximumExpiration = authenticatedAt.Add(MaximumAuthenticationAge);
|
||||||
|
var expiration = now.Add(AccessTokenLifetime);
|
||||||
|
if (expiration > maximumExpiration)
|
||||||
|
{
|
||||||
|
expiration = maximumExpiration;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expiration <= now)
|
||||||
|
{
|
||||||
|
throw new SecurityTokenExpiredException("The original authentication is older than two days.");
|
||||||
|
}
|
||||||
|
|
||||||
var claims = new List<Claim>
|
var claims = new List<Claim>
|
||||||
{
|
{
|
||||||
new(JwtRegisteredClaimNames.Name, userName),
|
new(JwtRegisteredClaimNames.Name, userName),
|
||||||
new (JwtRegisteredClaimNames.UniqueName, uuid.ToString()),
|
new(JwtRegisteredClaimNames.UniqueName, uuid.ToString()),
|
||||||
new(JwtRegisteredClaimNames.AuthTime, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()),
|
new(JwtRegisteredClaimNames.AuthTime, authenticatedAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)),
|
||||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||||
};
|
};
|
||||||
|
|
||||||
var tokenHandler = new JwtSecurityTokenHandler();
|
var tokenHandler = new JwtSecurityTokenHandler { MapInboundClaims = false };
|
||||||
var key = Encoding.ASCII.GetBytes(_secret);
|
|
||||||
|
|
||||||
var tokenDescriptor = new SecurityTokenDescriptor
|
var tokenDescriptor = new SecurityTokenDescriptor
|
||||||
{
|
{
|
||||||
Subject = new ClaimsIdentity(claims),
|
Subject = new ClaimsIdentity(claims),
|
||||||
Expires = DateTime.UtcNow.AddHours(AccessTokenExpirationMinutes),
|
Expires = expiration.UtcDateTime,
|
||||||
Issuer = _issuer,
|
Issuer = _issuer,
|
||||||
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature),
|
SigningCredentials = new SigningCredentials(
|
||||||
IssuedAt = DateTime.UtcNow
|
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret)),
|
||||||
|
SecurityAlgorithms.HmacSha256),
|
||||||
|
IssuedAt = now.UtcDateTime
|
||||||
};
|
};
|
||||||
|
|
||||||
var token = tokenHandler.CreateToken(tokenDescriptor);
|
return tokenHandler.WriteToken(tokenHandler.CreateToken(tokenDescriptor));
|
||||||
|
}
|
||||||
|
|
||||||
return tokenHandler.WriteToken(token);
|
public ValidatedAccessToken ValidateAccessToken(string token)
|
||||||
|
{
|
||||||
|
var tokenHandler = new JwtSecurityTokenHandler { MapInboundClaims = false };
|
||||||
|
var principal = tokenHandler.ValidateToken(token, new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidIssuer = _issuer,
|
||||||
|
ValidateAudience = false,
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret)),
|
||||||
|
ValidateLifetime = true,
|
||||||
|
RequireExpirationTime = true,
|
||||||
|
RequireSignedTokens = true,
|
||||||
|
ClockSkew = ClockSkew,
|
||||||
|
ValidAlgorithms = [SecurityAlgorithms.HmacSha256]
|
||||||
|
}, out var validatedToken);
|
||||||
|
|
||||||
|
if (validatedToken is not JwtSecurityToken jwtToken ||
|
||||||
|
!string.Equals(jwtToken.Header.Alg, SecurityAlgorithms.HmacSha256, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new SecurityTokenValidationException("Unsupported JWT signing algorithm.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var userName = principal.FindFirst(JwtRegisteredClaimNames.Name)?.Value;
|
||||||
|
var uniqueName = principal.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
|
||||||
|
var authTimeValue = principal.FindFirst(JwtRegisteredClaimNames.AuthTime)?.Value;
|
||||||
|
var expirationValue = principal.FindFirst(JwtRegisteredClaimNames.Exp)?.Value;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(userName) ||
|
||||||
|
!Guid.TryParse(uniqueName, out var userId) ||
|
||||||
|
!long.TryParse(authTimeValue, NumberStyles.None, CultureInfo.InvariantCulture, out var authTimeSeconds) ||
|
||||||
|
!long.TryParse(expirationValue, NumberStyles.None, CultureInfo.InvariantCulture, out var expirationSeconds))
|
||||||
|
{
|
||||||
|
throw new SecurityTokenValidationException("Required JWT claims are missing or invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeOffset authenticatedAt;
|
||||||
|
DateTimeOffset expiresAt;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
authenticatedAt = DateTimeOffset.FromUnixTimeSeconds(authTimeSeconds);
|
||||||
|
expiresAt = DateTimeOffset.FromUnixTimeSeconds(expirationSeconds);
|
||||||
|
}
|
||||||
|
catch (ArgumentOutOfRangeException exception)
|
||||||
|
{
|
||||||
|
throw new SecurityTokenValidationException("JWT timestamps are invalid.", exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
if (authenticatedAt > now.Add(ClockSkew) || now - authenticatedAt > MaximumAuthenticationAge)
|
||||||
|
{
|
||||||
|
throw new SecurityTokenExpiredException("Reauthentication is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ValidatedAccessToken(userId, userName, authenticatedAt, expiresAt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,9 @@
|
|||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
|
||||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.19.1" />
|
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.19.1" />
|
||||||
<PackageReference Include="MongoDB.EntityFrameworkCore" Version="10.0.2" />
|
<PackageReference Include="MongoDB.EntityFrameworkCore" Version="10.0.2" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
|
||||||
<PackageReference Include="spworlds-api" Version="1.0.3" />
|
<PackageReference Include="spworlds-api" Version="1.0.3" />
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.19.1" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.19.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APaymentWrapper_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F28d7b99649ed43d899c3d8518f41c1976400_003F94_003Fbc0e580e_003FPaymentWrapper_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASPWorlds_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F28d7b99649ed43d899c3d8518f41c1976400_003F64_003Fd3041423_003FSPWorlds_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary>
|
||||||
-113
@@ -1,113 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id 'fabric-loom' version '1.14.10'
|
|
||||||
id 'maven-publish'
|
|
||||||
}
|
|
||||||
|
|
||||||
version = project.mod_version
|
|
||||||
group = project.maven_group
|
|
||||||
|
|
||||||
base {
|
|
||||||
archivesName = project.archives_base_name
|
|
||||||
}
|
|
||||||
|
|
||||||
loom {
|
|
||||||
splitEnvironmentSourceSets()
|
|
||||||
|
|
||||||
mods {
|
|
||||||
"spmega" {
|
|
||||||
sourceSet sourceSets.main
|
|
||||||
sourceSet sourceSets.client
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fabricApi {
|
|
||||||
configureDataGeneration {
|
|
||||||
client = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
repositories {
|
|
||||||
maven { url "https://maven.shedaniel.me/" }
|
|
||||||
maven { url "https://maven.terraformersmc.com/" }
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
minecraft "com.mojang:minecraft:${project.minecraft_version}"
|
|
||||||
mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2"
|
|
||||||
modImplementation "net.fabricmc:fabric-loader:${project.loader_version}"
|
|
||||||
|
|
||||||
modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}"
|
|
||||||
|
|
||||||
modImplementation("me.shedaniel.cloth:cloth-config-fabric:${project.cloth_config_version}") {
|
|
||||||
exclude(group: "net.fabricmc.fabric-api")
|
|
||||||
}
|
|
||||||
|
|
||||||
modCompileOnly "com.terraformersmc:modmenu:11.0.3"
|
|
||||||
|
|
||||||
include(implementation("org.xerial:sqlite-jdbc:3.46.1.3"))
|
|
||||||
include(clientImplementation("com.google.zxing:core:${project.zxing_version}"))
|
|
||||||
include(clientImplementation("com.google.zxing:javase:${project.zxing_version}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
processResources {
|
|
||||||
inputs.property "version", project.version
|
|
||||||
inputs.property "minecraft_version", project.minecraft_version
|
|
||||||
inputs.property "loader_version", project.loader_version
|
|
||||||
inputs.property "cloth_config_version", project.cloth_config_version
|
|
||||||
filteringCharset "UTF-8"
|
|
||||||
|
|
||||||
filesMatching("fabric.mod.json") {
|
|
||||||
expand "version": project.version,
|
|
||||||
"minecraft_version": project.minecraft_version,
|
|
||||||
"loader_version": project.loader_version,
|
|
||||||
"cloth_config_version": project.cloth_config_version
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
def targetJavaVersion = 21
|
|
||||||
tasks.withType(JavaCompile).configureEach {
|
|
||||||
// ensure that the encoding is set to UTF-8, no matter what the system default is
|
|
||||||
// this fixes some edge cases with special characters not displaying correctly
|
|
||||||
// see http://yodaconditions.net/blog/fix-for-java-file-encoding-problems-with-gradle.html
|
|
||||||
// If Javadoc is generated, this must be specified in that task too.
|
|
||||||
it.options.encoding = "UTF-8"
|
|
||||||
if (targetJavaVersion >= 10 || JavaVersion.current().isJava10Compatible()) {
|
|
||||||
it.options.release.set(targetJavaVersion)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
java {
|
|
||||||
def javaVersion = JavaVersion.toVersion(targetJavaVersion)
|
|
||||||
if (JavaVersion.current() < javaVersion) {
|
|
||||||
toolchain.languageVersion = JavaLanguageVersion.of(targetJavaVersion)
|
|
||||||
}
|
|
||||||
// Loom will automatically attach sourcesJar to a RemapSourcesJar task and to the "build" task
|
|
||||||
// if it is present.
|
|
||||||
// If you remove this line, sources will not be generated.
|
|
||||||
withSourcesJar()
|
|
||||||
}
|
|
||||||
|
|
||||||
jar {
|
|
||||||
from("LICENSE") {
|
|
||||||
rename { "${it}_${project.archives_base_name}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// configure the maven publication
|
|
||||||
publishing {
|
|
||||||
publications {
|
|
||||||
create("mavenJava", MavenPublication) {
|
|
||||||
artifactId = project.archives_base_name
|
|
||||||
from components.java
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// See https://docs.gradle.org/current/userguide/publishing_maven.html for information on how to set up publishing.
|
|
||||||
repositories {
|
|
||||||
// Add repositories to publish to here.
|
|
||||||
// Notice: This block does NOT have the same function as the block in the top level.
|
|
||||||
// The repositories here will be used for publishing your artifact, not for
|
|
||||||
// retrieving dependencies.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
plugins {
|
||||||
|
id("dev.kikugie.loom-back-compat")
|
||||||
|
id("com.modrinth.minotaur")
|
||||||
|
`maven-publish`
|
||||||
|
}
|
||||||
|
|
||||||
|
version = "${property("mod_version")}+${sc.current.version}"
|
||||||
|
group = property("maven_group") as String
|
||||||
|
|
||||||
|
base {
|
||||||
|
archivesName = property("archives_base_name") as String
|
||||||
|
}
|
||||||
|
|
||||||
|
val requiredJava = if (sc.current.parsed >= "26.1") JavaVersion.VERSION_25 else JavaVersion.VERSION_21
|
||||||
|
val fabricVersion: String = sc.properties["dependencies.fabric_version"]
|
||||||
|
val clothConfigVersion: String = sc.properties["dependencies.cloth_config_version"]
|
||||||
|
val modMenuVersion: String = sc.properties["dependencies.modmenu_version"]
|
||||||
|
val minecraftCompat: String = sc.properties["metadata.minecraft_compat"]
|
||||||
|
val loaderCompat: String = sc.properties["metadata.loader_compat"]
|
||||||
|
val modrinthGameVersions = when (sc.current.project) {
|
||||||
|
"1.21.1" -> listOf("1.21.1")
|
||||||
|
"1.21.11" -> listOf("1.21.11")
|
||||||
|
"26.1.x" -> listOf("26.1", "26.1.1", "26.1.2")
|
||||||
|
"26.2.x" -> listOf("26.2")
|
||||||
|
else -> error("Missing Modrinth game versions for ${sc.current.project}")
|
||||||
|
}
|
||||||
|
|
||||||
|
loom {
|
||||||
|
splitEnvironmentSourceSets()
|
||||||
|
interfaceInjection {
|
||||||
|
enableDependencyInterfaceInjection = false
|
||||||
|
}
|
||||||
|
|
||||||
|
mods {
|
||||||
|
create("spmega") {
|
||||||
|
sourceSet(sourceSets.main.get())
|
||||||
|
sourceSet(sourceSets["client"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fabricApi {
|
||||||
|
configureDataGeneration {
|
||||||
|
client = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
maven("https://maven.shedaniel.me/")
|
||||||
|
exclusiveContent {
|
||||||
|
forRepository {
|
||||||
|
maven("https://api.modrinth.com/maven") { name = "Modrinth" }
|
||||||
|
}
|
||||||
|
filter { includeGroup("maven.modrinth") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
minecraft("com.mojang:minecraft:${sc.current.version}")
|
||||||
|
loomx.applyMojangMappings()
|
||||||
|
|
||||||
|
modImplementation("net.fabricmc:fabric-loader:${property("loader_version")}")
|
||||||
|
if (sc.current.parsed >= "26.1") {
|
||||||
|
modImplementation("net.fabricmc.fabric-api:fabric-api:$fabricVersion")
|
||||||
|
} else {
|
||||||
|
listOf(
|
||||||
|
"fabric-api-base",
|
||||||
|
"fabric-data-generation-api-v1",
|
||||||
|
"fabric-events-interaction-v0",
|
||||||
|
"fabric-key-binding-api-v1",
|
||||||
|
"fabric-lifecycle-events-v1",
|
||||||
|
"fabric-message-api-v1",
|
||||||
|
"fabric-networking-api-v1",
|
||||||
|
"fabric-rendering-v1",
|
||||||
|
"fabric-screen-api-v1"
|
||||||
|
).forEach { module ->
|
||||||
|
modImplementation(fabricApi.module(module, fabricVersion))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modImplementation("me.shedaniel.cloth:cloth-config-fabric:$clothConfigVersion") {
|
||||||
|
exclude(group = "net.fabricmc.fabric-api")
|
||||||
|
}
|
||||||
|
modCompileOnly("maven.modrinth:mOgUt4GM:$modMenuVersion")
|
||||||
|
|
||||||
|
val sqlite = "org.xerial:sqlite-jdbc:${property("sqlite_version")}"
|
||||||
|
val zxingCore = "com.google.zxing:core:${property("zxing_version")}"
|
||||||
|
val zxingJavase = "com.google.zxing:javase:${property("zxing_version")}"
|
||||||
|
implementation(sqlite)
|
||||||
|
include(sqlite)
|
||||||
|
add("clientImplementation", zxingCore)
|
||||||
|
add("clientImplementation", zxingJavase)
|
||||||
|
include(zxingCore)
|
||||||
|
include(zxingJavase)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks {
|
||||||
|
processResources {
|
||||||
|
val values: Map<String, String> = mapOf(
|
||||||
|
"version" to project.version.toString(),
|
||||||
|
"minecraft_version" to minecraftCompat,
|
||||||
|
"loader_version" to loaderCompat,
|
||||||
|
"java_version" to requiredJava.majorVersion,
|
||||||
|
"cloth_config_version" to clothConfigVersion
|
||||||
|
)
|
||||||
|
values.forEach(inputs::property)
|
||||||
|
filteringCharset = "UTF-8"
|
||||||
|
filesMatching("fabric.mod.json") { expand(values) }
|
||||||
|
}
|
||||||
|
|
||||||
|
withType<JavaCompile>().configureEach {
|
||||||
|
options.encoding = "UTF-8"
|
||||||
|
options.release = requiredJava.majorVersion.toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
jar {
|
||||||
|
from(rootProject.file("LICENSE.txt")) {
|
||||||
|
rename { "${it}_${project.property("archives_base_name")}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register<Copy>("buildAndCollect") {
|
||||||
|
group = "build"
|
||||||
|
description = "Builds this Minecraft version and collects its distributable JAR"
|
||||||
|
from(loomx.modJar.flatMap { it.archiveFile })
|
||||||
|
into(rootProject.layout.buildDirectory.dir("libs/${project.property("mod_version")}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
java {
|
||||||
|
sourceCompatibility = requiredJava
|
||||||
|
targetCompatibility = requiredJava
|
||||||
|
toolchain.languageVersion = JavaLanguageVersion.of(requiredJava.majorVersion)
|
||||||
|
withSourcesJar()
|
||||||
|
}
|
||||||
|
|
||||||
|
publishing {
|
||||||
|
publications {
|
||||||
|
create<MavenPublication>("mavenJava") {
|
||||||
|
artifactId = property("archives_base_name") as String
|
||||||
|
from(components["java"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modrinth {
|
||||||
|
token.set(providers.environmentVariable("MODRINTH_TOKEN"))
|
||||||
|
projectId.set(providers.environmentVariable("MODRINTH_PROJECT_ID"))
|
||||||
|
versionNumber.set(project.version.toString())
|
||||||
|
versionName.set("SPMega ${project.version}")
|
||||||
|
versionType.set(property("modrinth_version_type") as String)
|
||||||
|
uploadFile.set(loomx.modJar)
|
||||||
|
gameVersions.addAll(modrinthGameVersions)
|
||||||
|
loaders.add("fabric")
|
||||||
|
dependencies {
|
||||||
|
required.project("fabric-api")
|
||||||
|
optional.project("cloth-config")
|
||||||
|
optional.project("modmenu")
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-10
@@ -1,16 +1,14 @@
|
|||||||
# Done to increase the memory available to gradle.
|
# Done to increase the memory available to gradle.
|
||||||
org.gradle.jvmargs=-Xmx1G
|
org.gradle.jvmargs=-Xmx1G
|
||||||
# Fabric Properties
|
# Shared Fabric properties. Per-Minecraft-version dependencies are in
|
||||||
# check these on https://modmuss50.me/fabric.html
|
# stonecutter.properties.toml.
|
||||||
minecraft_version=1.21.11
|
loader_version=0.19.3
|
||||||
yarn_mappings=1.21.11+build.4
|
loomx.loom_version=1.17-SNAPSHOT
|
||||||
loader_version=0.18.1
|
|
||||||
# Mod Properties
|
# Mod Properties
|
||||||
mod_version=0.4-pre-alpha
|
mod_version=0.6-prerelease
|
||||||
maven_group=git.yawaflua.tech
|
maven_group=git.yawaflua.tech
|
||||||
archives_base_name=SPMega
|
archives_base_name=SPMega
|
||||||
# Dependencies
|
modrinth_version_type=beta
|
||||||
# check this on https://modmuss50.me/fabric.html
|
# Bundled dependencies
|
||||||
fabric_version=0.141.1+1.21.11
|
sqlite_version=3.46.1.3
|
||||||
cloth_config_version=21.11.153
|
|
||||||
zxing_version=3.5.3
|
zxing_version=3.5.3
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
pluginManagement {
|
|
||||||
repositories {
|
|
||||||
maven {
|
|
||||||
name = 'Fabric'
|
|
||||||
url = 'https://maven.fabricmc.net/'
|
|
||||||
}
|
|
||||||
gradlePluginPortal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
maven("https://maven.fabricmc.net/") { name = "Fabric" }
|
||||||
|
maven("https://maven.kikugie.dev/releases") { name = "KikuGie Releases" }
|
||||||
|
maven("https://maven.kikugie.dev/snapshots") { name = "KikuGie Snapshots" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("dev.kikugie.stonecutter") version "0.9.7"
|
||||||
|
id("dev.kikugie.loom-back-compat") version "0.4"
|
||||||
|
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
stonecutter {
|
||||||
|
create(rootProject) {
|
||||||
|
versions("1.21.1", "1.21.11")
|
||||||
|
version("26.1.x", "26.1.2")
|
||||||
|
version("26.2.x", "26.2")
|
||||||
|
vcsVersion = "1.21.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "SPMega"
|
||||||
@@ -3,13 +3,11 @@ package git.yawaflua.tech.spmega.client;
|
|||||||
import git.yawaflua.tech.spmega.client.ui.UiNotifications;
|
import git.yawaflua.tech.spmega.client.ui.UiNotifications;
|
||||||
import git.yawaflua.tech.spmega.client.ui.service.BankUiService;
|
import git.yawaflua.tech.spmega.client.ui.service.BankUiService;
|
||||||
import net.fabricmc.fabric.api.client.message.v1.ClientReceiveMessageEvents;
|
import net.fabricmc.fabric.api.client.message.v1.ClientReceiveMessageEvents;
|
||||||
import net.minecraft.client.MinecraftClient;
|
import net.minecraft.client.Minecraft;
|
||||||
import net.minecraft.text.ClickEvent;
|
import net.minecraft.network.chat.ClickEvent;
|
||||||
import net.minecraft.text.Text;
|
import net.minecraft.network.chat.Component;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.CompletableFuture;
|
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
public class ChatListener {
|
public class ChatListener {
|
||||||
@@ -39,7 +37,7 @@ public class ChatListener {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleMessage(Text message) {
|
private void handleMessage(Component message) {
|
||||||
if (message == null) {
|
if (message == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -53,12 +51,11 @@ public class ChatListener {
|
|||||||
String tokenId = clickValues.get(0);
|
String tokenId = clickValues.get(0);
|
||||||
String cardId = clickValues.get(1);
|
String cardId = clickValues.get(1);
|
||||||
|
|
||||||
MinecraftClient client = MinecraftClient.getInstance();
|
Minecraft client = Minecraft.getInstance();
|
||||||
if (client.player != null) {
|
if (client.player != null) {
|
||||||
String playerUuid = client.player.getUuidAsString();
|
String playerUuid = client.player.getStringUUID();
|
||||||
|
|
||||||
CompletableFuture
|
BankUiService.instance().addCardAsync(cardId, tokenId, playerUuid)
|
||||||
.supplyAsync(() -> BankUiService.instance().addCard(cardId, tokenId, playerUuid))
|
|
||||||
.thenAccept(msg -> {
|
.thenAccept(msg -> {
|
||||||
if (client == null) {
|
if (client == null) {
|
||||||
return;
|
return;
|
||||||
@@ -70,7 +67,7 @@ public class ChatListener {
|
|||||||
.exceptionally(exception -> {
|
.exceptionally(exception -> {
|
||||||
if (client != null) {
|
if (client != null) {
|
||||||
client.execute(() -> {
|
client.execute(() -> {
|
||||||
notifications.show(Text.literal("Ошибка добавления карты: " + exception.getMessage()));
|
notifications.show(Component.literal("Ошибка добавления карты: " + exception.getMessage()));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -82,7 +79,7 @@ public class ChatListener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void collectClickEventValues(Text text, List<String> values) {
|
private void collectClickEventValues(Component text, List<String> values) {
|
||||||
if (text == null) {
|
if (text == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -100,12 +97,13 @@ public class ChatListener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (Text sibling : text.getSiblings()) {
|
for (Component sibling : text.getSiblings()) {
|
||||||
collectClickEventValues(sibling, values);
|
collectClickEventValues(sibling, values);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getEventValue(ClickEvent clickEvent) {
|
private String getEventValue(ClickEvent clickEvent) {
|
||||||
|
/*? if mc_1_21_11 {*/
|
||||||
if (clickEvent instanceof ClickEvent.CopyToClipboard(String value)) {
|
if (clickEvent instanceof ClickEvent.CopyToClipboard(String value)) {
|
||||||
return value;
|
return value;
|
||||||
} else if (clickEvent instanceof ClickEvent.RunCommand(String command1)) {
|
} else if (clickEvent instanceof ClickEvent.RunCommand(String command1)) {
|
||||||
@@ -120,5 +118,8 @@ public class ChatListener {
|
|||||||
return String.valueOf(page);
|
return String.valueOf(page);
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
|
/*?} else {*/
|
||||||
|
// return clickEvent.getValue();
|
||||||
|
/*?}*/
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import git.yawaflua.tech.spmega.client.ui.service.BackendAuthenticator;
|
|||||||
import me.shedaniel.clothconfig2.api.ConfigBuilder;
|
import me.shedaniel.clothconfig2.api.ConfigBuilder;
|
||||||
import me.shedaniel.clothconfig2.api.ConfigCategory;
|
import me.shedaniel.clothconfig2.api.ConfigCategory;
|
||||||
import me.shedaniel.clothconfig2.api.ConfigEntryBuilder;
|
import me.shedaniel.clothconfig2.api.ConfigEntryBuilder;
|
||||||
import net.minecraft.client.MinecraftClient;
|
import net.minecraft.ChatFormatting;
|
||||||
import net.minecraft.text.Text;
|
import net.minecraft.client.Minecraft;
|
||||||
import net.minecraft.util.Formatting;
|
import net.minecraft.network.chat.Component;
|
||||||
|
|
||||||
public class ModMenuIntegration implements ModMenuApi {
|
public class ModMenuIntegration implements ModMenuApi {
|
||||||
@Override
|
@Override
|
||||||
@@ -19,10 +19,10 @@ public class ModMenuIntegration implements ModMenuApi {
|
|||||||
return parent -> {
|
return parent -> {
|
||||||
ConfigBuilder builder = ConfigBuilder.create()
|
ConfigBuilder builder = ConfigBuilder.create()
|
||||||
.setParentScreen(parent)
|
.setParentScreen(parent)
|
||||||
.setTitle(Text.translatable("title.spmega.config"));
|
.setTitle(Component.translatable("title.spmega.config"));
|
||||||
|
|
||||||
ConfigEntryBuilder entryBuilder = builder.entryBuilder();
|
ConfigEntryBuilder entryBuilder = builder.entryBuilder();
|
||||||
ConfigCategory general = builder.getOrCreateCategory(Text.translatable("category.spmega.general"));
|
ConfigCategory general = builder.getOrCreateCategory(Component.translatable("category.spmega.general"));
|
||||||
|
|
||||||
ModConfig current = SPMega.getConfig();
|
ModConfig current = SPMega.getConfig();
|
||||||
if (current == null) {
|
if (current == null) {
|
||||||
@@ -34,52 +34,86 @@ public class ModMenuIntegration implements ModMenuApi {
|
|||||||
final boolean[] allowBackend = {current.allowBackend()};
|
final boolean[] allowBackend = {current.allowBackend()};
|
||||||
final boolean[] signQuickPayEnabled = {current.signQuickPayEnabled()};
|
final boolean[] signQuickPayEnabled = {current.signQuickPayEnabled()};
|
||||||
final GpsHudPosition[] gpsPosition = {current.gpsPosition()};
|
final GpsHudPosition[] gpsPosition = {current.gpsPosition()};
|
||||||
|
final GpsHudPosition[] notificationPosition = {current.notificationPosition()};
|
||||||
|
|
||||||
general.addEntry(entryBuilder.startStrField(Text.translatable("option.spmega.api_domain"), current.apiDomain())
|
final int[] telemetryIntervalSeconds = {current.telemetryIntervalSeconds()};
|
||||||
|
final boolean[] telemetryCollectSystemInfo = {current.telemetryCollectSystemInfo()};
|
||||||
|
|
||||||
|
general.addEntry(entryBuilder.startStrField(Component.translatable("option.spmega.api_domain"), current.apiDomain())
|
||||||
.setDefaultValue(ModConfig.DEFAULT_API_DOMAIN)
|
.setDefaultValue(ModConfig.DEFAULT_API_DOMAIN)
|
||||||
.setSaveConsumer(newValue -> apiDomain[0] = newValue)
|
.setSaveConsumer(newValue -> apiDomain[0] = newValue)
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
general.addEntry(entryBuilder.startStrField(Text.translatable("option.spmega.api_token"), current.apiToken())
|
general.addEntry(entryBuilder.startStrField(Component.translatable("option.spmega.api_token"), current.apiToken())
|
||||||
.setDefaultValue(ModConfig.DEFAULT_API_TOKEN)
|
.setDefaultValue(ModConfig.DEFAULT_API_TOKEN)
|
||||||
.setSaveConsumer(newValue -> apiToken[0] = newValue)
|
.setSaveConsumer(newValue -> apiToken[0] = newValue)
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
general.addEntry(entryBuilder.startBooleanToggle(Text.translatable("option.spmega.allow_backend"), current.allowBackend())
|
general.addEntry(entryBuilder.startBooleanToggle(Component.translatable("option.spmega.allow_backend"), current.allowBackend())
|
||||||
.setDefaultValue(ModConfig.ALLOW_BACKEND)
|
.setDefaultValue(ModConfig.ALLOW_BACKEND)
|
||||||
.setSaveConsumer(newValue -> allowBackend[0] = newValue)
|
.setSaveConsumer(newValue -> allowBackend[0] = newValue)
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
boolean isLoggedIn = !current.apiToken().isEmpty() && !current.apiToken().equals(ModConfig.DEFAULT_API_TOKEN);
|
boolean isLoggedIn = !current.apiToken().isEmpty() && !current.apiToken().equals(ModConfig.DEFAULT_API_TOKEN);
|
||||||
general.addEntry(entryBuilder.startBooleanToggle(Text.literal("Авторизоваться").formatted(Formatting.RED), isLoggedIn)
|
general.addEntry(entryBuilder.startBooleanToggle(Component.literal("Авторизоваться").withStyle(ChatFormatting.RED), isLoggedIn)
|
||||||
.setTooltip(Text.literal("Запустить процесс авторизации через Mojang и ваш бэкенд"))
|
.setTooltip(Component.literal("Запустить процесс авторизации через Mojang и ваш бэкенд"))
|
||||||
.setSaveConsumer(newValue -> {
|
.setSaveConsumer(newValue -> {
|
||||||
if (newValue) {
|
if (newValue) {
|
||||||
BackendAuthenticator.authenticate(MinecraftClient.getInstance());
|
BackendAuthenticator.authenticateAsync(Minecraft.getInstance())
|
||||||
if (SPMega.getConfig() != null) {
|
.thenAccept(authenticated -> {
|
||||||
apiToken[0] = SPMega.getConfig().apiToken();
|
if (authenticated && SPMega.getConfig() != null) {
|
||||||
}
|
apiToken[0] = SPMega.getConfig().apiToken();
|
||||||
|
}
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
apiToken[0] = ModConfig.DEFAULT_API_TOKEN;
|
apiToken[0] = ModConfig.DEFAULT_API_TOKEN;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
general.addEntry(entryBuilder.startBooleanToggle(Text.translatable("option.spmega.sign_quick_pay"), current.signQuickPayEnabled())
|
general.addEntry(entryBuilder.startBooleanToggle(Component.translatable("option.spmega.sign_quick_pay"), current.signQuickPayEnabled())
|
||||||
.setDefaultValue(ModConfig.DEFAULT_SIGN_QUICK_PAY_ENABLED)
|
.setDefaultValue(ModConfig.DEFAULT_SIGN_QUICK_PAY_ENABLED)
|
||||||
.setSaveConsumer(newValue -> signQuickPayEnabled[0] = newValue)
|
.setSaveConsumer(newValue -> signQuickPayEnabled[0] = newValue)
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
general.addEntry(entryBuilder.startEnumSelector(
|
general.addEntry(entryBuilder.startEnumSelector(
|
||||||
Text.translatable("option.spmega.gps_position"),
|
Component.translatable("option.spmega.gps_position"),
|
||||||
GpsHudPosition.class,
|
GpsHudPosition.class,
|
||||||
current.gpsPosition()
|
current.gpsPosition()
|
||||||
)
|
)
|
||||||
.setDefaultValue(ModConfig.DEFAULT_GPS_POSITION)
|
.setDefaultValue(ModConfig.DEFAULT_GPS_POSITION)
|
||||||
.setEnumNameProvider(position -> Text.translatable("option.spmega.gps_position." + position.name().toLowerCase()))
|
.setEnumNameProvider(position -> Component.translatable("option.spmega.gps_position." + position.name().toLowerCase()))
|
||||||
.setSaveConsumer(newValue -> gpsPosition[0] = newValue)
|
.setSaveConsumer(newValue -> gpsPosition[0] = newValue)
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
|
general.addEntry(entryBuilder.startEnumSelector(
|
||||||
|
Component.translatable("option.spmega.notification_position"),
|
||||||
|
GpsHudPosition.class,
|
||||||
|
current.notificationPosition()
|
||||||
|
)
|
||||||
|
.setDefaultValue(ModConfig.DEFAULT_NOTIFICATION_POSITION)
|
||||||
|
.setEnumNameProvider(position -> Component.translatable(
|
||||||
|
"option.spmega.notification_position." + position.name().toLowerCase()))
|
||||||
|
.setSaveConsumer(newValue -> notificationPosition[0] = newValue)
|
||||||
|
.build());
|
||||||
|
|
||||||
|
// Telemetry settings
|
||||||
|
ConfigCategory telemetry = builder.getOrCreateCategory(Component.translatable("category.spmega.telemetry"));
|
||||||
|
|
||||||
|
telemetry.addEntry(entryBuilder.startIntField(Component.translatable("option.spmega.telemetry_interval"), current.telemetryIntervalSeconds())
|
||||||
|
.setDefaultValue(ModConfig.DEFAULT_TELEMETRY_INTERVAL_SECONDS)
|
||||||
|
.setMin(10)
|
||||||
|
.setMax(600)
|
||||||
|
.setTooltip(Component.translatable("tooltip.spmega.telemetry_interval"))
|
||||||
|
.setSaveConsumer(newValue -> telemetryIntervalSeconds[0] = newValue)
|
||||||
|
.build());
|
||||||
|
|
||||||
|
telemetry.addEntry(entryBuilder.startBooleanToggle(Component.translatable("option.spmega.telemetry_system_info"), current.telemetryCollectSystemInfo())
|
||||||
|
.setDefaultValue(ModConfig.DEFAULT_TELEMETRY_COLLECT_SYSTEM_INFO)
|
||||||
|
.setTooltip(Component.translatable("tooltip.spmega.telemetry_system_info"))
|
||||||
|
.setSaveConsumer(newValue -> telemetryCollectSystemInfo[0] = newValue)
|
||||||
|
.build());
|
||||||
|
|
||||||
builder.setSavingRunnable(() -> {
|
builder.setSavingRunnable(() -> {
|
||||||
boolean gpsEnabledVal = true;
|
boolean gpsEnabledVal = true;
|
||||||
if (SPMega.getConfig() != null) {
|
if (SPMega.getConfig() != null) {
|
||||||
@@ -91,7 +125,10 @@ public class ModMenuIntegration implements ModMenuApi {
|
|||||||
allowBackend[0],
|
allowBackend[0],
|
||||||
signQuickPayEnabled[0],
|
signQuickPayEnabled[0],
|
||||||
gpsEnabledVal,
|
gpsEnabledVal,
|
||||||
gpsPosition[0]
|
gpsPosition[0],
|
||||||
|
notificationPosition[0],
|
||||||
|
telemetryIntervalSeconds[0],
|
||||||
|
telemetryCollectSystemInfo[0]
|
||||||
);
|
);
|
||||||
SPMega.setConfig(updated);
|
SPMega.setConfig(updated);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package git.yawaflua.tech.spmega.client;
|
package git.yawaflua.tech.spmega.client;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.platform.InputConstants;
|
||||||
import git.yawaflua.tech.spmega.ModConfig;
|
import git.yawaflua.tech.spmega.ModConfig;
|
||||||
import git.yawaflua.tech.spmega.SPMega;
|
import git.yawaflua.tech.spmega.SPMega;
|
||||||
import git.yawaflua.tech.spmega.client.qr.QRCodeScanner;
|
import git.yawaflua.tech.spmega.client.qr.QRCodeScanner;
|
||||||
|
import git.yawaflua.tech.spmega.client.telemetry.*;
|
||||||
import git.yawaflua.tech.spmega.client.ui.GpsHudRenderer;
|
import git.yawaflua.tech.spmega.client.ui.GpsHudRenderer;
|
||||||
import git.yawaflua.tech.spmega.client.ui.UiNotifications;
|
import git.yawaflua.tech.spmega.client.ui.UiNotifications;
|
||||||
import git.yawaflua.tech.spmega.client.ui.UiOpeners;
|
import git.yawaflua.tech.spmega.client.ui.UiOpeners;
|
||||||
@@ -10,20 +12,24 @@ import git.yawaflua.tech.spmega.client.ui.service.BackendAuthenticator;
|
|||||||
import git.yawaflua.tech.spmega.client.ui.service.BankUiService;
|
import git.yawaflua.tech.spmega.client.ui.service.BankUiService;
|
||||||
import net.fabricmc.api.ClientModInitializer;
|
import net.fabricmc.api.ClientModInitializer;
|
||||||
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
|
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
|
||||||
import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper;
|
import net.fabricmc.fabric.api.client.keymapping.v1.KeyMappingHelper;
|
||||||
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
|
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
|
||||||
import net.fabricmc.fabric.api.client.rendering.v1.HudRenderCallback;
|
/*? if mc_26 {*/
|
||||||
|
import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElementRegistry;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
/*?} else {*/
|
||||||
|
/*import net.fabricmc.fabric.api.client.rendering.v1.HudRenderCallback;
|
||||||
|
*//*?}*/
|
||||||
import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents;
|
import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents;
|
||||||
import net.fabricmc.fabric.api.event.player.UseBlockCallback;
|
import net.fabricmc.fabric.api.event.player.UseBlockCallback;
|
||||||
import net.fabricmc.fabric.api.event.player.UseEntityCallback;
|
import net.fabricmc.fabric.api.event.player.UseEntityCallback;
|
||||||
import net.minecraft.block.entity.SignBlockEntity;
|
import net.minecraft.client.KeyMapping;
|
||||||
import net.minecraft.client.MinecraftClient;
|
import net.minecraft.client.Minecraft;
|
||||||
import net.minecraft.client.option.KeyBinding;
|
import net.minecraft.network.chat.Component;
|
||||||
import net.minecraft.client.util.InputUtil;
|
import net.minecraft.world.InteractionResult;
|
||||||
import net.minecraft.entity.decoration.ItemFrameEntity;
|
import net.minecraft.world.entity.decoration.ItemFrame;
|
||||||
import net.minecraft.entity.player.PlayerEntity;
|
import net.minecraft.world.entity.player.Player;
|
||||||
import net.minecraft.text.Text;
|
import net.minecraft.world.level.block.entity.SignBlockEntity;
|
||||||
import net.minecraft.util.ActionResult;
|
|
||||||
import org.lwjgl.glfw.GLFW;
|
import org.lwjgl.glfw.GLFW;
|
||||||
|
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
@@ -31,9 +37,9 @@ import java.util.regex.Pattern;
|
|||||||
|
|
||||||
public class SPMegaClient implements ClientModInitializer {
|
public class SPMegaClient implements ClientModInitializer {
|
||||||
private static final Pattern ISOLATED_FIVE_DIGITS = Pattern.compile("(?<!\\d)(\\d{5})(?!\\d)");
|
private static final Pattern ISOLATED_FIVE_DIGITS = Pattern.compile("(?<!\\d)(\\d{5})(?!\\d)");
|
||||||
private static KeyBinding openBankMenuKeyBinding;
|
private static KeyMapping openBankMenuKeyBinding;
|
||||||
private static KeyBinding scanQrKeyBinding;
|
private static KeyMapping scanQrKeyBinding;
|
||||||
private static KeyBinding toggleGpsKeyBinding;
|
private static KeyMapping toggleGpsKeyBinding;
|
||||||
private static boolean wasPaymentShortcutPressed = false;
|
private static boolean wasPaymentShortcutPressed = false;
|
||||||
|
|
||||||
|
|
||||||
@@ -45,15 +51,15 @@ public class SPMegaClient implements ClientModInitializer {
|
|||||||
return findFiveDigits(signBlockEntity.getBackText().getMessages(false));
|
return findFiveDigits(signBlockEntity.getBackText().getMessages(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String extractCardNumber(ItemFrameEntity itemFrameEntity) {
|
private static String extractCardNumber(ItemFrame itemFrameEntity) {
|
||||||
if (itemFrameEntity.getHeldItemStack().isEmpty()) {
|
if (itemFrameEntity.getItem().isEmpty()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return findFiveDigits(itemFrameEntity.getHeldItemStack().getName().getString());
|
return findFiveDigits(itemFrameEntity.getItem().getHoverName().getString());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String findFiveDigits(Text[] lines) {
|
private static String findFiveDigits(Component[] lines) {
|
||||||
for (Text line : lines) {
|
for (Component line : lines) {
|
||||||
String candidate = findFiveDigits(line.getString());
|
String candidate = findFiveDigits(line.getString());
|
||||||
if (candidate != null) {
|
if (candidate != null) {
|
||||||
return candidate;
|
return candidate;
|
||||||
@@ -73,25 +79,41 @@ public class SPMegaClient implements ClientModInitializer {
|
|||||||
@Override
|
@Override
|
||||||
public void onInitializeClient() {
|
public void onInitializeClient() {
|
||||||
GpsHudRenderer.instance();
|
GpsHudRenderer.instance();
|
||||||
openBankMenuKeyBinding = KeyBindingHelper.registerKeyBinding(new KeyBinding(
|
|
||||||
|
UiNotifications.instance().showMessage("Этот мод собирает телеметрию. Проверьте настройки.");
|
||||||
|
|
||||||
|
|
||||||
|
openBankMenuKeyBinding = KeyMappingHelper.registerKeyMapping(new KeyMapping(
|
||||||
"key.spmega.open_menu",
|
"key.spmega.open_menu",
|
||||||
InputUtil.Type.KEYSYM,
|
InputConstants.Type.KEYSYM,
|
||||||
GLFW.GLFW_KEY_P,
|
GLFW.GLFW_KEY_P,
|
||||||
KeyBinding.Category.GAMEPLAY
|
/*? if mc_1_21_11 {*/
|
||||||
|
KeyMapping.Category.GAMEPLAY
|
||||||
|
/*?} else {*/
|
||||||
|
// "key.categories.gameplay"
|
||||||
|
/*?}*/
|
||||||
));
|
));
|
||||||
|
|
||||||
scanQrKeyBinding = KeyBindingHelper.registerKeyBinding(new KeyBinding(
|
scanQrKeyBinding = KeyMappingHelper.registerKeyMapping(new KeyMapping(
|
||||||
"key.spmega.scan_qr",
|
"key.spmega.scan_qr",
|
||||||
InputUtil.Type.KEYSYM,
|
InputConstants.Type.KEYSYM,
|
||||||
GLFW.GLFW_KEY_O,
|
GLFW.GLFW_KEY_O,
|
||||||
KeyBinding.Category.GAMEPLAY
|
/*? if mc_1_21_11 {*/
|
||||||
|
KeyMapping.Category.GAMEPLAY
|
||||||
|
/*?} else {*/
|
||||||
|
// "key.categories.gameplay"
|
||||||
|
/*?}*/
|
||||||
));
|
));
|
||||||
|
|
||||||
toggleGpsKeyBinding = KeyBindingHelper.registerKeyBinding(new KeyBinding(
|
toggleGpsKeyBinding = KeyMappingHelper.registerKeyMapping(new KeyMapping(
|
||||||
"key.spmega.toggle_gps",
|
"key.spmega.toggle_gps",
|
||||||
InputUtil.Type.KEYSYM,
|
InputConstants.Type.KEYSYM,
|
||||||
GLFW.GLFW_KEY_J,
|
GLFW.GLFW_KEY_J,
|
||||||
KeyBinding.Category.GAMEPLAY
|
/*? if mc_1_21_11 {*/
|
||||||
|
KeyMapping.Category.GAMEPLAY
|
||||||
|
/*?} else {*/
|
||||||
|
// "key.categories.gameplay"
|
||||||
|
/*?}*/
|
||||||
));
|
));
|
||||||
|
|
||||||
if (SPMega.getConfig() != null) {
|
if (SPMega.getConfig() != null) {
|
||||||
@@ -99,14 +121,16 @@ public class SPMegaClient implements ClientModInitializer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ClientTickEvents.END_CLIENT_TICK.register(client -> {
|
ClientTickEvents.END_CLIENT_TICK.register(client -> {
|
||||||
|
PerformanceSampler.instance().tick();
|
||||||
UiNotifications.instance().tick();
|
UiNotifications.instance().tick();
|
||||||
while (openBankMenuKeyBinding.wasPressed()) {
|
BackendAuthenticator.tickTokenRefresh(client);
|
||||||
|
while (openBankMenuKeyBinding.consumeClick()) {
|
||||||
UiOpeners.openMainMenu(client);
|
UiOpeners.openMainMenu(client);
|
||||||
}
|
}
|
||||||
while (scanQrKeyBinding.wasPressed()) {
|
while (scanQrKeyBinding.consumeClick()) {
|
||||||
QRCodeScanner.ScanQrCode(client);
|
QRCodeScanner.scanQrCode(client);
|
||||||
}
|
}
|
||||||
while (toggleGpsKeyBinding.wasPressed()) {
|
while (toggleGpsKeyBinding.consumeClick()) {
|
||||||
GpsHudRenderer.instance().toggle();
|
GpsHudRenderer.instance().toggle();
|
||||||
if (SPMega.getConfig() != null) {
|
if (SPMega.getConfig() != null) {
|
||||||
ModConfig current = SPMega.getConfig();
|
ModConfig current = SPMega.getConfig();
|
||||||
@@ -116,20 +140,23 @@ public class SPMegaClient implements ClientModInitializer {
|
|||||||
current.allowBackend(),
|
current.allowBackend(),
|
||||||
current.signQuickPayEnabled(),
|
current.signQuickPayEnabled(),
|
||||||
GpsHudRenderer.instance().isEnabled(),
|
GpsHudRenderer.instance().isEnabled(),
|
||||||
current.gpsPosition()
|
current.gpsPosition(),
|
||||||
|
current.notificationPosition(),
|
||||||
|
current.telemetryIntervalSeconds(),
|
||||||
|
current.telemetryCollectSystemInfo()
|
||||||
);
|
);
|
||||||
SPMega.setConfig(updated);
|
SPMega.setConfig(updated);
|
||||||
}
|
}
|
||||||
String status = GpsHudRenderer.instance().isEnabled() ? "включен" : "выключен";
|
String status = GpsHudRenderer.instance().isEnabled() ? "включен" : "выключен";
|
||||||
UiNotifications.instance().show(Text.literal("GPS Ада " + status));
|
UiNotifications.instance().show(Component.literal("GPS Ада " + status));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (client.player != null && client.options != null) {
|
if (client.player != null && client.options != null) {
|
||||||
boolean isCombinationPressed = client.options.sprintKey.isPressed() && client.options.sneakKey.isPressed() && client.options.leftKey.isPressed();
|
boolean isCombinationPressed = client.options.keySprint.isDown() && client.options.keyShift.isDown() && client.options.keyLeft.isDown();
|
||||||
if (isCombinationPressed) {
|
if (isCombinationPressed) {
|
||||||
if (!wasPaymentShortcutPressed && client.currentScreen == null) {
|
if (!wasPaymentShortcutPressed && client.gui.screen() == null) {
|
||||||
if (client.targetedEntity instanceof PlayerEntity targetedPlayer && targetedPlayer != client.player) {
|
if (client.crosshairPickEntity instanceof Player targetedPlayer && targetedPlayer != client.player) {
|
||||||
String recipient = targetedPlayer.getStringifiedName();
|
String recipient = targetedPlayer.getScoreboardName();
|
||||||
UiOpeners.openPaymentMenu(client, recipient);
|
UiOpeners.openPaymentMenu(client, recipient);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,106 +167,120 @@ public class SPMegaClient implements ClientModInitializer {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
HudRenderCallback.EVENT.register((drawContext, tickDeltaManager) -> {
|
/*? if mc_26 {*/
|
||||||
MinecraftClient client = MinecraftClient.getInstance();
|
HudElementRegistry.addLast(Identifier.fromNamespaceAndPath("spmega", "main_hud"), (drawContext, tickDeltaManager) -> {
|
||||||
if (client.currentScreen != null || client.textRenderer == null) {
|
/*?} else {*/
|
||||||
|
/*HudRenderCallback.EVENT.register((drawContext, tickDeltaManager) -> {
|
||||||
|
*//*?}*/
|
||||||
|
Minecraft client = Minecraft.getInstance();
|
||||||
|
if (client.gui.screen() != null || client.font == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
UiNotifications.instance().render(
|
UiNotifications.instance().render(
|
||||||
drawContext,
|
drawContext,
|
||||||
client.textRenderer,
|
client.font,
|
||||||
client.getWindow().getScaledWidth(),
|
client.getWindow().getGuiScaledWidth(),
|
||||||
client.getWindow().getScaledHeight()
|
client.getWindow().getGuiScaledHeight()
|
||||||
);
|
);
|
||||||
GpsHudRenderer.instance().render(
|
GpsHudRenderer.instance().render(
|
||||||
drawContext,
|
drawContext,
|
||||||
client.textRenderer,
|
client.font,
|
||||||
client.getWindow().getScaledWidth(),
|
client.getWindow().getGuiScaledWidth(),
|
||||||
client.getWindow().getScaledHeight()
|
client.getWindow().getGuiScaledHeight()
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
ScreenEvents.AFTER_INIT.register((client, screen, scaledWidth, scaledHeight) -> {
|
ScreenEvents.AFTER_INIT.register((client, screen, scaledWidth, scaledHeight) -> {
|
||||||
ScreenEvents.afterRender(screen).register((screenInstance, drawContext, mouseX, mouseY, tickDelta) -> {
|
ScreenEvents.afterExtract(screen).register((screenInstance, drawContext, mouseX, mouseY, tickDelta) -> {
|
||||||
if (client.textRenderer == null) {
|
if (client.font == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
UiNotifications.instance().render(drawContext, client.textRenderer, scaledWidth, scaledHeight);
|
UiNotifications.instance().render(drawContext, client.font, scaledWidth, scaledHeight);
|
||||||
});
|
});
|
||||||
// CompletableFuture.supplyAsync(() -> BackendAuthenticator.authenticate(client))
|
|
||||||
// .thenAccept(authResult -> {
|
|
||||||
// if (authResult) {
|
|
||||||
// System.out.println("SPMega: Authenticated on backend successfully.");
|
|
||||||
// } else {
|
|
||||||
// System.err.println("SPMega: Authentication on backend failed");
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
// .exceptionally(ex -> {
|
|
||||||
// System.err.println("SPMega: Authentication error: " + ex.getMessage());
|
|
||||||
// return null;
|
|
||||||
// });
|
|
||||||
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> {
|
ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> {
|
||||||
if (client.player != null) {
|
if (client.player != null) {
|
||||||
BankUiService.instance().refreshOnServerJoin(client.player.getUuidAsString());
|
BankUiService.instance().refreshOnServerJoinAsync(client.player.getStringUUID());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
UseBlockCallback.EVENT.register((player, world, hand, hitResult) -> {
|
UseBlockCallback.EVENT.register((player, world, hand, hitResult) -> {
|
||||||
if (!world.isClient()) {
|
if (!world.isClientSide()) {
|
||||||
return ActionResult.PASS;
|
return InteractionResult.PASS;
|
||||||
}
|
}
|
||||||
if (!player.isSneaking()) {
|
if (!player.isShiftKeyDown()) {
|
||||||
return ActionResult.PASS;
|
return InteractionResult.PASS;
|
||||||
}
|
}
|
||||||
if (SPMega.getConfig() == null || !SPMega.getConfig().signQuickPayEnabled()) {
|
if (SPMega.getConfig() == null || !SPMega.getConfig().signQuickPayEnabled()) {
|
||||||
return ActionResult.PASS;
|
return InteractionResult.PASS;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (world.getBlockEntity(hitResult.getBlockPos()) instanceof SignBlockEntity signBlockEntity) {
|
if (world.getBlockEntity(hitResult.getBlockPos()) instanceof SignBlockEntity signBlockEntity) {
|
||||||
String cardNumber = extractCardNumber(signBlockEntity);
|
String cardNumber = extractCardNumber(signBlockEntity);
|
||||||
if (cardNumber == null) {
|
if (cardNumber == null) {
|
||||||
return ActionResult.PASS;
|
return InteractionResult.PASS;
|
||||||
}
|
}
|
||||||
|
|
||||||
UiOpeners.openPaymentMenu(MinecraftClient.getInstance(), cardNumber);
|
UiOpeners.openPaymentMenu(Minecraft.getInstance(), cardNumber);
|
||||||
return ActionResult.SUCCESS;
|
return InteractionResult.SUCCESS;
|
||||||
} else {
|
} else {
|
||||||
return ActionResult.PASS;
|
return InteractionResult.PASS;
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
UseEntityCallback.EVENT.register((player, world, hand, entity, hitResult) -> {
|
UseEntityCallback.EVENT.register((player, world, hand, entity, hitResult) -> {
|
||||||
if (!world.isClient()) {
|
if (!world.isClientSide()) {
|
||||||
return ActionResult.PASS;
|
return InteractionResult.PASS;
|
||||||
}
|
}
|
||||||
if (!player.isSneaking()) {
|
if (!player.isShiftKeyDown()) {
|
||||||
return ActionResult.PASS;
|
return InteractionResult.PASS;
|
||||||
}
|
}
|
||||||
if (SPMega.getConfig() == null || !SPMega.getConfig().signQuickPayEnabled()) {
|
if (SPMega.getConfig() == null || !SPMega.getConfig().signQuickPayEnabled()) {
|
||||||
return ActionResult.PASS;
|
return InteractionResult.PASS;
|
||||||
}
|
}
|
||||||
if (entity instanceof ItemFrameEntity itemFrameEntity) {
|
if (entity instanceof ItemFrame itemFrameEntity) {
|
||||||
String cardNumber = extractCardNumber(itemFrameEntity);
|
String cardNumber = extractCardNumber(itemFrameEntity);
|
||||||
if (cardNumber == null) {
|
if (cardNumber == null) {
|
||||||
return ActionResult.PASS;
|
return InteractionResult.PASS;
|
||||||
}
|
}
|
||||||
|
|
||||||
UiOpeners.openPaymentMenu(MinecraftClient.getInstance(), cardNumber);
|
UiOpeners.openPaymentMenu(Minecraft.getInstance(), cardNumber);
|
||||||
return ActionResult.SUCCESS;
|
return InteractionResult.SUCCESS;
|
||||||
} else {
|
} else {
|
||||||
return ActionResult.PASS;
|
return InteractionResult.PASS;
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
new ChatListener().register();
|
new ChatListener().register();
|
||||||
BackendAuthenticator.authenticate(MinecraftClient.getInstance());
|
WebhookNotificationPoller.instance().start();
|
||||||
|
|
||||||
|
// Telemetry init
|
||||||
|
long clientInitStart = System.nanoTime();
|
||||||
|
ModConfig cfg = SPMega.getConfig();
|
||||||
|
int interval = (cfg != null) ? cfg.telemetryIntervalSeconds() : ModConfig.DEFAULT_TELEMETRY_INTERVAL_SECONDS;
|
||||||
|
SystemInfoCollector.instance().init();
|
||||||
|
TelemetrySender.instance().start(interval);
|
||||||
|
long clientInitMs = (System.nanoTime() - clientInitStart) / 1_000_000L;
|
||||||
|
com.google.gson.JsonObject initPayload = new com.google.gson.JsonObject();
|
||||||
|
initPayload.addProperty("phase", "client_init");
|
||||||
|
initPayload.addProperty("durationMs", clientInitMs);
|
||||||
|
TelemetryCollector.instance().record(TelemetryEvent.now("lifecycle", initPayload));
|
||||||
|
|
||||||
|
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
||||||
|
com.google.gson.JsonObject deinitPayload = new com.google.gson.JsonObject();
|
||||||
|
deinitPayload.addProperty("phase", "client_deinit");
|
||||||
|
TelemetryCollector.instance().record(TelemetryEvent.now("lifecycle", deinitPayload));
|
||||||
|
TelemetrySender.instance().flush();
|
||||||
|
TelemetrySender.instance().stop();
|
||||||
|
}, "SPMega-Telemetry-Shutdown"));
|
||||||
|
|
||||||
System.out.println("Author of SPMega make it with 4 cans of monster");
|
System.out.println("Author of SPMega make it with 4 cans of monster");
|
||||||
|
System.out.println("If u want to see more updates - give me like 10 shekels for monster plzzz");
|
||||||
System.out.println("Initialized beshalom! Tieie tovim!");
|
System.out.println("Initialized beshalom! Tieie tovim!");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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 java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
Minecraft client = Minecraft.getInstance();
|
||||||
|
if (client == null || notifications.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
client.execute(() -> notifications.forEach(notification ->
|
||||||
|
UiNotifications.instance().showQueued(Component.literal(format(notification)))));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package git.yawaflua.tech.spmega.client.api;
|
||||||
|
|
||||||
|
import com.google.gson.*;
|
||||||
|
import git.yawaflua.tech.spmega.client.telemetry.InstrumentedHttpClient;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
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;
|
||||||
|
private final Gson gson;
|
||||||
|
private final String baseUrl;
|
||||||
|
|
||||||
|
public SPWorldsApiClient(String baseUrl) {
|
||||||
|
this.httpClient = new InstrumentedHttpClient();
|
||||||
|
this.gson = new Gson();
|
||||||
|
this.baseUrl = normalizeBaseUrl(baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String encodeAuth(CardAuth auth) {
|
||||||
|
String raw = auth.cardId() + ":" + auth.cardToken();
|
||||||
|
return Base64.getEncoder().encodeToString(raw.getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeBaseUrl(String rawBaseUrl) {
|
||||||
|
String fallback = "https://spworlds.ru";
|
||||||
|
if (rawBaseUrl == null || rawBaseUrl.trim().isEmpty()) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
String value = rawBaseUrl.trim();
|
||||||
|
if (value.endsWith("/")) {
|
||||||
|
return value.substring(0, value.length() - 1);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String getString(JsonObject json, String key) {
|
||||||
|
if (!json.has(key) || json.get(key).isJsonNull()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return json.get(key).getAsString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<CardInfo> getCardInfoAsync(CardAuth auth) {
|
||||||
|
HttpRequest request = requestBuilder("/api/public/card", auth).GET().build();
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<List<PlayerCard>> getPlayerCardsAsync(String username, CardAuth auth) {
|
||||||
|
HttpRequest request = requestBuilder("/api/public/accounts/" + username + "/cards", auth).GET().build();
|
||||||
|
return sendAsync(request).thenApply(body -> {
|
||||||
|
try {
|
||||||
|
JsonArray json = JsonParser.parseString(body).getAsJsonArray();
|
||||||
|
List<PlayerCard> cards = new ArrayList<>();
|
||||||
|
for (JsonElement element : json) {
|
||||||
|
JsonObject card = element.getAsJsonObject();
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<AccountMe> getAccountMeAsync(CardAuth auth) {
|
||||||
|
HttpRequest request = requestBuilder("/api/public/accounts/me", auth).GET().build();
|
||||||
|
return sendAsync(request).thenApply(body -> {
|
||||||
|
try {
|
||||||
|
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
||||||
|
|
||||||
|
String id = json.has("id") ? json.get("id").getAsString() : "";
|
||||||
|
String username = json.has("username") ? json.get("username").getAsString() : "";
|
||||||
|
String minecraftUuid = json.has("minecraftUUID") ? json.get("minecraftUUID").getAsString() : "";
|
||||||
|
|
||||||
|
List<AccountCard> cards = new ArrayList<>();
|
||||||
|
if (json.has("cards") && json.get("cards").isJsonArray()) {
|
||||||
|
for (JsonElement element : json.getAsJsonArray("cards")) {
|
||||||
|
JsonObject card = element.getAsJsonObject();
|
||||||
|
cards.add(new AccountCard(
|
||||||
|
getString(card, "id"),
|
||||||
|
getString(card, "name"),
|
||||||
|
getString(card, "number"),
|
||||||
|
card.has("color") && !card.get("color").isJsonNull() ? card.get("color").getAsInt() : 0
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AccountMe(id, username, minecraftUuid, cards);
|
||||||
|
} catch (Exception exception) {
|
||||||
|
System.out.println("Failed to parse account info response: " + exception.getMessage());
|
||||||
|
System.out.println(body);
|
||||||
|
throw new CompletionException(new IOException("Failed to parse account info response", exception));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<TransactionResult> createTransactionAsync(
|
||||||
|
CardAuth auth, String receiver, long amount, String comment) {
|
||||||
|
JsonObject payload = new JsonObject();
|
||||||
|
payload.addProperty("receiver", receiver);
|
||||||
|
payload.addProperty("amount", amount);
|
||||||
|
payload.addProperty("comment", comment.isEmpty() ? "Перевод через SPMega" : comment);
|
||||||
|
|
||||||
|
HttpRequest request = requestBuilder("/api/public/transactions", auth)
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(gson.toJson(payload)))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return HttpRequest.newBuilder(URI.create(baseUrl + path))
|
||||||
|
.header("Authorization", "Bearer " + encodeAuth(auth))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Accept", "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
private CompletableFuture<String> sendAsync(HttpRequest request) {
|
||||||
|
return httpClient.sendAsync(request).thenApply(response -> {
|
||||||
|
int statusCode = response.statusCode();
|
||||||
|
if (statusCode < 200 || statusCode >= 300) {
|
||||||
|
System.out.println(response.body());
|
||||||
|
JsonElement parsed = JsonParser.parseString(response.body());
|
||||||
|
JsonObject object = parsed.isJsonArray()
|
||||||
|
? parsed.getAsJsonArray().get(0).getAsJsonObject()
|
||||||
|
: parsed.getAsJsonObject();
|
||||||
|
String message = "";
|
||||||
|
if (object.has("error") && !object.get("error").isJsonNull()) {
|
||||||
|
message = "Ошибка в запросе: " + object.get("error").getAsString();
|
||||||
|
} else if (object.has("message") && !object.get("message").isJsonNull()) {
|
||||||
|
message = object.get("message").getAsString();
|
||||||
|
}
|
||||||
|
throw new CompletionException(new IOException(statusCode + ": " + message));
|
||||||
|
}
|
||||||
|
return response.body();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CardAuth(String cardId, String cardToken) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CardInfo(long balance, String webhook) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PlayerCard(String name, String number) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record AccountMe(String id, String username, String minecraftUuid, List<AccountCard> cards) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record AccountCard(String id, String name, String number, int color) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record TransactionResult(long balance) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,95 +4,157 @@ import com.google.zxing.*;
|
|||||||
import com.google.zxing.common.GlobalHistogramBinarizer;
|
import com.google.zxing.common.GlobalHistogramBinarizer;
|
||||||
import com.google.zxing.common.HybridBinarizer;
|
import com.google.zxing.common.HybridBinarizer;
|
||||||
import com.google.zxing.multi.GenericMultipleBarcodeReader;
|
import com.google.zxing.multi.GenericMultipleBarcodeReader;
|
||||||
|
import git.yawaflua.tech.spmega.client.telemetry.TelemetryCollector;
|
||||||
|
import git.yawaflua.tech.spmega.client.telemetry.TelemetryEvent;
|
||||||
import git.yawaflua.tech.spmega.client.ui.QRcodeAcceptScreen;
|
import git.yawaflua.tech.spmega.client.ui.QRcodeAcceptScreen;
|
||||||
import git.yawaflua.tech.spmega.client.ui.UiNotifications;
|
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.Collections;
|
||||||
import java.util.EnumMap;
|
import java.util.EnumMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import com.mojang.blaze3d.platform.NativeImage;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.Screenshot;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
|
||||||
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() {
|
||||||
if (client == null || client.player == null || client.getFramebuffer() == null) {
|
}
|
||||||
|
|
||||||
|
public static void scanQrCode(Minecraft client) {
|
||||||
|
if (client == null || client.player == null || client.gameRenderer.mainRenderTarget() == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
UiNotifications notifications = UiNotifications.instance();
|
UiNotifications notifications = UiNotifications.instance();
|
||||||
int framebufferWidth = client.getWindow().getFramebufferWidth();
|
int framebufferWidth = client.getWindow().getWidth();
|
||||||
int framebufferHeight = client.getWindow().getFramebufferHeight();
|
int framebufferHeight = client.getWindow().getHeight();
|
||||||
if (framebufferWidth <= 0 || framebufferHeight <= 0) {
|
if (framebufferWidth <= 0 || framebufferHeight <= 0) {
|
||||||
client.player.sendMessage(Text.translatable("message.spmega.qr.capture_failed"), false);
|
/*? if mc_26 {*/
|
||||||
|
client.player.sendSystemMessage(Component.translatable("message.spmega.qr.capture_failed"));
|
||||||
|
/*?} else {*/
|
||||||
|
/*client.player.displayClientMessage(Component.translatable("message.spmega.qr.capture_failed"), false);
|
||||||
|
*//*?}*/
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
long startNs = System.nanoTime();
|
||||||
try {
|
try {
|
||||||
ScreenshotRecorder.takeScreenshot(client.getFramebuffer(), nativeImage -> {
|
/*? if mc_1_21_11 {*/
|
||||||
try {
|
Screenshot.takeScreenshot(client.gameRenderer.mainRenderTarget(), nativeImage -> {
|
||||||
if (nativeImage == null || nativeImage.getWidth() <= 0 || nativeImage.getHeight() <= 0) {
|
processCapturedImage(client, notifications, nativeImage, startNs);
|
||||||
client.execute(() -> {
|
|
||||||
if (client.player != null) {
|
|
||||||
notifications.show(Text.translatable("message.spmega.qr.capture_failed"));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String result = decodeQRCode(nativeImageToBufferedImage(nativeImage));
|
|
||||||
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));
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
if (nativeImage != null) {
|
|
||||||
nativeImage.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
/*?} else {*/
|
||||||
|
// processCapturedImage(client, notifications, Screenshot.takeScreenshot(client.gameRenderer.mainRenderTarget()), startNs);
|
||||||
|
/*?}*/
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
notifications.show(Text.translatable("message.spmega.qr.error"));
|
notifications.show(Component.translatable("message.spmega.qr.error"));
|
||||||
|
recordQrEvent(false, false, null, ex.getClass().getSimpleName() + ": " + ex.getMessage(), startNs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static BufferedImage nativeImageToBufferedImage(NativeImage screenshot) {
|
private static void processCapturedImage(
|
||||||
BufferedImage bufferedImage = new BufferedImage(
|
Minecraft client,
|
||||||
screenshot.getWidth(),
|
UiNotifications notifications,
|
||||||
screenshot.getHeight(),
|
NativeImage nativeImage,
|
||||||
BufferedImage.TYPE_INT_RGB
|
long startNs
|
||||||
);
|
) {
|
||||||
|
try {
|
||||||
|
if (nativeImage == null || nativeImage.getWidth() <= 0 || nativeImage.getHeight() <= 0) {
|
||||||
|
client.execute(() -> {
|
||||||
|
if (client.player != null) {
|
||||||
|
notifications.show(Component.translatable("message.spmega.qr.capture_failed"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
recordQrEvent(false, false, null, "capture_failed", startNs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (int y = 0; y < screenshot.getHeight(); y++) {
|
int width = nativeImage.getWidth();
|
||||||
for (int x = 0; x < screenshot.getWidth(); x++) {
|
int height = nativeImage.getHeight();
|
||||||
int color = screenshot.getColorArgb(x, y);
|
/*? if mc_1_21_11 {*/
|
||||||
bufferedImage.setRGB(x, y, color);
|
int[] pixels = nativeImage.getPixels();
|
||||||
|
/*?} else {*/
|
||||||
|
// int[] pixels = nativeImage.getPixelsRGBA();
|
||||||
|
/*?}*/
|
||||||
|
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(Component.translatable("message.spmega.qr.error")));
|
||||||
|
recordQrEvent(false, false, null,
|
||||||
|
exception.getClass().getSimpleName() + ": " + exception.getMessage(), startNs);
|
||||||
|
} finally {
|
||||||
|
if (nativeImage != null) {
|
||||||
|
nativeImage.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return bufferedImage;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String decodeQRCode(BufferedImage image) {
|
private static void showResult(
|
||||||
|
Minecraft client,
|
||||||
|
UiNotifications notifications,
|
||||||
|
String result,
|
||||||
|
Throwable throwable
|
||||||
|
) {
|
||||||
|
if (client.player == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (throwable != null) {
|
||||||
|
notifications.show(Component.translatable("message.spmega.qr.error"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result == null) {
|
||||||
|
notifications.show(Component.translatable("message.spmega.qr.not_found"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Component clickableLink = Component.literal(result).withStyle(style -> style.withInsertion(result));
|
||||||
|
/*? if mc_26 {*/
|
||||||
|
client.player.sendSystemMessage(Component.translatable("message.spmega.qr.found_link", clickableLink));
|
||||||
|
/*?} else {*/
|
||||||
|
/*client.player.displayClientMessage(Component.translatable("message.spmega.qr.found_link", clickableLink), false);
|
||||||
|
*//*?}*/
|
||||||
|
client.gui.setScreen(new QRcodeAcceptScreen(result, client.gui.screen()));
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
payload.addProperty("didLag", didLag);
|
||||||
|
payload.addProperty("durationMs", (System.nanoTime() - startNs) / 1_000_000L);
|
||||||
|
if (decodedUrl != null) {
|
||||||
|
payload.addProperty("decodedUrl", decodedUrl);
|
||||||
|
}
|
||||||
|
if (error != null) {
|
||||||
|
payload.addProperty("error", error);
|
||||||
|
}
|
||||||
|
TelemetryCollector.instance().record(TelemetryEvent.now("qr_scan", payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String decodeQrCode(int width, int height, int[] pixels) {
|
||||||
Map<DecodeHintType, Object> hints = new EnumMap<>(DecodeHintType.class);
|
Map<DecodeHintType, Object> hints = new EnumMap<>(DecodeHintType.class);
|
||||||
hints.put(DecodeHintType.TRY_HARDER, Boolean.TRUE);
|
hints.put(DecodeHintType.TRY_HARDER, Boolean.TRUE);
|
||||||
hints.put(DecodeHintType.POSSIBLE_FORMATS, Collections.singletonList(BarcodeFormat.QR_CODE));
|
hints.put(DecodeHintType.POSSIBLE_FORMATS, Collections.singletonList(BarcodeFormat.QR_CODE));
|
||||||
hints.put(DecodeHintType.CHARACTER_SET, "UTF-8");
|
hints.put(DecodeHintType.CHARACTER_SET, "UTF-8");
|
||||||
hints.put(DecodeHintType.ALSO_INVERTED, Boolean.TRUE);
|
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);
|
String result = tryDecodeWithStrategies(source, hints);
|
||||||
|
|
||||||
@@ -128,4 +190,3 @@ public class QRCodeScanner {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package git.yawaflua.tech.spmega.client.telemetry;
|
||||||
|
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import git.yawaflua.tech.spmega.client.ui.service.BackendAuthenticator;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
|
public record InstrumentedHttpClient(HttpClient delegate) {
|
||||||
|
public InstrumentedHttpClient() {
|
||||||
|
this(HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(15))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void recordHttpEvent(URI uri, String method, int statusCode,
|
||||||
|
long durationMs, boolean success, String errorType,
|
||||||
|
int fpsBefore, int fpsAfter) {
|
||||||
|
JsonObject payload = new JsonObject();
|
||||||
|
payload.addProperty("target", TelemetryUriSanitizer.classifyTarget(uri));
|
||||||
|
payload.addProperty("method", method);
|
||||||
|
payload.addProperty("path", TelemetryUriSanitizer.sanitize(uri));
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<HttpResponse<String>> sendAsync(HttpRequest request) {
|
||||||
|
URI uri = request.uri();
|
||||||
|
String method = request.method();
|
||||||
|
|
||||||
|
int fpsBefore = PerformanceSampler.instance().currentFps();
|
||||||
|
long startNs = System.nanoTime();
|
||||||
|
|
||||||
|
return delegate.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
||||||
|
.whenComplete((response, throwable) -> {
|
||||||
|
long durationMs = (System.nanoTime() - startNs) / 1_000_000L;
|
||||||
|
int fpsAfter = PerformanceSampler.instance().currentFps();
|
||||||
|
|
||||||
|
if (throwable != null) {
|
||||||
|
recordHttpEvent(uri, method, -1, durationMs,
|
||||||
|
false, throwable.getClass().getSimpleName(), fpsBefore, fpsAfter);
|
||||||
|
} else {
|
||||||
|
recordHttpEvent(uri, method, response.statusCode(), durationMs,
|
||||||
|
true, null, fpsBefore, fpsAfter);
|
||||||
|
if (response.statusCode() == 401
|
||||||
|
&& response.headers().firstValue("X-SPMega-Reauthenticate")
|
||||||
|
.map("true"::equalsIgnoreCase)
|
||||||
|
.orElse(false)) {
|
||||||
|
BackendAuthenticator.onReauthenticationRequired();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package git.yawaflua.tech.spmega.client.telemetry;
|
||||||
|
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
|
||||||
|
public final class PerformanceSampler {
|
||||||
|
private static final PerformanceSampler INSTANCE = new PerformanceSampler();
|
||||||
|
|
||||||
|
private final AtomicInteger fpsSum = new AtomicInteger();
|
||||||
|
private final AtomicInteger fpsCount = new AtomicInteger();
|
||||||
|
private final AtomicInteger fpsMin = new AtomicInteger(Integer.MAX_VALUE);
|
||||||
|
private final AtomicInteger fpsMax = new AtomicInteger(0);
|
||||||
|
private final AtomicLong lastSnapshotMs = new AtomicLong(System.currentTimeMillis());
|
||||||
|
|
||||||
|
private PerformanceSampler() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PerformanceSampler instance() {
|
||||||
|
return INSTANCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void tick() {
|
||||||
|
int fps = Minecraft.getInstance().getFps();
|
||||||
|
fpsSum.addAndGet(fps);
|
||||||
|
fpsCount.incrementAndGet();
|
||||||
|
fpsMin.updateAndGet(prev -> Math.min(prev, fps));
|
||||||
|
fpsMax.updateAndGet(prev -> Math.max(prev, fps));
|
||||||
|
}
|
||||||
|
|
||||||
|
public int currentFps() {
|
||||||
|
return Minecraft.getInstance().getFps();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void emitSnapshot() {
|
||||||
|
int count = fpsCount.getAndSet(0);
|
||||||
|
int sum = fpsSum.getAndSet(0);
|
||||||
|
int min = fpsMin.getAndSet(Integer.MAX_VALUE);
|
||||||
|
int max = fpsMax.getAndSet(0);
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
long periodMs = now - lastSnapshotMs.getAndSet(now);
|
||||||
|
|
||||||
|
if (count == 0) return;
|
||||||
|
|
||||||
|
JsonObject payload = new JsonObject();
|
||||||
|
payload.addProperty("fpsAvg", sum / count);
|
||||||
|
payload.addProperty("fpsMin", min == Integer.MAX_VALUE ? 0 : min);
|
||||||
|
payload.addProperty("fpsMax", max);
|
||||||
|
payload.addProperty("sampleCount", count);
|
||||||
|
payload.addProperty("periodMs", periodMs);
|
||||||
|
|
||||||
|
Runtime rt = Runtime.getRuntime();
|
||||||
|
payload.addProperty("usedMemoryMb", (rt.totalMemory() - rt.freeMemory()) / (1024 * 1024));
|
||||||
|
payload.addProperty("totalMemoryMb", rt.totalMemory() / (1024 * 1024));
|
||||||
|
payload.addProperty("maxMemoryMb", rt.maxMemory() / (1024 * 1024));
|
||||||
|
|
||||||
|
TelemetryCollector.instance().record(TelemetryEvent.now("performance_snapshot", payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package git.yawaflua.tech.spmega.client.telemetry;
|
||||||
|
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import org.lwjgl.opengl.GL11;
|
||||||
|
import oshi.SystemInfo;
|
||||||
|
import oshi.hardware.CentralProcessor;
|
||||||
|
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.nio.file.FileStore;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
|
||||||
|
public final class SystemInfoCollector {
|
||||||
|
private static final SystemInfoCollector INSTANCE = new SystemInfoCollector();
|
||||||
|
private static final long MB = 1024L * 1024L;
|
||||||
|
|
||||||
|
private final CentralProcessor cpu = new SystemInfo().getHardware().getProcessor();
|
||||||
|
private final AtomicReference<String> cachedIp = new AtomicReference<>("<unknown>");
|
||||||
|
private final AtomicReference<String> gpuRenderer = new AtomicReference<>("<pending>");
|
||||||
|
private final AtomicReference<String> gpuVendor = new AtomicReference<>("<pending>");
|
||||||
|
private volatile boolean gpuCollected = false;
|
||||||
|
|
||||||
|
private SystemInfoCollector() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SystemInfoCollector instance() {
|
||||||
|
return INSTANCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void init() {
|
||||||
|
fetchIpAsync();
|
||||||
|
collectGpuOnRenderThread();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fetchIpAsync() {
|
||||||
|
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() {
|
||||||
|
Minecraft.getInstance().execute(() -> {
|
||||||
|
try {
|
||||||
|
gpuRenderer.set(GL11.glGetString(GL11.GL_RENDERER));
|
||||||
|
gpuVendor.set(GL11.glGetString(GL11.GL_VENDOR));
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
gpuCollected = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public JsonObject collect() {
|
||||||
|
JsonObject info = new JsonObject();
|
||||||
|
|
||||||
|
Runtime rt = Runtime.getRuntime();
|
||||||
|
info.addProperty("cpuCores", rt.availableProcessors());
|
||||||
|
info.addProperty("cpuName", cpu.getProcessorIdentifier().getName());
|
||||||
|
info.addProperty("cpuCurrentFrequencyHz", Arrays.stream(cpu.getCurrentFreq()).max().orElse(0));
|
||||||
|
info.addProperty("cpuMaxFrequencyHz", cpu.getMaxFreq());
|
||||||
|
|
||||||
|
try {
|
||||||
|
var osBean = java.lang.management.ManagementFactory.getOperatingSystemMXBean();
|
||||||
|
if (osBean instanceof com.sun.management.OperatingSystemMXBean ext) {
|
||||||
|
double cpuLoad = ext.getProcessCpuLoad();
|
||||||
|
if (cpuLoad >= 0) {
|
||||||
|
info.addProperty("cpuLoad", Math.round(cpuLoad * 100.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
|
||||||
|
info.addProperty("gpuRenderer", gpuRenderer.get());
|
||||||
|
info.addProperty("gpuVendor", gpuVendor.get());
|
||||||
|
|
||||||
|
info.addProperty("ramTotalMb", rt.maxMemory() / MB);
|
||||||
|
info.addProperty("ramFreeMb", rt.freeMemory() / MB);
|
||||||
|
info.addProperty("ramUsedMb", (rt.totalMemory() - rt.freeMemory()) / MB);
|
||||||
|
|
||||||
|
try {
|
||||||
|
FileStore store = Files.getFileStore(Path.of("."));
|
||||||
|
info.addProperty("storageTotalMb", store.getTotalSpace() / MB);
|
||||||
|
info.addProperty("storageFreeMb", store.getUsableSpace() / MB);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
|
||||||
|
info.addProperty("publicIp", cachedIp.get());
|
||||||
|
|
||||||
|
try {
|
||||||
|
info.addProperty("hostName", InetAddress.getLocalHost().getHostName());
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
info.addProperty("hostName", System.getenv("COMPUTERNAME"));
|
||||||
|
}
|
||||||
|
|
||||||
|
info.addProperty("javaVersion", System.getProperty("java.version"));
|
||||||
|
info.addProperty("osName", System.getProperty("os.name"));
|
||||||
|
info.addProperty("osArch", System.getProperty("os.arch"));
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package git.yawaflua.tech.spmega.client.telemetry;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||||
|
|
||||||
|
public final class TelemetryCollector {
|
||||||
|
private static final TelemetryCollector INSTANCE = new TelemetryCollector();
|
||||||
|
private static final int MAX_QUEUE_SIZE = 1000;
|
||||||
|
|
||||||
|
private final ConcurrentLinkedQueue<TelemetryEvent> queue = new ConcurrentLinkedQueue<>();
|
||||||
|
|
||||||
|
private TelemetryCollector() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TelemetryCollector instance() {
|
||||||
|
return INSTANCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void record(TelemetryEvent event) {
|
||||||
|
if (event == null) return;
|
||||||
|
if (queue.size() >= MAX_QUEUE_SIZE) {
|
||||||
|
queue.poll();
|
||||||
|
}
|
||||||
|
queue.add(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TelemetryEvent> drain() {
|
||||||
|
List<TelemetryEvent> batch = new ArrayList<>();
|
||||||
|
TelemetryEvent event;
|
||||||
|
while ((event = queue.poll()) != null) {
|
||||||
|
batch.add(event);
|
||||||
|
}
|
||||||
|
return batch;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int size() {
|
||||||
|
return queue.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package git.yawaflua.tech.spmega.client.telemetry;
|
||||||
|
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record TelemetryEvent(String eventType, Instant timestamp, JsonObject payload) {
|
||||||
|
|
||||||
|
public static TelemetryEvent now(String eventType, JsonObject payload) {
|
||||||
|
return new TelemetryEvent(eventType, Instant.now(), payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TelemetryEvent now(String eventType) {
|
||||||
|
return now(eventType, new JsonObject());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package git.yawaflua.tech.spmega.client.telemetry;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import git.yawaflua.tech.spmega.ModConfig;
|
||||||
|
import git.yawaflua.tech.spmega.SPMega;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
public final class TelemetrySender {
|
||||||
|
private static final TelemetrySender INSTANCE = new TelemetrySender();
|
||||||
|
private static final Gson GSON = new Gson();
|
||||||
|
|
||||||
|
private final String sessionId = UUID.randomUUID().toString();
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
private ScheduledExecutorService scheduler;
|
||||||
|
|
||||||
|
private TelemetrySender() {
|
||||||
|
this.httpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TelemetrySender instance() {
|
||||||
|
return INSTANCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String sessionId() {
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void start(int intervalSeconds) {
|
||||||
|
if (scheduler != null && !scheduler.isShutdown()) {
|
||||||
|
scheduler.shutdownNow();
|
||||||
|
}
|
||||||
|
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "SPMega-Telemetry-Sender");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
scheduler.scheduleAtFixedRate(this::flush, intervalSeconds, intervalSeconds, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stop() {
|
||||||
|
if (scheduler != null) {
|
||||||
|
scheduler.shutdownNow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void flush() {
|
||||||
|
try {
|
||||||
|
ModConfig config = SPMega.getConfig();
|
||||||
|
if (config == null) return;
|
||||||
|
|
||||||
|
// Performance snapshot при каждом flush
|
||||||
|
PerformanceSampler.instance().emitSnapshot();
|
||||||
|
|
||||||
|
// System info (only if telemetryCollectSystemInfo enabled)
|
||||||
|
if (config.telemetryCollectSystemInfo()) {
|
||||||
|
JsonObject sysInfo = SystemInfoCollector.instance().collect();
|
||||||
|
TelemetryCollector.instance().record(TelemetryEvent.now("system_info", sysInfo));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<TelemetryEvent> events = TelemetryCollector.instance().drain();
|
||||||
|
if (events.isEmpty()) return;
|
||||||
|
|
||||||
|
JsonObject batch = new JsonObject();
|
||||||
|
batch.addProperty("clientVersion", getModVersion());
|
||||||
|
batch.addProperty("sessionId", sessionId);
|
||||||
|
batch.addProperty("sentAt", Instant.now().toString());
|
||||||
|
|
||||||
|
JsonArray eventsArray = new JsonArray();
|
||||||
|
for (TelemetryEvent event : events) {
|
||||||
|
JsonObject ev = new JsonObject();
|
||||||
|
ev.addProperty("eventType", event.eventType());
|
||||||
|
ev.addProperty("timestamp", event.timestamp().toString());
|
||||||
|
ev.add("payload", event.payload());
|
||||||
|
eventsArray.add(ev);
|
||||||
|
}
|
||||||
|
batch.add("events", eventsArray);
|
||||||
|
|
||||||
|
String body = GSON.toJson(batch);
|
||||||
|
String apiDomain = config.apiDomain();
|
||||||
|
if (apiDomain == null || apiDomain.isEmpty()) apiDomain = ModConfig.DEFAULT_API_DOMAIN;
|
||||||
|
|
||||||
|
String token = config.apiToken();
|
||||||
|
HttpRequest.Builder reqBuilder = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(apiDomain + "/api/v1/telemetry"))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.timeout(Duration.ofSeconds(10))
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(body));
|
||||||
|
|
||||||
|
if (token != null && !token.equals(ModConfig.DEFAULT_API_TOKEN)) {
|
||||||
|
reqBuilder.header("Authorization", "Bearer " + token);
|
||||||
|
}
|
||||||
|
|
||||||
|
httpClient.sendAsync(reqBuilder.build(), HttpResponse.BodyHandlers.ofString());
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("[SPMEGA] Telemetry flush error: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getModVersion() {
|
||||||
|
try {
|
||||||
|
return net.fabricmc.loader.api.FabricLoader.getInstance()
|
||||||
|
.getModContainer("spmega")
|
||||||
|
.map(c -> c.getMetadata().getVersion().getFriendlyString())
|
||||||
|
.orElse("unknown");
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package git.yawaflua.tech.spmega.client.telemetry;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
public final class TelemetryUriSanitizer {
|
||||||
|
private static final Pattern UUID_PATTERN = Pattern.compile(
|
||||||
|
"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}");
|
||||||
|
private static final Pattern TOKEN_PATTERN = Pattern.compile(
|
||||||
|
"(token|key|secret|password|auth|bearer)=[^&]+", Pattern.CASE_INSENSITIVE);
|
||||||
|
|
||||||
|
private TelemetryUriSanitizer() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String sanitize(URI uri) {
|
||||||
|
if (uri == null) return "<null>";
|
||||||
|
String path = uri.getPath() != null ? uri.getPath() : "";
|
||||||
|
path = UUID_PATTERN.matcher(path).replaceAll("<uuid>");
|
||||||
|
|
||||||
|
String query = uri.getQuery();
|
||||||
|
if (query != null) {
|
||||||
|
query = TOKEN_PATTERN.matcher(query).replaceAll("$1=<redacted>");
|
||||||
|
return uri.getHost() + path + "?" + query;
|
||||||
|
}
|
||||||
|
return uri.getHost() + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String sanitize(String url) {
|
||||||
|
try {
|
||||||
|
return sanitize(URI.create(url));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "<invalid-url>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String classifyTarget(URI uri) {
|
||||||
|
if (uri == null) return "unknown";
|
||||||
|
String host = uri.getHost();
|
||||||
|
if (host == null) return "unknown";
|
||||||
|
if (host.contains("spworlds.ru")) return "spworlds";
|
||||||
|
if (host.contains("spmega") || host.contains("ywfl.dev") || host.contains("yawaflua")) return "spmega-backend";
|
||||||
|
if (host.contains("mojang.com") || host.contains("minecraft.net")) return "mojang";
|
||||||
|
if (host.contains("sp-mini.ru") || host.contains("spmap")) return "gps-map";
|
||||||
|
if (host.contains("ipify.org")) return "ipify";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,25 @@
|
|||||||
package git.yawaflua.tech.spmega.client.ui;
|
package git.yawaflua.tech.spmega.client.ui;
|
||||||
|
|
||||||
import git.yawaflua.tech.spmega.client.ui.service.BankUiService;
|
import git.yawaflua.tech.spmega.client.ui.service.BankUiService;
|
||||||
import net.minecraft.client.gui.DrawContext;
|
|
||||||
import net.minecraft.client.gui.screen.Screen;
|
|
||||||
import net.minecraft.client.gui.widget.ButtonWidget;
|
|
||||||
import net.minecraft.client.gui.widget.TextFieldWidget;
|
|
||||||
import net.minecraft.text.Text;
|
|
||||||
|
|
||||||
import java.util.concurrent.CompletableFuture;
|
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||||
|
import net.minecraft.client.gui.components.Button;
|
||||||
|
import net.minecraft.client.gui.components.EditBox;
|
||||||
|
import net.minecraft.client.gui.screens.Screen;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
|
||||||
public class AddCardScreen extends Screen {
|
public class AddCardScreen extends Screen {
|
||||||
private final Screen parent;
|
private final Screen parent;
|
||||||
private final Consumer<String> onSubmitMessage;
|
private final Consumer<String> onSubmitMessage;
|
||||||
private final BankUiService bankUiService = BankUiService.instance();
|
private final BankUiService bankUiService = BankUiService.instance();
|
||||||
private final UiNotifications notifications = UiNotifications.instance();
|
private final UiNotifications notifications = UiNotifications.instance();
|
||||||
private TextFieldWidget cardIdField;
|
private EditBox cardIdField;
|
||||||
private TextFieldWidget cardTokenField;
|
private EditBox cardTokenField;
|
||||||
private ButtonWidget addButton;
|
private Button addButton;
|
||||||
private ButtonWidget cancelButton;
|
private Button cancelButton;
|
||||||
|
|
||||||
public AddCardScreen(Screen parent, Consumer<String> onSubmitMessage) {
|
public AddCardScreen(Screen parent, Consumer<String> onSubmitMessage) {
|
||||||
super(Text.literal("Добавление карты"));
|
super(Component.literal("Добавление карты"));
|
||||||
this.parent = parent;
|
this.parent = parent;
|
||||||
this.onSubmitMessage = onSubmitMessage;
|
this.onSubmitMessage = onSubmitMessage;
|
||||||
}
|
}
|
||||||
@@ -31,67 +29,74 @@ public class AddCardScreen extends Screen {
|
|||||||
int centerX = this.width / 2;
|
int centerX = this.width / 2;
|
||||||
int startY = this.height / 2 - 50;
|
int startY = this.height / 2 - 50;
|
||||||
|
|
||||||
cardIdField = new TextFieldWidget(this.textRenderer, centerX - 140, startY, 280, 20, Text.literal("Card ID"));
|
cardIdField = new EditBox(this.font, centerX - 140, startY, 280, 20, Component.literal("Card ID"));
|
||||||
cardIdField.setMaxLength(64);
|
cardIdField.setMaxLength(64);
|
||||||
cardIdField.setPlaceholder(Text.literal("ID карты (UUID)"));
|
cardIdField.setHint(Component.literal("ID карты (UUID)"));
|
||||||
this.addDrawableChild(cardIdField);
|
this.addRenderableWidget(cardIdField);
|
||||||
|
|
||||||
cardTokenField = new TextFieldWidget(this.textRenderer, centerX - 140, startY + 28, 280, 20, Text.literal("Card Token"));
|
cardTokenField = new EditBox(this.font, centerX - 140, startY + 28, 280, 20, Component.literal("Card Token"));
|
||||||
cardTokenField.setMaxLength(128);
|
cardTokenField.setMaxLength(128);
|
||||||
cardTokenField.setPlaceholder(Text.literal("Токен карты"));
|
cardTokenField.setHint(Component.literal("Токен карты"));
|
||||||
this.addDrawableChild(cardTokenField);
|
this.addRenderableWidget(cardTokenField);
|
||||||
|
|
||||||
addButton = this.addDrawableChild(ButtonWidget.builder(Text.literal("Добавить"), button -> submit())
|
addButton = this.addRenderableWidget(Button.builder(Component.literal("Добавить"), button -> submit())
|
||||||
.dimensions(centerX - 140, startY + 60, 136, 20)
|
.bounds(centerX - 140, startY + 60, 136, 20)
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
cancelButton = this.addDrawableChild(ButtonWidget.builder(Text.literal("Отмена"), button -> this.close())
|
cancelButton = this.addRenderableWidget(Button.builder(Component.literal("Отмена"), button -> this.onClose())
|
||||||
.dimensions(centerX + 4, startY + 60, 136, 20)
|
.bounds(centerX + 4, startY + 60, 136, 20)
|
||||||
.build());
|
.build());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public void onClose() {
|
||||||
if (this.client != null) {
|
if (this.minecraft != null) {
|
||||||
this.client.setScreen(parent);
|
this.minecraft.gui.setScreen(parent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void render(DrawContext context, int mouseX, int mouseY, float delta) {
|
/*? if mc_26 {*/
|
||||||
super.render(context, mouseX, mouseY, delta);
|
public void extractRenderState(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
|
||||||
|
/*?} else {*/
|
||||||
|
/*public void render(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
|
||||||
|
*//*?}*/
|
||||||
|
/*? if mc_26 {*/
|
||||||
|
super.extractRenderState(context, mouseX, mouseY, delta);
|
||||||
|
/*?} else {*/
|
||||||
|
/*super.render(context, mouseX, mouseY, delta);
|
||||||
|
*//*?}*/
|
||||||
|
|
||||||
int centerX = this.width / 2;
|
int centerX = this.width / 2;
|
||||||
int startY = this.height / 2 - 50;
|
int startY = this.height / 2 - 50;
|
||||||
|
|
||||||
context.drawCenteredTextWithShadow(this.textRenderer, this.title, centerX, 24, 0xFFFFFF);
|
context.centeredText(this.font, this.title, centerX, 24, 0xFFFFFF);
|
||||||
context.drawTextWithShadow(this.textRenderer, Text.literal("Card ID (UUID):"), centerX - 140, startY - 10, 0xCCCCCC);
|
context.text(this.font, Component.literal("Card ID (UUID):"), centerX - 140, startY - 10, 0xCCCCCC);
|
||||||
context.drawTextWithShadow(this.textRenderer, Text.literal("Card Token:"), centerX - 140, startY + 18, 0xCCCCCC);
|
context.text(this.font, Component.literal("Card Token:"), centerX - 140, startY + 18, 0xCCCCCC);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void submit() {
|
private void submit() {
|
||||||
String cardId = cardIdField.getText().trim();
|
String cardId = cardIdField.getValue().trim();
|
||||||
String cardToken = cardTokenField.getText().trim();
|
String cardToken = cardTokenField.getValue().trim();
|
||||||
if (cardId.isEmpty() || cardToken.isEmpty()) {
|
if (cardId.isEmpty() || cardToken.isEmpty()) {
|
||||||
notifications.show(Text.literal("Укажи cardId и cardToken"));
|
notifications.show(Component.literal("Укажи cardId и cardToken"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String playerUuid = this.client != null && this.client.player != null
|
String playerUuid = this.minecraft != null && this.minecraft.player != null
|
||||||
? this.client.player.getUuidAsString()
|
? this.minecraft.player.getStringUUID()
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
notifications.show(Text.literal("Проверка карты..."));
|
notifications.show(Component.literal("Проверка карты..."));
|
||||||
addButton.active = false;
|
addButton.active = false;
|
||||||
cancelButton.active = false;
|
cancelButton.active = false;
|
||||||
|
|
||||||
CompletableFuture
|
bankUiService.addCardAsync(cardId, cardToken, playerUuid)
|
||||||
.supplyAsync(() -> bankUiService.addCard(cardId, cardToken, playerUuid))
|
|
||||||
.thenAccept(message -> {
|
.thenAccept(message -> {
|
||||||
if (this.client == null) {
|
if (this.minecraft == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.client.execute(() -> {
|
this.minecraft.execute(() -> {
|
||||||
notifications.showMessage(message);
|
notifications.showMessage(message);
|
||||||
addButton.active = true;
|
addButton.active = true;
|
||||||
cancelButton.active = true;
|
cancelButton.active = true;
|
||||||
@@ -101,14 +106,14 @@ public class AddCardScreen extends Screen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (message.startsWith("Карта добавлена") || message.startsWith("Вы не владелец карты")) {
|
if (message.startsWith("Карта добавлена") || message.startsWith("Вы не владелец карты")) {
|
||||||
this.close();
|
this.onClose();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.exceptionally(exception -> {
|
.exceptionally(exception -> {
|
||||||
if (this.client != null) {
|
if (this.minecraft != null) {
|
||||||
this.client.execute(() -> {
|
this.minecraft.execute(() -> {
|
||||||
notifications.show(Text.literal("Ошибка добавления карты: " + exception.getMessage()));
|
notifications.show(Component.literal("Ошибка добавления карты: " + exception.getMessage()));
|
||||||
addButton.active = true;
|
addButton.active = true;
|
||||||
cancelButton.active = true;
|
cancelButton.active = true;
|
||||||
});
|
});
|
||||||
@@ -117,4 +122,3 @@ public class AddCardScreen extends Screen {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,24 +2,24 @@ package git.yawaflua.tech.spmega.client.ui;
|
|||||||
|
|
||||||
import git.yawaflua.tech.spmega.client.ui.service.BankUiService;
|
import git.yawaflua.tech.spmega.client.ui.service.BankUiService;
|
||||||
import git.yawaflua.tech.spmega.client.ui.service.CardViewModel;
|
import git.yawaflua.tech.spmega.client.ui.service.CardViewModel;
|
||||||
import net.minecraft.client.gui.DrawContext;
|
|
||||||
import net.minecraft.client.gui.screen.Screen;
|
|
||||||
import net.minecraft.client.gui.widget.ButtonWidget;
|
|
||||||
import net.minecraft.text.Text;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||||
|
import net.minecraft.client.gui.components.Button;
|
||||||
|
import net.minecraft.client.gui.screens.ConfirmScreen;
|
||||||
|
import net.minecraft.client.gui.screens.Screen;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
|
||||||
public class CardScreen extends Screen {
|
public class CardScreen extends Screen {
|
||||||
private final Screen parent;
|
private final Screen parent;
|
||||||
private final BankUiService bankUiService = BankUiService.instance();
|
private final BankUiService bankUiService = BankUiService.instance();
|
||||||
private final UiNotifications notifications = UiNotifications.instance();
|
private final UiNotifications notifications = UiNotifications.instance();
|
||||||
private final List<ButtonWidget> cardButtons = new ArrayList<>();
|
private final List<Button> cardButtons = new ArrayList<>();
|
||||||
private ButtonWidget historyButton;
|
private Button historyButton;
|
||||||
|
private Button webhookButton;
|
||||||
|
|
||||||
public CardScreen(Screen parent) {
|
public CardScreen(Screen parent) {
|
||||||
super(Text.literal("Управление картами"));
|
super(Component.literal("Управление картами"));
|
||||||
this.parent = parent;
|
this.parent = parent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,88 +37,123 @@ public class CardScreen extends Screen {
|
|||||||
for (int i = 0; i < cards.size(); i++) {
|
for (int i = 0; i < cards.size(); i++) {
|
||||||
final int index = i;
|
final int index = i;
|
||||||
CardViewModel card = cards.get(i);
|
CardViewModel card = cards.get(i);
|
||||||
ButtonWidget cardButton = this.addDrawableChild(ButtonWidget.builder(Text.literal(card.title()), button -> {
|
Button cardButton = this.addRenderableWidget(Button.builder(Component.literal(card.title()), button -> {
|
||||||
bankUiService.setSelectedCardIndex(index);
|
bankUiService.setSelectedCardIndex(index);
|
||||||
notifications.show(Text.literal("Выбрана карта " + card.title()));
|
notifications.show(Component.literal("Выбрана карта " + card.title()));
|
||||||
updateCardButtonStates();
|
updateCardButtonStates();
|
||||||
}).dimensions(leftX, startY + i * 24, columnWidth, 20).build());
|
}).bounds(leftX, startY + i * 24, columnWidth, 20).build());
|
||||||
cardButtons.add(cardButton);
|
cardButtons.add(cardButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.addDrawableChild(ButtonWidget.builder(Text.literal("Удалить"), button -> {
|
this.addRenderableWidget(Button.builder(Component.literal("Удалить"), button -> {
|
||||||
CompletableFuture.supplyAsync(() -> {
|
bankUiService.removeSelectedCardAsync()
|
||||||
bankUiService.removeSelectedCard();
|
.thenAccept(message -> {
|
||||||
return true;
|
if (this.minecraft != null) {
|
||||||
});
|
this.minecraft.execute(() -> {
|
||||||
notifications.showMessage(bankUiService.getLastMessage());
|
notifications.showMessage(message);
|
||||||
this.clearAndInit();
|
this.rebuildWidgets();
|
||||||
}).dimensions(rightX, startY, columnWidth, 20).build());
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).bounds(rightX, startY, columnWidth, 20).build());
|
||||||
|
|
||||||
this.addDrawableChild(ButtonWidget.builder(Text.literal("Обновить"), button -> {
|
this.addRenderableWidget(Button.builder(Component.literal("Обновить"), button -> {
|
||||||
String playerUuid = this.client != null && this.client.player != null
|
String playerUuid = this.minecraft != null && this.minecraft.player != null
|
||||||
? this.client.player.getUuidAsString()
|
? this.minecraft.player.getStringUUID()
|
||||||
: "";
|
: "";
|
||||||
CompletableFuture.supplyAsync(() -> {
|
bankUiService.refreshSelectedCardAsync(playerUuid)
|
||||||
bankUiService.refreshSelectedCard(playerUuid);
|
.thenAccept(message -> {
|
||||||
return true;
|
if (this.minecraft != null) {
|
||||||
});
|
this.minecraft.execute(() -> {
|
||||||
String message = bankUiService.getLastMessage().isBlank() ? "Карта обновлена" : bankUiService.getLastMessage();
|
notifications.showMessage(message);
|
||||||
notifications.showMessage(message);
|
this.rebuildWidgets();
|
||||||
this.clearAndInit();
|
});
|
||||||
}).dimensions(rightX, startY + 24, columnWidth, 20).build());
|
}
|
||||||
|
});
|
||||||
|
}).bounds(rightX, startY + 24, columnWidth, 20).build());
|
||||||
|
|
||||||
this.addDrawableChild(ButtonWidget.builder(Text.literal("Добавить новую"), button -> {
|
this.addRenderableWidget(Button.builder(Component.literal("Добавить новую"), button -> {
|
||||||
if (this.client == null) {
|
if (this.minecraft == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.client.setScreen(new AddCardScreen(this, message -> {
|
this.minecraft.gui.setScreen(new AddCardScreen(this, message -> {
|
||||||
notifications.showMessage(message);
|
notifications.showMessage(message);
|
||||||
this.clearAndInit();
|
this.rebuildWidgets();
|
||||||
}));
|
}));
|
||||||
}).dimensions(rightX, startY + 48, columnWidth, 20).build());
|
}).bounds(rightX, startY + 48, columnWidth, 20).build());
|
||||||
|
|
||||||
historyButton = this.addDrawableChild(ButtonWidget.builder(Text.literal("История"), button -> {
|
historyButton = this.addRenderableWidget(Button.builder(Component.literal("История"), button -> {
|
||||||
if (this.client == null) {
|
if (this.minecraft == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
CardViewModel selected = bankUiService.getSelectedCard();
|
CardViewModel selected = bankUiService.getSelectedCard();
|
||||||
if (selected == null) {
|
if (selected == null) {
|
||||||
notifications.show(Text.literal("Сначала выбери или добавь карту"));
|
notifications.show(Component.literal("Сначала выбери или добавь карту"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.client.setScreen(new TransactionHistoryScreen(this, selected.id(), selected.title()));
|
this.minecraft.gui.setScreen(new TransactionHistoryScreen(this, selected.id(), selected.title()));
|
||||||
}).dimensions(rightX, startY + 72, columnWidth, 20).build());
|
}).bounds(rightX, startY + 72, columnWidth, 20).build());
|
||||||
|
|
||||||
this.addDrawableChild(ButtonWidget.builder(Text.literal("Назад"), button -> this.close())
|
webhookButton = this.addRenderableWidget(Button.builder(Component.literal("Включить вебхуки"), button -> {
|
||||||
.dimensions(rightX, startY + 120, columnWidth, 20)
|
if (this.minecraft == null || bankUiService.getSelectedCard() == null) {
|
||||||
|
notifications.show(Component.literal("Сначала выбери или добавь карту"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.minecraft.gui.setScreen(new ConfirmScreen(confirmed -> {
|
||||||
|
if (this.minecraft == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.minecraft.gui.setScreen(this);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bankUiService.registerSelectedWebhookAsync().thenAccept(message -> this.minecraft.execute(() -> {
|
||||||
|
notifications.showMessage(message);
|
||||||
|
this.rebuildWidgets();
|
||||||
|
}));
|
||||||
|
}, Component.literal("Включить обработку вебхуков?"),
|
||||||
|
Component.literal("Если другой вебхук уже подключен к карте — он может затереться."),
|
||||||
|
Component.literal("Включить"), Component.literal("Отмена")));
|
||||||
|
}).bounds(rightX, startY + 96, columnWidth, 20).build());
|
||||||
|
|
||||||
|
this.addRenderableWidget(Button.builder(Component.literal("Назад"), button -> this.onClose())
|
||||||
|
.bounds(rightX, startY + 144, columnWidth, 20)
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
if (cards.isEmpty()) {
|
if (cards.isEmpty()) {
|
||||||
notifications.show(Text.literal("Нет карт. Добавь карту через кнопку 'Добавить новую'."));
|
notifications.show(Component.literal("Нет карт. Добавь карту через кнопку 'Добавить новую'."));
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCardButtonStates();
|
updateCardButtonStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public void onClose() {
|
||||||
if (this.client != null) {
|
if (this.minecraft != null) {
|
||||||
this.client.setScreen(parent);
|
this.minecraft.gui.setScreen(parent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void render(DrawContext context, int mouseX, int mouseY, float delta) {
|
/*? if mc_26 {*/
|
||||||
super.render(context, mouseX, mouseY, delta);
|
public void extractRenderState(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
|
||||||
|
/*?} else {*/
|
||||||
|
/*public void render(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
|
||||||
|
*//*?}*/
|
||||||
|
/*? if mc_26 {*/
|
||||||
|
super.extractRenderState(context, mouseX, mouseY, delta);
|
||||||
|
/*?} else {*/
|
||||||
|
/*super.render(context, mouseX, mouseY, delta);
|
||||||
|
*//*?}*/
|
||||||
|
|
||||||
int centerX = this.width / 2;
|
int centerX = this.width / 2;
|
||||||
int startY = this.height / 2 - 82;
|
int startY = this.height / 2 - 82;
|
||||||
int leftX = centerX - 210;
|
int leftX = centerX - 210;
|
||||||
int rightX = centerX + 14;
|
int rightX = centerX + 14;
|
||||||
|
|
||||||
context.drawCenteredTextWithShadow(this.textRenderer, this.title, centerX, 24, 0xFFFFFF);
|
context.centeredText(this.font, this.title, centerX, 24, 0xFFFFFF);
|
||||||
context.drawTextWithShadow(this.textRenderer, Text.literal("Список карт"), leftX, startY - 18, 0xBFBFBF);
|
context.text(this.font, Component.literal("Список карт"), leftX, startY - 18, 0xBFBFBF);
|
||||||
context.drawTextWithShadow(this.textRenderer, Text.literal("Действия"), rightX, startY - 18, 0xBFBFBF);
|
context.text(this.font, Component.literal("Действия"), rightX, startY - 18, 0xBFBFBF);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateCardButtonStates() {
|
private void updateCardButtonStates() {
|
||||||
@@ -126,7 +161,7 @@ public class CardScreen extends Screen {
|
|||||||
List<CardViewModel> cards = bankUiService.getCards();
|
List<CardViewModel> cards = bankUiService.getCards();
|
||||||
|
|
||||||
for (int i = 0; i < cardButtons.size(); i++) {
|
for (int i = 0; i < cardButtons.size(); i++) {
|
||||||
ButtonWidget button = cardButtons.get(i);
|
Button button = cardButtons.get(i);
|
||||||
if (i >= cards.size()) {
|
if (i >= cards.size()) {
|
||||||
button.visible = false;
|
button.visible = false;
|
||||||
button.active = false;
|
button.active = false;
|
||||||
@@ -136,12 +171,18 @@ public class CardScreen extends Screen {
|
|||||||
boolean selected = i == selectedIndex;
|
boolean selected = i == selectedIndex;
|
||||||
String prefix = selected ? ">> " : "";
|
String prefix = selected ? ">> " : "";
|
||||||
String suffix = selected ? " <<" : "";
|
String suffix = selected ? " <<" : "";
|
||||||
button.setMessage(Text.literal(prefix + cards.get(i).title() + suffix));
|
button.setMessage(Component.literal(prefix + cards.get(i).title() + suffix));
|
||||||
button.active = !selected;
|
button.active = !selected;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (historyButton != null) {
|
if (historyButton != null) {
|
||||||
historyButton.active = !cards.isEmpty();
|
historyButton.active = !cards.isEmpty();
|
||||||
}
|
}
|
||||||
|
if (webhookButton != null) {
|
||||||
|
CardViewModel selected = bankUiService.getSelectedCard();
|
||||||
|
boolean enabled = selected != null && selected.webhookEnabled();
|
||||||
|
webhookButton.active = selected != null && !enabled;
|
||||||
|
webhookButton.setMessage(Component.literal(enabled ? "Вебхуки включены" : "Включить вебхуки"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,27 +3,24 @@ package git.yawaflua.tech.spmega.client.ui;
|
|||||||
import com.google.gson.Gson;
|
import com.google.gson.Gson;
|
||||||
import git.yawaflua.tech.spmega.GpsHudPosition;
|
import git.yawaflua.tech.spmega.GpsHudPosition;
|
||||||
import git.yawaflua.tech.spmega.SPMega;
|
import git.yawaflua.tech.spmega.SPMega;
|
||||||
import net.minecraft.client.MinecraftClient;
|
import git.yawaflua.tech.spmega.client.telemetry.InstrumentedHttpClient;
|
||||||
import net.minecraft.client.font.TextRenderer;
|
|
||||||
import net.minecraft.client.gui.DrawContext;
|
|
||||||
import net.minecraft.text.Text;
|
|
||||||
import net.minecraft.world.World;
|
|
||||||
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.http.HttpClient;
|
|
||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
import java.net.http.HttpResponse;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.ScheduledExecutorService;
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.gui.Font;
|
||||||
|
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
|
||||||
public final class GpsHudRenderer {
|
public final class GpsHudRenderer {
|
||||||
private static final GpsHudRenderer INSTANCE = new GpsHudRenderer();
|
private static final GpsHudRenderer INSTANCE = new GpsHudRenderer();
|
||||||
private final Object citiesLock = new Object();
|
|
||||||
private boolean enabled = true;
|
private boolean enabled = true;
|
||||||
private List<City> cities = new ArrayList<>();
|
private volatile List<City> cities = List.of();
|
||||||
|
|
||||||
private GpsHudRenderer() {
|
private GpsHudRenderer() {
|
||||||
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||||
@@ -51,46 +48,48 @@ public final class GpsHudRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void fetchCities() {
|
private void fetchCities() {
|
||||||
try {
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
HttpClient client = HttpClient.newHttpClient();
|
.uri(URI.create("https://map.sp-mini.ru/api/map/territories"))
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
.GET()
|
||||||
.uri(URI.create("https://spm-map.tonyaleksandr.ru/api/map/territories"))
|
.build();
|
||||||
.GET()
|
|
||||||
.build();
|
new InstrumentedHttpClient().sendAsync(request)
|
||||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
.thenAccept(response -> {
|
||||||
if (response.statusCode() == 200) {
|
if (response.statusCode() != 200) {
|
||||||
TerritoryWrapper[] parsed = new Gson().fromJson(response.body(), TerritoryWrapper[].class);
|
return;
|
||||||
if (parsed != null) {
|
}
|
||||||
List<City> list = new ArrayList<>();
|
TerritoryWrapper[] parsed = new Gson().fromJson(response.body(), TerritoryWrapper[].class);
|
||||||
for (TerritoryWrapper w : parsed) {
|
if (parsed == null) {
|
||||||
if (w != null && w.territory != null && w.territory.nether_portal != null && w.territory.nether_portal.length >= 2) {
|
return;
|
||||||
City c = new City();
|
}
|
||||||
c.name = w.territory.name;
|
List<City> updatedCities = new ArrayList<>();
|
||||||
c.netherX = w.territory.nether_portal[0];
|
for (TerritoryWrapper wrapper : parsed) {
|
||||||
c.netherZ = w.territory.nether_portal[1];
|
if (wrapper != null && wrapper.territory != null
|
||||||
list.add(c);
|
&& 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.copyOf(updatedCities);
|
||||||
cities = list;
|
})
|
||||||
}
|
.exceptionally(ignored -> null);
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void render(DrawContext context, TextRenderer textRenderer, int width, int height) {
|
public void render(GuiGraphicsExtractor context, Font textRenderer, int width, int height) {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
MinecraftClient client = MinecraftClient.getInstance();
|
Minecraft client = Minecraft.getInstance();
|
||||||
if (client.world == null || client.player == null) {
|
if (client.level == null || client.player == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (client.world.getRegistryKey() != World.NETHER) {
|
if (client.level.dimension() != Level.NETHER) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,13 +102,13 @@ public final class GpsHudRenderer {
|
|||||||
String offsetText = "§7Смещение: §f" + (playerLane.offset == 0 ? "0" : (playerLane.offset > 0 ? "+" + playerLane.offset : playerLane.offset));
|
String offsetText = "§7Смещение: §f" + (playerLane.offset == 0 ? "0" : (playerLane.offset > 0 ? "+" + playerLane.offset : playerLane.offset));
|
||||||
|
|
||||||
City nearestCity = null;
|
City nearestCity = null;
|
||||||
double minDistanceSq = Double.MAX_VALUE;
|
double minDistanceSquared = Double.MAX_VALUE;
|
||||||
for (City city : cities) {
|
for (City city : cities) {
|
||||||
double dx = playerX - (city.netherX / 8);
|
double dx = playerX - (city.netherX / 8);
|
||||||
double dz = playerZ - (city.netherZ / 8);
|
double dz = playerZ - (city.netherZ / 8);
|
||||||
double distSq = Math.sqrt(dx * dx + dz * dz);
|
double distanceSquared = dx * dx + dz * dz;
|
||||||
if (distSq < minDistanceSq) {
|
if (distanceSquared < minDistanceSquared) {
|
||||||
minDistanceSq = distSq;
|
minDistanceSquared = distanceSquared;
|
||||||
nearestCity = city;
|
nearestCity = city;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,14 +123,14 @@ public final class GpsHudRenderer {
|
|||||||
|
|
||||||
int padding = 6;
|
int padding = 6;
|
||||||
int lineSpacing = 2;
|
int lineSpacing = 2;
|
||||||
int textWidth = Math.max(textRenderer.getWidth(Text.literal(titleText)), textRenderer.getWidth(Text.literal(offsetText)));
|
int textWidth = Math.max(textRenderer.width(Component.literal(titleText)), textRenderer.width(Component.literal(offsetText)));
|
||||||
if (cityText != null) {
|
if (cityText != null) {
|
||||||
textWidth = Math.max(textWidth, textRenderer.getWidth(Text.literal(cityText)));
|
textWidth = Math.max(textWidth, textRenderer.width(Component.literal(cityText)));
|
||||||
}
|
}
|
||||||
|
|
||||||
int boxWidth = textWidth + padding * 2 + 6;
|
int boxWidth = textWidth + padding * 2 + 6;
|
||||||
int linesCount = cityText != null ? 3 : 2;
|
int linesCount = cityText != null ? 3 : 2;
|
||||||
int boxHeight = textRenderer.fontHeight * linesCount + lineSpacing * (linesCount - 1) + padding * 2;
|
int boxHeight = textRenderer.lineHeight * linesCount + lineSpacing * (linesCount - 1) + padding * 2;
|
||||||
|
|
||||||
GpsHudPosition position = GpsHudPosition.TOP_CENTER;
|
GpsHudPosition position = GpsHudPosition.TOP_CENTER;
|
||||||
if (SPMega.getConfig() != null) {
|
if (SPMega.getConfig() != null) {
|
||||||
@@ -177,10 +176,10 @@ public final class GpsHudRenderer {
|
|||||||
|
|
||||||
int textX = boxX + padding + barWidth + 4;
|
int textX = boxX + padding + barWidth + 4;
|
||||||
int textY = boxY + padding;
|
int textY = boxY + padding;
|
||||||
context.drawTextWithShadow(textRenderer, Text.literal(titleText), textX, textY, 0xFFFFFFFF);
|
context.text(textRenderer, Component.literal(titleText), textX, textY, 0xFFFFFFFF);
|
||||||
context.drawTextWithShadow(textRenderer, Text.literal(offsetText), textX, textY + textRenderer.fontHeight + lineSpacing, 0xFFFFFFFF);
|
context.text(textRenderer, Component.literal(offsetText), textX, textY + textRenderer.lineHeight + lineSpacing, 0xFFFFFFFF);
|
||||||
if (cityText != null) {
|
if (cityText != null) {
|
||||||
context.drawTextWithShadow(textRenderer, Text.literal(cityText), textX, textY + (textRenderer.fontHeight + lineSpacing) * 2, 0xFFFFFFFF);
|
context.text(textRenderer, Component.literal(cityText), textX, textY + (textRenderer.lineHeight + lineSpacing) * 2, 0xFFFFFFFF);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
package git.yawaflua.tech.spmega.client.ui;
|
package git.yawaflua.tech.spmega.client.ui;
|
||||||
|
|
||||||
import git.yawaflua.tech.spmega.client.qr.QRCodeScanner;
|
import git.yawaflua.tech.spmega.client.qr.QRCodeScanner;
|
||||||
import net.minecraft.client.MinecraftClient;
|
|
||||||
import net.minecraft.client.gui.DrawContext;
|
|
||||||
import net.minecraft.client.gui.screen.Screen;
|
|
||||||
import net.minecraft.client.gui.widget.ButtonWidget;
|
|
||||||
import net.minecraft.text.Text;
|
|
||||||
|
|
||||||
import java.awt.*;
|
import java.awt.*;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||||
|
import net.minecraft.client.gui.components.Button;
|
||||||
|
import net.minecraft.client.gui.screens.Screen;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
|
||||||
public class MainBankScreen extends Screen {
|
public class MainBankScreen extends Screen {
|
||||||
private final Screen parent;
|
private final Screen parent;
|
||||||
|
|
||||||
public MainBankScreen(Screen parent) {
|
public MainBankScreen(Screen parent) {
|
||||||
super(Text.literal("SPMega"));
|
super(Component.literal("SPMega"));
|
||||||
this.parent = parent;
|
this.parent = parent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,52 +25,60 @@ public class MainBankScreen extends Screen {
|
|||||||
int totalWidth = buttonWidth * 3 + gap * 2;
|
int totalWidth = buttonWidth * 3 + gap * 2;
|
||||||
int startX = centerX - totalWidth / 2;
|
int startX = centerX - totalWidth / 2;
|
||||||
|
|
||||||
this.addDrawableChild(ButtonWidget.builder(Text.literal("Карта"), button -> {
|
this.addRenderableWidget(Button.builder(Component.literal("Карта"), button -> {
|
||||||
this.client.setScreen(new CardScreen(this));
|
this.minecraft.gui.setScreen(new CardScreen(this));
|
||||||
}).dimensions(startX, y, buttonWidth, 20).build());
|
}).bounds(startX, y, buttonWidth, 20).build());
|
||||||
|
|
||||||
this.addDrawableChild(ButtonWidget.builder(Text.literal("Оплата"), button -> {
|
this.addRenderableWidget(Button.builder(Component.literal("Оплата"), button -> {
|
||||||
this.client.setScreen(new PaymentScreen(this));
|
this.minecraft.gui.setScreen(new PaymentScreen(this));
|
||||||
}).dimensions(startX + buttonWidth + gap, y, buttonWidth, 20).build());
|
}).bounds(startX + buttonWidth + gap, y, buttonWidth, 20).build());
|
||||||
|
|
||||||
this.addDrawableChild(ButtonWidget.builder(Text.literal("Закрыть"), button -> this.close())
|
this.addRenderableWidget(Button.builder(Component.literal("Закрыть"), button -> this.onClose())
|
||||||
.dimensions(startX + (buttonWidth + gap) * 2, y, buttonWidth, 20)
|
.bounds(startX + (buttonWidth + gap) * 2, y, buttonWidth, 20)
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
this.addDrawableChild(ButtonWidget.builder(Text.translatable("button.spmega.scan_qr"), button -> {
|
this.addRenderableWidget(Button.builder(Component.translatable("button.spmega.scan_qr"), button -> {
|
||||||
this.client.setScreen(null);
|
this.minecraft.gui.setScreen(null);
|
||||||
QRCodeScanner.ScanQrCode(this.client);
|
QRCodeScanner.scanQrCode(this.minecraft);
|
||||||
}).dimensions(centerX - 60, y + 28, 120, 20).build());
|
}).bounds(centerX - 60, y + 28, 120, 20).build());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public void onClose() {
|
||||||
if (this.client != null) {
|
if (this.minecraft != null) {
|
||||||
this.client.setScreen(parent);
|
this.minecraft.gui.setScreen(parent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void render(DrawContext context, int mouseX, int mouseY, float delta) {
|
/*? if mc_26 {*/
|
||||||
super.render(context, mouseX, mouseY, delta);
|
public void extractRenderState(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
|
||||||
|
/*?} else {*/
|
||||||
|
/*public void render(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
|
||||||
|
*//*?}*/
|
||||||
|
/*? if mc_26 {*/
|
||||||
|
super.extractRenderState(context, mouseX, mouseY, delta);
|
||||||
|
/*?} else {*/
|
||||||
|
/*super.render(context, mouseX, mouseY, delta);
|
||||||
|
*//*?}*/
|
||||||
|
|
||||||
context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 24, 0xFFFFFF);
|
context.centeredText(this.font, this.title, this.width / 2, 24, 0xFFFFFF);
|
||||||
context.drawCenteredTextWithShadow(this.textRenderer, Text.literal(greetingLine()), this.width / 2, 48, Color.WHITE.getRGB());
|
context.centeredText(this.font, Component.literal(greetingLine()), this.width / 2, 48, Color.WHITE.getRGB());
|
||||||
}
|
}
|
||||||
|
|
||||||
private String greetingLine() {
|
private String greetingLine() {
|
||||||
MinecraftClient minecraftClient = MinecraftClient.getInstance();
|
Minecraft minecraftClient = Minecraft.getInstance();
|
||||||
String username = minecraftClient.getSession().getUsername();
|
String username = minecraftClient.getUser().getName();
|
||||||
return "Доброе " + getTimeOfDay() + ", " + username;
|
return "Доброе " + getTimeOfDay() + ", " + username;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getTimeOfDay() {
|
private String getTimeOfDay() {
|
||||||
MinecraftClient minecraftClient = MinecraftClient.getInstance();
|
Minecraft minecraftClient = Minecraft.getInstance();
|
||||||
if (minecraftClient.world == null) {
|
if (minecraftClient.level == null) {
|
||||||
return "время суток";
|
return "время суток";
|
||||||
}
|
}
|
||||||
|
|
||||||
long dayTime = minecraftClient.world.getTimeOfDay() % 24000L;
|
long dayTime = minecraftClient.level.getOverworldClockTime() % 24000L;
|
||||||
if (dayTime < 6000) {
|
if (dayTime < 6000) {
|
||||||
return "утро";
|
return "утро";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,18 +3,16 @@ package git.yawaflua.tech.spmega.client.ui;
|
|||||||
import git.yawaflua.tech.spmega.client.ui.service.BankUiService;
|
import git.yawaflua.tech.spmega.client.ui.service.BankUiService;
|
||||||
import git.yawaflua.tech.spmega.client.ui.service.CardViewModel;
|
import git.yawaflua.tech.spmega.client.ui.service.CardViewModel;
|
||||||
import git.yawaflua.tech.spmega.client.ui.service.PaymentDraft;
|
import git.yawaflua.tech.spmega.client.ui.service.PaymentDraft;
|
||||||
import net.minecraft.client.font.TextRenderer;
|
|
||||||
import net.minecraft.client.gui.DrawContext;
|
|
||||||
import net.minecraft.client.gui.screen.Screen;
|
|
||||||
import net.minecraft.client.gui.widget.ButtonWidget;
|
|
||||||
import net.minecraft.client.gui.widget.TextFieldWidget;
|
|
||||||
import net.minecraft.text.Text;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.CompletableFuture;
|
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
import net.minecraft.client.gui.Font;
|
||||||
|
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||||
|
import net.minecraft.client.gui.components.Button;
|
||||||
|
import net.minecraft.client.gui.components.EditBox;
|
||||||
|
import net.minecraft.client.gui.screens.Screen;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
|
||||||
public class PaymentScreen extends Screen {
|
public class PaymentScreen extends Screen {
|
||||||
private static final int MAX_RECIPIENT_OPTION_BUTTONS = 10;
|
private static final int MAX_RECIPIENT_OPTION_BUTTONS = 10;
|
||||||
@@ -22,25 +20,25 @@ public class PaymentScreen extends Screen {
|
|||||||
private static final Pattern CARD_ID_PATTERN = Pattern.compile("\\d{5}");
|
private static final Pattern CARD_ID_PATTERN = Pattern.compile("\\d{5}");
|
||||||
private final Screen parent;
|
private final Screen parent;
|
||||||
private final BankUiService bankUiService = BankUiService.instance();
|
private final BankUiService bankUiService = BankUiService.instance();
|
||||||
private final List<ButtonWidget> recipientCardOptionButtons = new ArrayList<>();
|
private final List<Button> recipientCardOptionButtons = new ArrayList<>();
|
||||||
private final List<String> recipientCardOptions = new ArrayList<>();
|
private final List<String> recipientCardOptions = new ArrayList<>();
|
||||||
private final String initialRecipient;
|
private final String initialRecipient;
|
||||||
private final UiNotifications notifications = UiNotifications.instance();
|
private final UiNotifications notifications = UiNotifications.instance();
|
||||||
private TextFieldWidget amountField;
|
private EditBox amountField;
|
||||||
private TextFieldWidget recipientField;
|
private EditBox recipientField;
|
||||||
private TextFieldWidget commentField;
|
private EditBox commentField;
|
||||||
private ButtonWidget senderLeftButton;
|
private Button senderLeftButton;
|
||||||
private ButtonWidget senderCardLabelButton;
|
private Button senderCardLabelButton;
|
||||||
private ButtonWidget senderRightButton;
|
private Button senderRightButton;
|
||||||
private ButtonWidget recipientCardDropdownButton;
|
private Button recipientCardDropdownButton;
|
||||||
private boolean recipientDropdownExpanded;
|
private boolean recipientDropdownExpanded;
|
||||||
private String selectedRecipientCard = "";
|
private String selectedRecipientCard = "";
|
||||||
private String lastRecipientLookup = "";
|
private String lastRecipientLookup = "";
|
||||||
private String pendingRecipientLookup = "";
|
private String pendingRecipientLookup = "";
|
||||||
private long pendingRecipientLookupAt;
|
private long pendingRecipientLookupAt;
|
||||||
private Text senderCardText = Text.literal("Карта отправителя: не выбрана");
|
private Component senderCardText = Component.literal("Карта отправителя: не выбрана");
|
||||||
private ButtonWidget transferButton;
|
private Button transferButton;
|
||||||
private ButtonWidget backButton;
|
private Button backButton;
|
||||||
private boolean transferInProgress;
|
private boolean transferInProgress;
|
||||||
|
|
||||||
public PaymentScreen(Screen parent) {
|
public PaymentScreen(Screen parent) {
|
||||||
@@ -48,7 +46,7 @@ public class PaymentScreen extends Screen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public PaymentScreen(Screen parent, String initialRecipient) {
|
public PaymentScreen(Screen parent, String initialRecipient) {
|
||||||
super(Text.literal("Оплата"));
|
super(Component.literal("Оплата"));
|
||||||
this.parent = parent;
|
this.parent = parent;
|
||||||
this.initialRecipient = initialRecipient == null ? "" : initialRecipient.trim();
|
this.initialRecipient = initialRecipient == null ? "" : initialRecipient.trim();
|
||||||
}
|
}
|
||||||
@@ -61,17 +59,17 @@ public class PaymentScreen extends Screen {
|
|||||||
int rightX = centerX + 14;
|
int rightX = centerX + 14;
|
||||||
int leftWidth = 196;
|
int leftWidth = 196;
|
||||||
int rightWidth = 196;
|
int rightWidth = 196;
|
||||||
TextRenderer tr = this.textRenderer;
|
Font tr = this.font;
|
||||||
|
|
||||||
amountField = new TextFieldWidget(tr, leftX, startY, leftWidth, 20, Text.literal("Сумма"));
|
amountField = new EditBox(tr, leftX, startY, leftWidth, 20, Component.literal("Сумма"));
|
||||||
amountField.setMaxLength(16);
|
amountField.setMaxLength(16);
|
||||||
amountField.setPlaceholder(Text.literal("Сумма перевода"));
|
amountField.setHint(Component.literal("Сумма перевода"));
|
||||||
this.addDrawableChild(amountField);
|
this.addRenderableWidget(amountField);
|
||||||
|
|
||||||
recipientField = new TextFieldWidget(tr, leftX, startY + 28, leftWidth, 20, Text.literal("Получатель"));
|
recipientField = new EditBox(tr, leftX, startY + 28, leftWidth, 20, Component.literal("Получатель"));
|
||||||
recipientField.setMaxLength(32);
|
recipientField.setMaxLength(32);
|
||||||
recipientField.setPlaceholder(Text.literal("Ник или 5 цифр карты"));
|
recipientField.setHint(Component.literal("Ник или 5 цифр карты"));
|
||||||
recipientField.setChangedListener(value -> {
|
recipientField.setResponder(value -> {
|
||||||
String input = value.trim();
|
String input = value.trim();
|
||||||
if (isNickname(input)) {
|
if (isNickname(input)) {
|
||||||
scheduleRecipientLookup(input);
|
scheduleRecipientLookup(input);
|
||||||
@@ -84,60 +82,60 @@ public class PaymentScreen extends Screen {
|
|||||||
updateRecipientDropdownVisibility();
|
updateRecipientDropdownVisibility();
|
||||||
updateSenderCardText();
|
updateSenderCardText();
|
||||||
});
|
});
|
||||||
this.addDrawableChild(recipientField);
|
this.addRenderableWidget(recipientField);
|
||||||
|
|
||||||
commentField = new TextFieldWidget(tr, leftX, startY + 56, leftWidth, 20, Text.literal("Комментарий"));
|
commentField = new EditBox(tr, leftX, startY + 56, leftWidth, 20, Component.literal("Комментарий"));
|
||||||
commentField.setMaxLength(64);
|
commentField.setMaxLength(64);
|
||||||
commentField.setPlaceholder(Text.literal("Комментарий (необязательно)"));
|
commentField.setHint(Component.literal("Комментарий (необязательно)"));
|
||||||
this.addDrawableChild(commentField);
|
this.addRenderableWidget(commentField);
|
||||||
|
|
||||||
recipientCardDropdownButton = this.addDrawableChild(ButtonWidget.builder(Text.literal("Карта игрока: -"), button -> {
|
recipientCardDropdownButton = this.addRenderableWidget(Button.builder(Component.literal("Карта игрока: -"), button -> {
|
||||||
recipientDropdownExpanded = !recipientDropdownExpanded;
|
recipientDropdownExpanded = !recipientDropdownExpanded;
|
||||||
updateRecipientDropdownVisibility();
|
updateRecipientDropdownVisibility();
|
||||||
}).dimensions(leftX, startY + 84, leftWidth, 20).build());
|
}).bounds(leftX, startY + 84, leftWidth, 20).build());
|
||||||
|
|
||||||
recipientCardOptionButtons.clear();
|
recipientCardOptionButtons.clear();
|
||||||
for (int i = 0; i < MAX_RECIPIENT_OPTION_BUTTONS; i++) {
|
for (int i = 0; i < MAX_RECIPIENT_OPTION_BUTTONS; i++) {
|
||||||
final int index = i;
|
final int index = i;
|
||||||
ButtonWidget optionButton = this.addDrawableChild(ButtonWidget.builder(Text.literal("-"), button -> {
|
Button optionButton = this.addRenderableWidget(Button.builder(Component.literal("-"), button -> {
|
||||||
if (index < recipientCardOptions.size()) {
|
if (index < recipientCardOptions.size()) {
|
||||||
selectedRecipientCard = recipientCardOptions.get(index);
|
selectedRecipientCard = recipientCardOptions.get(index);
|
||||||
recipientDropdownExpanded = false;
|
recipientDropdownExpanded = false;
|
||||||
updateRecipientDropdownVisibility();
|
updateRecipientDropdownVisibility();
|
||||||
updateSenderCardText();
|
updateSenderCardText();
|
||||||
}
|
}
|
||||||
}).dimensions(leftX, startY + 108 + i * 22, leftWidth, 20).build());
|
}).bounds(leftX, startY + 108 + i * 22, leftWidth, 20).build());
|
||||||
recipientCardOptionButtons.add(optionButton);
|
recipientCardOptionButtons.add(optionButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!initialRecipient.isEmpty()) {
|
if (!initialRecipient.isEmpty()) {
|
||||||
recipientField.setText(initialRecipient);
|
recipientField.setValue(initialRecipient);
|
||||||
}
|
}
|
||||||
|
|
||||||
int arrowWidth = 36;
|
int arrowWidth = 36;
|
||||||
int middleWidth = rightWidth - arrowWidth * 2 - 8;
|
int middleWidth = rightWidth - arrowWidth * 2 - 8;
|
||||||
senderLeftButton = this.addDrawableChild(ButtonWidget.builder(Text.literal("<"), button -> {
|
senderLeftButton = this.addRenderableWidget(Button.builder(Component.literal("<"), button -> {
|
||||||
bankUiService.cycleSelectedCard(-1);
|
bankUiService.cycleSelectedCard(-1);
|
||||||
updateSenderCardSelector();
|
updateSenderCardSelector();
|
||||||
updateSenderCardText();
|
updateSenderCardText();
|
||||||
}).dimensions(rightX, startY, arrowWidth, 20).build());
|
}).bounds(rightX, startY, arrowWidth, 20).build());
|
||||||
|
|
||||||
senderCardLabelButton = this.addDrawableChild(ButtonWidget.builder(Text.literal("00000: 0 АР"), button -> {
|
senderCardLabelButton = this.addRenderableWidget(Button.builder(Component.literal("00000: 0 АР"), button -> {
|
||||||
}).dimensions(rightX + arrowWidth + 4, startY, middleWidth, 20).build());
|
}).bounds(rightX + arrowWidth + 4, startY, middleWidth, 20).build());
|
||||||
senderCardLabelButton.active = false;
|
senderCardLabelButton.active = false;
|
||||||
|
|
||||||
senderRightButton = this.addDrawableChild(ButtonWidget.builder(Text.literal(">"), button -> {
|
senderRightButton = this.addRenderableWidget(Button.builder(Component.literal(">"), button -> {
|
||||||
bankUiService.cycleSelectedCard(1);
|
bankUiService.cycleSelectedCard(1);
|
||||||
updateSenderCardSelector();
|
updateSenderCardSelector();
|
||||||
updateSenderCardText();
|
updateSenderCardText();
|
||||||
}).dimensions(rightX + arrowWidth + 4 + middleWidth + 4, startY, arrowWidth, 20).build());
|
}).bounds(rightX + arrowWidth + 4 + middleWidth + 4, startY, arrowWidth, 20).build());
|
||||||
|
|
||||||
transferButton = this.addDrawableChild(ButtonWidget.builder(Text.literal("Перевести"), button -> submit())
|
transferButton = this.addRenderableWidget(Button.builder(Component.literal("Перевести"), button -> submit())
|
||||||
.dimensions(rightX, startY + 28, rightWidth, 20)
|
.bounds(rightX, startY + 28, rightWidth, 20)
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
backButton = this.addDrawableChild(ButtonWidget.builder(Text.literal("Назад"), button -> this.close())
|
backButton = this.addRenderableWidget(Button.builder(Component.literal("Назад"), button -> this.onClose())
|
||||||
.dimensions(rightX, startY + 56, rightWidth, 20)
|
.bounds(rightX, startY + 56, rightWidth, 20)
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
updateSenderCardSelector();
|
updateSenderCardSelector();
|
||||||
@@ -146,9 +144,9 @@ public class PaymentScreen extends Screen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public void onClose() {
|
||||||
if (this.client != null) {
|
if (this.minecraft != null) {
|
||||||
this.client.setScreen(parent);
|
this.minecraft.gui.setScreen(parent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,63 +168,71 @@ public class PaymentScreen extends Screen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void render(DrawContext context, int mouseX, int mouseY, float delta) {
|
/*? if mc_26 {*/
|
||||||
super.render(context, mouseX, mouseY, delta);
|
public void extractRenderState(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
|
||||||
|
/*?} else {*/
|
||||||
|
/*public void render(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
|
||||||
|
*//*?}*/
|
||||||
|
/*? if mc_26 {*/
|
||||||
|
super.extractRenderState(context, mouseX, mouseY, delta);
|
||||||
|
/*?} else {*/
|
||||||
|
/*super.render(context, mouseX, mouseY, delta);
|
||||||
|
*//*?}*/
|
||||||
|
|
||||||
int centerX = this.width / 2;
|
int centerX = this.width / 2;
|
||||||
int startY = this.height / 2 - 82;
|
int startY = this.height / 2 - 82;
|
||||||
int leftX = centerX - 210;
|
int leftX = centerX - 210;
|
||||||
int rightX = centerX + 14;
|
int rightX = centerX + 14;
|
||||||
|
|
||||||
context.drawCenteredTextWithShadow(this.textRenderer, this.title, centerX, 20, 0xFFFFFF);
|
context.centeredText(this.font, this.title, centerX, 20, 0xFFFFFF);
|
||||||
context.drawTextWithShadow(this.textRenderer, Text.literal("Левая колонка: ввод"), leftX, startY - 18, 0xBFBFBF);
|
context.text(this.font, Component.literal("Левая колонка: ввод"), leftX, startY - 18, 0xBFBFBF);
|
||||||
context.drawTextWithShadow(this.textRenderer, Text.literal("Правая колонка: действия"), rightX, startY - 18, 0xBFBFBF);
|
context.text(this.font, Component.literal("Правая колонка: действия"), rightX, startY - 18, 0xBFBFBF);
|
||||||
context.drawTextWithShadow(this.textRenderer, Text.literal("Сумма:"), leftX, startY - 10, 0xCCCCCC);
|
context.text(this.font, Component.literal("Сумма:"), leftX, startY - 10, 0xCCCCCC);
|
||||||
context.drawTextWithShadow(this.textRenderer, Text.literal("Получатель:"), leftX, startY + 18, 0xCCCCCC);
|
context.text(this.font, Component.literal("Получатель:"), leftX, startY + 18, 0xCCCCCC);
|
||||||
context.drawTextWithShadow(this.textRenderer, Text.literal("Комментарий:"), leftX, startY + 46, 0xCCCCCC);
|
context.text(this.font, Component.literal("Комментарий:"), leftX, startY + 46, 0xCCCCCC);
|
||||||
|
|
||||||
if (recipientCardDropdownButton.visible) {
|
if (recipientCardDropdownButton.visible) {
|
||||||
context.drawTextWithShadow(this.textRenderer, Text.literal("Карта игрока"), leftX, startY + 74, 0xD7B2FF);
|
context.text(this.font, Component.literal("Карта игрока"), leftX, startY + 74, 0xD7B2FF);
|
||||||
}
|
}
|
||||||
|
|
||||||
context.drawCenteredTextWithShadow(this.textRenderer, senderCardText, centerX, this.height - 20, 0xA9E5A9);
|
context.centeredText(this.font, senderCardText, centerX, this.height - 20, 0xA9E5A9);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void submit() {
|
private void submit() {
|
||||||
if (transferInProgress) {
|
if (transferInProgress) {
|
||||||
notifications.show(Text.literal("Перевод уже выполняется"));
|
notifications.show(Component.literal("Перевод уже выполняется"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
CardViewModel selectedCard = bankUiService.getSelectedCard();
|
CardViewModel selectedCard = bankUiService.getSelectedCard();
|
||||||
if (selectedCard == null) {
|
if (selectedCard == null) {
|
||||||
notifications.show(Text.literal("Нет выбранной карты отправителя"));
|
notifications.show(Component.literal("Нет выбранной карты отправителя"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
long amount;
|
long amount;
|
||||||
try {
|
try {
|
||||||
amount = Long.parseLong(amountField.getText().trim());
|
amount = Long.parseLong(amountField.getValue().trim());
|
||||||
if (amount <= 0) {
|
if (amount <= 0) {
|
||||||
notifications.show(Text.literal("Сумма должна быть больше 0"));
|
notifications.show(Component.literal("Сумма должна быть больше 0"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (Exception exception) {
|
} catch (Exception exception) {
|
||||||
System.out.println(exception.getMessage());
|
System.out.println(exception.getMessage());
|
||||||
notifications.show(Text.literal("Некорректная сумма"));
|
notifications.show(Component.literal("Некорректная сумма"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String recipientInput = recipientField.getText().trim();
|
String recipientInput = recipientField.getValue().trim();
|
||||||
if (!isValidRecipient(recipientInput)) {
|
if (!isValidRecipient(recipientInput)) {
|
||||||
notifications.show(Text.literal("Укажи ник или 5 цифр карты"));
|
notifications.show(Component.literal("Укажи ник или 5 цифр карты"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String receiver = recipientInput;
|
String receiver = recipientInput;
|
||||||
if (isNickname(recipientInput)) {
|
if (isNickname(recipientInput)) {
|
||||||
if (selectedRecipientCard.isEmpty()) {
|
if (selectedRecipientCard.isEmpty()) {
|
||||||
notifications.show(Text.literal("Выбери карту получателя из списка"));
|
notifications.show(Component.literal("Выбери карту получателя из списка"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
receiver = selectedRecipientCard;
|
receiver = selectedRecipientCard;
|
||||||
@@ -236,18 +242,18 @@ public class PaymentScreen extends Screen {
|
|||||||
selectedCard.id(),
|
selectedCard.id(),
|
||||||
receiver,
|
receiver,
|
||||||
amount,
|
amount,
|
||||||
commentField.getText().trim()
|
commentField.getValue().trim()
|
||||||
);
|
);
|
||||||
|
|
||||||
setTransferInProgress(true);
|
setTransferInProgress(true);
|
||||||
notifications.show(Text.literal("Отправка перевода..."));
|
notifications.show(Component.literal("Отправка перевода..."));
|
||||||
|
|
||||||
bankUiService.submitPaymentAsync(draft)
|
bankUiService.submitPaymentAsync(draft)
|
||||||
.thenAccept(accepted -> {
|
.thenAccept(accepted -> {
|
||||||
if (this.client == null) {
|
if (this.minecraft == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.client.execute(() -> {
|
this.minecraft.execute(() -> {
|
||||||
setTransferInProgress(false);
|
setTransferInProgress(false);
|
||||||
|
|
||||||
String serviceMessage = bankUiService.getLastMessage();
|
String serviceMessage = bankUiService.getLastMessage();
|
||||||
@@ -255,8 +261,8 @@ public class PaymentScreen extends Screen {
|
|||||||
notifications.showMessage(serviceMessage);
|
notifications.showMessage(serviceMessage);
|
||||||
} else {
|
} else {
|
||||||
notifications.show(accepted
|
notifications.show(accepted
|
||||||
? Text.literal("Перевод выполнен")
|
? Component.literal("Перевод выполнен")
|
||||||
: Text.literal("Перевод отклонен"));
|
: Component.literal("Перевод отклонен"));
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSenderCardSelector();
|
updateSenderCardSelector();
|
||||||
@@ -264,10 +270,10 @@ public class PaymentScreen extends Screen {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.exceptionally(exception -> {
|
.exceptionally(exception -> {
|
||||||
if (this.client != null) {
|
if (this.minecraft != null) {
|
||||||
this.client.execute(() -> {
|
this.minecraft.execute(() -> {
|
||||||
setTransferInProgress(false);
|
setTransferInProgress(false);
|
||||||
notifications.show(Text.literal("Ошибка перевода: " + exception.getMessage()));
|
notifications.show(Component.literal("Ошибка перевода: " + exception.getMessage()));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -291,11 +297,11 @@ public class PaymentScreen extends Screen {
|
|||||||
private void updateSenderCardText() {
|
private void updateSenderCardText() {
|
||||||
CardViewModel selectedCard = bankUiService.getSelectedCard();
|
CardViewModel selectedCard = bankUiService.getSelectedCard();
|
||||||
if (selectedCard == null) {
|
if (selectedCard == null) {
|
||||||
senderCardText = Text.literal("Карта отправителя: не выбрана");
|
senderCardText = Component.literal("Карта отправителя: не выбрана");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String recipient = recipientField == null ? "" : recipientField.getText().trim();
|
String recipient = recipientField == null ? "" : recipientField.getValue().trim();
|
||||||
String suffix;
|
String suffix;
|
||||||
if (isNickname(recipient)) {
|
if (isNickname(recipient)) {
|
||||||
String recipientCard = selectedRecipientCard.isEmpty() ? "карта не выбрана" : selectedRecipientCard;
|
String recipientCard = selectedRecipientCard.isEmpty() ? "карта не выбрана" : selectedRecipientCard;
|
||||||
@@ -304,7 +310,7 @@ public class PaymentScreen extends Screen {
|
|||||||
suffix = " (режим: перевод по номеру карты)";
|
suffix = " (режим: перевод по номеру карты)";
|
||||||
}
|
}
|
||||||
|
|
||||||
senderCardText = Text.literal(
|
senderCardText = Component.literal(
|
||||||
"Карта отправителя: " + selectedCard.title() + " | Баланс: " + selectedCard.balance() + suffix
|
"Карта отправителя: " + selectedCard.title() + " | Баланс: " + selectedCard.balance() + suffix
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -312,7 +318,7 @@ public class PaymentScreen extends Screen {
|
|||||||
private void updateSenderCardSelector() {
|
private void updateSenderCardSelector() {
|
||||||
CardViewModel selectedCard = bankUiService.getSelectedCard();
|
CardViewModel selectedCard = bankUiService.getSelectedCard();
|
||||||
if (selectedCard == null) {
|
if (selectedCard == null) {
|
||||||
senderCardLabelButton.setMessage(Text.literal("TEST 00000: 0 АР"));
|
senderCardLabelButton.setMessage(Component.literal("TEST 00000: 0 АР"));
|
||||||
senderLeftButton.active = false;
|
senderLeftButton.active = false;
|
||||||
senderRightButton.active = false;
|
senderRightButton.active = false;
|
||||||
return;
|
return;
|
||||||
@@ -321,7 +327,7 @@ public class PaymentScreen extends Screen {
|
|||||||
senderLeftButton.active = true;
|
senderLeftButton.active = true;
|
||||||
senderRightButton.active = true;
|
senderRightButton.active = true;
|
||||||
String balance = Long.toString(selectedCard.balance());
|
String balance = Long.toString(selectedCard.balance());
|
||||||
senderCardLabelButton.setMessage(Text.literal(selectedCard.title() + ": " + balance + " АР"));
|
senderCardLabelButton.setMessage(Component.literal(selectedCard.title() + ": " + balance + " АР"));
|
||||||
senderCardLabelButton.active = false;
|
senderCardLabelButton.active = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,7 +336,7 @@ public class PaymentScreen extends Screen {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean nicknameMode = isNickname(recipientField == null ? "" : recipientField.getText().trim());
|
boolean nicknameMode = isNickname(recipientField == null ? "" : recipientField.getValue().trim());
|
||||||
|
|
||||||
boolean hasRecipientCards = !recipientCardOptions.isEmpty();
|
boolean hasRecipientCards = !recipientCardOptions.isEmpty();
|
||||||
recipientCardDropdownButton.visible = nicknameMode;
|
recipientCardDropdownButton.visible = nicknameMode;
|
||||||
@@ -351,15 +357,15 @@ public class PaymentScreen extends Screen {
|
|||||||
} else {
|
} else {
|
||||||
dropdownText = "Карта игрока: " + selectedRecipientCard;
|
dropdownText = "Карта игрока: " + selectedRecipientCard;
|
||||||
}
|
}
|
||||||
recipientCardDropdownButton.setMessage(Text.literal(dropdownText));
|
recipientCardDropdownButton.setMessage(Component.literal(dropdownText));
|
||||||
|
|
||||||
for (int i = 0; i < recipientCardOptionButtons.size(); i++) {
|
for (int i = 0; i < recipientCardOptionButtons.size(); i++) {
|
||||||
ButtonWidget optionButton = recipientCardOptionButtons.get(i);
|
Button optionButton = recipientCardOptionButtons.get(i);
|
||||||
boolean showOption = nicknameMode && recipientDropdownExpanded && i < recipientCardOptions.size();
|
boolean showOption = nicknameMode && recipientDropdownExpanded && i < recipientCardOptions.size();
|
||||||
optionButton.visible = showOption;
|
optionButton.visible = showOption;
|
||||||
optionButton.active = showOption;
|
optionButton.active = showOption;
|
||||||
if (i < recipientCardOptions.size()) {
|
if (i < recipientCardOptions.size()) {
|
||||||
optionButton.setMessage(Text.literal(recipientCardOptions.get(i)));
|
optionButton.setMessage(Component.literal(recipientCardOptions.get(i)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -382,14 +388,13 @@ public class PaymentScreen extends Screen {
|
|||||||
}
|
}
|
||||||
lastRecipientLookup = username;
|
lastRecipientLookup = username;
|
||||||
|
|
||||||
CompletableFuture
|
bankUiService.loadRecipientCardsAsync(username)
|
||||||
.supplyAsync(() -> bankUiService.loadRecipientCards(username))
|
|
||||||
.thenAccept(cards -> {
|
.thenAccept(cards -> {
|
||||||
if (this.client == null) {
|
if (this.minecraft == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.client.execute(() -> {
|
this.minecraft.execute(() -> {
|
||||||
if (recipientField == null || !username.equals(recipientField.getText().trim())) {
|
if (recipientField == null || !username.equals(recipientField.getValue().trim())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package git.yawaflua.tech.spmega.client.ui;
|
package git.yawaflua.tech.spmega.client.ui;
|
||||||
|
|
||||||
import net.minecraft.client.gui.DrawContext;
|
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||||
import net.minecraft.client.gui.screen.Screen;
|
import net.minecraft.client.gui.components.Button;
|
||||||
import net.minecraft.client.gui.widget.ButtonWidget;
|
import net.minecraft.client.gui.screens.Screen;
|
||||||
import net.minecraft.text.Text;
|
import net.minecraft.network.chat.Component;
|
||||||
|
/*? if mc_1_21_11 {*/
|
||||||
import net.minecraft.util.Util;
|
import net.minecraft.util.Util;
|
||||||
|
/*?} else {*/
|
||||||
|
// import net.minecraft.Util;
|
||||||
|
/*?}*/
|
||||||
|
|
||||||
import java.awt.*;
|
import java.awt.*;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
@@ -14,7 +18,7 @@ public class QRcodeAcceptScreen extends Screen {
|
|||||||
private final Screen parent;
|
private final Screen parent;
|
||||||
|
|
||||||
public QRcodeAcceptScreen(String url, Screen parent) {
|
public QRcodeAcceptScreen(String url, Screen parent) {
|
||||||
super(Text.translatable("screen.spmega.qr.confirm_title"));
|
super(Component.translatable("screen.spmega.qr.confirm_title"));
|
||||||
this.url = url;
|
this.url = url;
|
||||||
this.parent = parent;
|
this.parent = parent;
|
||||||
}
|
}
|
||||||
@@ -23,44 +27,55 @@ public class QRcodeAcceptScreen extends Screen {
|
|||||||
protected void init() {
|
protected void init() {
|
||||||
|
|
||||||
|
|
||||||
this.addDrawableChild(ButtonWidget.builder(Text.translatable("button.spmega.qr.cancel"), button -> {
|
this.addRenderableWidget(Button.builder(Component.translatable("button.spmega.qr.cancel"), button -> {
|
||||||
if (this.client != null) {
|
if (this.minecraft != null) {
|
||||||
this.client.setScreen(parent);
|
this.minecraft.gui.setScreen(parent);
|
||||||
}
|
}
|
||||||
}).dimensions(this.width / 2 - 155, this.height / 2 + 30, 150, 20).build());
|
}).bounds(this.width / 2 - 155, this.height / 2 + 30, 150, 20).build());
|
||||||
|
|
||||||
this.addDrawableChild(ButtonWidget.builder(Text.translatable("button.spmega.qr.open_link"), button -> {
|
this.addRenderableWidget(Button.builder(Component.translatable("button.spmega.qr.open_link"), button -> {
|
||||||
try {
|
try {
|
||||||
Util.getOperatingSystem().open(new URI(url));
|
Util.getPlatform().openUri(new URI(url));
|
||||||
} catch (Exception exception) {
|
} catch (Exception exception) {
|
||||||
if (this.client != null && this.client.player != null) {
|
if (this.minecraft != null && this.minecraft.player != null) {
|
||||||
this.client.player.sendMessage(Text.translatable("message.spmega.qr.failed_open"), false);
|
/*? if mc_26 {*/
|
||||||
|
this.minecraft.player.sendSystemMessage(Component.translatable("message.spmega.qr.failed_open"));
|
||||||
|
/*?} else {*/
|
||||||
|
/*this.minecraft.player.displayClientMessage(Component.translatable("message.spmega.qr.failed_open"), false);
|
||||||
|
*//*?}*/
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this.client != null) {
|
if (this.minecraft != null) {
|
||||||
this.client.setScreen(parent);
|
this.minecraft.gui.setScreen(parent);
|
||||||
}
|
}
|
||||||
}).dimensions(this.width / 2 + 5, this.height / 2 + 30, 150, 20).build());
|
}).bounds(this.width / 2 + 5, this.height / 2 + 30, 150, 20).build());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public void onClose() {
|
||||||
if (this.client != null) {
|
if (this.minecraft != null) {
|
||||||
this.client.setScreen(parent);
|
this.minecraft.gui.setScreen(parent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void render(DrawContext context, int mouseX, int mouseY, float delta) {
|
/*? if mc_26 {*/
|
||||||
super.render(context, mouseX, mouseY, delta);
|
public void extractRenderState(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
|
||||||
|
/*?} else {*/
|
||||||
|
/*public void render(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
|
||||||
|
*//*?}*/
|
||||||
|
/*? if mc_26 {*/
|
||||||
|
super.extractRenderState(context, mouseX, mouseY, delta);
|
||||||
|
/*?} else {*/
|
||||||
|
/*super.render(context, mouseX, mouseY, delta);
|
||||||
|
*//*?}*/
|
||||||
|
|
||||||
context.drawCenteredTextWithShadow(
|
context.centeredText(
|
||||||
this.textRenderer,
|
this.font,
|
||||||
Text.literal("Найдена ссылка: " + url),
|
Component.literal("Найдена ссылка: " + url),
|
||||||
this.width / 2,
|
this.width / 2,
|
||||||
this.height / 2,
|
this.height / 2,
|
||||||
Color.WHITE.getRGB()
|
Color.WHITE.getRGB()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ package git.yawaflua.tech.spmega.client.ui;
|
|||||||
import git.yawaflua.tech.spmega.client.ui.service.BackendAuthenticator;
|
import git.yawaflua.tech.spmega.client.ui.service.BackendAuthenticator;
|
||||||
import git.yawaflua.tech.spmega.client.ui.service.BankDatabase.LocalTransaction;
|
import git.yawaflua.tech.spmega.client.ui.service.BankDatabase.LocalTransaction;
|
||||||
import git.yawaflua.tech.spmega.client.ui.service.BankUiService;
|
import git.yawaflua.tech.spmega.client.ui.service.BankUiService;
|
||||||
import net.minecraft.client.gui.DrawContext;
|
|
||||||
import net.minecraft.client.gui.screen.Screen;
|
|
||||||
import net.minecraft.client.gui.widget.ButtonWidget;
|
|
||||||
import net.minecraft.text.Text;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||||
|
import net.minecraft.client.gui.components.Button;
|
||||||
|
import net.minecraft.client.gui.screens.Screen;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
|
||||||
public class TransactionHistoryScreen extends Screen {
|
public class TransactionHistoryScreen extends Screen {
|
||||||
private final Screen parent;
|
private final Screen parent;
|
||||||
@@ -19,9 +19,11 @@ public class TransactionHistoryScreen extends Screen {
|
|||||||
private final List<LocalTransaction> transactions = new ArrayList<>();
|
private final List<LocalTransaction> transactions = new ArrayList<>();
|
||||||
private boolean loading = true;
|
private boolean loading = true;
|
||||||
private String errorMessage = "";
|
private String errorMessage = "";
|
||||||
|
private int scrollOffset = 0;
|
||||||
|
private int currentPage = 1;
|
||||||
|
|
||||||
public TransactionHistoryScreen(Screen parent, String cardId, String cardTitle) {
|
public TransactionHistoryScreen(Screen parent, String cardId, String cardTitle) {
|
||||||
super(Text.literal("История транзакций"));
|
super(Component.literal("История транзакций"));
|
||||||
this.parent = parent;
|
this.parent = parent;
|
||||||
this.cardId = cardId;
|
this.cardId = cardId;
|
||||||
this.cardTitle = cardTitle;
|
this.cardTitle = cardTitle;
|
||||||
@@ -32,91 +34,176 @@ public class TransactionHistoryScreen extends Screen {
|
|||||||
int centerX = this.width / 2;
|
int centerX = this.width / 2;
|
||||||
int startY = this.height / 2 + 50;
|
int startY = this.height / 2 + 50;
|
||||||
|
|
||||||
this.addDrawableChild(ButtonWidget.builder(Text.literal("Назад"), button -> this.close())
|
this.addRenderableWidget(Button.builder(Component.literal("Назад"), button -> this.onClose())
|
||||||
.dimensions(centerX - 60, startY + 10, 120, 20)
|
.bounds(centerX - 60, startY + 10, 120, 20)
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
|
// Scroll Up/Down buttons
|
||||||
|
this.addRenderableWidget(Button.builder(Component.literal("▲"), button -> scrollUp())
|
||||||
|
.bounds(centerX + 140, 65, 20, 20)
|
||||||
|
.build());
|
||||||
|
|
||||||
|
this.addRenderableWidget(Button.builder(Component.literal("▼"), button -> scrollDown())
|
||||||
|
.bounds(centerX + 140, 155, 20, 20)
|
||||||
|
.build());
|
||||||
|
|
||||||
|
// Prev Page button
|
||||||
|
this.addRenderableWidget(Button.builder(Component.literal("<"), button -> {
|
||||||
|
if (currentPage > 1) {
|
||||||
|
currentPage--;
|
||||||
|
scrollOffset = 0;
|
||||||
|
loadTransactions();
|
||||||
|
}
|
||||||
|
}).bounds(centerX - 100, startY - 15, 30, 20).build());
|
||||||
|
|
||||||
|
// Next Page button
|
||||||
|
this.addRenderableWidget(Button.builder(Component.literal(">"), button -> {
|
||||||
|
currentPage++;
|
||||||
|
scrollOffset = 0;
|
||||||
|
loadTransactions();
|
||||||
|
}).bounds(centerX + 70, startY - 15, 30, 20).build());
|
||||||
|
|
||||||
loadTransactions();
|
loadTransactions();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void scrollUp() {
|
||||||
|
if (scrollOffset > 0) {
|
||||||
|
scrollOffset--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void scrollDown() {
|
||||||
|
if (scrollOffset < Math.max(0, transactions.size() - 6)) {
|
||||||
|
scrollOffset++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Support pre-1.20.2 mouse scroll
|
||||||
|
public boolean mouseScrolled(double mouseX, double mouseY, double amount) {
|
||||||
|
if (amount > 0) {
|
||||||
|
scrollUp();
|
||||||
|
} else if (amount < 0) {
|
||||||
|
scrollDown();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Support 1.20.2+ mouse scroll
|
||||||
|
@Override
|
||||||
|
public boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmount, double verticalAmount) {
|
||||||
|
if (verticalAmount > 0) {
|
||||||
|
scrollUp();
|
||||||
|
} else if (verticalAmount < 0) {
|
||||||
|
scrollDown();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private void loadTransactions() {
|
private void loadTransactions() {
|
||||||
loading = true;
|
loading = true;
|
||||||
errorMessage = "";
|
errorMessage = "";
|
||||||
transactions.clear();
|
transactions.clear();
|
||||||
|
|
||||||
CompletableFuture.runAsync(() -> {
|
UiNotifications.instance().show(Component.literal("Загрузка..."));
|
||||||
try {
|
|
||||||
List<LocalTransaction> list = null;
|
int page = currentPage;
|
||||||
git.yawaflua.tech.spmega.ModConfig config = git.yawaflua.tech.spmega.SPMega.getConfig();
|
System.out.println("[SPMEGA] Transaction history loading started for card: " + cardId + ", page: " + page);
|
||||||
if (config != null && config.allowBackend()) {
|
git.yawaflua.tech.spmega.ModConfig config = git.yawaflua.tech.spmega.SPMega.getConfig();
|
||||||
try {
|
CompletableFuture<List<LocalTransaction>> remote = config != null && config.allowBackend()
|
||||||
list = BackendAuthenticator.fetchTransactionsFromBackend(cardId);
|
? BackendAuthenticator.fetchTransactionsFromBackendAsync(cardId, page)
|
||||||
} catch (Exception e) {
|
.whenComplete((list, exception) -> {
|
||||||
System.err.println("[SPMEGA] Failed to fetch transactions from server, falling back to DB: " + e.getMessage());
|
if (exception == null) {
|
||||||
|
System.out.println("[SPMEGA] Fetched " + list.size() + " transactions from backend.");
|
||||||
|
} else {
|
||||||
|
System.err.println("[SPMEGA] Failed to fetch transactions from server, falling back to DB: "
|
||||||
|
+ exception.getMessage());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.exceptionally(exception -> null)
|
||||||
|
: CompletableFuture.completedFuture(null);
|
||||||
|
|
||||||
|
remote.thenApply(list -> {
|
||||||
|
if (list != null) {
|
||||||
|
return list;
|
||||||
}
|
}
|
||||||
}
|
List<LocalTransaction> local = BankUiService.instance().getDatabase().loadTransferHistory(cardId);
|
||||||
|
System.out.println("[SPMEGA] Loaded " + local.size() + " transactions from database.");
|
||||||
if (list == null) {
|
return local;
|
||||||
list = BankUiService.instance().getDatabase().loadTransferHistory(cardId);
|
})
|
||||||
}
|
.whenComplete((list, exception) -> {
|
||||||
|
Minecraft minecraftClient = Minecraft.getInstance();
|
||||||
List<LocalTransaction> finalList = list;
|
if (minecraftClient == null) {
|
||||||
if (this.client != null) {
|
System.out.println("[SPMEGA] MinecraftClient.getInstance() is null in loadTransactions callback!");
|
||||||
this.client.execute(() -> {
|
return;
|
||||||
this.transactions.addAll(finalList);
|
}
|
||||||
this.loading = false;
|
minecraftClient.execute(() -> {
|
||||||
|
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;
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
} catch (Exception e) {
|
|
||||||
if (this.client != null) {
|
|
||||||
this.client.execute(() -> {
|
|
||||||
this.errorMessage = e.getMessage();
|
|
||||||
this.loading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public void onClose() {
|
||||||
if (this.client != null) {
|
if (this.minecraft != null) {
|
||||||
this.client.setScreen(parent);
|
this.minecraft.gui.setScreen(parent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void render(DrawContext context, int mouseX, int mouseY, float delta) {
|
/*? if mc_26 {*/
|
||||||
super.render(context, mouseX, mouseY, delta);
|
public void extractRenderState(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
|
||||||
|
/*?} else {*/
|
||||||
|
/*public void render(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
|
||||||
|
*//*?}*/
|
||||||
|
/*? if mc_26 {*/
|
||||||
|
super.extractRenderState(context, mouseX, mouseY, delta);
|
||||||
|
/*?} else {*/
|
||||||
|
/*super.render(context, mouseX, mouseY, delta);
|
||||||
|
*//*?}*/
|
||||||
|
|
||||||
int centerX = this.width / 2;
|
int centerX = this.width / 2;
|
||||||
int startY = 50;
|
int startY = 50;
|
||||||
|
int bottomStartY = this.height / 2 + 50;
|
||||||
|
|
||||||
context.drawCenteredTextWithShadow(this.textRenderer, this.title, centerX, 20, 0xFFFFFF);
|
context.centeredText(this.font, this.title, centerX, 20, 0xFFFFFFFF);
|
||||||
context.drawCenteredTextWithShadow(this.textRenderer, Text.literal("Карта: " + cardTitle), centerX, 35, 0xBFBFBF);
|
context.centeredText(this.font, Component.literal("Карта: " + cardTitle), centerX, 35, 0xFFBFBFBF);
|
||||||
|
context.centeredText(this.font, Component.literal("Стр. " + currentPage), centerX, bottomStartY - 9, 0xFFFFFFFF);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
context.drawCenteredTextWithShadow(this.textRenderer, Text.literal("Загрузка транзакций..."), centerX, this.height / 2 - 10, 0xCCCCCC);
|
context.centeredText(this.font, Component.literal("Загрузка транзакций..."), centerX, this.height / 2 - 10, 0xFFCCCCCC);
|
||||||
} else if (!errorMessage.isEmpty()) {
|
} else if (!errorMessage.isEmpty()) {
|
||||||
context.drawCenteredTextWithShadow(this.textRenderer, Text.literal("Ошибка: " + errorMessage), centerX, this.height / 2 - 10, 0xFF5555);
|
context.centeredText(this.font, Component.literal("Ошибка: " + errorMessage), centerX, this.height / 2 - 10, 0xFFFF5555);
|
||||||
} else if (transactions.isEmpty()) {
|
} else if (transactions.isEmpty()) {
|
||||||
context.drawCenteredTextWithShadow(this.textRenderer, Text.literal("Транзакций не найдено"), centerX, this.height / 2 - 10, 0xCCCCCC);
|
context.centeredText(this.font, Component.literal("Транзакций не найдено"), centerX, this.height / 2 - 10, 0xFFCCCCCC);
|
||||||
} else {
|
} else {
|
||||||
int y = startY + 15;
|
int y = startY + 15;
|
||||||
for (int i = 0; i < Math.min(6, transactions.size()); i++) {
|
int limit = Math.min(6, transactions.size() - scrollOffset);
|
||||||
LocalTransaction tx = transactions.get(i);
|
for (int i = 0; i < limit; i++) {
|
||||||
|
LocalTransaction tx = transactions.get(scrollOffset + i);
|
||||||
|
|
||||||
String amountText = tx.amount() + " АР";
|
String amountText = tx.amount() + " АР";
|
||||||
String dateText = tx.createdAt();
|
String dateText = tx.createdAt();
|
||||||
|
if (dateText == null) {
|
||||||
|
dateText = "";
|
||||||
|
}
|
||||||
if (dateText.length() > 19) {
|
if (dateText.length() > 19) {
|
||||||
dateText = dateText.substring(0, 19).replace("T", " ");
|
dateText = dateText.substring(0, 19).replace("T", " ");
|
||||||
}
|
}
|
||||||
String commentText = tx.comment().isEmpty() ? "" : " (" + tx.comment() + ")";
|
String commentText = tx.comment().isEmpty() ? "" : " (" + tx.comment() + ")";
|
||||||
|
|
||||||
String line = String.format("%s -> %s | %s%s", dateText, tx.receiver(), amountText, commentText);
|
String line = String.format("%s -> %s | %s%s", dateText, tx.receiver(), amountText, commentText);
|
||||||
int color = tx.status().equalsIgnoreCase("SUCCESS") ? 0x55FF55 : 0xFF5555;
|
|
||||||
|
|
||||||
context.drawCenteredTextWithShadow(this.textRenderer, Text.literal(line), centerX, y, color);
|
String status = tx.status();
|
||||||
|
int color = (status != null && status.equalsIgnoreCase("SUCCESS")) ? 0xFF55FF55 : 0xFFFF5555;
|
||||||
|
|
||||||
|
context.centeredText(this.font, Component.literal(line), centerX, y, color);
|
||||||
y += 18;
|
y += 18;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,19 +3,22 @@ package git.yawaflua.tech.spmega.client.ui;
|
|||||||
import com.google.gson.JsonElement;
|
import com.google.gson.JsonElement;
|
||||||
import com.google.gson.JsonObject;
|
import com.google.gson.JsonObject;
|
||||||
import com.google.gson.JsonParser;
|
import com.google.gson.JsonParser;
|
||||||
import com.google.gson.Strictness;
|
|
||||||
import com.google.gson.stream.JsonReader;
|
import com.google.gson.stream.JsonReader;
|
||||||
import net.minecraft.client.font.TextRenderer;
|
import git.yawaflua.tech.spmega.GpsHudPosition;
|
||||||
import net.minecraft.client.gui.DrawContext;
|
import git.yawaflua.tech.spmega.SPMega;
|
||||||
import net.minecraft.text.Text;
|
|
||||||
|
|
||||||
import java.awt.*;
|
import java.awt.*;
|
||||||
|
import java.util.ArrayDeque;
|
||||||
|
import java.util.Queue;
|
||||||
|
import net.minecraft.client.gui.Font;
|
||||||
|
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
|
||||||
public final class UiNotifications {
|
public final class UiNotifications {
|
||||||
private static final int DEFAULT_DURATION_TICKS = 70;
|
private static final int DEFAULT_DURATION_TICKS = 70;
|
||||||
private static final UiNotifications INSTANCE = new UiNotifications();
|
private static final UiNotifications INSTANCE = new UiNotifications();
|
||||||
|
|
||||||
private Text currentText = Text.empty();
|
private Component currentText = Component.empty();
|
||||||
|
private final Queue<Component> queuedTexts = new ArrayDeque<>();
|
||||||
private int remainingTicks;
|
private int remainingTicks;
|
||||||
|
|
||||||
private UiNotifications() {
|
private UiNotifications() {
|
||||||
@@ -32,7 +35,7 @@ public final class UiNotifications {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
var reader = new JsonReader(new java.io.StringReader(raw));
|
var reader = new JsonReader(new java.io.StringReader(raw));
|
||||||
reader.setStrictness(Strictness.LENIENT);
|
reader.setLenient(true);
|
||||||
|
|
||||||
JsonElement parsed = JsonParser.parseReader(reader);
|
JsonElement parsed = JsonParser.parseReader(reader);
|
||||||
JsonObject object = parsed.isJsonArray() ? parsed.getAsJsonArray().get(0).getAsJsonObject() : parsed.getAsJsonObject();
|
JsonObject object = parsed.isJsonArray() ? parsed.getAsJsonArray().get(0).getAsJsonObject() : parsed.getAsJsonObject();
|
||||||
@@ -47,7 +50,7 @@ public final class UiNotifications {
|
|||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized void show(Text text) {
|
public synchronized void show(Component text) {
|
||||||
if (text == null || text.getString().isBlank()) {
|
if (text == null || text.getString().isBlank()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -59,24 +62,45 @@ public final class UiNotifications {
|
|||||||
if (message == null || message.isBlank()) {
|
if (message == null || message.isBlank()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
show(Text.literal(extractMessage(message)));
|
show(Component.literal(extractMessage(message)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized void render(DrawContext context, TextRenderer textRenderer, int width, int height) {
|
public synchronized void showQueued(Component text) {
|
||||||
|
if (text == null || text.getString().isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isVisible()) {
|
||||||
|
show(text);
|
||||||
|
} else {
|
||||||
|
queuedTexts.add(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void render(GuiGraphicsExtractor context, Font textRenderer, int width, int height) {
|
||||||
if (!isVisible()) {
|
if (!isVisible()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String message = currentText.getString();
|
String message = currentText.getString();
|
||||||
int padding = 6;
|
int padding = 6;
|
||||||
int textWidth = textRenderer.getWidth(message);
|
int textWidth = textRenderer.width(message);
|
||||||
int boxWidth = textWidth + padding * 2;
|
int boxWidth = textWidth + padding * 2;
|
||||||
int boxHeight = textRenderer.fontHeight + padding * 2;
|
int boxHeight = textRenderer.lineHeight + padding * 2;
|
||||||
int x = width - boxWidth - 10;
|
GpsHudPosition position = SPMega.getConfig() == null
|
||||||
int y = height - boxHeight - 10;
|
? 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.fill(x, y, x + boxWidth, y + boxHeight, Color.DARK_GRAY.getRGB());
|
||||||
context.drawTextWithShadow(textRenderer, currentText, x + padding, y + padding, Color.WHITE.getRGB());
|
context.text(textRenderer, currentText, x + padding, y + padding, Color.WHITE.getRGB());
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized void tick() {
|
public synchronized void tick() {
|
||||||
@@ -85,8 +109,8 @@ public final class UiNotifications {
|
|||||||
}
|
}
|
||||||
remainingTicks--;
|
remainingTicks--;
|
||||||
if (remainingTicks <= 0) {
|
if (remainingTicks <= 0) {
|
||||||
currentText = Text.empty();
|
currentText = queuedTexts.isEmpty() ? Component.empty() : queuedTexts.remove();
|
||||||
remainingTicks = 0;
|
remainingTicks = currentText.getString().isBlank() ? 0 : DEFAULT_DURATION_TICKS;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,4 +118,3 @@ public final class UiNotifications {
|
|||||||
return currentText != null && !currentText.getString().isBlank() && remainingTicks > 0;
|
return currentText != null && !currentText.getString().isBlank() && remainingTicks > 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
package git.yawaflua.tech.spmega.client.ui;
|
package git.yawaflua.tech.spmega.client.ui;
|
||||||
|
|
||||||
import net.minecraft.client.MinecraftClient;
|
import net.minecraft.client.Minecraft;
|
||||||
import net.minecraft.client.gui.screen.Screen;
|
import net.minecraft.client.gui.screens.Screen;
|
||||||
|
|
||||||
public final class UiOpeners {
|
public final class UiOpeners {
|
||||||
private UiOpeners() {
|
private UiOpeners() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void openMainMenu(MinecraftClient client) {
|
public static void openMainMenu(Minecraft client) {
|
||||||
if (client == null) {
|
if (client == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Screen current = client.currentScreen;
|
Screen current = client.gui.screen();
|
||||||
client.setScreen(new MainBankScreen(current));
|
client.gui.setScreen(new MainBankScreen(current));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void openPaymentMenu(MinecraftClient client, String recipient) {
|
public static void openPaymentMenu(Minecraft client, String recipient) {
|
||||||
if (client == null) {
|
if (client == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Screen current = client.currentScreen;
|
Screen current = client.gui.screen();
|
||||||
client.setScreen(new PaymentScreen(current, recipient));
|
client.gui.setScreen(new PaymentScreen(current, recipient));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+386
-128
@@ -1,59 +1,165 @@
|
|||||||
package git.yawaflua.tech.spmega.client.ui.service;
|
package git.yawaflua.tech.spmega.client.ui.service;
|
||||||
|
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
import com.google.gson.JsonObject;
|
import com.google.gson.JsonObject;
|
||||||
import com.google.gson.JsonParser;
|
import com.google.gson.JsonParser;
|
||||||
import com.mojang.authlib.exceptions.AuthenticationException;
|
import com.mojang.authlib.exceptions.AuthenticationException;
|
||||||
import com.mojang.authlib.minecraft.MinecraftSessionService;
|
import com.mojang.authlib.minecraft.MinecraftSessionService;
|
||||||
import git.yawaflua.tech.spmega.ModConfig;
|
import git.yawaflua.tech.spmega.ModConfig;
|
||||||
import git.yawaflua.tech.spmega.SPMega;
|
import git.yawaflua.tech.spmega.SPMega;
|
||||||
import net.minecraft.client.MinecraftClient;
|
import git.yawaflua.tech.spmega.client.telemetry.InstrumentedHttpClient;
|
||||||
import net.minecraft.client.session.Session;
|
import git.yawaflua.tech.spmega.client.ui.UiNotifications;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.http.HttpClient;
|
|
||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.CompletionException;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.User;
|
||||||
|
|
||||||
public class BackendAuthenticator {
|
public final class BackendAuthenticator {
|
||||||
|
private static final long TOKEN_CHECK_INTERVAL_MILLIS = 60L * 60L * 1000L;
|
||||||
|
private static final AtomicBoolean tokenMaintenanceRunning = new AtomicBoolean();
|
||||||
|
private static volatile long nextTokenCheckMillis;
|
||||||
|
private static CompletableFuture<Boolean> activeReauthentication;
|
||||||
|
|
||||||
public static boolean authenticate(MinecraftClient client) {
|
private BackendAuthenticator() {
|
||||||
Session session = client.getSession();
|
}
|
||||||
if (session == null || session.getUuidOrNull() == null) {
|
|
||||||
System.err.println("Cannot authenticate: Client has no valid session.");
|
public static void tickTokenRefresh(Minecraft client) {
|
||||||
return false;
|
long now = System.currentTimeMillis();
|
||||||
|
if (now < nextTokenCheckMillis || !tokenMaintenanceRunning.compareAndSet(false, true)) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
MinecraftSessionService sessionService = client.getApiServices().sessionService();
|
nextTokenCheckMillis = now + TOKEN_CHECK_INTERVAL_MILLIS;
|
||||||
|
maintainTokenAsync(client).whenComplete((ignored, throwable) -> {
|
||||||
|
tokenMaintenanceRunning.set(false);
|
||||||
|
if (throwable != null) {
|
||||||
|
Throwable cause = throwable.getCause() == null ? throwable : throwable.getCause();
|
||||||
|
System.err.println("[SPMEGA] Token maintenance failed: " + cause.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
private static CompletableFuture<Boolean> maintainTokenAsync(Minecraft client) {
|
||||||
var serverId = sendStartSessionRequestToBackend(session.getUsername(), session.getUuidOrNull());
|
ModConfig config = SPMega.getConfig();
|
||||||
System.out.println("[SPMEGA] Trying to auth in mojang with serverId: " + serverId);
|
if (config == null || !config.allowBackend()) {
|
||||||
sessionService.joinServer(
|
return CompletableFuture.completedFuture(false);
|
||||||
session.getUuidOrNull(),
|
}
|
||||||
session.getAccessToken(),
|
|
||||||
serverId
|
|
||||||
);
|
|
||||||
System.out.println("[SPMEGA] Sending session submitter to backend");
|
|
||||||
|
|
||||||
return sendAuthRequestToBackend(session.getUuidOrNull(), serverId);
|
if (config.apiToken() == null || config.apiToken().isBlank()
|
||||||
|
|| ModConfig.DEFAULT_API_TOKEN.equals(config.apiToken())) {
|
||||||
|
return reauthenticateAsync(client);
|
||||||
|
}
|
||||||
|
|
||||||
} catch (AuthenticationException e) {
|
HttpRequest request = HttpRequest.newBuilder(URI.create(backendUrl(config, "/api/v1/auth/refresh")))
|
||||||
System.err.println("I cant auth by Mojang: " + e.getMessage());
|
.header("Accept", "application/json")
|
||||||
System.err.println("Please check your credentials and try again.");
|
.header("Authorization", "Bearer " + config.apiToken())
|
||||||
|
.POST(HttpRequest.BodyPublishers.noBody())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return new InstrumentedHttpClient().sendAsync(request).thenCompose(response -> {
|
||||||
|
if (response.statusCode() == 401) {
|
||||||
|
return reauthenticateAsync(client);
|
||||||
|
}
|
||||||
|
if (response.statusCode() != 200) {
|
||||||
|
throw new CompletionException(new IOException(
|
||||||
|
"Refresh endpoint returned status " + response.statusCode() + ": " + response.body()));
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
|
||||||
|
if (json.has("refreshed") && json.get("refreshed").getAsBoolean()) {
|
||||||
|
if (!json.has("token") || json.get("token").isJsonNull()) {
|
||||||
|
throw new CompletionException(new IOException("Refresh endpoint did not return a token."));
|
||||||
|
}
|
||||||
|
saveToken(json.get("token").getAsString());
|
||||||
|
System.out.println("[SPMEGA] Backend access token refreshed.");
|
||||||
|
}
|
||||||
|
return CompletableFuture.completedFuture(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void onReauthenticationRequired() {
|
||||||
|
ModConfig config = SPMega.getConfig();
|
||||||
|
if (config == null || !config.allowBackend()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reauthenticateAsync(Minecraft.getInstance()).exceptionally(throwable -> {
|
||||||
|
Throwable cause = throwable.getCause() == null ? throwable : throwable.getCause();
|
||||||
|
System.err.println("[SPMEGA] Reauthentication failed: " + cause.getMessage());
|
||||||
return false;
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
} catch (Exception e) {
|
private static synchronized CompletableFuture<Boolean> reauthenticateAsync(Minecraft client) {
|
||||||
System.err.println("Failed to authenticate with backend: " + e.getMessage());
|
if (activeReauthentication != null && !activeReauthentication.isDone()) {
|
||||||
return false;
|
return activeReauthentication;
|
||||||
|
}
|
||||||
|
|
||||||
|
UiNotifications.instance().showMessage("Сессия SPMega устарела. Выполняется повторная авторизация…");
|
||||||
|
CompletableFuture<Boolean> reauthentication = authenticateAsync(client).thenApply(success -> {
|
||||||
|
if (success) {
|
||||||
|
UiNotifications.instance().showMessage("Авторизация SPMega обновлена.");
|
||||||
|
} else {
|
||||||
|
UiNotifications.instance().showMessage("Не удалось обновить авторизацию SPMega.");
|
||||||
|
}
|
||||||
|
return success;
|
||||||
|
});
|
||||||
|
activeReauthentication = reauthentication;
|
||||||
|
reauthentication.whenComplete((ignored, throwable) -> clearActiveReauthentication(reauthentication));
|
||||||
|
return reauthentication;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static synchronized void clearActiveReauthentication(CompletableFuture<Boolean> completed) {
|
||||||
|
if (activeReauthentication == completed) {
|
||||||
|
activeReauthentication = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String sendStartSessionRequestToBackend(String username, UUID uuid) throws IOException, InterruptedException {
|
public static CompletableFuture<Boolean> authenticateAsync(Minecraft client) {
|
||||||
|
User session = client.getUser();
|
||||||
|
if (session == null || session.getProfileId() == null) {
|
||||||
|
System.err.println("Cannot authenticate: Client has no valid session.");
|
||||||
|
return CompletableFuture.completedFuture(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*? if mc_1_21_11 {*/
|
||||||
|
MinecraftSessionService sessionService = client.services().sessionService();
|
||||||
|
/*?} else {*/
|
||||||
|
// MinecraftSessionService sessionService = client.getMinecraftSessionService();
|
||||||
|
/*?}*/
|
||||||
|
return sendStartSessionRequestToBackendAsync(session.getName(), session.getProfileId())
|
||||||
|
.thenCompose(serverId -> CompletableFuture.runAsync(() -> {
|
||||||
|
System.out.println("[SPMEGA] Trying to auth in mojang with serverId: " + serverId);
|
||||||
|
try {
|
||||||
|
sessionService.joinServer(session.getProfileId(), session.getAccessToken(), serverId);
|
||||||
|
} catch (AuthenticationException exception) {
|
||||||
|
throw new CompletionException(exception);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.thenCompose(ignored -> {
|
||||||
|
System.out.println("[SPMEGA] Sending session submitter to backend");
|
||||||
|
return sendAuthRequestToBackendAsync(session.getProfileId(), 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 CompletableFuture<String> sendStartSessionRequestToBackendAsync(String username, UUID uuid) {
|
||||||
ModConfig config = SPMega.getConfig();
|
ModConfig config = SPMega.getConfig();
|
||||||
String apiDomain = (config != null) ? config.apiDomain() : ModConfig.DEFAULT_API_DOMAIN;
|
String apiDomain = (config != null) ? config.apiDomain() : ModConfig.DEFAULT_API_DOMAIN;
|
||||||
if (apiDomain == null || apiDomain.isBlank()) {
|
if (apiDomain == null || apiDomain.isBlank()) {
|
||||||
@@ -70,29 +176,29 @@ public class BackendAuthenticator {
|
|||||||
|
|
||||||
String url = apiDomain + "/api/v1/auth/start";
|
String url = apiDomain + "/api/v1/auth/start";
|
||||||
|
|
||||||
HttpClient httpClient = HttpClient.newHttpClient();
|
InstrumentedHttpClient httpClient = new InstrumentedHttpClient();
|
||||||
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.method("POST", HttpRequest.BodyPublishers.ofString(requestBody))
|
.method("POST", HttpRequest.BodyPublishers.ofString(requestBody))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
return httpClient.sendAsync(request).thenApply(response -> {
|
||||||
if (response.statusCode() != 200) {
|
if (response.statusCode() != 200) {
|
||||||
System.err.println("[SPMEGA] Server returned status code " + response.statusCode() + " on start: " + response.body());
|
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());
|
throw new CompletionException(new IOException(
|
||||||
}
|
"Server returned status code " + response.statusCode() + " on start: " + response.body()));
|
||||||
|
}
|
||||||
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
|
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
|
||||||
if (json.has("sessionId")) {
|
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();
|
return json.get("sessionId").getAsString();
|
||||||
} else {
|
});
|
||||||
System.err.println("[SPMEGA] Invalid response from start endpoint: " + response.body());
|
|
||||||
throw new IOException("Invalid response from start endpoint: " + response.body());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean sendAuthRequestToBackend(UUID uuid, String serverId) throws IOException, InterruptedException {
|
private static CompletableFuture<Boolean> sendAuthRequestToBackendAsync(UUID uuid, String serverId) {
|
||||||
ModConfig config = SPMega.getConfig();
|
ModConfig config = SPMega.getConfig();
|
||||||
String apiDomain = (config != null) ? config.apiDomain() : ModConfig.DEFAULT_API_DOMAIN;
|
String apiDomain = (config != null) ? config.apiDomain() : ModConfig.DEFAULT_API_DOMAIN;
|
||||||
if (apiDomain == null || apiDomain.isBlank()) {
|
if (apiDomain == null || apiDomain.isBlank()) {
|
||||||
@@ -109,42 +215,44 @@ public class BackendAuthenticator {
|
|||||||
|
|
||||||
String url = apiDomain + "/api/v1/auth/validate";
|
String url = apiDomain + "/api/v1/auth/validate";
|
||||||
|
|
||||||
HttpClient httpClient = HttpClient.newHttpClient();
|
InstrumentedHttpClient httpClient = new InstrumentedHttpClient();
|
||||||
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
|
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
return httpClient.sendAsync(request).thenApply(response -> {
|
||||||
if (response.statusCode() != 200) {
|
if (response.statusCode() != 200) {
|
||||||
System.err.println("[SPMEGA] Server returned status code " + response.statusCode() + " on validate: " + response.body());
|
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());
|
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")) {
|
|
||||||
String token = json.get("token").getAsString();
|
|
||||||
if (config != null) {
|
|
||||||
ModConfig updated = new ModConfig(
|
|
||||||
config.apiDomain(),
|
|
||||||
token,
|
|
||||||
config.allowBackend(),
|
|
||||||
config.signQuickPayEnabled(),
|
|
||||||
config.gpsEnabled(),
|
|
||||||
config.gpsPosition()
|
|
||||||
);
|
|
||||||
SPMega.setConfig(updated);
|
|
||||||
System.out.println("[SPMEGA] Backend auth successful, saved token.");
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
System.err.println("[SPMEGA] Config is null, cannot save token.");
|
|
||||||
throw new IOException("Config is null, cannot save token.");
|
|
||||||
}
|
}
|
||||||
} else {
|
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
|
||||||
System.err.println("[SPMEGA] Invalid response from validate endpoint: " + response.body());
|
if (!json.has("token")) {
|
||||||
throw new IOException("Invalid response from validate endpoint: " + response.body());
|
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."));
|
||||||
|
}
|
||||||
|
|
||||||
|
saveToken(json.get("token").getAsString());
|
||||||
|
System.out.println("[SPMEGA] Backend auth successful, saved token.");
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void saveToken(String token) {
|
||||||
|
ModConfig current = SPMega.getConfig();
|
||||||
|
if (current == null) {
|
||||||
|
throw new CompletionException(new IOException("Config is null, cannot save token."));
|
||||||
}
|
}
|
||||||
|
SPMega.setConfig(new ModConfig(
|
||||||
|
current.apiDomain(), token, current.allowBackend(), current.signQuickPayEnabled(),
|
||||||
|
current.gpsEnabled(), current.gpsPosition(), current.notificationPosition(),
|
||||||
|
current.telemetryIntervalSeconds(), current.telemetryCollectSystemInfo()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void sendCardToBackend(String cardId, String cardToken) {
|
public static void sendCardToBackend(String cardId, String cardToken) {
|
||||||
@@ -168,7 +276,7 @@ public class BackendAuthenticator {
|
|||||||
jsonPayload.addProperty("token", cardToken);
|
jsonPayload.addProperty("token", cardToken);
|
||||||
String requestBody = jsonPayload.toString();
|
String requestBody = jsonPayload.toString();
|
||||||
|
|
||||||
HttpClient httpClient = HttpClient.newHttpClient();
|
InstrumentedHttpClient httpClient = new InstrumentedHttpClient();
|
||||||
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
@@ -176,7 +284,7 @@ public class BackendAuthenticator {
|
|||||||
.PUT(HttpRequest.BodyPublishers.ofString(requestBody))
|
.PUT(HttpRequest.BodyPublishers.ofString(requestBody))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
httpClient.sendAsync(request)
|
||||||
.thenAccept(response -> {
|
.thenAccept(response -> {
|
||||||
if (response.statusCode() != 200 && response.statusCode() != 204) {
|
if (response.statusCode() != 200 && response.statusCode() != 204) {
|
||||||
System.err.println("[SPMEGA] Failed to send card to backend: status code "
|
System.err.println("[SPMEGA] Failed to send card to backend: status code "
|
||||||
@@ -191,10 +299,10 @@ public class BackendAuthenticator {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public static List<CardCredentials> fetchCardsFromBackend() throws IOException, InterruptedException {
|
public static CompletableFuture<List<CardCredentials>> fetchCardsFromBackendAsync() {
|
||||||
ModConfig config = SPMega.getConfig();
|
ModConfig config = SPMega.getConfig();
|
||||||
if (config == null || !config.allowBackend()) {
|
if (config == null || !config.allowBackend()) {
|
||||||
return List.of();
|
return CompletableFuture.completedFuture(List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
String apiDomain = config.apiDomain();
|
String apiDomain = config.apiDomain();
|
||||||
@@ -207,41 +315,36 @@ public class BackendAuthenticator {
|
|||||||
|
|
||||||
String url = apiDomain + "/api/v1/auth/cards";
|
String url = apiDomain + "/api/v1/auth/cards";
|
||||||
|
|
||||||
HttpClient httpClient = HttpClient.newHttpClient();
|
InstrumentedHttpClient httpClient = new InstrumentedHttpClient();
|
||||||
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.header("Authorization", "Bearer " + config.apiToken())
|
.header("Authorization", "Bearer " + config.apiToken())
|
||||||
.GET()
|
.GET()
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
return httpClient.sendAsync(request).thenApply(response -> {
|
||||||
if (response.statusCode() != 200) {
|
if (response.statusCode() != 200) {
|
||||||
throw new IOException("Server returned status code " + response.statusCode() + " on fetch cards.");
|
throw new CompletionException(new IOException(
|
||||||
}
|
"Server returned status code " + response.statusCode() + " on fetch cards."));
|
||||||
|
|
||||||
com.google.gson.JsonArray cardsArray = JsonParser.parseString(response.body()).getAsJsonArray();
|
|
||||||
List<CardCredentials> cards = new ArrayList<>();
|
|
||||||
for (com.google.gson.JsonElement el : cardsArray) {
|
|
||||||
JsonObject cardJson = el.getAsJsonObject();
|
|
||||||
String cardId = "";
|
|
||||||
if (cardJson.has("cardId") && !cardJson.get("cardId").isJsonNull()) {
|
|
||||||
cardId = cardJson.get("cardId").getAsString();
|
|
||||||
} else if (cardJson.has("id") && !cardJson.get("id").isJsonNull()) {
|
|
||||||
cardId = cardJson.get("id").getAsString();
|
|
||||||
}
|
}
|
||||||
|
com.google.gson.JsonArray cardsArray = JsonParser.parseString(response.body()).getAsJsonArray();
|
||||||
String cardToken = "";
|
List<CardCredentials> cards = new ArrayList<>();
|
||||||
if (cardJson.has("cardToken") && !cardJson.get("cardToken").isJsonNull()) {
|
for (com.google.gson.JsonElement element : cardsArray) {
|
||||||
cardToken = cardJson.get("cardToken").getAsString();
|
JsonObject cardJson = element.getAsJsonObject();
|
||||||
} else if (cardJson.has("token") && !cardJson.get("token").isJsonNull()) {
|
String cardId = cardJson.has("cardId") && !cardJson.get("cardId").isJsonNull()
|
||||||
cardToken = cardJson.get("token").getAsString();
|
? 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;
|
||||||
if (!cardId.isEmpty() && !cardToken.isEmpty()) {
|
});
|
||||||
cards.add(new CardCredentials(cardId, cardToken));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cards;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void deleteCardOnBackend(String cardId) {
|
public static void deleteCardOnBackend(String cardId) {
|
||||||
@@ -260,14 +363,14 @@ public class BackendAuthenticator {
|
|||||||
|
|
||||||
String url = apiDomain + "/api/v1/auth/cards/" + cardId;
|
String url = apiDomain + "/api/v1/auth/cards/" + cardId;
|
||||||
|
|
||||||
HttpClient httpClient = HttpClient.newHttpClient();
|
InstrumentedHttpClient httpClient = new InstrumentedHttpClient();
|
||||||
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.header("Authorization", "Bearer " + config.apiToken())
|
.header("Authorization", "Bearer " + config.apiToken())
|
||||||
.DELETE()
|
.DELETE()
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
httpClient.sendAsync(request)
|
||||||
.thenAccept(response -> {
|
.thenAccept(response -> {
|
||||||
if (response.statusCode() != 200 && response.statusCode() != 204) {
|
if (response.statusCode() != 200 && response.statusCode() != 204) {
|
||||||
System.err.println("[SPMEGA] Failed to delete card from backend: status code "
|
System.err.println("[SPMEGA] Failed to delete card from backend: status code "
|
||||||
@@ -282,10 +385,87 @@ public class BackendAuthenticator {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean createTransactionOnBackend(String senderCardId, String receiver, long amount, String comment) throws IOException, InterruptedException {
|
public static CompletableFuture<Void> registerWebhookForCardAsync(String cardId) {
|
||||||
|
ModConfig config = SPMega.getConfig();
|
||||||
|
if (config == null || !config.allowBackend()) {
|
||||||
|
return CompletableFuture.failedFuture(new IOException("Backend is disabled"));
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder(URI.create(backendUrl(config, "/api/v1/webhook/" + cardId)))
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.header("Authorization", "Bearer " + config.apiToken())
|
||||||
|
.PUT(HttpRequest.BodyPublishers.noBody())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return new InstrumentedHttpClient().sendAsync(request).thenApply(response -> {
|
||||||
|
if (response.statusCode() != 200 && response.statusCode() != 204) {
|
||||||
|
throw new CompletionException(new IOException(
|
||||||
|
"Server returned status code " + response.statusCode() + ": " + response.body()));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CompletableFuture<List<PaymentNotification>> readNotificationsAsync() {
|
||||||
|
ModConfig config = SPMega.getConfig();
|
||||||
|
if (config == null || !config.allowBackend()
|
||||||
|
|| config.apiToken() == null || config.apiToken().isBlank()
|
||||||
|
|| ModConfig.DEFAULT_API_TOKEN.equals(config.apiToken())) {
|
||||||
|
return CompletableFuture.completedFuture(List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder(URI.create(backendUrl(config, "/api/v1/webhook/read")))
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.header("Authorization", "Bearer " + config.apiToken())
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return new InstrumentedHttpClient().sendAsync(request).thenApply(response -> {
|
||||||
|
if (response.statusCode() != 200) {
|
||||||
|
throw new CompletionException(new IOException(
|
||||||
|
"Server returned status code " + response.statusCode() + " on notification read."));
|
||||||
|
}
|
||||||
|
JsonElement parsed = JsonParser.parseString(response.body());
|
||||||
|
com.google.gson.JsonArray array = parsed.isJsonArray()
|
||||||
|
? parsed.getAsJsonArray()
|
||||||
|
: parsed.getAsJsonObject().getAsJsonArray("$values");
|
||||||
|
if (array == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PaymentNotification> notifications = new ArrayList<>();
|
||||||
|
for (JsonElement element : array) {
|
||||||
|
JsonObject json = element.getAsJsonObject();
|
||||||
|
notifications.add(new PaymentNotification(
|
||||||
|
stringValue(json, "id"),
|
||||||
|
stringValue(json, "senderName"),
|
||||||
|
stringValue(json, "senderNumber"),
|
||||||
|
stringValue(json, "comment"),
|
||||||
|
json.has("amount") ? json.get("amount").getAsInt() : 0,
|
||||||
|
stringValue(json, "type")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return notifications;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String backendUrl(ModConfig config, String path) {
|
||||||
|
String domain = config.apiDomain();
|
||||||
|
if (domain == null || domain.isBlank()) {
|
||||||
|
domain = ModConfig.DEFAULT_API_DOMAIN;
|
||||||
|
}
|
||||||
|
return (domain.endsWith("/") ? domain.substring(0, domain.length() - 1) : domain) + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String stringValue(JsonObject json, String name) {
|
||||||
|
return json.has(name) && !json.get(name).isJsonNull() ? json.get(name).getAsString() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CompletableFuture<Boolean> createTransactionOnBackendAsync(
|
||||||
|
String senderCardId, String receiver, long amount, String comment) {
|
||||||
ModConfig config = SPMega.getConfig();
|
ModConfig config = SPMega.getConfig();
|
||||||
if (config == null) {
|
if (config == null) {
|
||||||
throw new IOException("ModConfig is null");
|
return CompletableFuture.failedFuture(new IOException("ModConfig is null"));
|
||||||
}
|
}
|
||||||
|
|
||||||
String apiDomain = config.apiDomain();
|
String apiDomain = config.apiDomain();
|
||||||
@@ -299,15 +479,15 @@ public class BackendAuthenticator {
|
|||||||
String url = apiDomain + "/api/v1/transactions";
|
String url = apiDomain + "/api/v1/transactions";
|
||||||
|
|
||||||
JsonObject jsonPayload = new JsonObject();
|
JsonObject jsonPayload = new JsonObject();
|
||||||
jsonPayload.addProperty("senderCardId", senderCardId);
|
jsonPayload.addProperty("cardToUse", senderCardId);
|
||||||
jsonPayload.addProperty("cardId", senderCardId);
|
jsonPayload.addProperty("cardId", senderCardId);
|
||||||
jsonPayload.addProperty("receiver", receiver);
|
jsonPayload.addProperty("receiverCard", receiver);
|
||||||
jsonPayload.addProperty("recipient", receiver);
|
jsonPayload.addProperty("receiverName", receiver);
|
||||||
jsonPayload.addProperty("amount", amount);
|
jsonPayload.addProperty("amount", amount);
|
||||||
jsonPayload.addProperty("comment", comment);
|
jsonPayload.addProperty("comment", comment);
|
||||||
String requestBody = jsonPayload.toString();
|
String requestBody = jsonPayload.toString();
|
||||||
|
|
||||||
HttpClient httpClient = HttpClient.newHttpClient();
|
InstrumentedHttpClient httpClient = new InstrumentedHttpClient();
|
||||||
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
@@ -315,18 +495,22 @@ public class BackendAuthenticator {
|
|||||||
.PUT(HttpRequest.BodyPublishers.ofString(requestBody))
|
.PUT(HttpRequest.BodyPublishers.ofString(requestBody))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
return httpClient.sendAsync(request).thenApply(response -> {
|
||||||
if (response.statusCode() == 200 || response.statusCode() == 201 || response.statusCode() == 204) {
|
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;
|
return true;
|
||||||
} else {
|
});
|
||||||
throw new IOException("Server returned status code " + response.statusCode() + ": " + response.body());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static List<BankDatabase.LocalTransaction> fetchTransactionsFromBackend(String cardId) throws IOException, InterruptedException {
|
public static CompletableFuture<List<BankDatabase.LocalTransaction>> fetchTransactionsFromBackendAsync(
|
||||||
|
String cardId, int page) {
|
||||||
|
System.out.println("[SPMEGA] fetchTransactionsFromBackend called for cardId: " + cardId + ", page: " + page);
|
||||||
ModConfig config = SPMega.getConfig();
|
ModConfig config = SPMega.getConfig();
|
||||||
if (config == null || !config.allowBackend()) {
|
if (config == null || !config.allowBackend()) {
|
||||||
return List.of();
|
System.out.println("[SPMEGA] fetchTransactionsFromBackend aborted: config is null or backend not allowed.");
|
||||||
|
return CompletableFuture.completedFuture(List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
String apiDomain = config.apiDomain();
|
String apiDomain = config.apiDomain();
|
||||||
@@ -337,32 +521,106 @@ public class BackendAuthenticator {
|
|||||||
apiDomain = apiDomain.substring(0, apiDomain.length() - 1);
|
apiDomain = apiDomain.substring(0, apiDomain.length() - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
String url = apiDomain + "/api/v1/transactions?cardId=" + cardId;
|
String url = apiDomain + "/api/v1/transactions?p=" + page;
|
||||||
|
System.out.println("[SPMEGA] Requesting transactions from URL: " + url);
|
||||||
|
|
||||||
HttpClient httpClient = HttpClient.newHttpClient();
|
InstrumentedHttpClient httpClient = new InstrumentedHttpClient();
|
||||||
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.header("Authorization", "Bearer " + config.apiToken())
|
.header("Authorization", "Bearer " + config.apiToken())
|
||||||
.GET()
|
.GET()
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
return httpClient.sendAsync(request)
|
||||||
|
.thenApply(response -> parseTransactionsResponse(response, cardId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<BankDatabase.LocalTransaction> parseTransactionsResponse(
|
||||||
|
HttpResponse<String> response, String cardId) {
|
||||||
|
System.out.println("[SPMEGA] Response status code: " + response.statusCode());
|
||||||
if (response.statusCode() != 200) {
|
if (response.statusCode() != 200) {
|
||||||
throw new IOException("Server returned status code " + response.statusCode() + " on fetch transactions.");
|
System.err.println("[SPMEGA] Failed response body: " + response.body());
|
||||||
|
throw new CompletionException(new IOException(
|
||||||
|
"Server returned status code " + response.statusCode() + " on fetch transactions."));
|
||||||
}
|
}
|
||||||
|
|
||||||
com.google.gson.JsonArray transactionsArray = JsonParser.parseString(response.body()).getAsJsonArray();
|
String body = response.body();
|
||||||
|
System.out.println("[SPMEGA] Response body: " + (body.length() > 500 ? body.substring(0, 500) + "..." : body));
|
||||||
|
|
||||||
|
JsonElement parsed = JsonParser.parseString(body);
|
||||||
|
com.google.gson.JsonArray transactionsArray = null;
|
||||||
|
|
||||||
|
if (parsed.isJsonArray()) {
|
||||||
|
transactionsArray = parsed.getAsJsonArray();
|
||||||
|
} else if (parsed.isJsonObject()) {
|
||||||
|
JsonObject obj = parsed.getAsJsonObject();
|
||||||
|
if (obj.has("$values") && obj.get("$values").isJsonArray()) {
|
||||||
|
transactionsArray = obj.getAsJsonArray("$values");
|
||||||
|
} else if (obj.has("transactions") && obj.get("transactions").isJsonArray()) {
|
||||||
|
transactionsArray = obj.getAsJsonArray("transactions");
|
||||||
|
} else if (obj.has("values") && obj.get("values").isJsonArray()) {
|
||||||
|
transactionsArray = obj.getAsJsonArray("values");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transactionsArray == null) {
|
||||||
|
System.err.println("[SPMEGA] Response body is not a JSON array or wrapped array: " + body);
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println("[SPMEGA] Total transactions returned from server: " + transactionsArray.size());
|
||||||
List<BankDatabase.LocalTransaction> list = new ArrayList<>();
|
List<BankDatabase.LocalTransaction> list = new ArrayList<>();
|
||||||
for (com.google.gson.JsonElement el : transactionsArray) {
|
for (com.google.gson.JsonElement el : transactionsArray) {
|
||||||
JsonObject json = el.getAsJsonObject();
|
try {
|
||||||
String receiver = json.has("receiver") ? json.get("receiver").getAsString() : (json.has("recipient") ? json.get("recipient").getAsString() : "unknown");
|
JsonObject json = el.getAsJsonObject();
|
||||||
long amount = json.has("amount") ? json.get("amount").getAsLong() : 0L;
|
|
||||||
String comment = json.has("comment") && !json.get("comment").isJsonNull() ? json.get("comment").getAsString() : "";
|
|
||||||
String status = json.has("status") ? json.get("status").getAsString() : "SUCCESS";
|
|
||||||
String createdAt = json.has("createdAt") ? json.get("createdAt").getAsString() : (json.has("created_at") ? json.get("created_at").getAsString() : "");
|
|
||||||
|
|
||||||
list.add(new BankDatabase.LocalTransaction(receiver, amount, comment, status, createdAt));
|
// Check cardId filter
|
||||||
|
String senderCardNumber = "";
|
||||||
|
if (json.has("senderCardNumber") && !json.get("senderCardNumber").isJsonNull()) {
|
||||||
|
senderCardNumber = json.get("senderCardNumber").getAsString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!senderCardNumber.equalsIgnoreCase(cardId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String receiver = "unknown";
|
||||||
|
if (json.has("receiver") && !json.get("receiver").isJsonNull()) {
|
||||||
|
receiver = json.get("receiver").getAsString();
|
||||||
|
} else if (json.has("receiverName") && !json.get("receiverName").isJsonNull()) {
|
||||||
|
receiver = json.get("receiverName").getAsString();
|
||||||
|
} else if (json.has("receiverCardNumber") && !json.get("receiverCardNumber").isJsonNull()) {
|
||||||
|
receiver = json.get("receiverCardNumber").getAsString();
|
||||||
|
} else if (json.has("recipient") && !json.get("recipient").isJsonNull()) {
|
||||||
|
receiver = json.get("recipient").getAsString();
|
||||||
|
}
|
||||||
|
|
||||||
|
long amount = json.has("amount") ? json.get("amount").getAsLong() : 0L;
|
||||||
|
String comment = json.has("comment") && !json.get("comment").isJsonNull() ? json.get("comment").getAsString() : "";
|
||||||
|
String status = json.has("status") && !json.get("status").isJsonNull() ? json.get("status").getAsString() : "SUCCESS";
|
||||||
|
|
||||||
|
String createdAt = "";
|
||||||
|
if (json.has("transactionDate") && !json.get("transactionDate").isJsonNull()) {
|
||||||
|
createdAt = json.get("transactionDate").getAsString();
|
||||||
|
} else if (json.has("createdAt") && !json.get("createdAt").isJsonNull()) {
|
||||||
|
createdAt = json.get("createdAt").getAsString();
|
||||||
|
} else if (json.has("created_at") && !json.get("created_at").isJsonNull()) {
|
||||||
|
createdAt = json.get("created_at").getAsString();
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.printf("[SPMEGA] Parsed transaction: %s -> %s, amount=%d, comment=%s, status=%s%n",
|
||||||
|
createdAt, receiver, amount, comment, status);
|
||||||
|
|
||||||
|
list.add(new BankDatabase.LocalTransaction(receiver, amount, comment, status, createdAt));
|
||||||
|
} catch (Exception parseEx) {
|
||||||
|
System.err.println("[SPMEGA] Error parsing transaction element: " + parseEx.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
System.out.println("[SPMEGA] Returning " + list.size() + " filtered transactions for card " + cardId);
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record PaymentNotification(
|
||||||
|
String id, String senderName, String senderNumber, String comment, int amount, String type) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,9 +23,23 @@ public final class BankDatabase {
|
|||||||
card_number TEXT,
|
card_number TEXT,
|
||||||
balance INTEGER NOT NULL DEFAULT 0,
|
balance INTEGER NOT NULL DEFAULT 0,
|
||||||
owner_uuid TEXT,
|
owner_uuid TEXT,
|
||||||
|
webhook_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
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("""
|
statement.executeUpdate("""
|
||||||
CREATE TABLE IF NOT EXISTS transfer_history (
|
CREATE TABLE IF NOT EXISTS transfer_history (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -100,8 +114,31 @@ public final class BankDatabase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public synchronized void setWebhookEnabled(String cardId, boolean enabled) {
|
||||||
|
String sql = "UPDATE cards SET webhook_enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE card_id = ?";
|
||||||
|
try (Connection connection = open(); PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||||
|
statement.setBoolean(1, enabled);
|
||||||
|
statement.setString(2, cardId);
|
||||||
|
statement.executeUpdate();
|
||||||
|
} catch (SQLException exception) {
|
||||||
|
throw new RuntimeException("Failed to update webhook state", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized boolean hasWebhookEnabledCards() {
|
||||||
|
try (Connection connection = open();
|
||||||
|
PreparedStatement statement = connection.prepareStatement(
|
||||||
|
"SELECT 1 FROM cards WHERE webhook_enabled = 1 LIMIT 1");
|
||||||
|
ResultSet result = statement.executeQuery()) {
|
||||||
|
return result.next();
|
||||||
|
} catch (SQLException exception) {
|
||||||
|
throw new RuntimeException("Failed to read webhook state", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public synchronized List<StoredCard> loadCards() {
|
public synchronized List<StoredCard> loadCards() {
|
||||||
String sql = "SELECT card_id, card_token, card_name, card_number, balance, owner_uuid FROM cards ORDER BY updated_at DESC";
|
String sql = "SELECT card_id, card_token, card_name, card_number, balance, owner_uuid, webhook_enabled "
|
||||||
|
+ "FROM cards ORDER BY updated_at DESC";
|
||||||
List<StoredCard> result = new ArrayList<>();
|
List<StoredCard> result = new ArrayList<>();
|
||||||
try (Connection connection = open();
|
try (Connection connection = open();
|
||||||
PreparedStatement statement = connection.prepareStatement(sql);
|
PreparedStatement statement = connection.prepareStatement(sql);
|
||||||
@@ -113,7 +150,8 @@ public final class BankDatabase {
|
|||||||
rs.getString("card_name"),
|
rs.getString("card_name"),
|
||||||
rs.getString("card_number"),
|
rs.getString("card_number"),
|
||||||
rs.getLong("balance"),
|
rs.getLong("balance"),
|
||||||
rs.getString("owner_uuid")
|
rs.getString("owner_uuid"),
|
||||||
|
rs.getBoolean("webhook_enabled")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package git.yawaflua.tech.spmega.client.ui.service;
|
|||||||
|
|
||||||
import git.yawaflua.tech.spmega.ModConfig;
|
import git.yawaflua.tech.spmega.ModConfig;
|
||||||
import git.yawaflua.tech.spmega.SPMega;
|
import git.yawaflua.tech.spmega.SPMega;
|
||||||
import git.yawaflua.tech.spmega.api.SPWorldsApiClient;
|
import git.yawaflua.tech.spmega.client.api.SPWorldsApiClient;
|
||||||
|
import git.yawaflua.tech.spmega.client.telemetry.TelemetryCollector;
|
||||||
|
import git.yawaflua.tech.spmega.client.telemetry.TelemetryEvent;
|
||||||
import net.fabricmc.loader.api.FabricLoader;
|
import net.fabricmc.loader.api.FabricLoader;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
@@ -17,7 +19,7 @@ public final class BankUiService {
|
|||||||
private final String apiDomain = "https://spworlds.ru";
|
private final String apiDomain = "https://spworlds.ru";
|
||||||
|
|
||||||
private int selectedCardIndex;
|
private int selectedCardIndex;
|
||||||
private String lastMessage = "";
|
private volatile String lastMessage = "";
|
||||||
|
|
||||||
private BankUiService() {
|
private BankUiService() {
|
||||||
this.database = new BankDatabase(FabricLoader.getInstance().getConfigDir().resolve("spmega.db"));
|
this.database = new BankDatabase(FabricLoader.getInstance().getConfigDir().resolve("spmega.db"));
|
||||||
@@ -54,11 +56,20 @@ public final class BankUiService {
|
|||||||
return normalized.substring(normalized.length() - 5);
|
return normalized.substring(normalized.length() - 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized List<CardViewModel> getCards() {
|
private void runOnMainThread(Runnable runnable) {
|
||||||
|
Minecraft client = Minecraft.getInstance();
|
||||||
|
if (client == null || client.isSameThread()) {
|
||||||
|
runnable.run();
|
||||||
|
} else {
|
||||||
|
client.execute(runnable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<CardViewModel> getCards() {
|
||||||
return Collections.unmodifiableList(cards);
|
return Collections.unmodifiableList(cards);
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized CardViewModel getSelectedCard() {
|
public CardViewModel getSelectedCard() {
|
||||||
if (cards.isEmpty()) {
|
if (cards.isEmpty()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -66,7 +77,7 @@ public final class BankUiService {
|
|||||||
return cards.get(selectedCardIndex);
|
return cards.get(selectedCardIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized int getSelectedCardIndex() {
|
public int getSelectedCardIndex() {
|
||||||
if (cards.isEmpty()) {
|
if (cards.isEmpty()) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -74,7 +85,7 @@ public final class BankUiService {
|
|||||||
return selectedCardIndex;
|
return selectedCardIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized void setSelectedCardIndex(int index) {
|
public void setSelectedCardIndex(int index) {
|
||||||
if (cards.isEmpty()) {
|
if (cards.isEmpty()) {
|
||||||
selectedCardIndex = 0;
|
selectedCardIndex = 0;
|
||||||
return;
|
return;
|
||||||
@@ -82,7 +93,7 @@ public final class BankUiService {
|
|||||||
selectedCardIndex = Math.floorMod(index, cards.size());
|
selectedCardIndex = Math.floorMod(index, cards.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized CardViewModel cycleSelectedCard(int direction) {
|
public CardViewModel cycleSelectedCard(int direction) {
|
||||||
if (cards.isEmpty()) {
|
if (cards.isEmpty()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -90,210 +101,220 @@ public final class BankUiService {
|
|||||||
return cards.get(selectedCardIndex);
|
return cards.get(selectedCardIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized String getLastMessage() {
|
public String getLastMessage() {
|
||||||
return lastMessage;
|
return lastMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized void syncCardsWithBackend(String playerUuid) {
|
private static String rootMessage(Throwable throwable) {
|
||||||
try {
|
Throwable cause = throwable;
|
||||||
List<CardCredentials> backendCards = BackendAuthenticator.fetchCardsFromBackend();
|
while (cause.getCause() != null) {
|
||||||
boolean changed = false;
|
cause = cause.getCause();
|
||||||
for (CardCredentials creds : backendCards) {
|
|
||||||
if (database.getCredentials(creds.cardId()) == null) {
|
|
||||||
database.upsertCardCredentials(creds.cardId(), creds.cardToken());
|
|
||||||
refreshCard(creds.cardId(), creds.cardToken(), playerUuid, false, false);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (changed) {
|
|
||||||
reloadCardsFromDb();
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println("[SPMEGA] Failed to sync cards with backend: " + e.getMessage());
|
|
||||||
}
|
}
|
||||||
|
return cause.getMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized void refreshOnServerJoin(String playerUuid) {
|
public CompletableFuture<Void> syncCardsWithBackendAsync(String playerUuid) {
|
||||||
syncCardsWithBackend(playerUuid);
|
return BackendAuthenticator.fetchCardsFromBackendAsync()
|
||||||
|
.thenCompose(cards -> syncMissingCardsAsync(cards, playerUuid))
|
||||||
List<StoredCard> storedCards = database.loadCards();
|
.thenAccept(changed -> {
|
||||||
for (StoredCard card : storedCards) {
|
if (changed) {
|
||||||
refreshCard(card.cardId(), card.cardToken(), playerUuid, false, false);
|
reloadCardsFromDb();
|
||||||
}
|
}
|
||||||
reloadCardsFromDb();
|
})
|
||||||
|
.exceptionally(exception -> {
|
||||||
|
System.err.println("[SPMEGA] Failed to sync cards with backend: " + exception.getMessage());
|
||||||
|
return null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized List<String> loadRecipientCards(String username) {
|
public CompletableFuture<Void> refreshOnServerJoinAsync(String playerUuid) {
|
||||||
|
return BackendAuthenticator.fetchCardsFromBackendAsync()
|
||||||
|
.exceptionally(exception -> {
|
||||||
|
System.err.println("[SPMEGA] Failed to sync cards with backend: " + exception.getMessage());
|
||||||
|
return List.of();
|
||||||
|
})
|
||||||
|
.thenCompose(cards -> syncMissingCardsAsync(cards, playerUuid))
|
||||||
|
.thenCompose(ignored -> refreshStoredCardsAsync(playerUuid))
|
||||||
|
.thenRun(this::reloadCardsFromDb);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<List<String>> loadRecipientCardsAsync(String username) {
|
||||||
CardCredentials credentials = getSelectedCredentials();
|
CardCredentials credentials = getSelectedCredentials();
|
||||||
if (credentials == null || username == null || username.isBlank()) {
|
if (credentials == null || username == null || username.isBlank()) {
|
||||||
return List.of();
|
return CompletableFuture.completedFuture(List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return apiClient.getPlayerCardsAsync(username, toApiAuth(credentials))
|
||||||
List<SPWorldsApiClient.PlayerCard> apiCards = apiClient.getPlayerCards(username, toApiAuth(credentials));
|
.thenApply(apiCards -> {
|
||||||
List<String> numbers = new ArrayList<>();
|
List<String> numbers = new ArrayList<>();
|
||||||
for (SPWorldsApiClient.PlayerCard apiCard : apiCards) {
|
for (SPWorldsApiClient.PlayerCard apiCard : apiCards) {
|
||||||
if (apiCard.number() != null && !apiCard.number().isBlank()) {
|
if (apiCard.number() != null && !apiCard.number().isBlank()) {
|
||||||
numbers.add(apiCard.name() + " : " + apiCard.number());
|
numbers.add(apiCard.name() + " : " + apiCard.number());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
lastMessage = "";
|
||||||
lastMessage = "";
|
return numbers;
|
||||||
return numbers;
|
})
|
||||||
} catch (Exception exception) {
|
.exceptionally(exception -> {
|
||||||
lastMessage = "Не удалось получить карты игрока: " + exception.getMessage();
|
lastMessage = "Не удалось получить карты игрока: " + exception.getMessage();
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized String addCard(String cardIdRaw, String cardTokenRaw, String playerUuid) {
|
public CompletableFuture<String> removeSelectedCardAsync() {
|
||||||
|
CardViewModel selected = getSelectedCard();
|
||||||
|
if (selected == null) {
|
||||||
|
return CompletableFuture.completedFuture("Нет карты для удаления");
|
||||||
|
}
|
||||||
|
|
||||||
|
String cardId = selected.id();
|
||||||
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
|
database.deleteCard(cardId);
|
||||||
|
ModConfig config = SPMega.getConfig();
|
||||||
|
if (config != null && config.allowBackend()) {
|
||||||
|
BackendAuthenticator.deleteCardOnBackend(cardId);
|
||||||
|
}
|
||||||
|
|
||||||
|
reloadCardsFromDb();
|
||||||
|
runOnMainThread(() -> {
|
||||||
|
if (selectedCardIndex >= cards.size()) {
|
||||||
|
selectedCardIndex = Math.max(0, cards.size() - 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return "Карта удалена";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<String> addCardAsync(String cardIdRaw, String cardTokenRaw, String playerUuid) {
|
||||||
String cardId = cardIdRaw == null ? "" : cardIdRaw.trim();
|
String cardId = cardIdRaw == null ? "" : cardIdRaw.trim();
|
||||||
String cardToken = cardTokenRaw == null ? "" : cardTokenRaw.trim();
|
String cardToken = cardTokenRaw == null ? "" : cardTokenRaw.trim();
|
||||||
|
|
||||||
if (cardId.isEmpty() || cardToken.isEmpty()) {
|
if (cardId.isEmpty() || cardToken.isEmpty()) {
|
||||||
lastMessage = "Укажи cardId и cardToken";
|
return CompletableFuture.completedFuture("Укажи cardId и cardToken");
|
||||||
return lastMessage;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
UUID.fromString(cardId);
|
UUID.fromString(cardId);
|
||||||
} catch (IllegalArgumentException exception) {
|
} catch (IllegalArgumentException exception) {
|
||||||
lastMessage = "cardId должен быть UUID";
|
return CompletableFuture.completedFuture("cardId должен быть UUID");
|
||||||
return lastMessage;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
database.upsertCardCredentials(cardId, cardToken);
|
return CompletableFuture.runAsync(() -> database.upsertCardCredentials(cardId, cardToken))
|
||||||
boolean refreshed = refreshCard(cardId, cardToken, playerUuid, true, true);
|
.thenCompose(ignored -> refreshCardAsync(cardId, cardToken, playerUuid, true, true))
|
||||||
if (!refreshed) {
|
.thenApply(refreshed -> {
|
||||||
database.deleteCard(cardId);
|
if (!refreshed) {
|
||||||
}
|
database.deleteCard(cardId);
|
||||||
reloadCardsFromDb();
|
|
||||||
|
|
||||||
if (refreshed && lastMessage.isBlank()) {
|
|
||||||
lastMessage = "Карта добавлена";
|
|
||||||
}
|
|
||||||
return lastMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
public synchronized void removeSelectedCard() {
|
|
||||||
CardViewModel selected = getSelectedCard();
|
|
||||||
if (selected == null) {
|
|
||||||
lastMessage = "Нет карты для удаления";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String cardId = selected.id();
|
|
||||||
database.deleteCard(cardId);
|
|
||||||
ModConfig config = SPMega.getConfig();
|
|
||||||
if (config.allowBackend())
|
|
||||||
BackendAuthenticator.deleteCardOnBackend(cardId);
|
|
||||||
|
|
||||||
reloadCardsFromDb();
|
|
||||||
if (selectedCardIndex >= cards.size()) {
|
|
||||||
selectedCardIndex = Math.max(0, cards.size() - 1);
|
|
||||||
}
|
|
||||||
lastMessage = "Карта удалена";
|
|
||||||
}
|
|
||||||
|
|
||||||
public synchronized void refreshSelectedCard(String playerUuid) {
|
|
||||||
CardViewModel selected = getSelectedCard();
|
|
||||||
if (selected == null) {
|
|
||||||
lastMessage = "Нет карты для обновления";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
CardCredentials credentials = database.getCredentials(selected.id());
|
|
||||||
if (credentials == null) {
|
|
||||||
lastMessage = "Не найдены креды карты";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshCard(credentials.cardId(), credentials.cardToken(), playerUuid, false, false);
|
|
||||||
reloadCardsFromDb();
|
|
||||||
}
|
|
||||||
|
|
||||||
public synchronized boolean submitPayment(PaymentDraft draft) {
|
|
||||||
CardCredentials credentials = database.getCredentials(draft.senderCardId());
|
|
||||||
if (credentials == null) {
|
|
||||||
lastMessage = "Не найдены креды карты отправителя";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
git.yawaflua.tech.spmega.ModConfig config = git.yawaflua.tech.spmega.SPMega.getConfig();
|
|
||||||
boolean useBackend = config != null && config.allowBackend();
|
|
||||||
|
|
||||||
try {
|
|
||||||
long newBalance = 0;
|
|
||||||
if (useBackend) {
|
|
||||||
BackendAuthenticator.createTransactionOnBackend(
|
|
||||||
draft.senderCardId(),
|
|
||||||
draft.recipient(),
|
|
||||||
draft.amount(),
|
|
||||||
draft.comment()
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
refreshCard(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) {
|
reloadCardsFromDb();
|
||||||
System.err.println("[SPMEGA] Failed to refresh card balance after transaction: " + e.getMessage());
|
return refreshed && lastMessage.isBlank() ? "Карта добавлена" : lastMessage;
|
||||||
}
|
});
|
||||||
} else {
|
}
|
||||||
SPWorldsApiClient.TransactionResult result = apiClient.createTransaction(
|
|
||||||
toApiAuth(credentials),
|
|
||||||
draft.recipient(),
|
|
||||||
draft.amount(),
|
|
||||||
draft.comment()
|
|
||||||
);
|
|
||||||
newBalance = result.balance();
|
|
||||||
database.updateCardBalance(credentials.cardId(), newBalance);
|
|
||||||
}
|
|
||||||
|
|
||||||
database.insertTransferHistory(
|
public CompletableFuture<String> refreshSelectedCardAsync(String playerUuid) {
|
||||||
credentials.cardId(),
|
CardViewModel selected = getSelectedCard();
|
||||||
draft.recipient(),
|
if (selected == null) {
|
||||||
draft.amount(),
|
return CompletableFuture.completedFuture("Нет карты для обновления");
|
||||||
draft.comment(),
|
|
||||||
newBalance,
|
|
||||||
"SUCCESS"
|
|
||||||
);
|
|
||||||
|
|
||||||
reloadCardsFromDb();
|
|
||||||
lastMessage = "Перевод выполнен";
|
|
||||||
return true;
|
|
||||||
} catch (Exception exception) {
|
|
||||||
database.insertTransferHistory(
|
|
||||||
credentials.cardId(),
|
|
||||||
draft.recipient(),
|
|
||||||
draft.amount(),
|
|
||||||
draft.comment(),
|
|
||||||
null,
|
|
||||||
"FAILED: " + trimMessage(exception.getMessage())
|
|
||||||
);
|
|
||||||
lastMessage = "Ошибка перевода: " + exception.getMessage();
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return CompletableFuture.supplyAsync(() -> database.getCredentials(selected.id()))
|
||||||
|
.thenCompose(credentials -> {
|
||||||
|
if (credentials == null) {
|
||||||
|
return CompletableFuture.completedFuture("Не найдены креды карты");
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
recordPaymentEvent(draft, success, mode, destination, durationMs, senderLast4, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void recordPaymentEvent(PaymentDraft draft, boolean success, String mode, String destination, long durationMs, String senderLast4, String error) {
|
||||||
|
com.google.gson.JsonObject payload = new com.google.gson.JsonObject();
|
||||||
|
payload.addProperty("success", success);
|
||||||
|
payload.addProperty("mode", mode);
|
||||||
|
payload.addProperty("destination", destination);
|
||||||
|
payload.addProperty("durationMs", durationMs);
|
||||||
|
payload.addProperty("senderCardLast4", senderLast4 != null ? senderLast4 : "unknown");
|
||||||
|
payload.addProperty("amount", draft.amount());
|
||||||
|
payload.addProperty("receiver", draft.recipient());
|
||||||
|
if (error != null) {
|
||||||
|
payload.addProperty("error", trimMessage(error));
|
||||||
|
}
|
||||||
|
TelemetryCollector.instance().record(TelemetryEvent.now("payment", payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
public CompletableFuture<Boolean> submitPaymentAsync(PaymentDraft draft) {
|
public CompletableFuture<Boolean> submitPaymentAsync(PaymentDraft draft) {
|
||||||
return CompletableFuture.supplyAsync(() -> submitPayment(draft));
|
return CompletableFuture.supplyAsync(() -> database.getCredentials(draft.senderCardId()))
|
||||||
|
.thenCompose(credentials -> {
|
||||||
|
if (credentials == null) {
|
||||||
|
lastMessage = "Не найдены креды карты отправителя";
|
||||||
|
recordPaymentEvent(draft, false, "local", "no_credentials", 0, null);
|
||||||
|
return CompletableFuture.completedFuture(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
ModConfig config = SPMega.getConfig();
|
||||||
|
boolean useBackend = config != null && config.allowBackend();
|
||||||
|
String mode = useBackend ? "backend" : "spworlds-direct";
|
||||||
|
String destination = useBackend ? "backend" : "local-db";
|
||||||
|
String senderLast4 = extractLastDigits(credentials.cardId());
|
||||||
|
long startNs = System.nanoTime();
|
||||||
|
|
||||||
|
CompletableFuture<Long> transaction = useBackend
|
||||||
|
? BackendAuthenticator.createTransactionOnBackendAsync(
|
||||||
|
draft.senderCardId(), draft.recipient(), draft.amount(), draft.comment())
|
||||||
|
.thenCompose(ignored -> refreshCardAsync(
|
||||||
|
credentials.cardId(), credentials.cardToken(), "", false, false))
|
||||||
|
.thenApply(ignored -> database.loadCards().stream()
|
||||||
|
.filter(card -> card.cardId().equals(credentials.cardId()))
|
||||||
|
.findFirst()
|
||||||
|
.map(StoredCard::balance)
|
||||||
|
.orElse(0L))
|
||||||
|
: apiClient.createTransactionAsync(
|
||||||
|
toApiAuth(credentials), draft.recipient(), draft.amount(), draft.comment())
|
||||||
|
.thenApply(result -> {
|
||||||
|
database.updateCardBalance(credentials.cardId(), result.balance());
|
||||||
|
return result.balance();
|
||||||
|
});
|
||||||
|
|
||||||
|
return transaction
|
||||||
|
.thenApply(newBalance -> {
|
||||||
|
database.insertTransferHistory(
|
||||||
|
credentials.cardId(), draft.recipient(), draft.amount(), draft.comment(),
|
||||||
|
newBalance, "SUCCESS");
|
||||||
|
reloadCardsFromDb();
|
||||||
|
lastMessage = "Перевод выполнен";
|
||||||
|
recordPaymentEvent(draft, true, mode, destination,
|
||||||
|
(System.nanoTime() - startNs) / 1_000_000L, senderLast4);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.exceptionally(exception -> {
|
||||||
|
String error = rootMessage(exception);
|
||||||
|
database.insertTransferHistory(
|
||||||
|
credentials.cardId(), draft.recipient(), draft.amount(), draft.comment(),
|
||||||
|
null, "FAILED: " + trimMessage(error));
|
||||||
|
lastMessage = "Ошибка перевода: " + error;
|
||||||
|
recordPaymentEvent(draft, false, mode, destination,
|
||||||
|
(System.nanoTime() - startNs) / 1_000_000L, senderLast4, error);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean refreshCard(
|
private CompletableFuture<Boolean> refreshCardAsync(
|
||||||
String cardId,
|
String cardId,
|
||||||
String cardToken,
|
String cardToken,
|
||||||
String playerUuid,
|
String playerUuid,
|
||||||
boolean reportOwnerWarning,
|
boolean reportOwnerWarning,
|
||||||
boolean requireCardInAccount
|
boolean requireCardInAccount
|
||||||
) {
|
) {
|
||||||
try {
|
SPWorldsApiClient.CardAuth auth = new SPWorldsApiClient.CardAuth(cardId, cardToken);
|
||||||
SPWorldsApiClient.CardAuth auth = new SPWorldsApiClient.CardAuth(cardId, cardToken);
|
return apiClient.getAccountMeAsync(auth)
|
||||||
SPWorldsApiClient.AccountMe me = apiClient.getAccountMe(auth);
|
.thenCompose(me -> apiClient.getCardInfoAsync(auth).thenApply(cardInfo -> {
|
||||||
SPWorldsApiClient.CardInfo cardInfo = apiClient.getCardInfo(auth);
|
|
||||||
|
|
||||||
SPWorldsApiClient.AccountCard currentCard = me.cards().stream()
|
SPWorldsApiClient.AccountCard currentCard = me.cards().stream()
|
||||||
.filter(card -> Objects.equals(card.id(), cardId))
|
.filter(card -> Objects.equals(card.id(), cardId))
|
||||||
@@ -318,40 +339,89 @@ public final class BankUiService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
git.yawaflua.tech.spmega.ModConfig config = git.yawaflua.tech.spmega.SPMega.getConfig();
|
ModConfig config = SPMega.getConfig();
|
||||||
if (config.allowBackend())
|
if (config != null && config.allowBackend()) {
|
||||||
CompletableFuture.supplyAsync(() -> {
|
BackendAuthenticator.sendCardToBackend(cardId, cardToken);
|
||||||
BackendAuthenticator.sendCardToBackend(cardId, cardToken);
|
}
|
||||||
return true;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
lastMessage = "";
|
lastMessage = "";
|
||||||
return true;
|
return true;
|
||||||
} catch (Exception exception) {
|
}))
|
||||||
lastMessage = "Не удалось обновить карту: " + exception.getMessage();
|
.exceptionally(exception -> {
|
||||||
return false;
|
lastMessage = "Не удалось обновить карту: " + rootMessage(exception);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<String> registerSelectedWebhookAsync() {
|
||||||
|
CardViewModel selected = getSelectedCard();
|
||||||
|
if (selected == null) {
|
||||||
|
return CompletableFuture.completedFuture("Сначала выбери или добавь карту");
|
||||||
}
|
}
|
||||||
|
if (selected.webhookEnabled()) {
|
||||||
|
return CompletableFuture.completedFuture("Вебхук для этой карты уже включён");
|
||||||
|
}
|
||||||
|
|
||||||
|
return BackendAuthenticator.registerWebhookForCardAsync(selected.id())
|
||||||
|
.thenApply(ignored -> {
|
||||||
|
database.setWebhookEnabled(selected.id(), true);
|
||||||
|
reloadCardsFromDb();
|
||||||
|
return "Обработка вебхуков включена";
|
||||||
|
})
|
||||||
|
.exceptionally(exception -> "Не удалось включить вебхук: " + rootMessage(exception));
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasWebhookEnabledCards() {
|
||||||
|
return database.hasWebhookEnabledCards();
|
||||||
|
}
|
||||||
|
|
||||||
|
private CompletableFuture<Boolean> syncMissingCardsAsync(List<CardCredentials> backendCards, String playerUuid) {
|
||||||
|
CompletableFuture<Boolean> result = CompletableFuture.completedFuture(false);
|
||||||
|
for (CardCredentials credentials : backendCards) {
|
||||||
|
result = result.thenCompose(changed -> {
|
||||||
|
if (database.getCredentials(credentials.cardId()) != null) {
|
||||||
|
database.setWebhookEnabled(credentials.cardId(), credentials.webhookEnabled());
|
||||||
|
return CompletableFuture.completedFuture(changed);
|
||||||
|
}
|
||||||
|
database.upsertCardCredentials(credentials.cardId(), credentials.cardToken());
|
||||||
|
database.setWebhookEnabled(credentials.cardId(), credentials.webhookEnabled());
|
||||||
|
return refreshCardAsync(
|
||||||
|
credentials.cardId(), credentials.cardToken(), playerUuid, false, false)
|
||||||
|
.thenApply(ignored -> true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CompletableFuture<Void> refreshStoredCardsAsync(String playerUuid) {
|
||||||
|
CompletableFuture<Void> result = CompletableFuture.completedFuture(null);
|
||||||
|
for (StoredCard card : database.loadCards()) {
|
||||||
|
result = result.thenCompose(ignored -> refreshCardAsync(
|
||||||
|
card.cardId(), card.cardToken(), playerUuid, false, false).thenApply(refreshed -> null));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void reloadCardsFromDb() {
|
private void reloadCardsFromDb() {
|
||||||
List<StoredCard> storedCards = database.loadCards();
|
List<StoredCard> storedCards = database.loadCards();
|
||||||
cards.clear();
|
runOnMainThread(() -> {
|
||||||
|
cards.clear();
|
||||||
|
for (StoredCard stored : storedCards) {
|
||||||
|
String cardNumber = stored.cardNumber() == null || stored.cardNumber().isBlank()
|
||||||
|
? extractLastDigits(stored.cardId())
|
||||||
|
: 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(), stored.webhookEnabled()));
|
||||||
|
}
|
||||||
|
|
||||||
for (StoredCard stored : storedCards) {
|
if (cards.isEmpty()) {
|
||||||
String cardNumber = stored.cardNumber() == null || stored.cardNumber().isBlank()
|
selectedCardIndex = 0;
|
||||||
? extractLastDigits(stored.cardId())
|
} else {
|
||||||
: stored.cardNumber();
|
selectedCardIndex = Math.floorMod(selectedCardIndex, cards.size());
|
||||||
String cardName = stored.cardName() == null || stored.cardName().isBlank() ? "Карта" : stored.cardName();
|
}
|
||||||
String title = cardNumber + ": " + cardName;
|
});
|
||||||
cards.add(new CardViewModel(stored.cardId(), title, stored.balance()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cards.isEmpty()) {
|
|
||||||
selectedCardIndex = 0;
|
|
||||||
} else {
|
|
||||||
selectedCardIndex = Math.floorMod(selectedCardIndex, cards.size());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private CardCredentials getSelectedCredentials() {
|
private CardCredentials getSelectedCredentials() {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package git.yawaflua.tech.spmega.client.ui.service;
|
package git.yawaflua.tech.spmega.client.ui.service;
|
||||||
|
|
||||||
public record CardCredentials(String cardId, String cardToken) {
|
public record CardCredentials(String cardId, String cardToken, boolean webhookEnabled) {
|
||||||
|
public CardCredentials(String cardId, String cardToken) {
|
||||||
|
this(cardId, cardToken, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
package git.yawaflua.tech.spmega.client.ui.service;
|
package git.yawaflua.tech.spmega.client.ui.service;
|
||||||
|
|
||||||
public record CardViewModel(String id, String title, long balance) {
|
public record CardViewModel(String id, String title, long balance, boolean webhookEnabled) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package git.yawaflua.tech.spmega.client.ui.service;
|
package git.yawaflua.tech.spmega.client.ui.service;
|
||||||
|
|
||||||
public record StoredCard(String cardId, String cardToken, String cardName, String cardNumber, long balance,
|
public record StoredCard(String cardId, String cardToken, String cardName, String cardNumber, long balance,
|
||||||
String ownerUuid) {
|
String ownerUuid, boolean webhookEnabled) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
package git.yawaflua.tech.spmega.mixin.client;
|
package git.yawaflua.tech.spmega.mixin.client;
|
||||||
|
|
||||||
import git.yawaflua.tech.spmega.client.ui.UiOpeners;
|
import git.yawaflua.tech.spmega.client.ui.UiOpeners;
|
||||||
import net.minecraft.client.gui.screen.GameMenuScreen;
|
import net.minecraft.client.gui.components.Button;
|
||||||
import net.minecraft.client.gui.screen.Screen;
|
import net.minecraft.client.gui.screens.PauseScreen;
|
||||||
import net.minecraft.client.gui.widget.ButtonWidget;
|
import net.minecraft.client.gui.screens.Screen;
|
||||||
import net.minecraft.text.Text;
|
import net.minecraft.network.chat.Component;
|
||||||
import org.spongepowered.asm.mixin.Mixin;
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
import org.spongepowered.asm.mixin.injection.At;
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
import org.spongepowered.asm.mixin.injection.Inject;
|
import org.spongepowered.asm.mixin.injection.Inject;
|
||||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||||
|
|
||||||
@Mixin(GameMenuScreen.class)
|
@Mixin(PauseScreen.class)
|
||||||
public abstract class GameMenuScreenMixin extends Screen {
|
public abstract class GameMenuScreenMixin extends Screen {
|
||||||
protected GameMenuScreenMixin(Text title) {
|
protected GameMenuScreenMixin(Component title) {
|
||||||
super(title);
|
super(title);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,9 +21,9 @@ public abstract class GameMenuScreenMixin extends Screen {
|
|||||||
int buttonWidth = 96;
|
int buttonWidth = 96;
|
||||||
int x = this.width / 2 + 106;
|
int x = this.width / 2 + 106;
|
||||||
int y = this.height / 4 + 24 - 16;
|
int y = this.height / 4 + 24 - 16;
|
||||||
this.addDrawableChild(ButtonWidget.builder(Text.literal("SPMega"), button -> {
|
this.addRenderableWidget(Button.builder(Component.literal("SPMega"), button -> {
|
||||||
UiOpeners.openMainMenu(this.client);
|
UiOpeners.openMainMenu(this.minecraft);
|
||||||
}).dimensions(x, y, buttonWidth, 20).build());
|
}).bounds(x, y, buttonWidth, 20).build());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,21 @@
|
|||||||
"option.spmega.gps_position.bottom_left": "Bottom-Left",
|
"option.spmega.gps_position.bottom_left": "Bottom-Left",
|
||||||
"option.spmega.gps_position.bottom_right": "Bottom-Right",
|
"option.spmega.gps_position.bottom_right": "Bottom-Right",
|
||||||
"option.spmega.gps_position.bottom_center": "Bottom-center",
|
"option.spmega.gps_position.bottom_center": "Bottom-center",
|
||||||
"option.spmega.gps_position.top_center": "Top-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.",
|
||||||
|
"option.spmega.telemetry_interval": "Send interval (sec)",
|
||||||
|
"tooltip.spmega.telemetry_interval": "How often telemetry batches are sent to the server (10-600 sec)",
|
||||||
|
"option.spmega.telemetry_system_info": "Send system information",
|
||||||
|
"tooltip.spmega.telemetry_system_info": "CPU, GPU, RAM, storage, IP, device name, Java version"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,19 @@
|
|||||||
"option.spmega.gps_position.bottom_left": "Слева внизу",
|
"option.spmega.gps_position.bottom_left": "Слева внизу",
|
||||||
"option.spmega.gps_position.bottom_center": "Снизу по-центру",
|
"option.spmega.gps_position.bottom_center": "Снизу по-центру",
|
||||||
"option.spmega.gps_position.top_center": "Сверху по-центру",
|
"option.spmega.gps_position.top_center": "Сверху по-центру",
|
||||||
"option.spmega.gps_position.bottom_right": "Снизу справа"
|
"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). Статистика производительности и запросов продолжает отправляться.",
|
||||||
|
"option.spmega.telemetry_interval": "Интервал отправки (сек)",
|
||||||
|
"tooltip.spmega.telemetry_interval": "Как часто батч телеметрии отправляется на сервер (от 10 до 600 сек)",
|
||||||
|
"option.spmega.telemetry_system_info": "Отправлять информацию о системе",
|
||||||
|
"tooltip.spmega.telemetry_system_info": "CPU, GPU, RAM, диск, IP, имя устройства, версия Java"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ public final class ConfigManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
boolean allowAccess = readBoolean(properties, "allow.backend", defaults.allowBackend());
|
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())) {
|
if (rawAllowAccess == null || !Boolean.toString(allowAccess).equalsIgnoreCase(rawAllowAccess.trim())) {
|
||||||
shouldSave = true;
|
shouldSave = true;
|
||||||
}
|
}
|
||||||
@@ -66,7 +66,28 @@ public final class ConfigManager {
|
|||||||
shouldSave = true;
|
shouldSave = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
ModConfig config = new ModConfig(apiDomain, apiToken, allowAccess, signQuickPayEnabled, gpsEnabled, gpsPosition);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
int telemetryIntervalSeconds = readInt(properties, "telemetry.intervalSeconds", defaults.telemetryIntervalSeconds());
|
||||||
|
String rawInterval = properties.getProperty("telemetry.intervalSeconds");
|
||||||
|
if (rawInterval == null || !Integer.toString(telemetryIntervalSeconds).equals(rawInterval.trim())) {
|
||||||
|
shouldSave = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean telemetryCollectSystemInfo = readBoolean(properties, "telemetry.collectSystemInfo", defaults.telemetryCollectSystemInfo());
|
||||||
|
String rawSysInfo = properties.getProperty("telemetry.collectSystemInfo");
|
||||||
|
if (rawSysInfo == null || !Boolean.toString(telemetryCollectSystemInfo).equalsIgnoreCase(rawSysInfo.trim())) {
|
||||||
|
shouldSave = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ModConfig config = new ModConfig(apiDomain, apiToken, allowAccess, signQuickPayEnabled, gpsEnabled, gpsPosition,
|
||||||
|
notificationPosition, telemetryIntervalSeconds, telemetryCollectSystemInfo);
|
||||||
|
|
||||||
|
|
||||||
if (shouldSave) {
|
if (shouldSave) {
|
||||||
@@ -126,9 +147,13 @@ public final class ConfigManager {
|
|||||||
Properties properties = new Properties();
|
Properties properties = new Properties();
|
||||||
properties.setProperty("api.domain", config.apiDomain());
|
properties.setProperty("api.domain", config.apiDomain());
|
||||||
properties.setProperty("api.token", config.apiToken());
|
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("sign.quickPay.enabled", Boolean.toString(config.signQuickPayEnabled()));
|
||||||
properties.setProperty("gps.enabled", Boolean.toString(config.gpsEnabled()));
|
properties.setProperty("gps.enabled", Boolean.toString(config.gpsEnabled()));
|
||||||
properties.setProperty("gps.position", config.gpsPosition().name());
|
properties.setProperty("gps.position", config.gpsPosition().name());
|
||||||
|
properties.setProperty("notifications.position", config.notificationPosition().name());
|
||||||
|
properties.setProperty("telemetry.intervalSeconds", Integer.toString(config.telemetryIntervalSeconds()));
|
||||||
|
properties.setProperty("telemetry.collectSystemInfo", Boolean.toString(config.telemetryCollectSystemInfo()));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Files.createDirectories(configPath.getParent());
|
Files.createDirectories(configPath.getParent());
|
||||||
@@ -140,6 +165,18 @@ public final class ConfigManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int readInt(Properties properties, String key, int fallback) {
|
||||||
|
String value = properties.getProperty(key);
|
||||||
|
if (value == null || value.trim().isEmpty()) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(value.trim());
|
||||||
|
} catch (NumberFormatException exception) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static <E extends Enum<E>> E readEnum(Properties properties, String key, Class<E> enumClass, E fallback) {
|
private static <E extends Enum<E>> E readEnum(Properties properties, String key, Class<E> enumClass, E fallback) {
|
||||||
String value = properties.getProperty(key);
|
String value = properties.getProperty(key);
|
||||||
if (value == null || value.trim().isEmpty()) {
|
if (value == null || value.trim().isEmpty()) {
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
package git.yawaflua.tech.spmega;
|
package git.yawaflua.tech.spmega;
|
||||||
|
|
||||||
public record ModConfig(String apiDomain, String apiToken, boolean allowBackend, boolean signQuickPayEnabled,
|
public record ModConfig(String apiDomain, String apiToken, boolean allowBackend, boolean signQuickPayEnabled,
|
||||||
boolean gpsEnabled, GpsHudPosition gpsPosition) {
|
boolean gpsEnabled, GpsHudPosition gpsPosition,
|
||||||
public static final String DEFAULT_API_DOMAIN = "http://localhost:5129";
|
GpsHudPosition notificationPosition, int telemetryIntervalSeconds,
|
||||||
public static final boolean ALLOW_BACKEND = false;
|
boolean telemetryCollectSystemInfo) {
|
||||||
public static final String DEFAULT_API_TOKEN = "ulBKE9MWEtIGiPAhXV69I28W9BRiSrV3";
|
public static final String DEFAULT_API_DOMAIN = "https://spmega.yawaflua.tech";
|
||||||
|
public static final boolean ALLOW_BACKEND = true;
|
||||||
|
public static final String DEFAULT_API_TOKEN = "-";
|
||||||
public static final boolean DEFAULT_SIGN_QUICK_PAY_ENABLED = true;
|
public static final boolean DEFAULT_SIGN_QUICK_PAY_ENABLED = true;
|
||||||
public static final boolean DEFAULT_GPS_ENABLED = true;
|
public static final boolean DEFAULT_GPS_ENABLED = true;
|
||||||
public static final GpsHudPosition DEFAULT_GPS_POSITION = GpsHudPosition.TOP_LEFT;
|
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;
|
||||||
|
|
||||||
public static ModConfig createDefault() {
|
public static ModConfig createDefault() {
|
||||||
return new ModConfig(
|
return new ModConfig(
|
||||||
@@ -16,9 +22,10 @@ public record ModConfig(String apiDomain, String apiToken, boolean allowBackend,
|
|||||||
ALLOW_BACKEND,
|
ALLOW_BACKEND,
|
||||||
DEFAULT_SIGN_QUICK_PAY_ENABLED,
|
DEFAULT_SIGN_QUICK_PAY_ENABLED,
|
||||||
DEFAULT_GPS_ENABLED,
|
DEFAULT_GPS_ENABLED,
|
||||||
DEFAULT_GPS_POSITION
|
DEFAULT_GPS_POSITION,
|
||||||
|
DEFAULT_NOTIFICATION_POSITION,
|
||||||
|
DEFAULT_TELEMETRY_INTERVAL_SECONDS,
|
||||||
|
DEFAULT_TELEMETRY_COLLECT_SYSTEM_INFO
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package git.yawaflua.tech.spmega;
|
|||||||
import net.fabricmc.api.ModInitializer;
|
import net.fabricmc.api.ModInitializer;
|
||||||
|
|
||||||
public class SPMega implements ModInitializer {
|
public class SPMega implements ModInitializer {
|
||||||
private static ModConfig config;
|
private static volatile ModConfig config;
|
||||||
|
|
||||||
public static ModConfig getConfig() {
|
public static ModConfig getConfig() {
|
||||||
return config;
|
return config;
|
||||||
@@ -16,6 +16,9 @@ public class SPMega implements ModInitializer {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onInitialize() {
|
public void onInitialize() {
|
||||||
|
long startNs = System.nanoTime();
|
||||||
config = ConfigManager.loadOrCreate();
|
config = ConfigManager.loadOrCreate();
|
||||||
|
long durationMs = (System.nanoTime() - startNs) / 1_000_000L;
|
||||||
|
System.out.println("[SPMEGA] Main init took " + durationMs + "ms");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
package git.yawaflua.tech.spmega.api;
|
|
||||||
|
|
||||||
import com.google.gson.*;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.http.HttpClient;
|
|
||||||
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;
|
|
||||||
|
|
||||||
public final class SPWorldsApiClient {
|
|
||||||
private final HttpClient httpClient;
|
|
||||||
private final Gson gson;
|
|
||||||
private final String baseUrl;
|
|
||||||
|
|
||||||
public SPWorldsApiClient(String baseUrl) {
|
|
||||||
this.httpClient = HttpClient.newHttpClient();
|
|
||||||
this.gson = new Gson();
|
|
||||||
this.baseUrl = normalizeBaseUrl(baseUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String encodeAuth(CardAuth auth) {
|
|
||||||
String raw = auth.cardId() + ":" + auth.cardToken();
|
|
||||||
return Base64.getEncoder().encodeToString(raw.getBytes(StandardCharsets.UTF_8));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String normalizeBaseUrl(String rawBaseUrl) {
|
|
||||||
String fallback = "https://spworlds.ru";
|
|
||||||
if (rawBaseUrl == null || rawBaseUrl.trim().isEmpty()) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
String value = rawBaseUrl.trim();
|
|
||||||
if (value.endsWith("/")) {
|
|
||||||
return value.substring(0, value.length() - 1);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String getString(JsonObject json, String key) {
|
|
||||||
if (!json.has(key) || json.get(key).isJsonNull()) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return json.get(key).getAsString();
|
|
||||||
}
|
|
||||||
|
|
||||||
public CardInfo getCardInfo(CardAuth auth) throws IOException, InterruptedException {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
long balance = json.has("balance") ? json.get("balance").getAsLong() : 0L;
|
|
||||||
String webhook = json.has("webhook") && !json.get("webhook").isJsonNull()
|
|
||||||
? json.get("webhook").getAsString()
|
|
||||||
: "";
|
|
||||||
return new CardInfo(balance, webhook);
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.out.println("Failed to parse card info response: " + e.getMessage());
|
|
||||||
System.out.println(body);
|
|
||||||
|
|
||||||
throw new IOException("Failed to parse card info response", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<PlayerCard> getPlayerCards(String username, CardAuth auth) throws IOException, InterruptedException {
|
|
||||||
HttpRequest request = requestBuilder("/api/public/accounts/" + username + "/cards", auth).GET().build();
|
|
||||||
String body = send(request);
|
|
||||||
try {
|
|
||||||
JsonArray json = JsonParser.parseString(body).getAsJsonArray();
|
|
||||||
|
|
||||||
List<PlayerCard> cards = new ArrayList<>();
|
|
||||||
for (JsonElement element : json) {
|
|
||||||
JsonObject card = element.getAsJsonObject();
|
|
||||||
String name = card.has("name") ? card.get("name").getAsString() : "";
|
|
||||||
String number = card.has("number") ? card.get("number").getAsString() : "";
|
|
||||||
cards.add(new PlayerCard(name, number));
|
|
||||||
}
|
|
||||||
return cards;
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.out.println("Failed to parse player cards response: " + e.getMessage());
|
|
||||||
System.out.println(body);
|
|
||||||
throw new IOException("Failed to parse player cards response", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public AccountMe getAccountMe(CardAuth auth) throws IOException, InterruptedException {
|
|
||||||
HttpRequest request = requestBuilder("/api/public/accounts/me", auth).GET().build();
|
|
||||||
String body = send(request);
|
|
||||||
try {
|
|
||||||
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
|
||||||
|
|
||||||
String id = json.has("id") ? json.get("id").getAsString() : "";
|
|
||||||
String username = json.has("username") ? json.get("username").getAsString() : "";
|
|
||||||
String minecraftUuid = json.has("minecraftUUID") ? json.get("minecraftUUID").getAsString() : "";
|
|
||||||
|
|
||||||
List<AccountCard> cards = new ArrayList<>();
|
|
||||||
if (json.has("cards") && json.get("cards").isJsonArray()) {
|
|
||||||
for (JsonElement element : json.getAsJsonArray("cards")) {
|
|
||||||
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 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 {
|
|
||||||
JsonObject payload = new JsonObject();
|
|
||||||
payload.addProperty("receiver", receiver);
|
|
||||||
payload.addProperty("amount", amount);
|
|
||||||
payload.addProperty("comment", comment.isEmpty() ? "Перевод через SPMega" : comment);
|
|
||||||
|
|
||||||
HttpRequest request = requestBuilder("/api/public/transactions", auth)
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private HttpRequest.Builder requestBuilder(String path, CardAuth auth) {
|
|
||||||
return HttpRequest.newBuilder(URI.create(baseUrl + path))
|
|
||||||
.header("Authorization", "Bearer " + encodeAuth(auth))
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.header("Accept", "application/json");
|
|
||||||
}
|
|
||||||
|
|
||||||
private String send(HttpRequest request) throws IOException, InterruptedException {
|
|
||||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
throw new IOException(statusCode + ": " + message);
|
|
||||||
}
|
|
||||||
return response.body();
|
|
||||||
}
|
|
||||||
|
|
||||||
public record CardAuth(String cardId, String cardToken) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public record CardInfo(long balance, String webhook) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public record PlayerCard(String name, String number) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public record AccountMe(String id, String username, String minecraftUuid, List<AccountCard> cards) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public record AccountCard(String id, String name, String number, int color) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public record TransactionResult(long balance) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -3,12 +3,20 @@
|
|||||||
"id": "spmega",
|
"id": "spmega",
|
||||||
"version": "${version}",
|
"version": "${version}",
|
||||||
"name": "SPMega",
|
"name": "SPMega",
|
||||||
"description": "yawaflua`s SPRadar+SPMHelper mod, thats make a lot!",
|
"description": "yawaflua's SPRadar+SPMHelper mod, that's make a lot!",
|
||||||
"authors": [
|
"authors": [
|
||||||
"Dmitri 'yawaflua' Shimanski aka DOLBAYEB"
|
{
|
||||||
|
"name": "Dmitri 'yawaflua' Shimanski aka DOLBAYEB",
|
||||||
|
"contact": {
|
||||||
|
"homepage": "https://yawaflua.tech",
|
||||||
|
"email": "spmega@yawaflua.tech"
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"contact": {
|
"contact": {
|
||||||
|
"homepage": "https://yawaflua.tech",
|
||||||
|
"issues": "https://github.com/yawaflua/SPMega",
|
||||||
|
"sources": "https://github.com/yawaflua/SPMega"
|
||||||
},
|
},
|
||||||
"license": "CC BY-NC-ND 4.0",
|
"license": "CC BY-NC-ND 4.0",
|
||||||
"icon": "icon.png",
|
"icon": "icon.png",
|
||||||
@@ -38,6 +46,9 @@
|
|||||||
"fabricloader": ">=${loader_version}",
|
"fabricloader": ">=${loader_version}",
|
||||||
"fabric-api": "*",
|
"fabric-api": "*",
|
||||||
"minecraft": "${minecraft_version}",
|
"minecraft": "${minecraft_version}",
|
||||||
|
"java": ">=${java_version}"
|
||||||
|
},
|
||||||
|
"suggests": {
|
||||||
"cloth-config2": ">=${cloth_config_version}"
|
"cloth-config2": ">=${cloth_config_version}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
plugins {
|
||||||
|
id("dev.kikugie.stonecutter")
|
||||||
|
id("com.modrinth.minotaur") version "2.9.0" apply false
|
||||||
|
}
|
||||||
|
|
||||||
|
stonecutter active "26.2.x"
|
||||||
|
|
||||||
|
stonecutter parameters {
|
||||||
|
constants["mc_26"] = node.metadata.version >= "26.1"
|
||||||
|
constants["mc_26_2"] = node.metadata.version >= "26.2"
|
||||||
|
constants["mc_1_21_11"] = node.metadata.version >= "1.21.11"
|
||||||
|
|
||||||
|
replacements {
|
||||||
|
string(node.metadata.version >= "26.1") {
|
||||||
|
replace("GuiGraphics", "GuiGraphicsExtractor")
|
||||||
|
replace(".drawString(", ".text(")
|
||||||
|
replace(".drawCenteredString(", ".centeredText(")
|
||||||
|
replace("ScreenEvents.afterRender", "ScreenEvents.afterExtract")
|
||||||
|
replace(".getDayTime()", ".getOverworldClockTime()")
|
||||||
|
replace("client.keybinding.v1.KeyBindingHelper", "client.keymapping.v1.KeyMappingHelper")
|
||||||
|
replace("KeyBindingHelper.registerKeyBinding", "KeyMappingHelper.registerKeyMapping")
|
||||||
|
}
|
||||||
|
string(node.metadata.version >= "26.2") {
|
||||||
|
replace(".setScreen(", ".gui.setScreen(")
|
||||||
|
replace("client.screen ==", "client.gui.screen() ==")
|
||||||
|
replace("client.screen !=", "client.gui.screen() !=")
|
||||||
|
replace("client.screen)", "client.gui.screen())")
|
||||||
|
replace("client.screen;", "client.gui.screen();")
|
||||||
|
replace("client.getMainRenderTarget()", "client.gameRenderer.mainRenderTarget()")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
[constants]
|
||||||
|
mod_id = "spmega"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
fabric_version = "0.141.5+1.21.11"
|
||||||
|
cloth_config_version = "21.11.153"
|
||||||
|
modmenu_version = "v6Xx3fbU"
|
||||||
|
|
||||||
|
[metadata]
|
||||||
|
minecraft_compat = "1.21.11"
|
||||||
|
loader_compat = "0.18.1"
|
||||||
|
|
||||||
|
["1.21.1"]
|
||||||
|
dependencies.fabric_version = "0.116.10+1.21.1"
|
||||||
|
dependencies.cloth_config_version = "15.0.140"
|
||||||
|
dependencies.modmenu_version = "v6Xx3fbU"
|
||||||
|
metadata.minecraft_compat = "1.21.1"
|
||||||
|
|
||||||
|
["26.1.x"]
|
||||||
|
dependencies.fabric_version = "0.155.2+26.1.2"
|
||||||
|
dependencies.cloth_config_version = "26.1.154"
|
||||||
|
dependencies.modmenu_version = "3rsoV1bk"
|
||||||
|
metadata.minecraft_compat = "~26.1"
|
||||||
|
metadata.loader_compat = "0.19.0"
|
||||||
|
|
||||||
|
["26.2.x"]
|
||||||
|
dependencies.fabric_version = "0.155.2+26.2"
|
||||||
|
dependencies.cloth_config_version = "26.2.155"
|
||||||
|
dependencies.modmenu_version = "njXb639R"
|
||||||
|
metadata.minecraft_compat = "~26.2"
|
||||||
|
metadata.loader_compat = "0.19.0"
|
||||||
Reference in New Issue
Block a user