65 Commits
Author SHA1 Message Date
ppc 2d3b129126 bump android package version 2024-12-13 20:46:08 +00:00
ppc b729b4e31b more package updates 2024-12-13 20:22:34 +00:00
ppc 377fe93645 Merge remote-tracking branch 'origin/master' 2024-12-13 19:52:42 +00:00
ppc cbd2825475 update to expo 52 2024-11-28 14:52:31 +00:00
ppc b9bbcc9228 update workflow to .net 9 2024-11-28 14:00:33 +00:00
ppc 61fb81c75c add android links to readme 2024-11-28 13:52:09 +00:00
ppc d3b96d3017 don't render invisible icons on android 2024-11-22 16:22:51 +00:00
ppc 9b136006a0 add build-properties plugin 2024-11-21 22:52:48 +00:00
ppc 2eb61d74a7 assetlinks.json fix 2024-11-18 22:13:28 +00:00
ppc 89101c5d5f more fun 2024-11-18 22:00:54 +00:00
ppc 2fe9238d02 update dll metadata 2024-11-18 22:00:54 +00:00
ppc d1b626ffac add android asset link validation file 2024-11-18 22:00:53 +00:00
ppc fe7ab1c50f prepare android release bundling 2024-11-18 22:00:53 +00:00
ppc 5a3a7be906 add android intent filters 2024-11-18 22:00:53 +00:00
ppc 703067e6d6 whitespace removal 2024-11-18 22:00:53 +00:00
ppc 67f90397b0 upgrade server to .net9 2024-11-18 22:00:53 +00:00
ppc 8ba439721f android-specific adjustments 2024-11-18 22:00:53 +00:00
ppc 35a8c28c0e more variable naming 2024-11-09 20:21:33 +00:00
ppc a0b44c165b improve variable naming 2024-11-09 20:14:25 +00:00
ppc 489a93adfe update card selection, automatically check for server startup to end 2024-11-09 20:11:00 +00:00
ppc 6172f2bbd7 fix site on ios 12 2024-11-09 16:47:07 +00:00
ppc 414a199552 target es2019 (safari 12+) 2024-11-09 15:53:27 +00:00
ppc a583b72798 update expo sdk version 2024-11-06 16:04:15 +00:00
ppc 33cd321325 improve card selection when changing games 2024-11-06 16:02:59 +00:00
ppc 85b900ea30 fix haptic feedback not happening on iOS (see https://github.com/facebook/react-native/issues/43388) 2024-10-26 18:08:31 +01:00
ppc 4038de583d fix card unselection on reload 2024-10-26 18:07:57 +01:00
ppc b08a7cd125 show oldest cards at the top 2024-10-14 09:41:15 +01:00
ppc 4b6037fe24 fix bad selection logic 2024-09-28 09:22:43 +01:00
ppc 4702052cc4 prevent invalid array access 2024-09-26 12:13:46 +01:00
ppc 969edd3675 add game-default card indicator 2024-09-26 12:09:38 +01:00
ppc 0d200a22fc update vulnerable packages (CVE-2024-47068, CVE-2024-45812 and CVE-2024-45811) 2024-09-24 12:44:26 +01:00
ppc c30abfe482 auto-select card last used with specific game 2024-09-20 11:57:06 +01:00
ppc 6628071f5b fix dark mode inconsistency 2024-09-20 11:55:27 +01:00
ppc b29daecdcd output var fix 2024-09-05 11:19:04 +01:00
ppc 57d9f07a64 Merge branch 'add-server-metrics' 2024-09-05 11:09:00 +01:00
ppc a538812aee disable auto tagging in actions 2024-09-05 11:06:10 +01:00
ppc dff641e6f1 colour fix 2024-09-05 11:05:27 +01:00
ppc 00736e72dd add startup alert to web 2024-09-05 10:55:03 +01:00
ppc f4dd845ee6 add game name to page, make detail icons use same classes 2024-09-05 10:45:50 +01:00
ppc e467329db8 version bump 2024-09-05 10:45:10 +01:00
ppc e7c521cf2e update padding values 2024-09-04 12:12:58 +01:00
ppc a332562928 add server startup warning message 2024-09-04 11:21:10 +01:00
ppc 245a26aecf explicitly serialize null values 2024-09-04 11:20:56 +01:00
ppc 8a1c751448 change param type for readability 2024-09-04 10:21:52 +01:00
ppc 73b7800bf3 merge serializer contents into webserver class 2024-09-04 10:19:51 +01:00
ppc cff5989a8d add instance metrics to server 2024-09-04 10:17:14 +01:00
ppc 20577f76f0 split and rename server response interfaces 2024-09-04 10:15:29 +01:00
ppc 91dc31226d fix manifest name 2024-09-03 18:15:58 +01:00
ppc 7b5c1b9672 move connection error message to own component 2024-09-03 18:00:50 +01:00
ppc 963f2ddb48 add game name display support 2024-09-03 17:59:10 +01:00
ppc 153d3ff7ad remove enum, replace with string-only method 2024-09-03 17:06:19 +01:00
ppc 9fe0dcc35c use empty string over null 2024-09-03 16:03:40 +01:00
ppc 6acddc2f78 add server share option to context menu 2024-09-03 15:02:11 +01:00
ppc 15329d5dcd add card edit support 2024-09-03 14:52:52 +01:00
ppc ef8e03ee9b fix 2024-09-03 14:19:09 +01:00
ppc a7217c8e31 add game name resolution 2024-09-03 14:15:43 +01:00
ppc 1b3865b325 apply updates to amnet-native 2024-09-03 14:12:26 +01:00
ppc 40934f999d update web to use new method signatures 2024-09-03 14:09:36 +01:00
ppc 087f657563 rebuild local lib 2024-09-03 14:07:22 +01:00
ppc fdfbe73bb2 require api version with server connection requests 2024-09-03 14:07:04 +01:00
ppc e0a0002ca1 add server/website build script 2024-09-03 14:03:40 +01:00
ppc ba58e3af1b document keyboard mode setting switch 2024-09-02 16:28:53 +01:00
ppc e4cb903198 add hold-enter-to-login support back 2024-09-02 16:24:00 +01:00
ppc 14efbc4971 return parsed card when setting 2024-09-02 16:23:48 +01:00
ppc ec98003d2c move card storage/web server handlers to separate files 2024-09-02 14:14:02 +01:00
51 changed files with 6761 additions and 5849 deletions
+81
View File
@@ -0,0 +1,81 @@
name: Release
on:
# push:
# tags:
# - '^v?(?:[0-9]+.?){1,4}$'
workflow_dispatch:
inputs:
tag:
description: 'Tag Name'
required: true
jobs:
get-upload-url:
runs-on: ubuntu-latest
name: Get Asset Upload URL
outputs:
upload_url: ${{ steps.get-url.outputs.url }}
env:
TAG: ${{ github.event.inputs.tag || github.ref_name }}
steps:
- name: Get Upload URL
id: get-url
run: |
UPLOAD_URL=$(curl -s -X GET -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" "https://gitea.tendokyu.moe/api/v1/repos/ppc/amnet/releases/tags/$TAG" | jq -r '.upload_url')
echo "url=$UPLOAD_URL" >> $GITHUB_OUTPUT
build-server:
runs-on: windows-latest
needs: get-upload-url
name: Build amnet-server
env:
DOTNET_CLI_TELEMETRY_OPTOUT: 1
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- name: Set version
run: |
$tag="${{ github.event.inputs.tag || github.ref_name }}".TrimStart("v")
echo "VERSION=$tag" >> $env:GITHUB_ENV
- name: Publish
run: dotnet publish -c Release -r win-x64 -o .\build-out -p:Version=$env:VERSION AMNet.Server\AMNet.Server.csproj
- name: Upload artifact
run: |
curl -s -X POST -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" -H "Content-Type: application/octet-stream" --data-binary "@.\build-out\amnet.dll" "${{ needs.get-upload-url.outputs.upload_url }}?name=amnet.dll"
build-website:
runs-on: ubuntu-latest
needs: get-upload-url
name: Build amnet-web
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
working-directory: ${{ github.workspace }}/amnet-web
run: npm install
- name: Build
working-directory: ${{ github.workspace }}/amnet-web
run: npm run build
- name: Archive Result
run: zip -r amnet-web.zip ./amnet-web/dist/
- name: Upload
run: |
curl -s -X POST -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" -H "Content-Type: application/octet-stream" --data-binary "@./amnet-web.zip" "${{ needs.get-upload-url.outputs.upload_url }}?name=amnet-web.zip"
+5 -5
View File
@@ -4,18 +4,18 @@
<PublishAot>true</PublishAot> <PublishAot>true</PublishAot>
<OutputType>Library</OutputType> <OutputType>Library</OutputType>
<EventSourceSupport>false</EventSourceSupport> <EventSourceSupport>false</EventSourceSupport>
<TargetFramework>net8.0-windows</TargetFramework> <TargetFramework>net9.0-windows</TargetFramework>
<OptimizationPreference>Size</OptimizationPreference> <OptimizationPreference>Size</OptimizationPreference>
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile> <NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
<AssemblyName>amnet</AssemblyName> <Product>AMNet</Product>
<ProductName>AMNet</ProductName>
<CompanyName>ppc</CompanyName> <CompanyName>ppc</CompanyName>
<Title>AMNet Server</Title> <AssemblyName>amnet</AssemblyName>
<Description>Server component for the AMNet IC Card switcher</Description> <AssemblyTitle>AMNet Server</AssemblyTitle>
<Copyright>Copyright 2024 (c) ppc</Copyright> <Copyright>Copyright 2024 (c) ppc</Copyright>
<Description>Server component for the AMNet IC Card switcher</Description>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
+79
View File
@@ -0,0 +1,79 @@
using System;
using System.Globalization;
using System.Linq;
using System.Threading;
namespace AMNet.Server;
/// <summary>
/// Represents a storage location to load/read cards from
/// </summary>
internal class CardPresenter
{
private readonly Lock _cardLock = new();
private StoredCard _currentCard;
public record StoredCard(byte[] Value, string OriginalValue, long ExpiresAt)
{
public bool Expired => Environment.TickCount64 >= ExpiresAt;
public override string ToString() => string.Join(" ", Enumerable.Range(0, 5).Select(x => OriginalValue.Substring(x * 4, 4)));
}
/// <summary>
/// Returns the currently active card without clearing it
/// </summary>
public StoredCard PeekActiveCard()
{
lock (_cardLock)
{
return _currentCard;
}
}
public StoredCard TakeActiveCard()
{
lock (_cardLock)
{
var card = _currentCard;
_currentCard = null;
return card;
}
}
/// <summary>
/// Sets the card
/// </summary>
/// <param name="cardId">The card id to set</param>
/// <param name="validFor">How long the card should be presented to the game for in milliseconds</param>
/// <returns>Whether the card was set successfully</returns>
public StoredCard SetCard(string cardId, long validFor = 5000)
{
// ensure the matrix code is 20-digits long, otherwise pad with zeros
var matrixCode = cardId.Replace(" ", "").PadLeft(20, '0');
var bytes = new byte[10];
for (var i = 0; i < bytes.Length; i++)
{
var value = matrixCode.Substring(i * 2, 2);
if (byte.TryParse(value, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b))
{
bytes[i] = b;
}
else
{
return null;
}
}
var card = new StoredCard(bytes, matrixCode, Environment.TickCount64 + validFor);
lock (_cardLock)
{
_currentCard = card;
}
return card;
}
}
+37
View File
@@ -0,0 +1,37 @@
using System;
using Windows.Win32;
using Windows.Win32.Foundation;
namespace AMNet.Server;
internal static class Config
{
private const string ConfigFileName = @".\segatools.ini";
internal const string IOSection = "aimeio";
static Config()
{
GameId = ReadKey(IOSection, "gameId", 4);
ServerName = ReadKey(IOSection, "serverName", 26, Environment.MachineName);
EnableAimeTxt = PInvoke.GetPrivateProfileInt(IOSection, "enableKeyboardMode", 1, ConfigFileName) > 0;
AimeTxtPath = ReadKey("aime", "aimePath", PInvoke.MAX_PATH, @"DEVICE\aime.txt");
}
public static string GameId { get; }
public static string ServerName { get; }
public static bool EnableAimeTxt { get; }
public static string AimeTxtPath { get; }
internal static unsafe string ReadKey(string section, string key, uint maxLength, string @default = null)
{
// +1 for null terminator
var buffer = stackalloc char[(int)maxLength + 1];
var bufferStr = new PWSTR(buffer);
PInvoke.GetPrivateProfileString(section, key, @default ?? string.Empty, bufferStr, maxLength + 1, ConfigFileName);
return bufferStr.ToString();
}
}
+68 -148
View File
@@ -1,206 +1,126 @@
using System; using System;
using System.Globalization; using System.IO;
using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text.Json; using System.Text;
using System.Threading; using System.Threading;
using Windows.Win32; using Windows.Win32;
using Windows.Win32.Foundation;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace AMNet.Server; namespace AMNet.Server;
public static class DllMain public static class DllMain
{ {
private const int ApiVersion = 1;
private const string WebAddress = "http://card.ppc.moe"; private const string WebAddress = "http://card.ppc.moe";
private static ILogger _serverLogger; // instance metrics
internal static long LastPollTime = -1;
private static string[] _serverAddresses; internal static long ServerStartedAt;
private static string _serverName;
private static string _gameId;
private static (byte[] IdBytes, string OriginalId, long Expires)? _currentCard;
private static readonly object _cardLock = new();
static DllMain() static DllMain()
{ {
PInvoke.AllocConsole(); PInvoke.AllocConsole();
App = WebServer.BuildServer(Config.ReadKey(Config.IOSection, "serverAddress", 1024, "http://+:6070").Split(';'));
} }
private static WebApplication App { get; }
[UnmanagedCallersOnly(EntryPoint = "aime_io_get_api_version")] [UnmanagedCallersOnly(EntryPoint = "aime_io_get_api_version")]
public static ushort GetApiVersion() => 0x0100; public static ushort GetApiVersion() => 0x0100;
[UnmanagedCallersOnly(EntryPoint = "aime_io_init")] [UnmanagedCallersOnly(EntryPoint = "aime_io_init")]
public static int Init() public static int Init()
{ {
LoadConfiguration(); CancellationTokenRegistration? cancellationRegistration = null;
cancellationRegistration = App.Lifetime.ApplicationStarted.Register(() =>
var builder = WebServer.BuildServer();
builder.WebHost.UseUrls(_serverAddresses);
var app = builder.Build();
_serverLogger = app.Logger;
app.UseCors();
app.MapGet("/amnet/info", () => Results.Ok(new SystemState(ApiVersion, _gameId, _serverName)));
app.MapPost("/amnet/signin", async ctx =>
{ {
CardReadRequest request; App.Logger.LogInformation("AMNet Server ({gameId}) started successfully.", Config.GameId);
App.Logger.LogInformation("Visit {addr} from a mobile device on the same network to get started.", WebAddress);
try // ReSharper disable once AccessToModifiedClosure
{ cancellationRegistration?.Dispose();
request = await ctx.Request.ReadFromJsonAsync<CardReadRequest>();
}
catch (JsonException e)
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsync(e.Message);
return;
}
if (string.IsNullOrEmpty(request?.MatrixCode))
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsync("Card id not provided.");
return;
}
if (request.MatrixCode.Length is 0 or < 20)
{
ctx.Response.StatusCode = 422;
await ctx.Response.WriteAsync("Invalid card id length.");
return;
}
if (request.MatrixCode.Any(x => !char.IsNumber(x)))
{
ctx.Response.StatusCode = 422;
await ctx.Response.WriteAsync("Invalid card id format.");
return;
}
lock (_cardLock)
{
// ratelimit check
if (_currentCard?.Expires > Environment.TickCount64)
{
ctx.Response.StatusCode = 429;
ctx.Response.Headers.RetryAfter = ((int)TimeSpan.FromMilliseconds(_currentCard.Value.Expires - Environment.TickCount64).TotalSeconds).ToString();
return;
}
// ensure the matrix code is 20-digits long, otherwise pad with zeros
var matrixCode = request.MatrixCode.PadLeft(20, '0');
var bytes = new byte[10];
for (var i = 0; i < 10; i++)
{
var value = matrixCode.Substring(i * 2, 2);
bytes[i] = byte.Parse(value, NumberStyles.HexNumber);
}
// it's called a tick count, but it uses milliseconds despite having a unit of time called a tick???
_currentCard = (bytes, matrixCode, Environment.TickCount64 + 5000);
}
ctx.Response.StatusCode = 202;
}); });
CancellationTokenRegistration cancellationRegistration = default; Volatile.Write(ref ServerStartedAt, Environment.TickCount64);
cancellationRegistration = app.Lifetime.ApplicationStarted.Register(() => App.RunAsync();
{
_serverLogger.LogInformation("AMNet Server ({gameId}) started successfully.", _gameId);
_serverLogger.LogInformation("Visit {addr} from a mobile device on the same network to get started.", WebAddress);
cancellationRegistration.Dispose();
});
app.RunAsync();
return 0; return 0;
} }
[UnmanagedCallersOnly(EntryPoint = "aime_io_nfc_poll")] [UnmanagedCallersOnly(EntryPoint = "aime_io_nfc_poll")]
public static int NfcPoll(byte unitNo) public static int NfcPoll(byte unitNo)
{ {
if (_currentCard.HasValue && _currentCard.Value.Expires < Environment.TickCount64) Volatile.Write(ref LastPollTime, Environment.TickCount64);
if (!Config.EnableAimeTxt)
{ {
lock (_cardLock) return 1;
}
// check keyboard input for the enter key being held down (0x0D)
var enterPressed = (PInvoke.GetAsyncKeyState(0x0D) & 0x8000) != 0;
if (!enterPressed)
{
return 0;
}
try
{
using var aimeTxtReader = new StreamReader(File.OpenRead(Config.AimeTxtPath), Encoding.UTF8);
var fileName = Path.GetFileName(Config.AimeTxtPath);
var cardResult = App.Services.GetRequiredService<CardPresenter>().SetCard(aimeTxtReader.ReadLine());
if (cardResult == null)
{ {
_currentCard = null; App.Logger.LogWarning("Failed to read card from {file}: invalid format", fileName);
} }
else
{
App.Logger.LogInformation("Card loaded from {fileName}", fileName);
}
}
catch (IOException e)
{
App.Logger.LogWarning("Failed to read '{path}': {message}", Path.GetFullPath(Config.AimeTxtPath), e.Message);
} }
// there's no polling here (handled by the webserver)
return 0; return 0;
} }
[UnmanagedCallersOnly(EntryPoint = "aime_io_nfc_get_aime_id")] [UnmanagedCallersOnly(EntryPoint = "aime_io_nfc_get_aime_id")]
public static int GetAimeId(byte unitNo, nint luid, nint luidSize) public static int GetAimeId(byte unitNo, IntPtr luid, nint luidSize)
{ {
lock (_cardLock) if (unitNo != 0)
{ {
if (unitNo != 0 || _currentCard == null) return 1;
{
return 1;
}
if (_currentCard.Value.Expires < Environment.TickCount64)
{
_serverLogger.LogWarning("Submitted card id expired before it could be read.");
_currentCard = null;
return 1;
}
Marshal.Copy(_currentCard.Value.IdBytes, 0, luid, (int)luidSize);
_serverLogger.LogInformation("Card read in: {0}", string.Join(" ", Enumerable.Range(0, 5).Select(x => _currentCard.Value.OriginalId.Substring(x * 4, 4))));
_currentCard = null;
} }
return 0; var card = App.Services.GetRequiredService<CardPresenter>().TakeActiveCard();
if (card?.Expired != false)
{
return 1;
}
try
{
Marshal.Copy(card.Value, 0, luid, (int)luidSize);
return 0;
}
finally
{
App.Logger.LogInformation("Card read in: {cardId}", card);
}
} }
[UnmanagedCallersOnly(EntryPoint = "aime_io_nfc_get_felica_id")] [UnmanagedCallersOnly(EntryPoint = "aime_io_nfc_get_felica_id")]
public static unsafe int GetFelicaId(byte unitNo, ulong* idm) public static unsafe int GetFelicaId(byte unitNo, ulong* idm) => 1; // felica not supported
{
// felica not supported
return 1;
}
[UnmanagedCallersOnly(EntryPoint = "aime_io_led_set_color")] [UnmanagedCallersOnly(EntryPoint = "aime_io_led_set_color")]
public static void SetLedColour(byte unitNo, byte r, byte g, byte b) public static void SetLedColour(byte unitNo, byte r, byte g, byte b)
{ {
// do nothing // do nothing
} }
private static unsafe void LoadConfiguration()
{
const string configFileName = @".\segatools.ini";
var gameId = stackalloc char[5];
var gameIdStr = new PWSTR(gameId);
var serverName = stackalloc char[64];
var serverNameStr = new PWSTR(serverName);
var serverAddress = stackalloc char[1024];
var serverAddressStr = new PWSTR(serverAddress);
PInvoke.GetPrivateProfileString("aimeio", "gameId", string.Empty, gameIdStr, 5, configFileName);
PInvoke.GetPrivateProfileString("aimeio", "serverName", Environment.MachineName, serverNameStr, 64, configFileName);
PInvoke.GetPrivateProfileString("aimeio", "serverAddress", "http://+:6070", serverAddressStr, 1024, configFileName);
_gameId = gameIdStr.ToString();
_serverName = serverNameStr.ToString();
_serverAddresses = serverAddressStr.ToString().Split(';');
}
} }
+2
View File
@@ -1,3 +1,5 @@
GetPrivateProfileStringW GetPrivateProfileStringW
GetPrivateProfileInt GetPrivateProfileInt
GetAsyncKeyState
AllocConsole AllocConsole
MAX_PATH
-16
View File
@@ -1,16 +0,0 @@
using System.Text.Json.Serialization;
namespace AMNet.Server;
public record SystemState(
[property: JsonPropertyName("apiVersion")] int ApiVersion,
[property: JsonPropertyName("gameId")] string GameId,
[property: JsonPropertyName("serverName")] string ServerName);
public record CardReadRequest(
[property: JsonPropertyName("cardId")] string MatrixCode);
[JsonSerializable(typeof(SystemState))]
[JsonSerializable(typeof(CardReadRequest))]
[JsonSourceGenerationOptions(WriteIndented = true)]
public partial class SerializerContext : JsonSerializerContext;
+106 -3
View File
@@ -1,16 +1,37 @@
using System; using System;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace AMNet.Server; namespace AMNet.Server;
internal static class WebServer internal static partial class WebServer
{ {
public static WebApplicationBuilder BuildServer() private const int ApiVersion = 1;
public record SystemState(
[property: JsonPropertyName("apiVersion")] int ApiVersion,
[property: JsonPropertyName("gameId")] string GameId,
[property: JsonPropertyName("serverName")] string ServerName,
[property: JsonPropertyName("sessionUptime")] long SessionUptime,
[property: JsonPropertyName("timeSinceLastPoll")] long? TimeSinceLastPoll);
public record CardReadRequest(
[property: JsonPropertyName("cardId")] string MatrixCode);
public static WebApplication BuildServer(params string[] listenAddresses)
{ {
var builder = WebApplication.CreateSlimBuilder([]); var builder = WebApplication.CreateSlimBuilder([]);
builder.WebHost.UseUrls(listenAddresses);
builder.Logging.ClearProviders(); builder.Logging.ClearProviders();
builder.Logging.AddSimpleConsole(o => builder.Logging.AddSimpleConsole(o =>
{ {
@@ -39,6 +60,88 @@ internal static class WebServer
options.SerializerOptions.TypeInfoResolverChain.Insert(0, SerializerContext.Default); options.SerializerOptions.TypeInfoResolverChain.Insert(0, SerializerContext.Default);
}); });
return builder; builder.Services.AddSingleton<CardPresenter>();
var app = builder.Build();
app.UseCors();
app.MapGet("/amnet/info", ServerInfo);
app.MapPost("/amnet/signin", ProcessCard);
return app;
} }
private static IResult ServerInfo()
{
var startedAt = Volatile.Read(ref DllMain.ServerStartedAt);
var lastPollAt = Volatile.Read(ref DllMain.LastPollTime);
var state = new SystemState(
ApiVersion,
Config.GameId,
Config.ServerName,
Environment.TickCount64 - startedAt,
lastPollAt < 0 ? null : Environment.TickCount64 - lastPollAt);
return Results.Ok(state);
}
private static async Task ProcessCard(HttpContext ctx)
{
CardReadRequest request;
try
{
request = await ctx.Request.ReadFromJsonAsync<CardReadRequest>();
}
catch (JsonException e)
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsync(e.Message);
return;
}
if (string.IsNullOrEmpty(request?.MatrixCode))
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsync("Card id not provided.");
return;
}
if (request.MatrixCode.Length is 0 or < 20)
{
ctx.Response.StatusCode = 422;
await ctx.Response.WriteAsync("Invalid card id length.");
return;
}
var cardPresenter = ctx.RequestServices.GetRequiredService<CardPresenter>();
var currentCard = cardPresenter.PeekActiveCard();
if (currentCard?.Expired == false)
{
var timeLeft = Math.Ceiling(TimeSpan.FromMilliseconds(currentCard.ExpiresAt - Environment.TickCount64).TotalSeconds);
ctx.Response.StatusCode = 429;
ctx.Response.Headers.RetryAfter = timeLeft.ToString(CultureInfo.InvariantCulture);
return;
}
if (cardPresenter.SetCard(request.MatrixCode) != null)
{
ctx.Response.StatusCode = 202;
}
else
{
ctx.Response.StatusCode = 422;
await ctx.Response.WriteAsync("Invalid card id format.");
}
}
[JsonSerializable(typeof(SystemState))]
[JsonSerializable(typeof(CardReadRequest))]
[JsonSourceGenerationOptions(WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.Never)]
private partial class SerializerContext : JsonSerializerContext;
} }
+57 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "AMNet", "name": "AMNet",
"scheme": "amnet", "scheme": "amnet",
"version": "1.0.0", "version": "1.2.0",
"slug": "amnet-native", "slug": "amnet-native",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
@@ -19,7 +19,61 @@
} }
}, },
"android": { "android": {
"package": "moe.ppc.amnet" "package": "moe.ppc.amnet",
"versionCode": 3,
"adaptiveIcon": {
"foregroundImage": "./assets/icon-android.png",
"backgroundColor": "#DED8FD"
},
"splash": {
"resizeMode": "cover",
"image": "./assets/icon-android.png",
"backgroundColor": "#DED8FD",
"dark": {
"backgroundColor": "#3B5ACC"
}
},
"intentFilters": [
{
"autoVerify": true,
"action": "VIEW",
"data": [
{
"scheme": "http",
"host": "card.ppc.moe",
"pathPrefix": "/#/servers"
},
{
"scheme": "http",
"host": "card.ppc.moe",
"pathPrefix": "/#/cards"
},
{
"scheme": "https",
"host": "card.ppc.moe",
"pathPrefix": "/#/servers"
},
{
"scheme": "https",
"host": "card.ppc.moe",
"pathPrefix": "/#/cards"
}
],
"category": [
"BROWSABLE",
"DEFAULT"
]
}
]
}, },
"plugins": [] "plugins": [
[
"expo-build-properties",
{
"android": {
"usesCleartextTraffic": true
}
}
]
]
} }
+45 -17
View File
@@ -1,7 +1,7 @@
import {generateCardNumber} from "amnet-shared"; import {generateCardNumber} from "amnet-shared";
import {useModalStyles} from "../utils/ModalStyles"; import {useModalStyles} from "../utils/ModalStyles";
import {useCallback, useLayoutEffect, useState} from "react"; import {useCallback, useLayoutEffect, useState} from "react";
import {TextInput, TouchableOpacity, View} from "react-native"; import {Platform, TextInput, TouchableOpacity, View} from "react-native";
import {Button, Card, Divider, Paragraph, useTheme} from "react-native-paper"; import {Button, Card, Divider, Paragraph, useTheme} from "react-native-paper";
import {AMCard} from "../models/AMCard"; import {AMCard} from "../models/AMCard";
@@ -9,7 +9,7 @@ import RealmContext from "../models/RealmContext";
const {useRealm} = RealmContext; const {useRealm} = RealmContext;
export default function CardAddModal({navigation}) { export default function CardAddModal({navigation, route}) {
const realm = useRealm(); const realm = useRealm();
const theme = useTheme(); const theme = useTheme();
const modalStyles = useModalStyles(); const modalStyles = useModalStyles();
@@ -17,9 +17,10 @@ export default function CardAddModal({navigation}) {
// state values // state values
const [cardName, setCardName] = useState(''); const [cardName, setCardName] = useState('');
const [cardNumber, setCardNumber] = useState(''); const [cardNumber, setCardNumber] = useState('');
const [cardNumberReadOnly, setCardNumberReadOnly] = useState(false);
const cardAddCallback = useCallback((cardId: string, cardName: string) => { const cardAddCallback = useCallback((cardId: string, cardName: string) => {
const duplicateCard = realm.objects<AMCard>("card").find(x => x.code == cardNumber); const duplicateCard = realm.objects<AMCard>("card").find(x => x.code === cardId);
if (duplicateCard) { if (duplicateCard) {
realm.write(() => { realm.write(() => {
@@ -35,13 +36,15 @@ export default function CardAddModal({navigation}) {
}, [realm]); }, [realm]);
// header effects // header effects
useLayoutEffect(() => { if (Platform.OS === 'ios') {
navigation.setOptions({ useLayoutEffect(() => {
headerLeft: () => <TouchableOpacity onPress={() => navigation.goBack()}> navigation.setOptions({
<Paragraph style={modalStyles.headerText}>Cancel</Paragraph> headerLeft: () => <TouchableOpacity onPress={() => navigation.goBack()}>
</TouchableOpacity>, <Paragraph style={modalStyles.headerText}>Cancel</Paragraph>
}); </TouchableOpacity>,
}, [navigation, modalStyles]); });
}, [navigation, modalStyles]);
}
useLayoutEffect(() => { useLayoutEffect(() => {
const canSave = cardName.length && cardNumber.length === 20; const canSave = cardName.length && cardNumber.length === 20;
@@ -55,6 +58,25 @@ export default function CardAddModal({navigation}) {
}) })
}, [navigation, modalStyles, cardName, cardNumber]); }, [navigation, modalStyles, cardName, cardNumber]);
// enable renaming by passing cardId to modal
useLayoutEffect(() => {
if (!route.params?.cardId) {
setCardNumberReadOnly(false);
return;
}
setCardNumber(route.params.cardId);
setCardNumberReadOnly(true);
if (route.params.cardName) {
setCardName(route.params.cardName);
}
navigation.setOptions({
title: "Edit Card"
});
}, [route]);
return (<View style={{display: "flex", gap: 10, justifyContent: "center", paddingHorizontal: 15, paddingTop: 25}}> return (<View style={{display: "flex", gap: 10, justifyContent: "center", paddingHorizontal: 15, paddingTop: 25}}>
<Card mode="contained"> <Card mode="contained">
<Card.Content> <Card.Content>
@@ -76,21 +98,27 @@ export default function CardAddModal({navigation}) {
<Paragraph style={modalStyles.formItemName}> <Paragraph style={modalStyles.formItemName}>
Matrix Code Matrix Code
</Paragraph> </Paragraph>
<TextInput style={modalStyles.formItemInput} <TextInput numberOfLines={1}
numberOfLines={1}
keyboardType="numeric" keyboardType="numeric"
readOnly={cardNumberReadOnly}
placeholder="0000 0000 0000 0000 0000" placeholder="0000 0000 0000 0000 0000"
placeholderTextColor={theme.colors.outline} placeholderTextColor={theme.colors.outline}
value={cardNumber.match(/.{1,4}/g)?.join(" ")} value={cardNumber.match(/.{1,4}/g)?.join(" ")}
onChangeText={val => setCardNumber(val?.replaceAll(/\D/g, '').substring(0, 20) ?? '')}/> onChangeText={val => setCardNumber(val?.replaceAll(/\D/g, '').substring(0, 20) ?? '')}
style={{...modalStyles.formItemInput, color: cardNumberReadOnly ? theme.colors.onSurfaceDisabled : theme.colors.onSurface}}/>
</View> </View>
</Card.Content> </Card.Content>
</Card> </Card>
<Paragraph style={modalStyles.formFootnote}> {/*only show card entry info when in create mode*/}
If you have a physical card, you can enter the 20-digit code on the back. {!cardNumberReadOnly && (
</Paragraph> <>
<Paragraph style={modalStyles.formFootnote}>
If you have a physical card, you can enter the 20-digit code on the back.
</Paragraph>
<Button mode="text" onPress={() => setCardNumber(generateCardNumber())}>Generate Matrix Code</Button> <Button mode="text" onPress={() => setCardNumber(generateCardNumber())}>Generate Matrix Code</Button>
</>
)}
</View>) </View>)
} }
+42 -23
View File
@@ -1,60 +1,72 @@
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import {FlatList, View} from "react-native"; import {FlatList, Platform, View} from "react-native";
import React, {useCallback, useMemo} from "react"; import React, {useCallback, useMemo} from "react";
import {Button, useTheme} from "react-native-paper"; import {Button, useTheme} from "react-native-paper";
import {CreditCard, Plus} from "lucide-react-native"; import {CreditCard, Plus} from "lucide-react-native";
import ContextMenu from "react-native-context-menu-view"; import ContextMenu from "react-native-context-menu-view";
import {AMCard} from "../models/AMCard";
import {AimeCard} from "../components/AimeCard"; import {AimeCard} from "../components/AimeCard";
import RealmContext from "../models/RealmContext";
import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder"; import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder";
import {AMCard} from "../models/AMCard";
import RealmContext from "../models/RealmContext";
const {useQuery, useRealm} = RealmContext; const {useQuery, useRealm} = RealmContext;
function CardEntry(props: { card: AMCard }) { function CardEntry(props: { card: AMCard, navigation: any }) {
const realm = useRealm(); const realm = useRealm();
const callback = useCallback(async (index: number) => { const callback = useCallback(async (indexPath: number[]) => {
if (!props.card.isValid()) { if (!props.card.isValid()) {
return; return;
} }
switch (index) { switch (indexPath.toString()) {
// copy card no // copy card
case 0: case "0":
await Clipboard.setStringAsync(props.card.code); await Clipboard.setStringAsync(props.card.code);
break; return;
case 1: // edit card
realm.write(() => { case "1,0":
realm.delete(props.card); props.navigation.navigate('add-card', {cardId: props.card.code, cardName: props.card.name});
}); return;
break;
// remove card
case "1,1":
realm.write(() => realm.delete(props.card));
return;
} }
}, [realm, props.card]); }, [realm, props.card, props.navigation]);
return ( return (
<ContextMenu <ContextMenu
onPress={e => callback(e.nativeEvent?.index)} onPress={e => callback(e.nativeEvent?.indexPath)}
actions={[ actions={[
{title: "Copy Card Number", systemIcon: "doc.on.doc"}, {title: "Copy Card Number", systemIcon: "doc.on.doc"},
{title: "Delete Card", destructive: true, systemIcon: "trash"}]}> {
title: Platform.OS === 'ios' ? "" : "Additional Actions",
inlineChildren: true,
actions: [
{title: "Edit Card", systemIcon: "square.and.pencil"},
{title: "Delete Card", destructive: true, systemIcon: "trash"}]
}]}>
<AimeCard card={props.card} showFullCardNumber/> <AimeCard card={props.card} showFullCardNumber/>
</ContextMenu> </ContextMenu>
); );
} }
export default function CardManager({navigation}) { export default function CardManager({navigation}) {
const query = useQuery(AMCard);
const cards = useMemo(() => query.sorted("added", true), [query]);
const theme = useTheme(); const theme = useTheme();
const query = useQuery(AMCard);
const cards = useMemo(() => query.sorted("added", false), [query]);
if (!cards.length) { if (!cards.length) {
return ( return (
<View style={{flex: 1, gap: 10, justifyContent: "center", alignItems: "center"}}> <View style={{flex: 1, gap: 10, justifyContent: "center", alignItems: "center"}}>
<EmptyStatePlaceholder message="No cards added yet" icon={<CreditCard color="gray"/>}/> <EmptyStatePlaceholder message="No cards added yet" icon={<CreditCard color="gray"/>}/>
<Button mode="text" onPress={() => navigation.navigate('add-card')} icon={() => <Plus size={18} color={theme.colors.primary}/>}>Add Card</Button> <Button mode="text"
onPress={() => navigation.navigate('add-card')}
icon={() => <Plus size={18} color={theme.colors.primary}/>}>Add Card</Button>
</View> </View>
) )
} }
@@ -64,8 +76,15 @@ export default function CardManager({navigation}) {
style={{padding: 16, paddingTop: 24}} style={{padding: 16, paddingTop: 24}}
data={cards} data={cards}
keyExtractor={i => i._id.toString()} keyExtractor={i => i._id.toString()}
contentContainerStyle={{paddingBottom: 40}} contentContainerStyle={{paddingBottom: 120}}
renderItem={i => <CardEntry card={i.item} navigation={navigation}/>}
ItemSeparatorComponent={() => <View style={{height: 15}}/>} ItemSeparatorComponent={() => <View style={{height: 15}}/>}
renderItem={i => <CardEntry card={i.item}/>}/> ListFooterComponent={() => Platform.OS === "ios" ? null : (
<Button mode="text"
onPress={() => navigation.navigate('add-card')}
style={{marginTop: 15, marginBottom: 25}}
icon={() => <Plus size={18} color={theme.colors.primary}/>}>Add Card</Button>
)}
/>
) )
} }
+2 -2
View File
@@ -1,6 +1,6 @@
import {Plus} from "lucide-react-native"; import {Plus} from "lucide-react-native";
import {TouchableOpacity} from "react-native";
import {useTheme} from "@react-navigation/native"; import {useTheme} from "@react-navigation/native";
import {Platform, TouchableOpacity} from "react-native";
import {createNativeStackNavigator} from "@react-navigation/native-stack"; import {createNativeStackNavigator} from "@react-navigation/native-stack";
import CardManager from "./CardManager"; import CardManager from "./CardManager";
@@ -22,7 +22,7 @@ export default function CardManagerStack({navigation}) {
component={CardManager} component={CardManager}
options={{ options={{
title: "Cards", title: "Cards",
headerRight: () => <TouchableOpacity onPress={() => navigation.navigate('add-card')}><Plus/></TouchableOpacity> headerRight: () => Platform.OS === 'android' ? null : <TouchableOpacity onPress={() => navigation.navigate('add-card')}><Plus/></TouchableOpacity>
}}/> }}/>
</Stack.Navigator> </Stack.Navigator>
); );
+10 -8
View File
@@ -1,7 +1,7 @@
import {useModalStyles} from "../utils/ModalStyles"; import {useModalStyles} from "../utils/ModalStyles";
import {Card, Paragraph, useTheme} from "react-native-paper"; import {Card, Paragraph, useTheme} from "react-native-paper";
import {TextInput, TouchableOpacity, View} from "react-native";
import {useCallback, useEffect, useLayoutEffect, useState} from "react"; import {useCallback, useEffect, useLayoutEffect, useState} from "react";
import {Platform, TextInput, TouchableOpacity, View} from "react-native";
import RealmContext from "../models/RealmContext"; import RealmContext from "../models/RealmContext";
import {AMServer} from "../models/AMServer"; import {AMServer} from "../models/AMServer";
@@ -20,13 +20,15 @@ export default function ServerAddModal({route, navigation}) {
handleDeepLink(realm, navigation, route, setServerAddress); handleDeepLink(realm, navigation, route, setServerAddress);
}, []); }, []);
useLayoutEffect(() => { if (Platform.OS === 'ios') {
navigation.setOptions({ useLayoutEffect(() => {
headerLeft: () => <TouchableOpacity onPress={() => navigation.goBack()}> navigation.setOptions({
<Paragraph style={modalStyles.headerText}>Cancel</Paragraph> headerLeft: () => <TouchableOpacity onPress={() => navigation.goBack()}>
</TouchableOpacity>, <Paragraph style={modalStyles.headerText}>Cancel</Paragraph>
}); </TouchableOpacity>,
}, [navigation, modalStyles]); });
}, [navigation, modalStyles]);
}
useLayoutEffect(() => { useLayoutEffect(() => {
let urlValid = false; let urlValid = false;
+208 -133
View File
@@ -1,16 +1,46 @@
import {BSON} from "realm"; import {BSON} from "realm";
import {fetchServerInfo, performCardIn} from "amnet-shared";
import React, {useCallback, useEffect, useLayoutEffect, useMemo, useState} from "react"; import React, {useCallback, useEffect, useLayoutEffect, useMemo, useState} from "react";
import {ActivityIndicator, Button, Card, Paragraph, Title, useTheme} from "react-native-paper"; import {ActivityIndicator, Button, Paragraph, Title, useTheme} from "react-native-paper";
import {Alert, RefreshControl, ScrollView, Share, StyleSheet, TouchableOpacity, View} from "react-native"; import {
import {CircleCheck, CircleDot, CreditCard, Nfc, Plus, SmartphoneNfcIcon, WifiOff, Share as ShareIcon} from "lucide-react-native"; Alert,
Platform,
RefreshControl,
ScrollView,
Share,
StyleSheet,
Text,
TouchableOpacity,
View
} from "react-native";
import {
CircleCheck,
CircleDot,
CreditCard,
Nfc,
Plus,
SmartphoneNfcIcon,
Share as ShareIcon,
WifiOff,
TvMinimalPlay,
Star
} from "lucide-react-native";
import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder"; import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder";
import {AMUIStatusAlert} from "../components/AMUIStatusAlert";
import {AMUICardDetail} from "../components/AMUICard";
import {AimeCard} from "../components/AimeCard"; import {AimeCard} from "../components/AimeCard";
import RealmContext from "../models/RealmContext"; import RealmContext from "../models/RealmContext";
import {AMServer} from "../models/AMServer"; import {AMServer} from "../models/AMServer";
import {AMCard} from "../models/AMCard"; import {AMCard} from "../models/AMCard";
import ObjectId = BSON.ObjectId; import ObjectId = BSON.ObjectId;
import {
AMServerConnectionInfo,
AMServerInstanceMetrics,
fetchServerInfo,
getGameName,
performCardIn
} from "amnet-shared";
const {useRealm, useQuery, useObject} = RealmContext; const {useRealm, useQuery, useObject} = RealmContext;
@@ -18,110 +48,116 @@ export default function ServerCardIn({route, navigation}) {
const realm = useRealm(); const realm = useRealm();
const theme = useTheme(); const theme = useTheme();
// card listing state let {serverId} = route.params;
const query = useQuery(AMCard); const server = useObject(AMServer, ObjectId.createFromHexString(serverId));
const cards = useMemo(() => query.sorted("lastUsed", true), [query]); const cards = useQuery({
type: AMCard,
const [selectedCard, setSelectedCard] = useState<AMCard>(null); query: q => q.sorted("lastUsed", true)
const [cardInCompleted, setCardInCompleted] = useState<boolean>(null); });
const unselectedCards = useMemo(() => {
// protect against selected card being removed
switch (selectedCard?.isValid()) {
case true:
return cards.filter(x => !x._id.equals(selectedCard._id));
// clear out invalid
case false:
setSelectedCard(null);
return cards;
default:
return cards;
}
}, [cards, selectedCard]);
// server state // server state
const [serverRefreshing, setServerRefreshing] = useState<boolean>(false); const [serverRefreshing, setServerRefreshing] = useState<boolean>(false);
const [serverReloadCounter, setServerReloadCounter] = useState<number>(0); const [serverReloadCounter, setServerReloadCounter] = useState<number>(0);
const [connectionTestResult, setConnectionTestResult] = useState<boolean | null>(null); const [serverConnectionSuccess, setServerConnectionSuccess] = useState<boolean | null>(null);
let {serverId} = route.params; const [userSelectedCard, setUserSelectedCard] = useState<AMCard>(null);
const server = useObject(AMServer, ObjectId.createFromHexString(serverId)); const [cardInCompleted, setCardInCompleted] = useState<boolean>(null);
const disableCardIn = useMemo(() => cardInCompleted !== null || !serverConnectionSuccess, [cardInCompleted, serverConnectionSuccess]);
const serverDefaultCard = useMemo(() => {
if (server?.isValid() !== true || !server.gameId) {
return null;
}
return cards.find(x => x.lastUsedGameId === server.gameId);
}, [cards, server?.gameId]);
const selectedCard = useMemo(() => [userSelectedCard, serverDefaultCard, cards.at(0)].find(x => x?.isValid() == true) ?? null, [userSelectedCard, serverDefaultCard, cards]);
const unselectedCards = useMemo(() => cards.filter(x => x.isValid() === true && x._id.toHexString() !== selectedCard?._id.toHexString()), [cards, selectedCard]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!server) { if (server?.isValid() !== true) {
return; return;
} }
navigation.setOptions({ navigation.setOptions({
title: server.serverName, title: server.serverName,
headerRight: () => ( headerRight: () => (
<TouchableOpacity onPress={() => Share.share({url: `http://card.ppc.moe/#/servers/add?address=${encodeURIComponent(server.serverAddress)}`})}> <TouchableOpacity onPress={() => Share.share({url: server.shareUrl})}>
<ShareIcon/> <ShareIcon color={theme.colors.primary}/>
</TouchableOpacity> </TouchableOpacity>
) )
}); });
}, [navigation, server]); }, [navigation, server]);
// set selected card (once) // invalid card/server selection
useEffect(() => {
if (selectedCard?.isValid() === true) {
return;
}
setSelectedCard(cards.length ? cards[0] : null);
}, [realm]);
// refresh server info (server change or reload request)
useEffect(() => { useEffect(() => {
if (server?.isValid() !== true) { if (server?.isValid() !== true) {
navigation.goBack(); navigation.goBack();
return;
}
}, [server]);
// don't show error on invalid server, just means it was deleted useEffect(() => {
if (!server) { if (userSelectedCard?.isValid() === false) {
Alert.alert("Server not found", "The server you are looking for does not exist."); setUserSelectedCard(null);
} }
}, [userSelectedCard]);
// handle auto-refresh
useEffect(() => {
if (!serverConnectionSuccess || server?.isValid() !== true) {
return; return;
} }
const metrics = server as AMServerInstanceMetrics;
if (metrics.sessionUptime > 0 && metrics.timeSinceLastPoll === null) {
const timeout = setTimeout(() => setServerReloadCounter(serverReloadCounter + 1), 5000);
return () => clearTimeout(timeout);
}
}, [serverConnectionSuccess, server]);
// server info update
useEffect(() => {
const abortController = new AbortController(); const abortController = new AbortController();
fetchServerInfo(server.serverAddress, abortController.signal).then(r => { fetchServerInfo(server.serverAddress, abortController.signal).then(r => {
if (r) { if (!r) {
setConnectionTestResult(true); setServerConnectionSuccess(false);
realm.write(() => { return;
server.lastUsed = new Date();
Object.keys(r).forEach(x => server[x] = r[x]);
});
} else {
setConnectionTestResult(false);
} }
setServerConnectionSuccess(true);
realm.write(() => {
server.lastUsed = new Date();
Object.keys(r).forEach(x => server[x] = r[x]);
});
}).catch(() => { }).catch(() => {
setConnectionTestResult(false); setServerConnectionSuccess(false);
}).finally(() => { }).finally(() => {
setServerRefreshing(false); setServerRefreshing(false);
}); });
setTimeout(() => abortController.abort(), 5000); const timeout = setTimeout(() => abortController.abort(), 5000);
return () => abortController.abort(); return () => {
clearTimeout(timeout);
abortController.abort();
}
}, [serverId, serverReloadCounter, realm]); }, [serverId, serverReloadCounter, realm]);
// card in callback
const cardInCallback = useCallback(() => { const cardInCallback = useCallback(() => {
setCardInCompleted(false); setCardInCompleted(false);
const abortController = new AbortController(); const abortController = new AbortController();
performCardIn(server.serverAddress, {cardId: selectedCard.code}, abortController.signal).then(async r => { performCardIn(server, {cardId: selectedCard.code}, abortController.signal).then(async r => {
const now = new Date(); const now = new Date();
setCardInCompleted(r);
realm.write(() => { realm.write(() => {
server.lastUsed = now; server.lastUsed = now;
selectedCard.lastUsed = now; selectedCard.lastUsed = now;
selectedCard.lastUsedGameId = server.gameId;
}); });
setSelectedCard(selectedCard); setCardInCompleted(r);
}).catch(() => { }).catch(() => {
setCardInCompleted(false); setCardInCompleted(false);
}).finally(() => { }).finally(() => {
@@ -132,9 +168,16 @@ export default function ServerCardIn({route, navigation}) {
return () => abortController.abort(); return () => abortController.abort();
}, [server, selectedCard]); }, [server, selectedCard]);
const disableCardIn = useMemo(() => cardInCompleted !== null || !connectionTestResult, [cardInCompleted, connectionTestResult]); const performRefresh = () => {
setServerRefreshing(true);
setServerReloadCounter(serverReloadCounter + 1);
};
if (connectionTestResult === null) { if (server?.isValid() !== true) {
return null;
}
if (serverConnectionSuccess === null) {
return ( return (
<View style={{flex: 1, justifyContent: "center", alignItems: "center", gap: 10}}> <View style={{flex: 1, justifyContent: "center", alignItems: "center", gap: 10}}>
<ActivityIndicator animating/> <ActivityIndicator animating/>
@@ -144,94 +187,126 @@ export default function ServerCardIn({route, navigation}) {
} }
return ( return (
<ScrollView contentInsetAdjustmentBehavior="automatic" <ScrollView contentInsetAdjustmentBehavior="automatic" refreshControl={<RefreshControl refreshing={serverRefreshing} onRefresh={performRefresh}/>}>
contentContainerStyle={{...styles.container, paddingTop: connectionTestResult ? 0 : 10}} <View style={styles.container}>
refreshControl={<RefreshControl refreshing={serverRefreshing} <StatusAlert server={server} connectionEstablished={serverConnectionSuccess}/>
tintColor={theme.colors.onSurface} <View style={styles.section}>
onRefresh={() => { <Title style={styles.sectionTitle}>{server.gameId ? `Play ${getGameName(server.gameId)}` : "Selected Card"}</Title>
setServerRefreshing(true); {selectedCard ? <>
setServerReloadCounter(serverReloadCounter + 1); <AimeCard actionDisabled
}}/>}> card={selectedCard}
{!connectionTestResult && ( actionIcon={<CircleCheck color="green" size={24}/>}>
<Card mode="outlined" style={{borderColor: theme.colors.secondary}}> {serverDefaultCard?.code == selectedCard.code && <DefaultCardDetail/>}
<Card.Content> </AimeCard>
<View style={{alignItems: "center", flexDirection: "row", gap: 10}}> <Button textColor="white"
<WifiOff color={theme.colors.secondary} size={24}/> onPress={cardInCallback}
<Title style={{color: theme.colors.secondary}}>Connection Failed</Title> disabled={disableCardIn}
</View> contentStyle={{flexDirection: 'row-reverse'}}
<Paragraph> mode={serverConnectionSuccess ? "contained" : "outlined"}
There was an issue connecting to <Paragraph buttonColor={cardInCompleted === false ? "red" : "green"}
style={{fontFamily: "JetBrainsMono_400Regular"}}>{server.serverAddress}</Paragraph>. icon={() => <Nfc color={disableCardIn ? "gray" : "white"} size={18}/>}>
Check the network connection and try again. {cardInCompleted === null ? "Card In" : cardInCompleted ? "Card In Success" : "Failed"}
</Paragraph> </Button>
</Card.Content> </>
</Card> : <View style={styles.emptyPlaceholderView}>
)} <EmptyStatePlaceholder message="No card selected" icon={<CreditCard color="gray"/>}/>
<Button mode="text"
icon={() => <Plus color={theme.colors.primary} size={18}/>}
onPress={() => navigation.navigate('add-card')}>
Add card
</Button>
</View>}
</View>
<View style={styles.section}> {cards.length > 0 && (
<Title style={{fontWeight: "bold"}}>Selected Card</Title> <View style={styles.section}>
{selectedCard ? <> <Title style={styles.sectionTitle}>Additional Cards</Title>
<AimeCard actionDisabled <Button mode="contained"
card={selectedCard} textColor="white"
actionIcon={<CircleCheck color="green" size={24}/>}/> buttonColor={theme.colors.tertiary}
<Button textColor="white" icon={() => <Plus color="white" size={18}/>}
onPress={cardInCallback}
disabled={disableCardIn}
contentStyle={{flexDirection: 'row-reverse'}} contentStyle={{flexDirection: 'row-reverse'}}
mode={connectionTestResult ? "contained" : "outlined"}
buttonColor={cardInCompleted === false ? "red" : "green"}
icon={() => <Nfc color={disableCardIn ? "gray" : "white"} size={18}/>}>
{cardInCompleted === null ? "Card In" : cardInCompleted ? "Card In Success" : "Failed"}
</Button>
</>
: <View style={styles.emptyPlaceholderView}>
<EmptyStatePlaceholder message="No card selected" icon={<CreditCard color="gray"/>}/>
<Button mode="text"
icon={() => <Plus color={theme.colors.primary} size={18}/>}
onPress={() => navigation.navigate('add-card')}> onPress={() => navigation.navigate('add-card')}>
Add card Add card
</Button> </Button>
</View>}
{unselectedCards.length
? unselectedCards.map((card: AMCard) => (
<AimeCard card={card}
key={card._id.toHexString()}
actionIcon={<CircleDot color={theme.colors.tertiary} size={24}/>}
onPress={() => setUserSelectedCard(card)}>
{serverDefaultCard?.code == card.code && <DefaultCardDetail/>}
</AimeCard>))
: <View style={styles.emptyPlaceholderView}>
<EmptyStatePlaceholder message="No additional cards"
icon={<SmartphoneNfcIcon color="gray"/>}/>
</View>}
</View>)}
</View> </View>
{cards.length > 0 && (
<View style={styles.section}>
<Title style={{fontWeight: "bold"}}>Additional Cards</Title>
<Button mode="contained"
textColor="white"
buttonColor={theme.colors.tertiary}
icon={() => <Plus color="white" size={18}/>}
contentStyle={{flexDirection: 'row-reverse'}}
onPress={() => navigation.navigate('add-card')}>
Add card
</Button>
{unselectedCards.length
? unselectedCards.map(card => (
<AimeCard card={card}
key={card._id.toHexString()}
actionIcon={<CircleDot color={theme.colors.tertiary} size={24}/>}
onPress={() => setSelectedCard(card)}/>))
: <View style={styles.emptyPlaceholderView}>
<EmptyStatePlaceholder message="No additional cards"
icon={<SmartphoneNfcIcon color="gray"/>}/>
</View>}
</View>)}
</ScrollView> </ScrollView>
) )
} }
function StatusAlert(props: {
server: AMServerConnectionInfo & Partial<AMServerInstanceMetrics>,
connectionEstablished: boolean
}) {
if (!props.connectionEstablished) {
return (
<AMUIStatusAlert color="red" icon={WifiOff} title="Connection Failed">
<Paragraph>
There was an issue connecting to <Paragraph
style={{fontFamily: "JetBrainsMono_400Regular"}}>{props.server.serverAddress}</Paragraph>.
Check the network connection and try again.
</Paragraph>
</AMUIStatusAlert>
)
}
// don't show if undefined (some older server versions)
if (props.server.timeSinceLastPoll === null) {
return (
<AMUIStatusAlert color="#822eff" icon={TvMinimalPlay} title="Server Startup">
<Paragraph>
Scanning a card below may not have any effect until the service has fully started up.
</Paragraph>
</AMUIStatusAlert>
)
}
return null;
}
function DefaultCardDetail() {
return (
<>
{/* spacer */}
<View></View>
<AMUICardDetail>
<Star size={18} color="goldenrod"/>
<Text style={{color: "goldenrod"}}>Previously used with this game</Text>
</AMUICardDetail>
</>
)
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
section: { section: {
gap: 10 gap: 10
}, },
sectionTitle: {
fontWeight: "bold"
},
emptyPlaceholderView: { emptyPlaceholderView: {
marginVertical: 30, marginVertical: 30,
gap: 15 gap: 15
}, },
container: { container: {
paddingTop: Platform.OS === 'android' ? 120 : 0,
paddingHorizontal: 16, paddingHorizontal: 16,
paddingBottom: 25, paddingBottom: 80,
gap: 20 gap: 20
} }
}) });
+41 -15
View File
@@ -1,7 +1,7 @@
import {Button, Paragraph, useTheme} from "react-native-paper"; import {Button, Paragraph, useTheme} from "react-native-paper";
import {Globe, History, Plus, ServerOff} from "lucide-react-native";
import {FlatList, NativeSyntheticEvent, Text, View} from "react-native";
import React, {useCallback, useLayoutEffect, useMemo, useState} from "react"; import React, {useCallback, useLayoutEffect, useMemo, useState} from "react";
import {Globe, History, Joystick, Plus, ServerOff} from "lucide-react-native";
import {FlatList, NativeSyntheticEvent, Platform, ScrollView, Share, View} from "react-native";
import ContextMenu, {ContextMenuOnPressNativeEvent} from "react-native-context-menu-view"; import ContextMenu, {ContextMenuOnPressNativeEvent} from "react-native-context-menu-view";
import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder"; import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder";
import {AMUICard, AMUICardDetail} from "../components/AMUICard"; import {AMUICard, AMUICardDetail} from "../components/AMUICard";
@@ -9,11 +9,14 @@ import TimeAgo from "../components/TimeAgo";
import RealmContext from "../models/RealmContext"; import RealmContext from "../models/RealmContext";
import {AMServer} from "../models/AMServer"; import {AMServer} from "../models/AMServer";
import {getGameName} from "amnet-shared";
const {useRealm, useQuery} = RealmContext; const {useRealm, useQuery} = RealmContext;
function ServerCard(props: { server: AMServer, navigation: any }) { function ServerCard(props: { server: AMServer, navigation: any }) {
const realm = useRealm(); const realm = useRealm();
const gameName = useMemo(() => getGameName(props.server.gameId), [props.server.gameId]);
const pageCallback = useCallback(() => { const pageCallback = useCallback(() => {
props.navigation.navigate('server', { props.navigation.navigate('server', {
serverId: props.server._id.toHexString() serverId: props.server._id.toHexString()
@@ -21,31 +24,46 @@ function ServerCard(props: { server: AMServer, navigation: any }) {
}, [props.navigation, props.server]); }, [props.navigation, props.server]);
const pageContextMenuCallback = useCallback((action: NativeSyntheticEvent<ContextMenuOnPressNativeEvent>) => { const pageContextMenuCallback = useCallback((action: NativeSyntheticEvent<ContextMenuOnPressNativeEvent>) => {
switch (action.nativeEvent.index) { switch (action.nativeEvent.indexPath.toString()) {
case 0: case "0":
pageCallback(); pageCallback();
break; return;
case 1: case "1,0":
realm.write(() => { Share.share({url: props.server.shareUrl});
realm.delete(props.server); return;
});
break; case "1,1":
realm.write(() => realm.delete(props.server));
return;
} }
}, [realm, props.server]); }, [realm, props.server]);
return ( return (
<ContextMenu onPress={pageContextMenuCallback} actions={[ <ContextMenu onPress={pageContextMenuCallback} actions={[
{title: `Select ${props.server.serverName}`, systemIcon: "chevron.right"}, {title: `Select ${props.server.serverName}`, systemIcon: "chevron.right"},
{title: "Delete Server", systemIcon: "trash", destructive: true} {
title: Platform.OS === "ios" ? "" : "Additional Actions",
inlineChildren: true,
actions: [
{title: "Share Server", systemIcon: "square.and.arrow.up"},
{title: "Delete Server", systemIcon: "trash", destructive: true}
]
}
]}> ]}>
<AMUICard title={props.server.serverName} onPress={pageCallback}> <AMUICard title={props.server.serverName} onPress={pageCallback}>
<AMUICardDetail> <AMUICardDetail>
<Globe size={18} color="gray"/> <Globe size={18} color="gray"/>
<Text style={{color: "gray", fontFamily: "JetBrainsMono_400Regular"}}> <Paragraph style={{color: "gray", fontFamily: "JetBrainsMono_400Regular"}}>
{props.server.serverAddress} {props.server.serverAddress}
</Text> </Paragraph>
</AMUICardDetail> </AMUICardDetail>
{gameName && (
<AMUICardDetail>
<Joystick size={18} color="gray"/>
<Paragraph style={{color: "gray"}}>{gameName}</Paragraph>
</AMUICardDetail>
)}
<AMUICardDetail> <AMUICardDetail>
<History size={18} color="gray"/> <History size={18} color="gray"/>
{props.server.lastUsed {props.server.lastUsed
@@ -80,6 +98,8 @@ export default function ServerManager({navigation}) {
value: searchQuery, value: searchQuery,
autoCorrect: "none", autoCorrect: "none",
autoCapitalize: "none", autoCapitalize: "none",
textColor: theme.colors.onSurface,
headerIconColor: theme.colors.primary,
onChangeText: e => setSearchQuery(e.nativeEvent.text) onChangeText: e => setSearchQuery(e.nativeEvent.text)
}, },
}) })
@@ -102,8 +122,14 @@ export default function ServerManager({navigation}) {
data={servers} data={servers}
keyExtractor={i => i._id.toString()} keyExtractor={i => i._id.toString()}
style={{padding: 16, paddingTop: 24}} style={{padding: 16, paddingTop: 24}}
contentContainerStyle={{paddingBottom: 40}} contentContainerStyle={{paddingBottom: 80}}
renderItem={i => <ServerCard navigation={navigation} server={i.item}/>}
ItemSeparatorComponent={() => <View style={{height: 15}}/>} ItemSeparatorComponent={() => <View style={{height: 15}}/>}
renderItem={i => <ServerCard navigation={navigation} server={i.item}/>}/> ListFooterComponent={() => Platform.OS === "ios" ? null : (
<Button mode="text"
onPress={() => navigation.navigate('add-server')}
style={{marginTop: 15, marginBottom: 25}}
icon={() => <Plus size={18} color={theme.colors.primary}/>}>Add Server</Button>
)}/>
) )
} }
+2 -2
View File
@@ -1,6 +1,6 @@
import {Plus} from "lucide-react-native"; import {Plus} from "lucide-react-native";
import {TouchableOpacity} from "react-native";
import {useTheme} from "@react-navigation/native"; import {useTheme} from "@react-navigation/native";
import {Platform, TouchableOpacity} from "react-native";
import {createNativeStackNavigator} from "@react-navigation/native-stack"; import {createNativeStackNavigator} from "@react-navigation/native-stack";
import ServerCardIn from "./ServerCardIn"; import ServerCardIn from "./ServerCardIn";
@@ -24,7 +24,7 @@ export default function ServerManagerStack({navigation}) {
component={ServerManager} component={ServerManager}
options={{ options={{
title: "Servers", title: "Servers",
headerRight: () => <TouchableOpacity headerRight: () => Platform.OS === 'android' ? null : <TouchableOpacity
onPress={() => navigation.navigate('add-server')}> onPress={() => navigation.navigate('add-server')}>
<Plus/> <Plus/>
</TouchableOpacity> </TouchableOpacity>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

@@ -0,0 +1,22 @@
import {View} from "react-native";
import React, {ReactNode} from "react";
import {Card, Title} from "react-native-paper";
import {LucideIcon, LucideProps} from "lucide-react-native";
export function AMUIStatusAlert(props: { title: string, color: string, icon: LucideIcon, children?: ReactNode }) {
return (
<Card mode="outlined" style={{borderColor: props.color}}>
<Card.Content>
<View style={{alignItems: "center", flexDirection: "row", gap: 10}}>
{renderIconWithProps(props.icon, {size: 22, color: props.color})}
<Title style={{color: props.color}}>{props.title}</Title>
</View>
{props.children}
</Card.Content>
</Card>
);
}
function renderIconWithProps(icon: LucideIcon, additionalProps: Partial<LucideProps>) {
return React.cloneElement(React.createElement(icon), additionalProps);
}
+3 -1
View File
@@ -1,4 +1,4 @@
import {useMemo} from "react"; import {ReactNode, useMemo} from "react";
import {Text} from "react-native"; import {Text} from "react-native";
import {CreditCard, History} from "lucide-react-native"; import {CreditCard, History} from "lucide-react-native";
@@ -8,6 +8,7 @@ import {AMUICard, AMUICardActionProps, AMUICardDetail} from "./AMUICard";
export interface AimeCardProps extends AMUICardActionProps { export interface AimeCardProps extends AMUICardActionProps {
card: AMCardData, card: AMCardData,
children?: ReactNode,
showFullCardNumber?: boolean showFullCardNumber?: boolean
} }
@@ -29,6 +30,7 @@ export function AimeCard(props: AimeCardProps) {
<TimeAgo style={{color: "gray"}} dateTo={props.card.lastUsed}/> <TimeAgo style={{color: "gray"}} dateTo={props.card.lastUsed}/>
</AMUICardDetail> </AMUICardDetail>
)} )}
{props.children}
</AMUICard> </AMUICard>
); );
} }
+6 -4
View File
@@ -15,14 +15,15 @@ export class AMCard extends Realm.Object<AMCard> implements AMCardData {
name: string; name: string;
added: Date; added: Date;
lastUsed?: Date | null; lastUsed?: Date;
lastUsedGameId?: string | null;
static generate(code: string, name: string) { static generate(code: string, name: string) {
return { return {
_id: new Realm.BSON.ObjectId(), _id: new Realm.BSON.ObjectId(),
added: new Date(),
code: code, code: code,
name: name, name: name
added: new Date()
}; };
} }
@@ -35,7 +36,8 @@ export class AMCard extends Realm.Object<AMCard> implements AMCardData {
code: "string", code: "string",
name: {type: "string", default: "New Card"}, name: {type: "string", default: "New Card"},
added: {type: "date", default: new Date()}, added: {type: "date", default: new Date()},
lastUsed: {type: "date", optional: true} lastUsed: {type: "date", optional: true},
lastUsedGameId: {type: "string", optional: true}
} }
}; };
} }
+13 -5
View File
@@ -1,25 +1,32 @@
import {Realm} from 'realm'; import {Realm} from 'realm';
import {AMServerInfoResponse} from "amnet-shared"; import {AMServerConnectionInfo, AMServerMetadata} from "amnet-shared";
export class AMServer extends Realm.Object<AMServer> implements AMServerInfoResponse { export class AMServer extends Realm.Object<AMServer> implements AMServerConnectionInfo, AMServerMetadata {
_id!: Realm.BSON.ObjectId; _id!: Realm.BSON.ObjectId;
apiVersion: number;
gameId?: string;
serverName: string; serverName: string;
serverAddress: string; serverAddress: string;
lastUsed: Date; lastUsed: Date;
createdAt: Date; createdAt: Date;
apiVersion: number;
get shareUrl() {
return `http://card.ppc.moe/#/servers/add?address=${encodeURIComponent(this.serverAddress)}`;
}
static generate(serverAddress: string) { static generate(serverAddress: string) {
return { return {
_id: new Realm.BSON.ObjectId(), _id: new Realm.BSON.ObjectId(),
apiVersion: 0, apiVersion: 0,
serverAddress,
serverName: "New Server", serverName: "New Server",
serverAddress: serverAddress,
createdAt: new Date(), createdAt: new Date(),
lastUsed: null lastUsed: null
} }
} }
@@ -31,6 +38,7 @@ export class AMServer extends Realm.Object<AMServer> implements AMServerInfoResp
properties: { properties: {
_id: "objectId", _id: "objectId",
apiVersion: {type: "int", default: 0}, apiVersion: {type: "int", default: 0},
gameId: {type: "string", optional: true},
serverAddress: "string", serverAddress: "string",
serverName: {type: "string", default: "New Server"}, serverName: {type: "string", default: "New Server"},
createdAt: {type: "date", default: new Date()}, createdAt: {type: "date", default: new Date()},
+1 -1
View File
@@ -4,7 +4,7 @@ import {AMServer} from "./AMServer";
// @ts-ignore // @ts-ignore
export default createRealmContext({ export default createRealmContext({
schemaVersion: 1, schemaVersion: 3,
schema: [AMCard, AMServer], schema: [AMCard, AMServer],
deleteRealmIfMigrationNeeded: false deleteRealmIfMigrationNeeded: false
}); });
+4667 -4454
View File
File diff suppressed because it is too large Load Diff
+20 -19
View File
@@ -1,6 +1,6 @@
{ {
"name": "amnet-native", "name": "amnet-native",
"version": "1.0.0", "version": "1.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "expo run:android", "android": "expo run:android",
@@ -13,32 +13,33 @@
"@react-navigation/bottom-tabs": "^6.6.1", "@react-navigation/bottom-tabs": "^6.6.1",
"@react-navigation/native": "^6.1.18", "@react-navigation/native": "^6.1.18",
"@react-navigation/native-stack": "^6.11.0", "@react-navigation/native-stack": "^6.11.0",
"@realm/react": "^0.9.0", "@realm/react": "^0.11.0",
"@types/react": "~18.2.79", "@types/react": "~18.3.12",
"amnet-shared": "file:../amnet-shared", "amnet-shared": "file:../amnet-shared",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"expo": "^51.0.0", "expo": "^52.0.0",
"expo-blur": "~13.0.2", "expo-blur": "~14.0.1",
"expo-clipboard": "~6.0.3", "expo-build-properties": "^0.13.1",
"expo-constants": "~16.0.2", "expo-clipboard": "~7.0.0",
"expo-dev-client": "~4.0.10", "expo-constants": "~17.0.3",
"expo-linking": "~6.3.1", "expo-dev-client": "~5.0.4",
"expo-status-bar": "~1.12.1", "expo-linking": "~7.0.3",
"expo-system-ui": "~3.0.7", "expo-status-bar": "~2.0.0",
"expo-system-ui": "~4.0.4",
"lucide-react-native": "^0.427.0", "lucide-react-native": "^0.427.0",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",
"react": "18.2.0", "react": "18.3.1",
"react-native": "0.74.1", "react-native": "0.76.5",
"react-native-context-menu-view": "^1.16.0", "react-native-context-menu-view": "^1.16.0",
"react-native-gesture-handler": "^2.18.1", "react-native-gesture-handler": "~2.20.2",
"react-native-get-random-values": "^1.11.0", "react-native-get-random-values": "^1.11.0",
"react-native-paper": "^5.12.5", "react-native-paper": "^5.12.5",
"react-native-reanimated": "^3.15.0", "react-native-reanimated": "~3.16.1",
"react-native-safe-area-context": "4.10.5", "react-native-safe-area-context": "4.12.0",
"react-native-screens": "3.31.1", "react-native-screens": "~4.1.0",
"react-native-svg": "^15.5.0", "react-native-svg": "15.8.0",
"react-native-url-polyfill": "^2.0.0", "react-native-url-polyfill": "^2.0.0",
"realm": "^12.12.1", "realm": "^12.14.0",
"typescript": "~5.3.3" "typescript": "~5.3.3"
} }
} }
+36 -25
View File
@@ -2,28 +2,51 @@
* The latest version of the AMNet API this library supports * The latest version of the AMNet API this library supports
*/ */
declare const AMNET_API_VERSION = 1; declare const AMNET_API_VERSION = 1;
/**
* The minimum version of the AMNet API this library supports
*/
declare const MIN_AMNET_API_VERSION = 1;
interface AMCardInRequest { interface AMCardInRequest {
cardId: string; cardId: string;
} }
interface AMServerInfoResponse { /**
serverAddress: string; * Server connection info (api version, address, etc.)
serverName: string; */
interface AMServerConnectionInfo {
apiVersion: number; apiVersion: number;
gameId: string; serverAddress: string;
} }
/**
* User-definable server metadata
*/
interface AMServerMetadata {
serverName: string;
gameId?: string | null | undefined;
}
/**
* Metrics about the current game session
*/
interface AMServerInstanceMetrics {
timeSinceLastPoll?: number;
sessionUptime?: number;
}
/**
* The response from an AMNet server's info endpoint
*/
type AMServerInfo = AMServerMetadata & AMServerConnectionInfo & AMServerInstanceMetrics;
/** /**
* Performs the card-in request to the specified server * Performs the card-in request to the specified server
* @param serverAddress The origin address of the server to perform the request against * @param server The server to perform the request against
* @param request The card-in request to send * @param request The card-in request to send
* @param signal The abort signal to use for the request * @param signal The abort signal to use for the request
*/ */
declare function performCardIn(serverAddress: string, request: AMCardInRequest, signal: AbortSignal): Promise<boolean>; declare function performCardIn(server: AMServerConnectionInfo, request: AMCardInRequest, signal: AbortSignal): Promise<boolean>;
/** /**
* Attempts to connect to the specified server and fetch its info * Attempts to connect to the specified server and fetch its info
* @param serverAddress The origin address of the server to connect to * @param serverAddress The origin address of the server to connect to
* @param abort The abort signal to use for the request (for cancellation) * @param abort The abort signal to use for the request (for cancellation)
*/ */
declare function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise<AMServerInfoResponse | null>; declare function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise<AMServerInfo | null>;
declare const CARD_ID_REGEX: RegExp; declare const CARD_ID_REGEX: RegExp;
declare function generateCardNumber(): string; declare function generateCardNumber(): string;
@@ -33,22 +56,10 @@ declare function generateCardNumber(): string;
*/ */
declare function validateCardId(id: string): boolean; declare function validateCardId(id: string): boolean;
declare enum GameIds { /**
/** * Get the game name from the game identifier (4-letter code)
* Initial D The Arcade * @param id
*/ */
IDAC = "SDGT", declare function getGameName(id: string): string | null;
/**
* Chunithm (Paradise Lost)
*/
Chuni = "SDBT",
/**
* Chunithm NEW onwards
*/
Chusan = "SDHD",
ONGEKI = "SDDT",
maimaiDX = "SDEZ",
DivaFT = "SBZV"
}
export { type AMCardInRequest, AMNET_API_VERSION, type AMServerInfoResponse, CARD_ID_REGEX, GameIds, fetchServerInfo, generateCardNumber, performCardIn, validateCardId }; export { type AMCardInRequest, AMNET_API_VERSION, type AMServerConnectionInfo, type AMServerInfo, type AMServerInstanceMetrics, type AMServerMetadata, CARD_ID_REGEX, MIN_AMNET_API_VERSION, fetchServerInfo, generateCardNumber, getGameName, performCardIn, validateCardId };
+36 -25
View File
@@ -2,28 +2,51 @@
* The latest version of the AMNet API this library supports * The latest version of the AMNet API this library supports
*/ */
declare const AMNET_API_VERSION = 1; declare const AMNET_API_VERSION = 1;
/**
* The minimum version of the AMNet API this library supports
*/
declare const MIN_AMNET_API_VERSION = 1;
interface AMCardInRequest { interface AMCardInRequest {
cardId: string; cardId: string;
} }
interface AMServerInfoResponse { /**
serverAddress: string; * Server connection info (api version, address, etc.)
serverName: string; */
interface AMServerConnectionInfo {
apiVersion: number; apiVersion: number;
gameId: string; serverAddress: string;
} }
/**
* User-definable server metadata
*/
interface AMServerMetadata {
serverName: string;
gameId?: string | null | undefined;
}
/**
* Metrics about the current game session
*/
interface AMServerInstanceMetrics {
timeSinceLastPoll?: number;
sessionUptime?: number;
}
/**
* The response from an AMNet server's info endpoint
*/
type AMServerInfo = AMServerMetadata & AMServerConnectionInfo & AMServerInstanceMetrics;
/** /**
* Performs the card-in request to the specified server * Performs the card-in request to the specified server
* @param serverAddress The origin address of the server to perform the request against * @param server The server to perform the request against
* @param request The card-in request to send * @param request The card-in request to send
* @param signal The abort signal to use for the request * @param signal The abort signal to use for the request
*/ */
declare function performCardIn(serverAddress: string, request: AMCardInRequest, signal: AbortSignal): Promise<boolean>; declare function performCardIn(server: AMServerConnectionInfo, request: AMCardInRequest, signal: AbortSignal): Promise<boolean>;
/** /**
* Attempts to connect to the specified server and fetch its info * Attempts to connect to the specified server and fetch its info
* @param serverAddress The origin address of the server to connect to * @param serverAddress The origin address of the server to connect to
* @param abort The abort signal to use for the request (for cancellation) * @param abort The abort signal to use for the request (for cancellation)
*/ */
declare function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise<AMServerInfoResponse | null>; declare function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise<AMServerInfo | null>;
declare const CARD_ID_REGEX: RegExp; declare const CARD_ID_REGEX: RegExp;
declare function generateCardNumber(): string; declare function generateCardNumber(): string;
@@ -33,22 +56,10 @@ declare function generateCardNumber(): string;
*/ */
declare function validateCardId(id: string): boolean; declare function validateCardId(id: string): boolean;
declare enum GameIds { /**
/** * Get the game name from the game identifier (4-letter code)
* Initial D The Arcade * @param id
*/ */
IDAC = "SDGT", declare function getGameName(id: string): string | null;
/**
* Chunithm (Paradise Lost)
*/
Chuni = "SDBT",
/**
* Chunithm NEW onwards
*/
Chusan = "SDHD",
ONGEKI = "SDDT",
maimaiDX = "SDEZ",
DivaFT = "SBZV"
}
export { type AMCardInRequest, AMNET_API_VERSION, type AMServerInfoResponse, CARD_ID_REGEX, GameIds, fetchServerInfo, generateCardNumber, performCardIn, validateCardId }; export { type AMCardInRequest, AMNET_API_VERSION, type AMServerConnectionInfo, type AMServerInfo, type AMServerInstanceMetrics, type AMServerMetadata, CARD_ID_REGEX, MIN_AMNET_API_VERSION, fetchServerInfo, generateCardNumber, getGameName, performCardIn, validateCardId };
+30 -13
View File
@@ -59,9 +59,10 @@ var src_exports = {};
__export(src_exports, { __export(src_exports, {
AMNET_API_VERSION: () => AMNET_API_VERSION, AMNET_API_VERSION: () => AMNET_API_VERSION,
CARD_ID_REGEX: () => CARD_ID_REGEX, CARD_ID_REGEX: () => CARD_ID_REGEX,
GameIds: () => GameIds, MIN_AMNET_API_VERSION: () => MIN_AMNET_API_VERSION,
fetchServerInfo: () => fetchServerInfo, fetchServerInfo: () => fetchServerInfo,
generateCardNumber: () => generateCardNumber, generateCardNumber: () => generateCardNumber,
getGameName: () => getGameName,
performCardIn: () => performCardIn, performCardIn: () => performCardIn,
validateCardId: () => validateCardId validateCardId: () => validateCardId
}); });
@@ -69,10 +70,14 @@ module.exports = __toCommonJS(src_exports);
// src/api.ts // src/api.ts
var AMNET_API_VERSION = 1; var AMNET_API_VERSION = 1;
function performCardIn(serverAddress, request, signal) { var MIN_AMNET_API_VERSION = 1;
function performCardIn(server, request, signal) {
return __async(this, null, function* () { return __async(this, null, function* () {
if (server.apiVersion < MIN_AMNET_API_VERSION || server.apiVersion > AMNET_API_VERSION) {
throw new Error("Unsupported server API version");
}
try { try {
const response = yield fetch(`${new URL(serverAddress).origin}/amnet/signin`, { const response = yield fetch(`${new URL(server.serverAddress).origin}/amnet/signin`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json" "Content-Type": "application/json"
@@ -92,6 +97,9 @@ function fetchServerInfo(serverAddress, abort) {
const response = yield fetch(`${new URL(serverAddress).origin}/amnet/info`, { const response = yield fetch(`${new URL(serverAddress).origin}/amnet/info`, {
signal: abort signal: abort
}); });
if (!response.ok) {
return null;
}
return __spreadProps(__spreadValues({}, yield response.json()), { return __spreadProps(__spreadValues({}, yield response.json()), {
serverAddress serverAddress
}); });
@@ -122,22 +130,31 @@ function validateCardId(id) {
} }
// src/gameIds.ts // src/gameIds.ts
var GameIds = /* @__PURE__ */ ((GameIds2) => { function getGameName(id) {
GameIds2["IDAC"] = "SDGT"; switch (id == null ? void 0 : id.toUpperCase()) {
GameIds2["Chuni"] = "SDBT"; case "SDGT":
GameIds2["Chusan"] = "SDHD"; return "Initial D The Arcade";
GameIds2["ONGEKI"] = "SDDT"; case "SDHD":
GameIds2["maimaiDX"] = "SDEZ"; case "SDBT":
GameIds2["DivaFT"] = "SBZV"; return "CHUNITHM";
return GameIds2; case "SDDT":
})(GameIds || {}); return "O.N.G.E.K.I.";
case "SDEZ":
return "maimai DX";
case "SBZV":
return "Project DIVA Future Tone";
default:
return null;
}
}
// Annotate the CommonJS export names for ESM import in node: // Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = { 0 && (module.exports = {
AMNET_API_VERSION, AMNET_API_VERSION,
CARD_ID_REGEX, CARD_ID_REGEX,
GameIds, MIN_AMNET_API_VERSION,
fetchServerInfo, fetchServerInfo,
generateCardNumber, generateCardNumber,
getGameName,
performCardIn, performCardIn,
validateCardId validateCardId
}); });
+1 -1
View File
File diff suppressed because one or more lines are too long
+28 -12
View File
@@ -40,10 +40,14 @@ var __async = (__this, __arguments, generator) => {
// src/api.ts // src/api.ts
var AMNET_API_VERSION = 1; var AMNET_API_VERSION = 1;
function performCardIn(serverAddress, request, signal) { var MIN_AMNET_API_VERSION = 1;
function performCardIn(server, request, signal) {
return __async(this, null, function* () { return __async(this, null, function* () {
if (server.apiVersion < MIN_AMNET_API_VERSION || server.apiVersion > AMNET_API_VERSION) {
throw new Error("Unsupported server API version");
}
try { try {
const response = yield fetch(`${new URL(serverAddress).origin}/amnet/signin`, { const response = yield fetch(`${new URL(server.serverAddress).origin}/amnet/signin`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json" "Content-Type": "application/json"
@@ -63,6 +67,9 @@ function fetchServerInfo(serverAddress, abort) {
const response = yield fetch(`${new URL(serverAddress).origin}/amnet/info`, { const response = yield fetch(`${new URL(serverAddress).origin}/amnet/info`, {
signal: abort signal: abort
}); });
if (!response.ok) {
return null;
}
return __spreadProps(__spreadValues({}, yield response.json()), { return __spreadProps(__spreadValues({}, yield response.json()), {
serverAddress serverAddress
}); });
@@ -93,21 +100,30 @@ function validateCardId(id) {
} }
// src/gameIds.ts // src/gameIds.ts
var GameIds = /* @__PURE__ */ ((GameIds2) => { function getGameName(id) {
GameIds2["IDAC"] = "SDGT"; switch (id == null ? void 0 : id.toUpperCase()) {
GameIds2["Chuni"] = "SDBT"; case "SDGT":
GameIds2["Chusan"] = "SDHD"; return "Initial D The Arcade";
GameIds2["ONGEKI"] = "SDDT"; case "SDHD":
GameIds2["maimaiDX"] = "SDEZ"; case "SDBT":
GameIds2["DivaFT"] = "SBZV"; return "CHUNITHM";
return GameIds2; case "SDDT":
})(GameIds || {}); return "O.N.G.E.K.I.";
case "SDEZ":
return "maimai DX";
case "SBZV":
return "Project DIVA Future Tone";
default:
return null;
}
}
export { export {
AMNET_API_VERSION, AMNET_API_VERSION,
CARD_ID_REGEX, CARD_ID_REGEX,
GameIds, MIN_AMNET_API_VERSION,
fetchServerInfo, fetchServerInfo,
generateCardNumber, generateCardNumber,
getGameName,
performCardIn, performCardIn,
validateCardId validateCardId
}; };
+1 -1
View File
File diff suppressed because one or more lines are too long
+70 -70
View File
@@ -541,9 +541,9 @@
} }
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.21.0", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz",
"integrity": "sha512-WTWD8PfoSAJ+qL87lE7votj3syLavxunWhzCnx3XFxFiI/BA/r3X7MUM8dVrH8rb2r4AiO8jJsr3ZjdaftmnfA==", "integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -555,9 +555,9 @@
] ]
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.21.0", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz",
"integrity": "sha512-a1sR2zSK1B4eYkiZu17ZUZhmUQcKjk2/j9Me2IDjk1GHW7LB5Z35LEzj9iJch6gtUfsnvZs1ZNyDW2oZSThrkA==", "integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -569,9 +569,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.21.0", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz",
"integrity": "sha512-zOnKWLgDld/svhKO5PD9ozmL6roy5OQ5T4ThvdYZLpiOhEGY+dp2NwUmxK0Ld91LrbjrvtNAE0ERBwjqhZTRAA==", "integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -583,9 +583,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.21.0", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz",
"integrity": "sha512-7doS8br0xAkg48SKE2QNtMSFPFUlRdw9+votl27MvT46vo44ATBmdZdGysOevNELmZlfd+NEa0UYOA8f01WSrg==", "integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -597,9 +597,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.21.0", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz",
"integrity": "sha512-pWJsfQjNWNGsoCq53KjMtwdJDmh/6NubwQcz52aEwLEuvx08bzcy6tOUuawAOncPnxz/3siRtd8hiQ32G1y8VA==", "integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -611,9 +611,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.21.0", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz",
"integrity": "sha512-efRIANsz3UHZrnZXuEvxS9LoCOWMGD1rweciD6uJQIx2myN3a8Im1FafZBzh7zk1RJ6oKcR16dU3UPldaKd83w==", "integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -625,9 +625,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.21.0", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz",
"integrity": "sha512-ZrPhydkTVhyeGTW94WJ8pnl1uroqVHM3j3hjdquwAcWnmivjAwOYjTEAuEDeJvGX7xv3Z9GAvrBkEzCgHq9U1w==", "integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -639,9 +639,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.21.0", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz",
"integrity": "sha512-cfaupqd+UEFeURmqNP2eEvXqgbSox/LHOyN9/d2pSdV8xTrjdg3NgOFJCtc1vQ/jEke1qD0IejbBfxleBPHnPw==", "integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -653,9 +653,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": { "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
"version": "4.21.0", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz",
"integrity": "sha512-ZKPan1/RvAhrUylwBXC9t7B2hXdpb/ufeu22pG2psV7RN8roOfGurEghw1ySmX/CmDDHNTDDjY3lo9hRlgtaHg==", "integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -667,9 +667,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.21.0", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz",
"integrity": "sha512-H1eRaCwd5E8eS8leiS+o/NqMdljkcb1d6r2h4fKSsCXQilLKArq6WS7XBLDu80Yz+nMqHVFDquwcVrQmGr28rg==", "integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -681,9 +681,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.21.0", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz",
"integrity": "sha512-zJ4hA+3b5tu8u7L58CCSI0A9N1vkfwPhWd/puGXwtZlsB5bTkwDNW/+JCU84+3QYmKpLi+XvHdmrlwUwDA6kqw==", "integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -695,9 +695,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.21.0", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz",
"integrity": "sha512-e2hrvElFIh6kW/UNBQK/kzqMNY5mO+67YtEh9OA65RM5IJXYTWiXjX6fjIiPaqOkBthYF1EqgiZ6OXKcQsM0hg==", "integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -709,9 +709,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.21.0", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz",
"integrity": "sha512-1vvmgDdUSebVGXWX2lIcgRebqfQSff0hMEkLJyakQ9JQUbLDkEaMsPTLOmyccyC6IJ/l3FZuJbmrBw/u0A0uCQ==", "integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -723,9 +723,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.21.0", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz",
"integrity": "sha512-s5oFkZ/hFcrlAyBTONFY1TWndfyre1wOMwU+6KCpm/iatybvrRgmZVM+vCFwxmC5ZhdlgfE0N4XorsDpi7/4XQ==", "integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -737,9 +737,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.21.0", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz",
"integrity": "sha512-G9+TEqRnAA6nbpqyUqgTiopmnfgnMkR3kMukFBDsiyy23LZvUCpiUwjTRx6ezYCjJODXrh52rBR9oXvm+Fp5wg==", "integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -751,9 +751,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.21.0", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz",
"integrity": "sha512-2jsCDZwtQvRhejHLfZ1JY6w6kEuEtfF9nzYsZxzSlNVKDX+DpsDJ+Rbjkm74nvg2rdx0gwBS+IMdvwJuq3S9pQ==", "integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1423,9 +1423,9 @@
} }
}, },
"node_modules/micromatch": { "node_modules/micromatch": {
"version": "4.0.7", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1723,9 +1723,9 @@
} }
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.21.0", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.21.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz",
"integrity": "sha512-vo+S/lfA2lMS7rZ2Qoubi6I5hwZwzXeUIctILZLbHI+laNtvhhOIon2S1JksA5UEDQ7l3vberd0fxK44lTYjbQ==", "integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1739,22 +1739,22 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.21.0", "@rollup/rollup-android-arm-eabi": "4.22.4",
"@rollup/rollup-android-arm64": "4.21.0", "@rollup/rollup-android-arm64": "4.22.4",
"@rollup/rollup-darwin-arm64": "4.21.0", "@rollup/rollup-darwin-arm64": "4.22.4",
"@rollup/rollup-darwin-x64": "4.21.0", "@rollup/rollup-darwin-x64": "4.22.4",
"@rollup/rollup-linux-arm-gnueabihf": "4.21.0", "@rollup/rollup-linux-arm-gnueabihf": "4.22.4",
"@rollup/rollup-linux-arm-musleabihf": "4.21.0", "@rollup/rollup-linux-arm-musleabihf": "4.22.4",
"@rollup/rollup-linux-arm64-gnu": "4.21.0", "@rollup/rollup-linux-arm64-gnu": "4.22.4",
"@rollup/rollup-linux-arm64-musl": "4.21.0", "@rollup/rollup-linux-arm64-musl": "4.22.4",
"@rollup/rollup-linux-powerpc64le-gnu": "4.21.0", "@rollup/rollup-linux-powerpc64le-gnu": "4.22.4",
"@rollup/rollup-linux-riscv64-gnu": "4.21.0", "@rollup/rollup-linux-riscv64-gnu": "4.22.4",
"@rollup/rollup-linux-s390x-gnu": "4.21.0", "@rollup/rollup-linux-s390x-gnu": "4.22.4",
"@rollup/rollup-linux-x64-gnu": "4.21.0", "@rollup/rollup-linux-x64-gnu": "4.22.4",
"@rollup/rollup-linux-x64-musl": "4.21.0", "@rollup/rollup-linux-x64-musl": "4.22.4",
"@rollup/rollup-win32-arm64-msvc": "4.21.0", "@rollup/rollup-win32-arm64-msvc": "4.22.4",
"@rollup/rollup-win32-ia32-msvc": "4.21.0", "@rollup/rollup-win32-ia32-msvc": "4.22.4",
"@rollup/rollup-win32-x64-msvc": "4.21.0", "@rollup/rollup-win32-x64-msvc": "4.22.4",
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
+44 -9
View File
@@ -3,26 +3,57 @@
*/ */
export const AMNET_API_VERSION = 1; export const AMNET_API_VERSION = 1;
/**
* The minimum version of the AMNet API this library supports
*/
export const MIN_AMNET_API_VERSION = 1;
export interface AMCardInRequest { export interface AMCardInRequest {
cardId: string; cardId: string;
} }
export interface AMServerInfoResponse { /**
serverAddress: string; * Server connection info (api version, address, etc.)
serverName: string; */
export interface AMServerConnectionInfo {
apiVersion: number; apiVersion: number;
gameId: string; serverAddress: string;
} }
/**
* User-definable server metadata
*/
export interface AMServerMetadata {
serverName: string;
gameId?: string | null | undefined;
}
/**
* Metrics about the current game session
*/
export interface AMServerInstanceMetrics {
timeSinceLastPoll?: number;
sessionUptime?: number;
}
/**
* The response from an AMNet server's info endpoint
*/
export type AMServerInfo = AMServerMetadata & AMServerConnectionInfo & AMServerInstanceMetrics;
/** /**
* Performs the card-in request to the specified server * Performs the card-in request to the specified server
* @param serverAddress The origin address of the server to perform the request against * @param server The server to perform the request against
* @param request The card-in request to send * @param request The card-in request to send
* @param signal The abort signal to use for the request * @param signal The abort signal to use for the request
*/ */
export async function performCardIn(serverAddress: string, request: AMCardInRequest, signal: AbortSignal) { export async function performCardIn(server: AMServerConnectionInfo, request: AMCardInRequest, signal: AbortSignal): Promise<boolean> {
if (server.apiVersion < MIN_AMNET_API_VERSION || server.apiVersion > AMNET_API_VERSION) {
throw new Error("Unsupported server API version");
}
try { try {
const response = await fetch(`${new URL(serverAddress).origin}/amnet/signin`, { const response = await fetch(`${new URL(server.serverAddress).origin}/amnet/signin`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json" "Content-Type": "application/json"
@@ -42,16 +73,20 @@ export async function performCardIn(serverAddress: string, request: AMCardInRequ
* @param serverAddress The origin address of the server to connect to * @param serverAddress The origin address of the server to connect to
* @param abort The abort signal to use for the request (for cancellation) * @param abort The abort signal to use for the request (for cancellation)
*/ */
export async function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise<AMServerInfoResponse | null> { export async function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise<AMServerInfo | null> {
try { try {
const response = await fetch(`${new URL(serverAddress).origin}/amnet/info`, { const response = await fetch(`${new URL(serverAddress).origin}/amnet/info`, {
signal: abort signal: abort
}); });
if (!response.ok) {
return null;
}
return { return {
...await response.json(), ...await response.json(),
serverAddress: serverAddress serverAddress: serverAddress
} satisfies AMServerInfoResponse; } satisfies AMServerMetadata;
} catch { } catch {
return null; return null;
} }
+20 -18
View File
@@ -1,20 +1,22 @@
export enum GameIds { /**
/** * Get the game name from the game identifier (4-letter code)
* Initial D The Arcade * @param id
*/ */
IDAC = "SDGT", export function getGameName(id: string): string | null {
switch (id?.toUpperCase()) {
case "SDGT":
return "Initial D The Arcade";
case "SDHD":
case "SDBT":
return "CHUNITHM";
case "SDDT":
return "O.N.G.E.K.I.";
case "SDEZ":
return "maimai DX";
case "SBZV":
return "Project DIVA Future Tone";
/** default:
* Chunithm (Paradise Lost) return null;
*/ }
Chuni = "SDBT",
/**
* Chunithm NEW onwards
*/
Chusan = "SDHD",
ONGEKI = "SDDT",
maimaiDX = "SDEZ",
DivaFT = "SBZV"
} }
+666 -661
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -22,6 +22,7 @@
"amnet-shared": "file:../amnet-shared", "amnet-shared": "file:../amnet-shared",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"core-js": "^3.39.0",
"detectincognitojs": "^1.3.5", "detectincognitojs": "^1.3.5",
"dexie": "^4.0.8", "dexie": "^4.0.8",
"dexie-react-hooks": "^1.1.7", "dexie-react-hooks": "^1.1.7",
@@ -52,6 +53,6 @@
"tailwindcss": "^3.4.9", "tailwindcss": "^3.4.9",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"typescript-eslint": "^8.0.0", "typescript-eslint": "^8.0.0",
"vite": "^5.4.0" "vite": "^5.4.6"
} }
} }
@@ -0,0 +1,10 @@
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "moe.ppc.amnet",
"sha256_cert_fingerprints": ["9E:4C:42:6F:9E:27:85:62:E5:DB:75:23:60:4A:B9:8D:B5:39:C8:1D:AD:FE:8E:D3:DC:BA:6E:55:A9:C6:59:05"]
}
}
]
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"id": "?pwa-home=1", "id": "?pwa-home=1",
"name": "AimeNet", "name": "AMNet",
"description": "Aime Card Switcher", "description": "IC Card Switcher",
"start_url": "/", "start_url": "/",
"categories": [ "categories": [
"utilities", "utilities",
+8 -9
View File
@@ -8,20 +8,19 @@ import {AMCard, AMCardActionProps, AMCardDetail} from "@/components/am-card.tsx"
export function AimeCardEntry(props: AMCardActionProps & { card: AimeCard, children?: Readonly<React.ReactNode> }) { export function AimeCardEntry(props: AMCardActionProps & { card: AimeCard, children?: Readonly<React.ReactNode> }) {
const isDesktop = useMediaQuery("(min-width: 512px)"); const isDesktop = useMediaQuery("(min-width: 512px)");
return ( return (
<AMCard title={props.card.name} actionIcon={props.actionIcon} actionClicked={props.actionClicked} actionDisabled={props.actionDisabled}> <AMCard title={props.card.name} actionIcon={props.actionIcon} actionClicked={props.actionClicked}
actionDisabled={props.actionDisabled}>
{props.card.lastUsed > 0 && ( {props.card.lastUsed > 0 && (
<AMCardDetail> <AMCardDetail icon={History}>
<History className="h-5 w-5"/>
<TimeAgo date={props.card.lastUsed}/> <TimeAgo date={props.card.lastUsed}/>
</AMCardDetail> </AMCardDetail>
)} )}
<AMCardDetail> <AMCardDetail icon={CreditCard} iconClicked={() => navigator.clipboard.writeText(props.card.id)}>
<CreditCard className="h-5 w-5 cursor-pointer" onClick={() => navigator.clipboard.writeText(props.card.id)}/> <code>
<code>{isDesktop {isDesktop
? props.card.id.match(/.{1,4}/g)?.join(" ") ? props.card.id.match(/.{1,4}/g)?.join(" ")
: `•••• ${props.card.id.substring(props.card.id.length - 4)}`} : `•••• ${props.card.id.substring(props.card.id.length - 4)}`}
</code> </code>
</AMCardDetail> </AMCardDetail>
</AMCard> </AMCard>
+5 -3
View File
@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import {ChevronRight} from "lucide-react"; import {ChevronRight, LucideIcon} from "lucide-react";
import {Card} from "@/components/ui/card.tsx"; import {Card} from "@/components/ui/card.tsx";
import {Button} from "@/components/ui/button.tsx"; import {Button} from "@/components/ui/button.tsx";
@@ -18,7 +19,7 @@ export function AMCard(props: AMCardActionProps & { title: string, titlePerforms
onClick={() => props.titlePerformsAction && props.actionClicked ? props.actionClicked() : null}> onClick={() => props.titlePerformsAction && props.actionClicked ? props.actionClicked() : null}>
{props.title} {props.title}
</h4> </h4>
<div className="text-sm lg:text-md flex flex-auto flex-wrap gap-3"> <div className="text-sm lg:text-md flex flex-auto flex-wrap gap-4">
{props.children} {props.children}
</div> </div>
</div> </div>
@@ -32,9 +33,10 @@ export function AMCard(props: AMCardActionProps & { title: string, titlePerforms
) )
} }
export function AMCardDetail(props: { children?: Readonly<React.ReactNode> }) { export function AMCardDetail(props: { icon: LucideIcon, iconClicked?: () => void | Promise<any>, children?: Readonly<React.ReactNode> }) {
return ( return (
<div className="inline-flex items-center max-w-full gap-2 text-gray-500 select-none md:select-text"> <div className="inline-flex items-center max-w-full gap-2 text-gray-500 select-none md:select-text">
{React.createElement(props.icon, {className: "h-5 w-5", onClick: props.iconClicked})}
{props.children} {props.children}
</div> </div>
) )
+45
View File
@@ -0,0 +1,45 @@
import {useMemo} from "react";
import TimeAgo from "react-timeago";
import {getGameName} from "amnet-shared";
import {useNavigate} from "react-router-dom";
import {Earth, History, Joystick, Trash} from "lucide-react";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from "@/components/ui/context-menu.tsx";
import {AimeServer, db} from "@/lib/database.ts";
import {AMCard, AMCardDetail} from "@/components/am-card.tsx";
export function AMServer(props: { server: AimeServer }) {
const navigate: (p: string) => void = useNavigate();
const gameName = useMemo(() => props.server.gameId ? getGameName(props.server.gameId) : null, [props.server.gameId]);
return (
<ContextMenu key={props.server.id}>
<ContextMenuTrigger>
<AMCard title={props.server.serverName} titlePerformsAction actionClicked={() => navigate(`/servers/${props.server.id}`)}>
<AMCardDetail icon={Earth}>
<code className="text-ellipsis whitespace-nowrap overflow-hidden">{props.server.serverAddress}</code>
</AMCardDetail>
<AMCardDetail icon={History}>
{props.server.lastConnected > 0 ? <TimeAgo date={props.server.lastConnected}/> :
<span>never</span>}
</AMCardDetail>
{gameName && <AMCardDetail icon={Joystick}>{gameName}</AMCardDetail>}
</AMCard>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem inset onClick={() => navigate(`/servers/${props.server.id}`)}>
Select Server
</ContextMenuItem>
<ContextMenuSeparator/>
<ContextMenuItem className="text-red-500" onClick={() => db.servers.delete(props.server.id)}>
<Trash className="mr-2 h-4 w-4"/> Remove Server
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>)
}
+21 -9
View File
@@ -1,19 +1,31 @@
import * as React from "react" import * as React from "react";
export function useMediaQuery(query: string) { export function useMediaQuery(query: string) {
const [value, setValue] = React.useState(false) const [value, setValue] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
function onChange(event: MediaQueryListEvent) { function onChange(event: MediaQueryListEvent) {
setValue(event.matches) setValue(event.matches);
} }
const result = matchMedia(query) const result = matchMedia(query);
result.addEventListener("change", onChange)
setValue(result.matches)
return () => result.removeEventListener("change", onChange) // addEventListener isn't available on older versions of safari
}, [query]) if (result.addEventListener) {
result.addEventListener("change", onChange);
} else {
result.addListener(onChange);
}
setValue(result.matches);
return value return () => {
if (result.removeEventListener) {
result.removeEventListener("change", onChange);
} else {
result.removeListener(onChange);
}
};
}, [query]);
return value;
} }
+3 -3
View File
@@ -1,9 +1,9 @@
import Dexie, { type EntityTable } from 'dexie'; import Dexie, { type EntityTable } from 'dexie';
import {AMServerInfoResponse} from "amnet-shared"; import {AMServerConnectionInfo, AMServerMetadata} from "amnet-shared";
export const CARD_LIMIT = 15; export const CARD_LIMIT = 15;
interface AimeServer extends AMServerInfoResponse { interface AimeServer extends AMServerMetadata, AMServerConnectionInfo {
id: number; id: number;
lastConnected: number; lastConnected: number;
} }
@@ -19,7 +19,7 @@ const db = new Dexie('amnet_db') as Dexie & {
cards: EntityTable<AimeCard, 'id'>; cards: EntityTable<AimeCard, 'id'>;
}; };
db.version(1).stores({ db.version(2).stores({
servers: '++id, serverAddress', servers: '++id, serverAddress',
cards: 'id' cards: 'id'
}); });
+11 -1
View File
@@ -1,5 +1,10 @@
import './index.css' import './index.css'
// import polyfills from core-js for older browsers
import 'core-js/actual/promise';
import 'core-js/actual/symbol';
import 'core-js/actual/object/assign';
import {StrictMode} from "react"; import {StrictMode} from "react";
import {createRoot} from "react-dom/client"; import {createRoot} from "react-dom/client";
import {createHashRouter, Navigate, RouterProvider} from "react-router-dom"; import {createHashRouter, Navigate, RouterProvider} from "react-router-dom";
@@ -10,6 +15,7 @@ import {ThemeProvider} from "@/components/theme-provider.tsx";
import CardManager from "@/pages/card-manager.tsx"; import CardManager from "@/pages/card-manager.tsx";
import ServerCardIn from "@/pages/card-in-screen.tsx"; import ServerCardIn from "@/pages/card-in-screen.tsx";
import ServerListing from "@/pages/server-listing.tsx"; import ServerListing from "@/pages/server-listing.tsx";
import Privacy from "@/pages/privacy.tsx";
// iOS PWAs show the navigation bar when changing routes // iOS PWAs show the navigation bar when changing routes
// HashRouter doesn't have this issue as the URL never changes // HashRouter doesn't have this issue as the URL never changes
@@ -33,7 +39,11 @@ const router = createHashRouter([
path: x, path: x,
element: <CardManager/> element: <CardManager/>
}; };
}) }),
{
path: "/privacy",
element: <Privacy/>
}
]) ])
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
+42 -24
View File
@@ -1,10 +1,8 @@
import {useLiveQuery} from "dexie-react-hooks"; import {useLiveQuery} from "dexie-react-hooks";
import {useEffect, useMemo, useState} from "react"; import {useEffect, useMemo, useState} from "react";
import {useNavigate, useParams} from "react-router-dom"; import {useNavigate, useParams} from "react-router-dom";
import {CircleDot, CircleCheckBig, Home, Nfc, Wallet, WifiOff} from "lucide-react"; import {performCardIn, fetchServerInfo, getGameName, AMServerInfo} from "amnet-shared";
import {CircleDot, CircleCheckBig, Home, Nfc, Wallet, WifiOff, TvMinimalPlay} from "lucide-react";
import {AimeCard, AimeServer, db} from "@/lib/database";
import {performCardIn, fetchServerInfo} from "amnet-shared";
import {Button} from "@/components/ui/button"; import {Button} from "@/components/ui/button";
import {useToast} from "@/components/ui/use-toast"; import {useToast} from "@/components/ui/use-toast";
@@ -21,6 +19,8 @@ import {
AlertDialogTrigger AlertDialogTrigger
} from "@/components/ui/alert-dialog.tsx"; } from "@/components/ui/alert-dialog.tsx";
import {AimeCard, AimeServer, db} from "@/lib/database";
import {AMHeader} from "@/components/am-header"; import {AMHeader} from "@/components/am-header";
import {AMFooter} from "@/components/am-footer"; import {AMFooter} from "@/components/am-footer";
import {Separator} from "@/components/ui/separator"; import {Separator} from "@/components/ui/separator";
@@ -38,7 +38,7 @@ async function performCardInAction(card: AimeCard, server: AimeServer, setCardIn
const abort = new AbortController(); const abort = new AbortController();
setTimeout(() => abort.abort("timeout"), 10000); setTimeout(() => abort.abort("timeout"), 10000);
await performCardIn(server.serverAddress, {cardId: card.id}, abort.signal); await performCardIn(server, {cardId: card.id}, abort.signal);
await db.cards.update(card.id, {lastUsed: Date.now()}); await db.cards.update(card.id, {lastUsed: Date.now()});
toaster({ toaster({
@@ -60,8 +60,6 @@ async function performCardInAction(card: AimeCard, server: AimeServer, setCardIn
} }
} }
/* eslint-enable @typescript-eslint/no-explicit-any */
function CardList(props: { cards: ReadonlyArray<AimeCard> | null, cardSelected: (card: AimeCard) => void }) { function CardList(props: { cards: ReadonlyArray<AimeCard> | null, cardSelected: (card: AimeCard) => void }) {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -82,6 +80,39 @@ function CardList(props: { cards: ReadonlyArray<AimeCard> | null, cardSelected:
</>); </>);
} }
function AlertPanel(props: { server: AMServerInfo | null, connectionError: boolean, onReloadRequested: () => void}) {
if (!props.server) {
return <LoadingIndicator/>
}
if (props.connectionError) {
return <Alert variant="destructive">
<WifiOff className="h-4 w-4"/>
<AlertTitle>Connection Issue</AlertTitle>
<AlertDescription>
There was an issue connecting to <code
className="max-w-full text-wrap">{props.server.serverAddress}</code>.
Please check the game is running and <span className="underline cursor-pointer"
onClick={props.onReloadRequested}>try again</span>.
</AlertDescription>
</Alert>
}
if (props.server.timeSinceLastPoll === null) {
return <Alert variant="blank" className="text-violet-600 border-violet-600">
<TvMinimalPlay className="h-4 w-4"/>
<AlertTitle>Server Startup</AlertTitle>
<AlertDescription>
Scanning a card below may not have any effect until the service has fully started up. <span
className="underline cursor-pointer"
onClick={props.onReloadRequested}>reload this page</span> to perform another check.
</AlertDescription>
</Alert>
}
return <IncognitoStorageWarning/>
}
function ServerCardInPage() { function ServerCardInPage() {
const {toast} = useToast(); const {toast} = useToast();
const {serverId} = useParams<{ serverId: string }>(); const {serverId} = useParams<{ serverId: string }>();
@@ -98,6 +129,7 @@ function ServerCardInPage() {
const [connectionError, setConnectionError] = useState(false); const [connectionError, setConnectionError] = useState(false);
const shareUrl = useMemo(() => `${location.origin}/#/servers/add?address=${encodeURIComponent(server?.serverAddress ?? "")}`, [server]); const shareUrl = useMemo(() => `${location.origin}/#/servers/add?address=${encodeURIComponent(server?.serverAddress ?? "")}`, [server]);
const gameName = useMemo(() => server?.gameId ? getGameName(server.gameId) : null, [server]);
// select newest card // select newest card
useEffect(() => setSelectedCard(cards?.sort((a, b) => b.lastUsed - a.lastUsed)[0] ?? null), [cards]); useEffect(() => setSelectedCard(cards?.sort((a, b) => b.lastUsed - a.lastUsed)[0] ?? null), [cards]);
@@ -152,25 +184,12 @@ function ServerCardInPage() {
</Button> </Button>
</AMHeader> </AMHeader>
{!server && (<LoadingIndicator/>)} <AlertPanel server={server} connectionError={connectionError} onReloadRequested={() => setServerReloadCounter(serverReloadCounter + 1)}/>
{(server && !connectionError) && <IncognitoStorageWarning/>}
{(server && connectionError) && (
<Alert variant="destructive">
<WifiOff className="h-4 w-4"/>
<AlertTitle>Connection Issue</AlertTitle>
<AlertDescription>
There was an issue connecting to <code
className="max-w-full text-wrap">{server.serverAddress}</code>.
Please check the game is running and <span className="underline cursor-pointer"
onClick={() => setServerReloadCounter(serverReloadCounter + 1)}>try again</span>.
</AlertDescription>
</Alert>
)}
{server && ( {server && (
<div className="flex flex-col gap-4 w-full flex-grow"> <div className="flex flex-col gap-4 w-full flex-grow">
{selectedCard && (<> {selectedCard && (<>
<h4 className="text-2xl font-bold">Selected Card</h4> <h4 className="text-2xl font-bold">{gameName ? `Play ${gameName}` : "Selected Card"}</h4>
<AimeCardEntry card={selectedCard}> <AimeCardEntry card={selectedCard}>
<Button size="icon" variant="ghost" disabled> <Button size="icon" variant="ghost" disabled>
@@ -198,8 +217,7 @@ function ServerCardInPage() {
</div> </div>
)} )}
<AMFooter <AMFooter className="flex flex-col-reverse sm:flex-row sm:flex-wrap justify-center sm:justify-between sm:gap-2 gap-4">
className="flex flex-col-reverse sm:flex-row sm:flex-wrap justify-center sm:justify-between sm:gap-2 gap-4">
{server?.id && ( {server?.id && (
<div className="flex items-center justify-center sm:gap-5 gap-2"> <div className="flex items-center justify-center sm:gap-5 gap-2">
{location.protocol === "https:" && (<> {location.protocol === "https:" && (<>
+39
View File
@@ -0,0 +1,39 @@
// Google Play forces a Privacy Policy even for closed tests...
import {Server} from "lucide-react";
import {useNavigate} from "react-router-dom";
import {Button} from "@/components/ui/button.tsx";
import {AMFooter} from "@/components/am-footer.tsx";
import {AMHeader} from "@/components/am-header.tsx";
function PrivacyPolicy() {
const navigate = useNavigate();
return (
<main className="flex flex-col gap-5 container min-h-screen py-10">
<AMHeader title="AMNet">
<Button variant="ghost" size={"default"} onClick={() => navigate('/')}>
<Server className="h-4 w-4 sm:mr-2"/>
<span className="hidden sm:block">Manage Servers</span>
</Button>
</AMHeader>
<div className="flex flex-col gap-4 w-full flex-grow">
<h4 className="font-semibold text-2xl">Privacy</h4>
<p>The AMNet card switcher collects and stores the following information locally (on-device):</p>
<ul className="list-disc">
<li>Server Addresses</li>
<li>Server Information (including machine name, identifiers and metrics that are cached and updated on each load)</li>
<li>Card Names and Access Codes</li>
</ul>
<p>The card switcher does <strong>NOT</strong> send any data to external servers or services, except to perform the card-in action where only the card number is transmitted.</p>
</div>
<AMFooter/>
</main>
)
}
export default PrivacyPolicy;
+25 -62
View File
@@ -1,21 +1,19 @@
import {z} from "zod"; import {z} from "zod";
import TimeAgo from "react-timeago";
import {useForm} from "react-hook-form"; import {useForm} from "react-hook-form";
import {useLiveQuery} from "dexie-react-hooks"; import {useLiveQuery} from "dexie-react-hooks";
import {AMNET_API_VERSION} from "amnet-shared";
import React, {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import {useLocation, useNavigate} from 'react-router-dom'; import {useLocation, useNavigate} from 'react-router-dom';
import {zodResolver} from "@hookform/resolvers/zod";
import { import {
Earth, Earth,
History,
Plus, Plus,
ShieldAlert, ShieldAlert,
SquareArrowOutUpRight, SquareArrowOutUpRight,
Trash,
WalletCards WalletCards
} from "lucide-react"; } from "lucide-react";
import {AimeServer, db} from "@/lib/database"; import {AimeServer, db} from "@/lib/database";
import {zodResolver} from "@hookform/resolvers/zod";
import {Input} from "@/components/ui/input"; import {Input} from "@/components/ui/input";
import {Button} from "@/components/ui/button"; import {Button} from "@/components/ui/button";
@@ -23,26 +21,38 @@ import {LoadingIndicator} from "@/components/loading-indicator";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert"; import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
import {Form, FormControl, FormField, FormItem, FormMessage} from "@/components/ui/form"; import {Form, FormControl, FormField, FormItem, FormMessage} from "@/components/ui/form";
import {HttpsMixedModeRequestWarning} from "@/components/https-mixed-mode-request-warning"; import {HttpsMixedModeRequestWarning} from "@/components/https-mixed-mode-request-warning";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from "@/components/ui/context-menu";
import {AMFooter} from "@/components/am-footer"; import {AMFooter} from "@/components/am-footer";
import {AMHeader} from "@/components/am-header"; import {AMHeader} from "@/components/am-header";
import {AMNET_API_VERSION} from "amnet-shared"; import {AMServer} from "@/components/am-server.tsx";
import {AMCard, AMCardDetail} from "@/components/am-card";
const serverAdditionSchema = z.object({ const serverAdditionSchema = z.object({
address: z.string().startsWith("http") address: z.string().startsWith("http")
}); });
function ServerList(props: { servers: AimeServer[] | undefined }) { function ServerListing() {
const navigate = useNavigate(); const navigate = useNavigate();
const servers = useLiveQuery(() => db.servers.toArray());
return (
<main className="flex flex-col gap-5 container min-h-screen py-10">
<AMHeader title="Servers">
<Button variant="ghost" size={"default"} onClick={() => navigate('/cards')}>
<WalletCards className="h-4 w-4 sm:mr-2"/>
<span className="hidden sm:block">Manage Cards</span>
</Button>
</AMHeader>
<div className="flex flex-col gap-4 w-full flex-grow">
<ServerList servers={servers}/>
</div>
<AMFooter/>
</main>
);
}
function ServerList(props: { servers: AimeServer[] | undefined }) {
if (!props.servers) { if (!props.servers) {
return (<LoadingIndicator/>); return (<LoadingIndicator/>);
} }
@@ -65,32 +75,7 @@ function ServerList(props: { servers: AimeServer[] | undefined }) {
<HttpsMixedModeRequestWarning/> <HttpsMixedModeRequestWarning/>
<ServerAddForm/> <ServerAddForm/>
{props.servers.map(s => {props.servers.map(s => <AMServer key={s.id} server={s}/>)}
<ContextMenu key={s.id}>
<ContextMenuTrigger>
<AMCard title={s.serverName} titlePerformsAction actionClicked={() => navigate(`/servers/${s.id}`)}>
{s.lastConnected > 0 && (
<AMCardDetail>
<History className="h-5 w-5"/>
<TimeAgo date={s.lastConnected}/>
</AMCardDetail>
)}
<AMCardDetail>
<Earth className="h-5 w-5"/>
<code className="text-ellipsis whitespace-nowrap overflow-hidden">{s.serverAddress}</code>
</AMCardDetail>
</AMCard>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem inset onClick={() => navigate(`/servers/${s.id}`)}>
Select Server
</ContextMenuItem>
<ContextMenuSeparator/>
<ContextMenuItem className="text-red-500" onClick={() => db.servers.delete(s.id)}>
<Trash className="mr-2 h-4 w-4"/> Remove Server
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>)}
</>); </>);
} }
@@ -224,26 +209,4 @@ function ServerAddForm() {
); );
} }
function ServerListing() {
const navigate = useNavigate();
const servers = useLiveQuery(() => db.servers.toArray());
return (
<main className="flex flex-col gap-5 container min-h-screen py-10">
<AMHeader title="Servers">
<Button variant="ghost" size={"default"} onClick={() => navigate('/cards')}>
<WalletCards className="h-4 w-4 sm:mr-2"/>
<span className="hidden sm:block">Manage Cards</span>
</Button>
</AMHeader>
<div className="flex flex-col gap-4 w-full flex-grow">
<ServerList servers={servers}/>
</div>
<AMFooter/>
</main>
);
}
export default ServerListing; export default ServerListing;
+4 -2
View File
@@ -1,5 +1,7 @@
import * as animate from "tailwindcss-animate";
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { export default {
darkMode: ["class"], darkMode: ["class"],
content: [ content: [
'./src/**/*.{ts,tsx}', './src/**/*.{ts,tsx}',
@@ -70,5 +72,5 @@ module.exports = {
}, },
}, },
}, },
plugins: [require("tailwindcss-animate")], plugins: [animate],
} }
+1 -1
View File
@@ -7,7 +7,7 @@
"DOM", "DOM",
"DOM.Iterable" "DOM.Iterable"
], ],
"module": "ESNext", "module": "ES2020",
"skipLibCheck": true, "skipLibCheck": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
+5
View File
@@ -11,6 +11,7 @@ export default defineConfig({
}, },
}, },
build: { build: {
target: "ES2018",
rollupOptions: { rollupOptions: {
output: { output: {
manualChunks(id: string) { manualChunks(id: string) {
@@ -21,6 +22,10 @@ export default defineConfig({
if (id.includes('react')) { if (id.includes('react')) {
return 'react'; return 'react';
} }
if (id.includes('core-js')) {
return "polyfills";
}
} }
} }
} }
+29
View File
@@ -0,0 +1,29 @@
diff --git a/amnet-native/android/app/build.gradle b/amnet-native/android/app/build.gradle
index 6ee4a34..eb8c5f0 100644
--- a/amnet-native/android/app/build.gradle
+++ b/amnet-native/android/app/build.gradle
@@ -124,15 +124,21 @@ android {
keyAlias 'androiddebugkey'
keyPassword 'android'
}
+ release {
+ if (project.hasProperty('AMNET_UPLOAD_STORE_FILE')) {
+ storeFile file(AMNET_UPLOAD_STORE_FILE)
+ storePassword AMNET_UPLOAD_STORE_PASSWORD
+ keyAlias AMNET_UPLOAD_KEY_ALIAS
+ keyPassword AMNET_UPLOAD_KEY_PASSWORD
+ }
+ }
}
buildTypes {
debug {
signingConfig signingConfigs.debug
}
release {
- // Caution! In production, you need to generate your own keystore file.
- // see https://reactnative.dev/docs/signed-apk-android.
- signingConfig signingConfigs.debug
+ signingConfig signingConfigs.release
shrinkResources (findProperty('android.enableShrinkResourcesInReleaseBuilds')?.toBoolean() ?: false)
minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
+51 -29
View File
@@ -1,7 +1,7 @@
<div align="center"> <div align="center">
# AMNet # AMNet
[TestFlight](https://testflight.apple.com/join/kHbeJTsQ) • [Website](http://card.ppc.moe) • [Server DLLs](https://gitea.tendokyu.moe/ppc/amnet/releases) [TestFlight](https://testflight.apple.com/join/kHbeJTsQ) • [Firebase](https://appdistribution.firebase.dev/i/6acc3151357deaa8) • [Website](http://card.ppc.moe) • [Server DLLs](https://gitea.tendokyu.moe/ppc/amnet/releases)
An Aime Card Switcher for segatools-based games. An Aime Card Switcher for segatools-based games.
@@ -9,40 +9,62 @@ An Aime Card Switcher for segatools-based games.
## Overview ## Overview
AMNet is designed to simulate the `DEVICE/aime.txt` file, allowing multiple users to log into a game using their phone. AMNet is designed to simulate the `DEVICE/aime.txt` file, allowing multiple users to log into a game using their phone.
This was originally designed for use with maimai/Chunithm, but should be compatible with any segatools-based game with Aime support. This was originally intended for use with maimai and Chunithm, but should be compatible with any segatools-based game with Aime support.
### Setup ## Setup
1. Download the latest `amnet.dll` from the releases page 1. Download the latest `amnet.dll` from the releases page
2. Move `amnet.dll` to the game directory 2. Move `amnet.dll` to the game directory
3. Edit `segatools.ini` to use the new DLL: 3. Edit `segatools.ini` to use the new DLL:
```ini ```ini
[aimeio] [aimeio]
; To use a custom card reader IO DLL enter its path here. ; To use a custom card reader IO DLL enter its path here.
; Leave empty if you want to use Segatools built-in keyboard input. ; Leave empty if you want to use Segatools built-in keyboard input.
path=amnet.dll path=amnet.dll
; AMNet-specific options (all optional) ; AMNet-specific options (all optional)
; gameId - the 4 character game ID (will show the game icon in the UI) ; gameId - the 4 character game ID (will show the game icon in the UI)
; serverName - the name of the server to show to users (defaults to the computer name if not set) ; serverName - the name of the server to show to users (defaults to the computer name if not set)
; serverAddress - the address to listen on, including the protocol (defaults to http://+:6070, where + is a wildcard) ; serverAddress - the address to listen on, including the protocol (defaults to http://+:6070, where + is a wildcard)
gameId=SDEZ ; enableKeyboardMode - whether to enable hold-enter-to-login mode. Reads the file set at aimePath for card number (or DEVICE/aime.txt if not set).
;serverName=CHUNI-PENGUIN gameId=SDEZ
;serverAddress=http://+:6070 ;serverName=CHUNI-PENGUIN
``` ;serverAddress=http://+:6070
;enableKeyboardMode=1
```
4. Launch the game, and check the amdaemon console for server startup messages 4. Launch the game, and check the amdaemon console for server startup messages
5. Access the using one of the interfaces (see below), connect to the running server, add a card and start playing! 5. Access the using one of the interfaces (see below), add a new server `http://<your-ip-address>:6070`, add a card and start playing!
### Web Interface ## Interfaces
The web interface can be accessed by navigating to http://card.ppc.moe/ on a device on the same network as the server. AMNet can be used on most platforms, see below for options available for each platform:
This website is also installable as a PWA, which will allow it to be registered as an app on your device.
> Because the server can only listen on HTTP, the website needs to be visited over HTTP as well. This may cause issues with some devices, as they may block HTTP requests on HTTPS websites. <table>
<tbody>
[![](./res/web.jpeg)](http://card.ppc.moe) <tr>
<th>Platform</th>
### Native Application <th>Method</th>
The AMNet application is available (and fully supported) for iOS devices, and can be downloaded by joining the TestFlight (see links at the top of the file) or built manually. <th>Notes</th>
Android is technically supported but hasn't been tested; as such APKs aren't provided at this moment. </tr>
<tr>
<td>iOS</td>
<td><a href="https://testflight.apple.com/join/kHbeJTsQ" target="_blank">TestFlight</a></td>
<td></td>
</tr>
<tr>
<td rowspan="2">Android</td>
<td><a href="https://appdistribution.firebase.dev/i/6acc3151357deaa8" target="_blank">Firebase</a></td>
<td>Recommended for reduced download/install size and easier upgrading</td>
</tr>
<tr>
<td>.apk</td>
<td>Larger download, manual installation required.</td>
</tr>
<tr>
<td>All</td>
<td><a href="http://card.ppc.moe" target="_blank">Web</a></td>
<td>Works on all platforms and is installable as a PWA. Ensure it's used on <code>http</code> to avoid CORS-related errors</td>
</tr>
</tbody>
</table>
<div align="center"> <div align="center">
<img src="./res/native.png" height="600"/> <img src="./res/native.png" height="600"/>
@@ -60,7 +82,7 @@ Building the server without publish mode will result in a DLL that cannot be run
dotnet publish -c Release -r win-x64 -p:Version=1.0.0 AMNet.Server/AMNet.Server.csproj dotnet publish -c Release -r win-x64 -p:Version=1.0.0 AMNet.Server/AMNet.Server.csproj
``` ```
The dll will be available at `.\AMNet.Server\bin\Release\net8.0-windows\win-x64\publish\amnet.dll`. The dll will be available at `.\AMNet.Server\bin\Release\net9.0-windows\win-x64\publish\amnet.dll`.
### Website (amnet-web) ### Website (amnet-web)
The web interface is built using Vite and React. Building the web interface requires Node.js (or alternative runtime). The web interface is built using Vite and React. Building the web interface requires Node.js (or alternative runtime).
@@ -77,7 +99,7 @@ npm run build # build the website, outputs to ./dist
The mobile app is built using Expo and React Native. The app requires Node.js and npm to build, along with either the Android SDK or Xcode for iOS. The mobile app is built using Expo and React Native. The app requires Node.js and npm to build, along with either the Android SDK or Xcode for iOS.
After cloning the repository, install dependencies then run the app, or prebuild for the platform of your choice then complete the build process for a standalone app: After cloning the repository, install dependencies then run the app, or prebuild for the platform of your choice then complete the build process for a standalone app:
> amnet-native relies on one or more native libraries, so Expo Go cannot be used. The app must be built and run on a physical device or simulator. > amnet-native relies on multiple native libraries not included in Expo Go. The app must be built from scratch and run on a physical device or simulator.
```shell ```shell
cd amnet-native cd amnet-native