mirror of
https://gitea.tendokyu.moe/ppc/amnet.git
synced 2026-10-06 13:48:10 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71f343e9b5 | ||
|
|
270c65cddb | ||
|
|
21766ae449 | ||
|
|
0bb34f04be | ||
|
|
1e5e8b3a4d | ||
|
|
805af19006 | ||
|
|
7f51e4a0ee | ||
|
|
4d43943568 | ||
|
|
d8d29c3f84 | ||
|
|
0a204b249a | ||
|
|
ae7483b156 | ||
|
|
5bb477741d | ||
|
|
3fbea0f925 | ||
|
|
2bc9c9f673 | ||
|
|
5b19207a91 | ||
|
|
bc48917fe8 | ||
|
|
13cee9a431 | ||
|
|
874ba06e29 | ||
|
|
16c0ff54c6 | ||
|
|
73d333cc23 | ||
|
|
393965d252 | ||
|
|
29b303cde8 | ||
|
|
e83287c7b7 | ||
|
|
9324b30e7b | ||
|
|
d0c7616ed1 | ||
|
|
99fd999c06 | ||
|
|
0a42a0a912 | ||
|
|
a6ad82ffda | ||
|
|
31c56d2449 | ||
|
|
b33670d849 | ||
|
|
9c8c4fd286 | ||
|
|
0a80da6614 | ||
|
|
489b56015c | ||
|
|
89338c794a | ||
|
|
aae5039660 | ||
|
|
690a5b591c | ||
|
|
f9b2326853 | ||
|
|
b2be622271 | ||
|
|
e0ba6c04f2 | ||
|
|
6c7479225c | ||
|
|
42cb241487 | ||
|
|
297ad685a7 | ||
|
|
d52757f134 | ||
|
|
a4d4e03eb9 | ||
|
|
3e3755471d | ||
|
|
adb60dd928 | ||
|
|
c8763dd65e | ||
|
|
2d3b129126 | ||
|
|
b729b4e31b | ||
|
|
377fe93645 | ||
|
|
cbd2825475 | ||
|
|
b9bbcc9228 | ||
|
|
61fb81c75c | ||
|
|
d3b96d3017 | ||
|
|
9b136006a0 | ||
|
|
2eb61d74a7 | ||
|
|
89101c5d5f | ||
|
|
2fe9238d02 | ||
|
|
d1b626ffac | ||
|
|
fe7ab1c50f | ||
|
|
5a3a7be906 | ||
|
|
703067e6d6 | ||
|
|
67f90397b0 | ||
|
|
8ba439721f | ||
|
|
35a8c28c0e | ||
|
|
a0b44c165b | ||
|
|
489a93adfe | ||
|
|
6172f2bbd7 | ||
|
|
414a199552 | ||
|
|
a583b72798 | ||
|
|
33cd321325 | ||
|
|
85b900ea30 | ||
|
|
4038de583d | ||
|
|
b08a7cd125 | ||
|
|
4b6037fe24 | ||
|
|
4702052cc4 | ||
|
|
969edd3675 | ||
|
|
0d200a22fc | ||
|
|
c30abfe482 | ||
|
|
6628071f5b | ||
|
|
b29daecdcd | ||
|
|
57d9f07a64 | ||
|
|
a538812aee | ||
|
|
dff641e6f1 | ||
|
|
00736e72dd | ||
|
|
f4dd845ee6 | ||
|
|
e467329db8 | ||
|
|
e7c521cf2e | ||
|
|
a332562928 | ||
|
|
245a26aecf | ||
|
|
8a1c751448 | ||
|
|
73b7800bf3 | ||
|
|
cff5989a8d | ||
|
|
20577f76f0 | ||
|
|
91dc31226d | ||
|
|
7b5c1b9672 | ||
|
|
963f2ddb48 | ||
|
|
153d3ff7ad | ||
|
|
9fe0dcc35c | ||
|
|
6acddc2f78 | ||
|
|
15329d5dcd | ||
|
|
ef8e03ee9b | ||
|
|
a7217c8e31 | ||
|
|
1b3865b325 | ||
|
|
40934f999d | ||
|
|
087f657563 | ||
|
|
fdfbe73bb2 | ||
|
|
e0a0002ca1 | ||
|
|
ba58e3af1b | ||
|
|
e4cb903198 | ||
|
|
14efbc4971 | ||
|
|
ec98003d2c |
@@ -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"
|
||||||
@@ -3,19 +3,18 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<PublishAot>true</PublishAot>
|
<PublishAot>true</PublishAot>
|
||||||
<OutputType>Library</OutputType>
|
<OutputType>Library</OutputType>
|
||||||
<EventSourceSupport>false</EventSourceSupport>
|
<TargetFramework>net9.0-windows</TargetFramework>
|
||||||
<TargetFramework>net8.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>
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
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[] AccessCode, byte[] IDm, long ExpiresAt)
|
||||||
|
{
|
||||||
|
public bool Expired => Environment.TickCount64 >= ExpiresAt;
|
||||||
|
|
||||||
|
public override string ToString() => FormatHexCode(AccessCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the currently active card without clearing it
|
||||||
|
/// </summary>
|
||||||
|
public StoredCard PeekActiveCard()
|
||||||
|
{
|
||||||
|
lock (_cardLock)
|
||||||
|
{
|
||||||
|
return _currentCard;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public StoredCard TakeActiveCard(bool? requireIDm = null)
|
||||||
|
{
|
||||||
|
lock (_cardLock)
|
||||||
|
{
|
||||||
|
// IDm and requireIDm are mutually exclusive
|
||||||
|
if (_currentCard == null || (requireIDm.HasValue && (_currentCard.IDm != null) ^ requireIDm.Value))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var card = _currentCard;
|
||||||
|
_currentCard = null;
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the card
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="accessCode">The access code of the card to use</param>
|
||||||
|
/// <param name="idmHex">The FeliCa card IDm, if a physical card was used</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 accessCode, string idmHex, long validFor = 5000)
|
||||||
|
{
|
||||||
|
// ensure the matrix code is 20-digits long, otherwise pad with zeros
|
||||||
|
var matrixCode = accessCode.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 idmBytes = !string.IsNullOrWhiteSpace(idmHex) && idmHex.Length <= 16 && idmHex.All(char.IsAsciiHexDigit) ? Convert.FromHexString(idmHex.PadLeft(16, '0')) : null;
|
||||||
|
var card = new StoredCard(bytes, idmBytes, Environment.TickCount64 + validFor);
|
||||||
|
|
||||||
|
lock (_cardLock)
|
||||||
|
{
|
||||||
|
_currentCard = card;
|
||||||
|
}
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats a byte array as a hex string, inserting a space every 2 characters
|
||||||
|
/// </summary>
|
||||||
|
public static string FormatHexCode(byte[] code) => string.Join(" ", code.Chunk(2).Select(Convert.ToHexString));
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using System;
|
||||||
|
using System.Reflection;
|
||||||
|
using Windows.Win32;
|
||||||
|
using Windows.Win32.Foundation;
|
||||||
|
|
||||||
|
namespace AMNet.Server;
|
||||||
|
|
||||||
|
internal static class Config
|
||||||
|
{
|
||||||
|
internal const string IOSection = "aimeio";
|
||||||
|
|
||||||
|
static Config()
|
||||||
|
{
|
||||||
|
GameId = ReadKey(IOSection, "gameId", 4);
|
||||||
|
ServerName = ReadKey(IOSection, "serverName", 26, Environment.MachineName);
|
||||||
|
|
||||||
|
PhysicalCardUseIDm = PInvoke.GetPrivateProfileInt(IOSection, "useAimeDBForPhysicalCards", 0, ConfigFilePath) > 0;
|
||||||
|
EnableAimeTxt = PInvoke.GetPrivateProfileInt(IOSection, "enableKeyboardMode", 1, ConfigFilePath) > 0;
|
||||||
|
AimeTxtPath = ReadKey("aime", "aimePath", PInvoke.MAX_PATH, @"DEVICE\aime.txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GameId { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The server name to show on devices. Defaults to the computer name.
|
||||||
|
/// </summary>
|
||||||
|
public static string ServerName { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether physically scanned cards should have their IDm forwarded to the game instead of the access code.
|
||||||
|
/// </summary>
|
||||||
|
public static bool PhysicalCardUseIDm { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether holding the enter key should trigger a card scan using the code from the aime.txt file
|
||||||
|
/// </summary>
|
||||||
|
public static bool EnableAimeTxt { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Path to the aime.txt file. Defaults to .\DEVICE\aime.txt
|
||||||
|
/// </summary>
|
||||||
|
public static string AimeTxtPath { get; }
|
||||||
|
|
||||||
|
public static Version ServerVersion { get; } = new(Assembly.GetExecutingAssembly().GetCustomAttribute<AssemblyFileVersionAttribute>()?.Version ?? "1.0");
|
||||||
|
|
||||||
|
private static string ConfigFilePath { get; } = Environment.GetEnvironmentVariable("SEGATOOLS_CONFIG_PATH") ?? ".\\segatools.ini";
|
||||||
|
|
||||||
|
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, ConfigFilePath);
|
||||||
|
return bufferStr.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
+82
-144
@@ -1,179 +1,139 @@
|
|||||||
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 v{version} ({gameId}) started.", Config.ServerVersion.ToString(Config.ServerVersion.Build > 0 ? 3 : 2), Config.GameId ?? "SXXX");
|
||||||
|
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(), null);
|
||||||
|
|
||||||
|
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if physical cards are being directed to
|
||||||
|
var card = App.Services.GetRequiredService<CardPresenter>().TakeActiveCard(Config.PhysicalCardUseIDm ? false : null);
|
||||||
|
if (card?.Expired != false)
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Marshal.Copy(card.AccessCode, 0, luid, (int)luidSize);
|
||||||
|
App.Logger.LogInformation("Access Code read: {cardId}", CardPresenter.FormatHexCode(card.AccessCode));
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[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)
|
||||||
{
|
{
|
||||||
// felica not supported
|
if (!Config.PhysicalCardUseIDm || unitNo != 0)
|
||||||
return 1;
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var card = App.Services.GetRequiredService<CardPresenter>().TakeActiveCard(true);
|
||||||
|
if (card?.Expired != false)
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ulong idmValue = 0;
|
||||||
|
for (var i = 0 ; i < 8 ; i++)
|
||||||
|
{
|
||||||
|
idmValue = (idmValue << 8) | card.IDm[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
*idm = idmValue;
|
||||||
|
|
||||||
|
App.Logger.LogInformation("FeliCa IDm read: {idm}", CardPresenter.FormatHexCode(card.IDm));
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[UnmanagedCallersOnly(EntryPoint = "aime_io_led_set_color")]
|
[UnmanagedCallersOnly(EntryPoint = "aime_io_led_set_color")]
|
||||||
@@ -181,26 +141,4 @@ public static class DllMain
|
|||||||
{
|
{
|
||||||
// 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(';');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
GetPrivateProfileStringW
|
GetPrivateProfileStringW
|
||||||
GetPrivateProfileInt
|
GetPrivateProfileInt
|
||||||
|
GetAsyncKeyState
|
||||||
AllocConsole
|
AllocConsole
|
||||||
|
MAX_PATH
|
||||||
|
|||||||
@@ -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;
|
|
||||||
+117
-3
@@ -1,16 +1,42 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
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 AccessCode,
|
||||||
|
[property: JsonPropertyName("physicalCardIDm")] string PhysicalCardIDm);
|
||||||
|
|
||||||
|
public record ServerErrorMessage(
|
||||||
|
[property: JsonPropertyName("message")] string Message);
|
||||||
|
|
||||||
|
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 +65,94 @@ 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)
|
||||||
|
{
|
||||||
|
await WriteErrorResponse(ctx, StatusCodes.Status400BadRequest, e.Message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(request?.AccessCode))
|
||||||
|
{
|
||||||
|
await WriteErrorResponse(ctx, StatusCodes.Status400BadRequest, "Access Code not provided.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.AccessCode.Length is 0 or < 20 || !request.AccessCode.Any(char.IsAsciiDigit))
|
||||||
|
{
|
||||||
|
await WriteErrorResponse(ctx, StatusCodes.Status422UnprocessableEntity, "Invalid Access Code.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.AccessCode.All(x => x == '0'))
|
||||||
|
{
|
||||||
|
await WriteErrorResponse(ctx, StatusCodes.Status403Forbidden, "All-zero access codes are forbidden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cardPresenter = ctx.RequestServices.GetRequiredService<CardPresenter>();
|
||||||
|
var currentCard = cardPresenter.PeekActiveCard();
|
||||||
|
|
||||||
|
if (currentCard?.Expired == false)
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
ctx.Response.Headers.RetryAfter = Math.Ceiling(TimeSpan.FromMilliseconds(currentCard.ExpiresAt - Environment.TickCount64).TotalSeconds).ToString(CultureInfo.InvariantCulture);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardPresenter.SetCard(request.AccessCode, request.PhysicalCardIDm) != null)
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = StatusCodes.Status202Accepted;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await WriteErrorResponse(ctx, StatusCodes.Status422UnprocessableEntity, "Invalid card id format.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task WriteErrorResponse(HttpContext ctx, int code, string message)
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = code;
|
||||||
|
return ctx.Response.WriteAsJsonAsync(new ServerErrorMessage(message), SerializerContext.Default.ServerErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonSerializable(typeof(SystemState))]
|
||||||
|
[JsonSerializable(typeof(CardReadRequest))]
|
||||||
|
[JsonSerializable(typeof(ServerErrorMessage))]
|
||||||
|
[JsonSourceGenerationOptions(WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.Never)]
|
||||||
|
private partial class SerializerContext : JsonSerializerContext;
|
||||||
}
|
}
|
||||||
+10
-6
@@ -1,14 +1,14 @@
|
|||||||
import React, {useEffect} from "react";
|
import React from "react";
|
||||||
import {StatusBar} from "expo-status-bar";
|
import {StatusBar} from "expo-status-bar";
|
||||||
import {useColorScheme, View} from "react-native";
|
|
||||||
import {Server, WalletCards} from "lucide-react-native";
|
import {Server, WalletCards} from "lucide-react-native";
|
||||||
|
import {Platform, useColorScheme, View} from "react-native";
|
||||||
import {NavigationContainer} from "@react-navigation/native";
|
import {NavigationContainer} from "@react-navigation/native";
|
||||||
import {SafeAreaProvider} from "react-native-safe-area-context";
|
import {SafeAreaProvider} from "react-native-safe-area-context";
|
||||||
import {MD3Colors} from "react-native-paper/lib/typescript/types";
|
import {MD3Colors} from "react-native-paper/lib/typescript/types";
|
||||||
import {createBottomTabNavigator} from "@react-navigation/bottom-tabs";
|
import {createBottomTabNavigator} from "@react-navigation/bottom-tabs";
|
||||||
import {createNativeStackNavigator} from "@react-navigation/native-stack";
|
import {createNativeStackNavigator} from "@react-navigation/native-stack";
|
||||||
import {DefaultTheme as NavigationLightTheme, DarkTheme as NavigationDarkTheme} from '@react-navigation/native';
|
|
||||||
import {JetBrainsMono_400Regular, useFonts} from "@expo-google-fonts/jetbrains-mono";
|
import {JetBrainsMono_400Regular, useFonts} from "@expo-google-fonts/jetbrains-mono";
|
||||||
|
import {DefaultTheme as NavigationLightTheme, DarkTheme as NavigationDarkTheme} from '@react-navigation/native';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
adaptNavigationTheme,
|
adaptNavigationTheme,
|
||||||
@@ -19,12 +19,15 @@ import {
|
|||||||
Paragraph
|
Paragraph
|
||||||
} from 'react-native-paper';
|
} from 'react-native-paper';
|
||||||
|
|
||||||
import RealmContext from "./models/RealmContext";
|
|
||||||
import linking from "./Linking";
|
import linking from "./Linking";
|
||||||
|
import RealmContext from "./models/RealmContext";
|
||||||
|
|
||||||
import {AMUITabBar} from "./components/AMUITabBar";
|
import {AMUITabBar} from "./components/AMUITabBar";
|
||||||
import CardAddModal from "./app/CardAddModal";
|
|
||||||
|
import CardEditorModal from "./app/card/CardEditorModal";
|
||||||
|
import CardAddModal from "./app/card/CardAddModal";
|
||||||
import ServerAddModal from "./app/ServerAddModal";
|
import ServerAddModal from "./app/ServerAddModal";
|
||||||
|
|
||||||
import CardManagerStack from "./app/CardManagerStack";
|
import CardManagerStack from "./app/CardManagerStack";
|
||||||
import ServerManagerStack from "./app/ServerManagerStack";
|
import ServerManagerStack from "./app/ServerManagerStack";
|
||||||
|
|
||||||
@@ -118,7 +121,8 @@ export default function AppRoot() {
|
|||||||
|
|
||||||
<Stack.Group screenOptions={{presentation: "modal"}}>
|
<Stack.Group screenOptions={{presentation: "modal"}}>
|
||||||
<Stack.Screen name="add-server" component={ServerAddModal} options={{title: "Add Server"}}/>
|
<Stack.Screen name="add-server" component={ServerAddModal} options={{title: "Add Server"}}/>
|
||||||
<Stack.Screen name="add-card" component={CardAddModal} options={{title: "Add Card"}}/>
|
<Stack.Screen name="add-card" component={CardAddModal} options={{title: "Add Card", headerShown: Platform.OS !== 'ios'}}/>
|
||||||
|
<Stack.Screen name="edit-card" component={CardEditorModal} options={{title: "Edit Card"}}/>
|
||||||
</Stack.Group>
|
</Stack.Group>
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
|
|||||||
+71
-4
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "AMNet",
|
"name": "AMNet",
|
||||||
"scheme": "amnet",
|
"scheme": "amnet",
|
||||||
"version": "1.0.0",
|
"version": "1.3.1",
|
||||||
"slug": "amnet-native",
|
"slug": "amnet-native",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
@@ -12,14 +12,81 @@
|
|||||||
"applinks:card.ppc.moe"
|
"applinks:card.ppc.moe"
|
||||||
],
|
],
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"UIViewControllerBasedStatusBarAppearance": "NO"
|
"NSAppTransportSecurity": {
|
||||||
|
"NSAllowsArbitraryLoads": true
|
||||||
|
},
|
||||||
|
"UIViewControllerBasedStatusBarAppearance": "NO",
|
||||||
|
"NFCReaderUsageDescription": "AMNet uses NFC to scan Physical IC Cards",
|
||||||
|
"com.apple.developer.nfc.readersession.felica.systemcodes": [
|
||||||
|
"0003",
|
||||||
|
"88b4"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"entitlements": {
|
||||||
|
"com.apple.developer.nfc.readersession.formats": [
|
||||||
|
"TAG"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"usesNonExemptEncryption": false
|
"usesNonExemptEncryption": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"package": "moe.ppc.amnet"
|
"package": "moe.ppc.amnet",
|
||||||
|
"versionCode": 5,
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
import {generateCardNumber} from "amnet-shared";
|
|
||||||
import {useModalStyles} from "../utils/ModalStyles";
|
|
||||||
import {useCallback, useLayoutEffect, useState} from "react";
|
|
||||||
import {TextInput, TouchableOpacity, View} from "react-native";
|
|
||||||
import {Button, Card, Divider, Paragraph, useTheme} from "react-native-paper";
|
|
||||||
|
|
||||||
import {AMCard} from "../models/AMCard";
|
|
||||||
import RealmContext from "../models/RealmContext";
|
|
||||||
|
|
||||||
const {useRealm} = RealmContext;
|
|
||||||
|
|
||||||
export default function CardAddModal({navigation}) {
|
|
||||||
const realm = useRealm();
|
|
||||||
const theme = useTheme();
|
|
||||||
const modalStyles = useModalStyles();
|
|
||||||
|
|
||||||
// state values
|
|
||||||
const [cardName, setCardName] = useState('');
|
|
||||||
const [cardNumber, setCardNumber] = useState('');
|
|
||||||
|
|
||||||
const cardAddCallback = useCallback((cardId: string, cardName: string) => {
|
|
||||||
const duplicateCard = realm.objects<AMCard>("card").find(x => x.code == cardNumber);
|
|
||||||
|
|
||||||
if (duplicateCard) {
|
|
||||||
realm.write(() => {
|
|
||||||
duplicateCard.name = cardName;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
realm.write(() => {
|
|
||||||
realm.create("card", AMCard.generate(cardId, cardName));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
navigation.goBack();
|
|
||||||
}, [realm]);
|
|
||||||
|
|
||||||
// header effects
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerLeft: () => <TouchableOpacity onPress={() => navigation.goBack()}>
|
|
||||||
<Paragraph style={modalStyles.headerText}>Cancel</Paragraph>
|
|
||||||
</TouchableOpacity>,
|
|
||||||
});
|
|
||||||
}, [navigation, modalStyles]);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const canSave = cardName.length && cardNumber.length === 20;
|
|
||||||
const themeOptions = canSave ? modalStyles.headerText : {color: theme.colors.outline};
|
|
||||||
|
|
||||||
navigation.setOptions({
|
|
||||||
headerRight: () => <TouchableOpacity disabled={!canSave}
|
|
||||||
onPress={() => cardAddCallback(cardNumber, cardName)}>
|
|
||||||
<Paragraph style={{...themeOptions, fontWeight: "600"}}>Save</Paragraph>
|
|
||||||
</TouchableOpacity>
|
|
||||||
})
|
|
||||||
}, [navigation, modalStyles, cardName, cardNumber]);
|
|
||||||
|
|
||||||
return (<View style={{display: "flex", gap: 10, justifyContent: "center", paddingHorizontal: 15, paddingTop: 25}}>
|
|
||||||
<Card mode="contained">
|
|
||||||
<Card.Content>
|
|
||||||
<View style={modalStyles.formItemRow}>
|
|
||||||
<Paragraph style={modalStyles.formItemName}>
|
|
||||||
Card Name
|
|
||||||
</Paragraph>
|
|
||||||
<TextInput style={modalStyles.formItemInput}
|
|
||||||
numberOfLines={1} autoFocus
|
|
||||||
value={cardName}
|
|
||||||
onChangeText={setCardName}
|
|
||||||
placeholder="Primary Card"
|
|
||||||
placeholderTextColor={theme.colors.outline}/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Divider style={{marginVertical: 15}}/>
|
|
||||||
|
|
||||||
<View style={modalStyles.formItemRow}>
|
|
||||||
<Paragraph style={modalStyles.formItemName}>
|
|
||||||
Matrix Code
|
|
||||||
</Paragraph>
|
|
||||||
<TextInput style={modalStyles.formItemInput}
|
|
||||||
numberOfLines={1}
|
|
||||||
keyboardType="numeric"
|
|
||||||
placeholder="0000 0000 0000 0000 0000"
|
|
||||||
placeholderTextColor={theme.colors.outline}
|
|
||||||
value={cardNumber.match(/.{1,4}/g)?.join(" ")}
|
|
||||||
onChangeText={val => setCardNumber(val?.replaceAll(/\D/g, '').substring(0, 20) ?? '')}/>
|
|
||||||
</View>
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</View>)
|
|
||||||
}
|
|
||||||
@@ -1,60 +1,73 @@
|
|||||||
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 {ICCard} from "../components/ICCard";
|
||||||
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 access code
|
||||||
case 0:
|
case "0":
|
||||||
|
case "0,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('edit-card', {cardId: props.card._id.toHexString()});
|
||||||
});
|
return;
|
||||||
break;
|
|
||||||
|
// remove card
|
||||||
|
case "1,1":
|
||||||
|
AMCard.delete(realm, 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"}]}>
|
{
|
||||||
<AimeCard card={props.card} showFullCardNumber/>
|
title: Platform.OS === 'ios' ? "" : "Additional Actions",
|
||||||
|
inlineChildren: true,
|
||||||
|
actions: [
|
||||||
|
{title: "Edit Card", systemIcon: "square.and.pencil"},
|
||||||
|
{title: "Delete Card", destructive: true, systemIcon: "trash"}]
|
||||||
|
}]}>
|
||||||
|
<ICCard 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 +77,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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 {Alert, 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,45 +20,70 @@ 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;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
new URL(serverAddress);
|
const protocol = new URL(serverAddress).protocol;
|
||||||
urlValid = true;
|
urlValid = protocol.localeCompare("http:", undefined, {sensitivity: "case"}) === 0 || protocol.localeCompare("https:", undefined, {sensitivity: "case"}) === 0;
|
||||||
} catch {
|
} catch {
|
||||||
// do nothing
|
// do nothing
|
||||||
}
|
}
|
||||||
|
|
||||||
const themeOptions = urlValid ? modalStyles.headerText : {color: theme.colors.outline};
|
const themeOptions = urlValid ? modalStyles.headerText : {color: theme.colors.outline};
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => <TouchableOpacity disabled={!urlValid} onPress={() => saveServer()}>
|
headerRight: () => <TouchableOpacity disabled={!urlValid} onPress={() => saveServer(serverAddress)}>
|
||||||
<Paragraph style={{...themeOptions, fontWeight: "600"}}>Save</Paragraph>
|
<Paragraph style={{...themeOptions, fontWeight: "600"}}>Save</Paragraph>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
})
|
})
|
||||||
}, [navigation, modalStyles, serverAddress]);
|
}, [navigation, modalStyles, serverAddress]);
|
||||||
|
|
||||||
const saveServer = useCallback(() => {
|
const saveServer = useCallback(async (address: string) => {
|
||||||
let serverObject = realm.objects<AMServer>("server").find(x => x.serverAddress == serverAddress);
|
if (address.startsWith("https:")) {
|
||||||
|
await new Promise<void>(done => {
|
||||||
|
Alert.alert(
|
||||||
|
"HTTPS Warning",
|
||||||
|
"By default, AMNet doesn't support HTTPS without advanced configuration. It's highly likely you won't be able to connect.",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: "Keep HTTPS",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => done()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Change to HTTP",
|
||||||
|
isPreferred: true,
|
||||||
|
onPress: () => {
|
||||||
|
address = address.replace(/^https:/i, "http:");
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let serverObject = realm.objects<AMServer>("server").find(x => x.serverAddress == address);
|
||||||
if (!serverObject) {
|
if (!serverObject) {
|
||||||
realm.write(() => {
|
realm.write(() => {
|
||||||
serverObject = realm.create<AMServer>("server", AMServer.generate(serverAddress));
|
serverObject = realm.create<AMServer>("server", AMServer.generate(address));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
navigation.goBack();
|
navigation.goBack();
|
||||||
setTimeout(() => navigation.navigate('server', {
|
setTimeout(() => navigation.popTo('server', {
|
||||||
serverId: serverObject._id.toHexString()
|
serverId: serverObject._id.toHexString()
|
||||||
}), 500);
|
}), 500);
|
||||||
}, [serverAddress, realm]);
|
}, [realm]);
|
||||||
|
|
||||||
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">
|
||||||
@@ -83,7 +108,7 @@ export default function ServerAddModal({route, navigation}) {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Paragraph style={modalStyles.formFootnote}>
|
<Paragraph style={modalStyles.formFootnote}>
|
||||||
If you're using HTTPS, ensure a trusted certificate has been configured via HTTP.SYS
|
AMNet uses HTTP by default. Unless has been configured to work with HTTP.SYS, HTTPS will not work.
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
</View>)
|
</View>)
|
||||||
}
|
}
|
||||||
@@ -102,7 +127,7 @@ function handleDeepLink(realm, navigation, route, setAddress: (address: string)
|
|||||||
navigation.goBack();
|
navigation.goBack();
|
||||||
|
|
||||||
// allow the homepage to render before moving on
|
// allow the homepage to render before moving on
|
||||||
setTimeout(() => navigation.navigate('server', {
|
setTimeout(() => navigation.popTo('server', {
|
||||||
serverId: item[0]._id.toHexString()
|
serverId: item[0]._id.toHexString()
|
||||||
}), 1000);
|
}), 1000);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+266
-133
@@ -1,15 +1,40 @@
|
|||||||
import {BSON} from "realm";
|
import {BSON} from "realm";
|
||||||
import {fetchServerInfo, performCardIn} from "amnet-shared";
|
import NfcManager from "react-native-nfc-manager";
|
||||||
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, IconButton, Paragraph, Title, useTheme} from "react-native-paper";
|
||||||
import {Alert, RefreshControl, ScrollView, Share, StyleSheet, TouchableOpacity, View} from "react-native";
|
import {Platform, RefreshControl, ScrollView, Share, StyleSheet, Text, TouchableOpacity, View} from "react-native";
|
||||||
import {CircleCheck, CircleDot, CreditCard, Nfc, Plus, SmartphoneNfcIcon, WifiOff, Share as ShareIcon} from "lucide-react-native";
|
import {
|
||||||
|
CircleCheck,
|
||||||
|
CircleDot,
|
||||||
|
CreditCard,
|
||||||
|
Plus,
|
||||||
|
SmartphoneNfcIcon,
|
||||||
|
Share as ShareIcon,
|
||||||
|
WifiOff,
|
||||||
|
TvMinimalPlay,
|
||||||
|
Star,
|
||||||
|
SmartphoneNfc,
|
||||||
|
SquareArrowOutUpRight
|
||||||
|
} from "lucide-react-native";
|
||||||
|
|
||||||
import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder";
|
import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder";
|
||||||
import {AimeCard} from "../components/AimeCard";
|
import {AMUIStatusAlert} from "../components/AMUIStatusAlert";
|
||||||
|
import {AMUICardDetail} from "../components/AMUICard";
|
||||||
|
import {ICCard} from "../components/ICCard";
|
||||||
|
|
||||||
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 {felicaCardReadAsync, icCard0008Number, ICType} from "../lib/iccard";
|
||||||
|
import {
|
||||||
|
AMServerConnectionInfo,
|
||||||
|
AMServerInstanceMetrics,
|
||||||
|
fetchServerInfo,
|
||||||
|
getGameName,
|
||||||
|
performCardIn
|
||||||
|
} from "amnet-shared";
|
||||||
|
|
||||||
import ObjectId = BSON.ObjectId;
|
import ObjectId = BSON.ObjectId;
|
||||||
|
|
||||||
const {useRealm, useQuery, useObject} = RealmContext;
|
const {useRealm, useQuery, useObject} = RealmContext;
|
||||||
@@ -18,110 +43,131 @@ 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,
|
||||||
|
query: q => q.sorted("lastUsed", true)
|
||||||
|
});
|
||||||
|
|
||||||
const [selectedCard, setSelectedCard] = useState<AMCard>(null);
|
// nfc support
|
||||||
const [cardInCompleted, setCardInCompleted] = useState<boolean>(null);
|
const [nfcSupported, setNfcSupported] = useState<boolean | null>(Platform.OS === 'android' ? false : null);
|
||||||
|
useEffect(() => {
|
||||||
const unselectedCards = useMemo(() => {
|
if (nfcSupported !== null) {
|
||||||
// protect against selected card being removed
|
return;
|
||||||
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]);
|
|
||||||
|
NfcManager.isSupported().then(setNfcSupported);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 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((card: AMCard) => {
|
||||||
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 => {
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
setCardInCompleted(r);
|
performCardIn(server, {
|
||||||
|
cardId: card.code,
|
||||||
|
physicalCardIDm: card.physicalCardData?.cardIDm ?? undefined
|
||||||
|
}, abortController.signal).then(r => {
|
||||||
|
const now = new Date();
|
||||||
realm.write(() => {
|
realm.write(() => {
|
||||||
server.lastUsed = now;
|
server.lastUsed = now;
|
||||||
selectedCard.lastUsed = now;
|
|
||||||
|
card.lastUsed = now;
|
||||||
|
card.lastUsedGameId = server.gameId;
|
||||||
});
|
});
|
||||||
|
|
||||||
setSelectedCard(selectedCard);
|
setCardInCompleted(r);
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
setCardInCompleted(false);
|
setCardInCompleted(false);
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
@@ -130,11 +176,50 @@ export default function ServerCardIn({route, navigation}) {
|
|||||||
|
|
||||||
setTimeout(() => abortController.abort(), 5000);
|
setTimeout(() => abortController.abort(), 5000);
|
||||||
return () => abortController.abort();
|
return () => abortController.abort();
|
||||||
}, [server, selectedCard]);
|
}, [server]);
|
||||||
|
|
||||||
const disableCardIn = useMemo(() => cardInCompleted !== null || !connectionTestResult, [cardInCompleted, connectionTestResult]);
|
const performPhysicalCardIn = useCallback(async () => {
|
||||||
|
const card = await felicaCardReadAsync();
|
||||||
|
|
||||||
if (connectionTestResult === null) {
|
if (!card) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedCard = cards.find(x => x.physicalCardData?.cardIDm === card.idm);
|
||||||
|
|
||||||
|
if (savedCard) {
|
||||||
|
setUserSelectedCard(savedCard);
|
||||||
|
cardInCallback(savedCard);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCardInCompleted(false);
|
||||||
|
|
||||||
|
const cardId = card.type !== ICType.Amusement ? icCard0008Number(card.idm) : card.accessCode;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
performCardIn(server, {cardId, physicalCardIDm: card.idm}, abortController.signal).then(r => {
|
||||||
|
setCardInCompleted(r);
|
||||||
|
}).catch(() => {
|
||||||
|
setCardInCompleted(false);
|
||||||
|
}).finally(() => {
|
||||||
|
setTimeout(() => setCardInCompleted(null), 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => abortController.abort(), 5000);
|
||||||
|
return () => abortController.abort();
|
||||||
|
}, [server]);
|
||||||
|
|
||||||
|
const performRefresh = () => {
|
||||||
|
setServerRefreshing(true);
|
||||||
|
setServerReloadCounter(serverReloadCounter + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
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/>
|
||||||
@@ -145,93 +230,141 @@ export default function ServerCardIn({route, navigation}) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView contentInsetAdjustmentBehavior="automatic"
|
<ScrollView contentInsetAdjustmentBehavior="automatic"
|
||||||
contentContainerStyle={{...styles.container, paddingTop: connectionTestResult ? 0 : 10}}
|
refreshControl={<RefreshControl refreshing={serverRefreshing} onRefresh={performRefresh}/>}>
|
||||||
refreshControl={<RefreshControl refreshing={serverRefreshing}
|
<View style={styles.container}>
|
||||||
tintColor={theme.colors.onSurface}
|
<StatusAlert server={server} connectionEstablished={serverConnectionSuccess}/>
|
||||||
onRefresh={() => {
|
<View style={styles.section}>
|
||||||
setServerRefreshing(true);
|
<Title
|
||||||
setServerReloadCounter(serverReloadCounter + 1);
|
style={styles.sectionTitle}>{server.gameId ? `Play ${getGameName(server.gameId)}` : "Selected Card"}</Title>
|
||||||
}}/>}>
|
{selectedCard ? <>
|
||||||
{!connectionTestResult && (
|
<ICCard actionDisabled
|
||||||
<Card mode="outlined" style={{borderColor: theme.colors.secondary}}>
|
card={selectedCard}
|
||||||
<Card.Content>
|
actionIcon={<CircleCheck color="green" size={24}/>}>
|
||||||
<View style={{alignItems: "center", flexDirection: "row", gap: 10}}>
|
{serverDefaultCard?.code == selectedCard.code && <DefaultCardDetail/>}
|
||||||
<WifiOff color={theme.colors.secondary} size={24}/>
|
</ICCard>
|
||||||
<Title style={{color: theme.colors.secondary}}>Connection Failed</Title>
|
<View style={{display: "flex", flexDirection: "row", gap: 5}}>
|
||||||
|
<Button style={{flex: 1}}
|
||||||
|
textColor="white"
|
||||||
|
disabled={disableCardIn}
|
||||||
|
contentStyle={{flexDirection: 'row-reverse'}}
|
||||||
|
mode={serverConnectionSuccess ? "contained" : "outlined"}
|
||||||
|
buttonColor={cardInCompleted === false ? "red" : "green"}
|
||||||
|
onPress={() => cardInCallback(selectedCard)}
|
||||||
|
icon={() => <SquareArrowOutUpRight color={disableCardIn ? "gray" : "white"}
|
||||||
|
size={18}/>}>
|
||||||
|
{cardInCompleted === null ? "Card In" : cardInCompleted ? "Card In Success" : "Failed"}
|
||||||
|
</Button>
|
||||||
|
{nfcSupported === true && (
|
||||||
|
<IconButton
|
||||||
|
mode="outlined"
|
||||||
|
disabled={disableCardIn}
|
||||||
|
onPress={performPhysicalCardIn}
|
||||||
|
style={{borderRadius: 10, margin: 0, height: "100%", flex: 0.2}}
|
||||||
|
icon={() => <SmartphoneNfc
|
||||||
|
color={disableCardIn ? "gray" : theme.colors.onBackground} size={18}/>}/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
: <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>
|
||||||
<Paragraph>
|
}
|
||||||
There was an issue connecting to <Paragraph
|
</View>
|
||||||
style={{fontFamily: "JetBrainsMono_400Regular"}}>{server.serverAddress}</Paragraph>.
|
|
||||||
Check the network connection and try again.
|
|
||||||
</Paragraph>
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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"
|
|
||||||
onPress={cardInCallback}
|
|
||||||
disabled={disableCardIn}
|
|
||||||
contentStyle={{flexDirection: 'row-reverse'}}
|
contentStyle={{flexDirection: 'row-reverse'}}
|
||||||
mode={connectionTestResult ? "contained" : "outlined"}
|
icon={() => <Plus color="white" size={18}/>}
|
||||||
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) => (
|
||||||
|
<ICCard card={card}
|
||||||
|
key={card._id.toHexString()}
|
||||||
|
actionIcon={<CircleDot color={theme.colors.tertiary} size={24}/>}
|
||||||
|
onPress={() => setUserSelectedCard(card)}>
|
||||||
|
{serverDefaultCard?.code == card.code && <DefaultCardDetail/>}
|
||||||
|
</ICCard>))
|
||||||
|
: <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
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -69,7 +87,7 @@ export default function ServerManager({navigation}) {
|
|||||||
q = q.filtered("serverName CONTAINS[c] $0 OR serverAddress CONTAINS[c] $0", searchQuery);
|
q = q.filtered("serverName CONTAINS[c] $0 OR serverAddress CONTAINS[c] $0", searchQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
return q.sorted("lastUsed");
|
return q.sorted("lastUsed", true);
|
||||||
}, [query, searchQuery]);
|
}, [query, searchQuery]);
|
||||||
|
|
||||||
// enable searchbar
|
// enable searchbar
|
||||||
@@ -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>
|
||||||
|
)}/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import NfcManager from "react-native-nfc-manager";
|
||||||
|
|
||||||
|
import React, {useEffect, useState} from "react";
|
||||||
|
import {Platform, TouchableOpacity, View} from "react-native";
|
||||||
|
import {createNativeStackNavigator} from "@react-navigation/native-stack";
|
||||||
|
|
||||||
|
import {Card, Paragraph, Text, Title, useTheme} from "react-native-paper";
|
||||||
|
import {SmartphoneNfc, SquareAsterisk, SquarePlus} from "lucide-react-native";
|
||||||
|
|
||||||
|
import ManualCardEntry from "./ManualCardEntry";
|
||||||
|
import PhysicalCardScan from "./PhyscialCardScan";
|
||||||
|
|
||||||
|
const Stack = createNativeStackNavigator();
|
||||||
|
|
||||||
|
function CardAddModal() {
|
||||||
|
return (
|
||||||
|
<Stack.Navigator initialRouteName="Select">
|
||||||
|
<Stack.Screen name="Select" component={CardAddMethodSelector} options={{headerShown: false}}/>
|
||||||
|
|
||||||
|
<Stack.Screen name="Scan" component={PhysicalCardScan} options={{title: "Add Physical Card"}}/>
|
||||||
|
<Stack.Screen name="Manual" component={ManualCardEntry} options={{title: "Add Card Manually"}}/>
|
||||||
|
</Stack.Navigator>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAddMethodSelector({navigation}) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const [nfcSupported, setNfcSupported] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
NfcManager.isSupported().then(setNfcSupported);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{paddingHorizontal: 15, paddingTop: 90, gap: 8}}>
|
||||||
|
<View style={{paddingBottom: 25, gap: 5}}>
|
||||||
|
<Title style={{textAlign: "center", fontSize: 32, fontWeight: "bold"}}>Add new Card</Title>
|
||||||
|
<Paragraph style={{textAlign: "center"}}>
|
||||||
|
AMNet supports scanning physical cards, entering details manually or generating new ones.
|
||||||
|
</Paragraph>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{nfcSupported !== null && (
|
||||||
|
<>
|
||||||
|
<MethodCard color="#03a9f4"
|
||||||
|
icon={<SmartphoneNfc/>}
|
||||||
|
disabled={!nfcSupported}
|
||||||
|
title="Scan Physical Card"
|
||||||
|
description={nfcSupported ? "Amusement IC or Transport cards supported" : "NFC is not supported on this device"}
|
||||||
|
onPress={() => navigation.navigate('Scan')}/>
|
||||||
|
|
||||||
|
<View></View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MethodCard color={theme.colors.secondary}
|
||||||
|
icon={<SquareAsterisk/>}
|
||||||
|
title="Enter Details Manually"
|
||||||
|
onPress={() => navigation.navigate('Manual')}/>
|
||||||
|
|
||||||
|
<MethodCard color="#4caf50"
|
||||||
|
icon={<SquarePlus/>}
|
||||||
|
title="Generate New Card"
|
||||||
|
onPress={() => navigation.navigate('Manual', {autoGenerate: true})}/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MethodCard(props: {
|
||||||
|
title: string,
|
||||||
|
description?: string,
|
||||||
|
color: string,
|
||||||
|
disabled?: boolean,
|
||||||
|
icon: React.ReactElement,
|
||||||
|
onPress: () => void
|
||||||
|
}) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const large = props.description?.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity onPress={() => !props.disabled && props.onPress()} disabled={props.disabled}>
|
||||||
|
<Card mode={theme.dark ? "elevated" : "contained"} disabled={props.disabled}>
|
||||||
|
<Card.Content style={{display: "flex", flexDirection: "row", alignItems: "center", gap: 15}}>
|
||||||
|
<View style={{
|
||||||
|
borderRadius: large ? 10 : 5,
|
||||||
|
height: large ? 50 : 30,
|
||||||
|
width: large ? 50 : 30,
|
||||||
|
backgroundColor: props.color,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center"
|
||||||
|
}}>
|
||||||
|
{React.cloneElement(props.icon, {size: large ? 24 : 16, color: theme.colors.surfaceVariant})}
|
||||||
|
</View>
|
||||||
|
<View style={{flexShrink: 1}}>
|
||||||
|
<Text style={{fontSize: large ? 16 : 14}}>{props.title}</Text>
|
||||||
|
{props.description && (<Paragraph style={{fontSize: 12}}>{props.description}</Paragraph>)}
|
||||||
|
</View>
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the nfc library doesn't offer the same support for felica cards on android compared to ios...
|
||||||
|
export default Platform.OS === 'ios' ? CardAddModal : ManualCardEntry;
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import {BSON} from "realm";
|
||||||
|
import {Card, Divider, Paragraph, useTheme} from "react-native-paper";
|
||||||
|
import {useCallback, useEffect, useLayoutEffect, useState} from "react";
|
||||||
|
import {Platform, TextInput, TouchableOpacity, View} from "react-native";
|
||||||
|
|
||||||
|
import {useModalStyles} from "../../utils/ModalStyles";
|
||||||
|
|
||||||
|
import {AMCard} from "../../models/AMCard";
|
||||||
|
import RealmContext from "../../models/RealmContext";
|
||||||
|
|
||||||
|
const {useObject, useRealm} = RealmContext;
|
||||||
|
|
||||||
|
export default function CardEditorModal({navigation, route}) {
|
||||||
|
if (!route.params || !route.params.cardId) {
|
||||||
|
navigation.goBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const theme = useTheme();
|
||||||
|
const modalStyles = useModalStyles();
|
||||||
|
|
||||||
|
const realm = useRealm();
|
||||||
|
const card = useObject(AMCard, BSON.ObjectId.createFromHexString(route.params.cardId));
|
||||||
|
|
||||||
|
const [cardName, setCardName] = useState<string>('');
|
||||||
|
|
||||||
|
const updateCardCallback = useCallback((newName: string) => {
|
||||||
|
realm.write(() => {
|
||||||
|
card.name = newName;
|
||||||
|
});
|
||||||
|
|
||||||
|
navigation.goBack();
|
||||||
|
}, [realm, navigation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (card?.isValid() !== true) {
|
||||||
|
navigation.goBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cardName.length) {
|
||||||
|
setCardName(card.name);
|
||||||
|
}
|
||||||
|
}, [card]);
|
||||||
|
|
||||||
|
// header effects
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerLeft: () => <TouchableOpacity onPress={() => navigation.goBack()}>
|
||||||
|
<Paragraph style={modalStyles.headerText}>Cancel</Paragraph>
|
||||||
|
</TouchableOpacity>,
|
||||||
|
});
|
||||||
|
}, [navigation, modalStyles]);
|
||||||
|
}
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => <TouchableOpacity onPress={() => updateCardCallback(cardName)} disabled={!cardName.length}>
|
||||||
|
<Paragraph style={modalStyles.headerText}>Save</Paragraph>
|
||||||
|
</TouchableOpacity>
|
||||||
|
});
|
||||||
|
}, [cardName]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={modalStyles.rootLayout}>
|
||||||
|
<Card mode="contained">
|
||||||
|
<Card.Content>
|
||||||
|
<View style={modalStyles.formItemRow}>
|
||||||
|
<Paragraph style={modalStyles.formItemName}>
|
||||||
|
Card Name
|
||||||
|
</Paragraph>
|
||||||
|
<TextInput style={modalStyles.formItemInput}
|
||||||
|
autoFocus
|
||||||
|
numberOfLines={1}
|
||||||
|
value={cardName}
|
||||||
|
onChangeText={setCardName}
|
||||||
|
placeholder="Primary Card"
|
||||||
|
placeholderTextColor={theme.colors.outline}/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Divider style={{marginVertical: 15}}/>
|
||||||
|
|
||||||
|
<View style={modalStyles.formItemRow}>
|
||||||
|
<Paragraph style={modalStyles.formItemName}>
|
||||||
|
Access Code
|
||||||
|
</Paragraph>
|
||||||
|
<TextInput readOnly
|
||||||
|
numberOfLines={1}
|
||||||
|
value={card?.code.match(/.{1,4}/g)?.join(" ")}
|
||||||
|
style={{...modalStyles.formItemInput, color: theme.colors.onSurfaceDisabled}}/>
|
||||||
|
</View>
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import {generateCardNumber, DecodeAMAccessCode} from "amnet-shared";
|
||||||
|
import {useCallback, useEffect, useLayoutEffect, useState} from "react";
|
||||||
|
import {Alert, Platform, TextInput, TouchableOpacity, View} from "react-native";
|
||||||
|
import {Button, Card, Divider, Paragraph, useTheme} from "react-native-paper";
|
||||||
|
|
||||||
|
import {AMCard} from "../../models/AMCard";
|
||||||
|
import RealmContext from "../../models/RealmContext";
|
||||||
|
import {useModalStyles} from "../../utils/ModalStyles";
|
||||||
|
|
||||||
|
const {useRealm} = RealmContext;
|
||||||
|
|
||||||
|
export default function CardAddModal({navigation, route}) {
|
||||||
|
const realm = useRealm();
|
||||||
|
const theme = useTheme();
|
||||||
|
const modalStyles = useModalStyles();
|
||||||
|
|
||||||
|
// state values
|
||||||
|
const [cardName, setCardName] = useState('');
|
||||||
|
const [cardNumber, setCardNumber] = useState('');
|
||||||
|
|
||||||
|
const cardAddCallback = useCallback(async (cardId: string, cardName: string) => {
|
||||||
|
try {
|
||||||
|
// validate AM card numbers
|
||||||
|
cardId.startsWith('5') && DecodeAMAccessCode(cardId);
|
||||||
|
} catch {
|
||||||
|
const shouldContinue = await new Promise<boolean>(done => {
|
||||||
|
Alert.alert(
|
||||||
|
"Invalid Access Code",
|
||||||
|
"It looks like the access code isn't from an Amusement IC card. Please double-check the code, and if it's correct, note that some networks might still reject it.",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
style: "destructive",
|
||||||
|
text: "Continue Anyway",
|
||||||
|
onPress: () => done(true)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
style: "cancel",
|
||||||
|
text: "Cancel",
|
||||||
|
onPress: () => done(false)
|
||||||
|
}
|
||||||
|
], {
|
||||||
|
cancelable: true,
|
||||||
|
onDismiss: () => done(false)
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!shouldContinue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicateCard = realm.objects<AMCard>(AMCard.schema.name).find(x => x.code === cardId);
|
||||||
|
|
||||||
|
if (duplicateCard) {
|
||||||
|
realm.write(() => {
|
||||||
|
duplicateCard.name = cardName;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
realm.write(() => {
|
||||||
|
realm.create<AMCard>(AMCard.schema.name, AMCard.generate(cardId, cardName));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
navigation.getParent()?.goBack();
|
||||||
|
} else {
|
||||||
|
navigation.goBack();
|
||||||
|
}
|
||||||
|
}, [realm]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const canSave = cardName.length && cardNumber.length === 20;
|
||||||
|
const themeOptions = canSave ? modalStyles.headerText : {color: theme.colors.outline};
|
||||||
|
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => <TouchableOpacity disabled={!canSave}
|
||||||
|
onPress={() => cardAddCallback(cardNumber, cardName)}>
|
||||||
|
<Paragraph style={{...themeOptions, fontWeight: "600"}}>Save</Paragraph>
|
||||||
|
</TouchableOpacity>
|
||||||
|
})
|
||||||
|
}, [navigation, modalStyles, cardName, cardNumber]);
|
||||||
|
|
||||||
|
// check params for autoGenerate request
|
||||||
|
useEffect(() => {
|
||||||
|
if (route.params?.autoGenerate) {
|
||||||
|
setCardNumber(generateCardNumber());
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={modalStyles.rootLayout}>
|
||||||
|
<Card mode="contained">
|
||||||
|
<Card.Content>
|
||||||
|
<View style={modalStyles.formItemRow}>
|
||||||
|
<Paragraph style={modalStyles.formItemName}>
|
||||||
|
Card Name
|
||||||
|
</Paragraph>
|
||||||
|
<TextInput style={modalStyles.formItemInput}
|
||||||
|
autoFocus
|
||||||
|
numberOfLines={1}
|
||||||
|
value={cardName}
|
||||||
|
onChangeText={setCardName}
|
||||||
|
placeholder="Primary Card"
|
||||||
|
placeholderTextColor={theme.colors.outline}/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Divider style={{marginVertical: 15}}/>
|
||||||
|
|
||||||
|
<View style={modalStyles.formItemRow}>
|
||||||
|
<Paragraph style={modalStyles.formItemName}>
|
||||||
|
Access Code
|
||||||
|
</Paragraph>
|
||||||
|
<TextInput numberOfLines={1}
|
||||||
|
keyboardType="numeric"
|
||||||
|
placeholder="0000 0000 0000 0000 0000"
|
||||||
|
placeholderTextColor={theme.colors.outline}
|
||||||
|
value={cardNumber.match(/.{1,4}/g)?.join(" ")}
|
||||||
|
onChangeText={val => setCardNumber(val?.replaceAll(/\D/g, '').substring(0, 20) ?? '')}
|
||||||
|
style={{...modalStyles.formItemInput, color: theme.colors.onSurface}}/>
|
||||||
|
</View>
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Button mode="text" onPress={() => setCardNumber(generateCardNumber())}>Generate Access Code</Button>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import {useNavigation} from "@react-navigation/native";
|
||||||
|
import {Card, Paragraph, useTheme} from "react-native-paper";
|
||||||
|
import {useCallback, useLayoutEffect, useState} from "react";
|
||||||
|
import {TextInput, TouchableOpacity, View} from "react-native";
|
||||||
|
|
||||||
|
import {useModalStyles} from "../../utils/ModalStyles";
|
||||||
|
|
||||||
|
import RealmContext from "../../models/RealmContext";
|
||||||
|
import {AMCard, AMPhysicalCardData} from "../../models/AMCard";
|
||||||
|
import {felicaCardReadAsync, icCard0008Number, ICType} from "../../lib/iccard";
|
||||||
|
|
||||||
|
const {useRealm} = RealmContext;
|
||||||
|
|
||||||
|
export default function PhysicalCardScan() {
|
||||||
|
const realm = useRealm();
|
||||||
|
const theme = useTheme();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const modalStyles = useModalStyles();
|
||||||
|
|
||||||
|
const [cardName, setCardName] = useState<string>('');
|
||||||
|
|
||||||
|
const handleScan = useCallback(async () => {
|
||||||
|
const cardInfo = await felicaCardReadAsync();
|
||||||
|
|
||||||
|
if (!cardInfo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
realm.write(() => {
|
||||||
|
// check if the card has already been registered (convert a manually entered card to a physically scanned card)
|
||||||
|
const accessCode = cardInfo.type === ICType.Amusement ? cardInfo.accessCode : icCard0008Number(cardInfo.idm);
|
||||||
|
const cardEntity = realm.objects<AMCard>(AMCard.schema.name).find(x => x.code === accessCode) ?? realm.create<AMCard>(AMCard.schema.name, AMCard.generate(accessCode, cardName));
|
||||||
|
|
||||||
|
// set physical card props
|
||||||
|
cardEntity.name = cardName;
|
||||||
|
cardEntity.added = new Date();
|
||||||
|
cardEntity.physicalCardData ??= realm.objectForPrimaryKey<AMPhysicalCardData>(AMPhysicalCardData.schema.name, cardInfo.idm) ?? realm.create<AMPhysicalCardData>(AMPhysicalCardData.schema.name, {
|
||||||
|
cardIDm: cardInfo.idm,
|
||||||
|
cardType: cardInfo.type,
|
||||||
|
cardVendor: cardInfo.type === ICType.Amusement ? cardInfo.vendor : null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// dismiss the parent screen (this screen can only be accessed from modal -> stack)
|
||||||
|
navigation.getParent()?.goBack();
|
||||||
|
}, [realm, navigation, cardName]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const canSave = cardName.length;
|
||||||
|
const themeOptions = canSave ? modalStyles.headerText : {color: theme.colors.outline};
|
||||||
|
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => <TouchableOpacity disabled={!canSave} onPress={() => handleScan()}>
|
||||||
|
<Paragraph style={{...themeOptions, fontWeight: "600"}}>Scan</Paragraph>
|
||||||
|
</TouchableOpacity>
|
||||||
|
})
|
||||||
|
}, [navigation, modalStyles, cardName]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={modalStyles.rootLayout}>
|
||||||
|
<Card mode="contained">
|
||||||
|
<Card.Content>
|
||||||
|
<View style={modalStyles.formItemRow}>
|
||||||
|
<Paragraph style={modalStyles.formItemName}>
|
||||||
|
Card Name
|
||||||
|
</Paragraph>
|
||||||
|
<TextInput style={modalStyles.formItemInput}
|
||||||
|
autoFocus
|
||||||
|
numberOfLines={1}
|
||||||
|
value={cardName}
|
||||||
|
onChangeText={setCardName}
|
||||||
|
placeholder="Primary Card"
|
||||||
|
placeholderTextColor={theme.colors.outline}/>
|
||||||
|
</View>
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Paragraph style={modalStyles.formFootnote}>
|
||||||
|
Ensure you have either an Amusement IC or Transport card ready to scan.
|
||||||
|
After the scan has completed, the card will be saved to your device.
|
||||||
|
</Paragraph>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import {useMemo} from "react";
|
|
||||||
import {Text} from "react-native";
|
|
||||||
import {CreditCard, History} from "lucide-react-native";
|
|
||||||
|
|
||||||
import TimeAgo from "./TimeAgo";
|
|
||||||
import {AMCardData} from "../models/AMCard";
|
|
||||||
import {AMUICard, AMUICardActionProps, AMUICardDetail} from "./AMUICard";
|
|
||||||
|
|
||||||
export interface AimeCardProps extends AMUICardActionProps {
|
|
||||||
card: AMCardData,
|
|
||||||
showFullCardNumber?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AimeCard(props: AimeCardProps) {
|
|
||||||
const displayedCardNumber = useMemo(() => props.showFullCardNumber
|
|
||||||
? props.card.code.match(/.{1,4}/g)?.join(" ")
|
|
||||||
: `•••• ${props.card.code.substring(props.card.code.length - 4)}`,
|
|
||||||
[props.showFullCardNumber, props.card.code]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AMUICard title={props.card.name} {...props}>
|
|
||||||
<AMUICardDetail>
|
|
||||||
<CreditCard size={18} color="gray"/>
|
|
||||||
<Text style={{color: "gray", fontFamily: "JetBrainsMono_400Regular"}}>{displayedCardNumber}</Text>
|
|
||||||
</AMUICardDetail>
|
|
||||||
{props.card.lastUsed && (
|
|
||||||
<AMUICardDetail>
|
|
||||||
<History size={18} color="gray"/>
|
|
||||||
<TimeAgo style={{color: "gray"}} dateTo={props.card.lastUsed}/>
|
|
||||||
</AMUICardDetail>
|
|
||||||
)}
|
|
||||||
</AMUICard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import {Text} from "react-native";
|
||||||
|
import React, {ReactNode, useMemo} from "react";
|
||||||
|
import {CreditCard, Gem, History, Joystick, LucideIcon, RailSymbol} from "lucide-react-native";
|
||||||
|
|
||||||
|
import TimeAgo from "./TimeAgo";
|
||||||
|
import {AMUICard, AMUICardActionProps, AMUICardDetail} from "./AMUICard";
|
||||||
|
|
||||||
|
import {AMCardData} from "../models/AMCard";
|
||||||
|
import {AmusementICVendor, ICType} from "../lib/iccard";
|
||||||
|
|
||||||
|
export interface ICCardProps extends AMUICardActionProps {
|
||||||
|
card: AMCardData,
|
||||||
|
children?: ReactNode,
|
||||||
|
showFullCardNumber?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ICCard(props: ICCardProps) {
|
||||||
|
const displayedCardNumber = useMemo(() => props.showFullCardNumber
|
||||||
|
? props.card.code.match(/.{1,4}/g)?.join(" ")
|
||||||
|
: `•••• ${props.card.code.substring(props.card.code.length - 4)}`,
|
||||||
|
[props.showFullCardNumber, props.card.code]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AMUICard title={props.card.name} {...props}>
|
||||||
|
<PhysicalICInfo card={props.card}/>
|
||||||
|
|
||||||
|
<AMUICardDetail>
|
||||||
|
<CreditCard size={18} color="gray"/>
|
||||||
|
<Text style={{color: "gray", fontFamily: "JetBrainsMono_400Regular"}}>{displayedCardNumber}</Text>
|
||||||
|
</AMUICardDetail>
|
||||||
|
|
||||||
|
{props.card.lastUsed && (
|
||||||
|
<AMUICardDetail>
|
||||||
|
<History size={18} color="gray"/>
|
||||||
|
<TimeAgo style={{color: "gray"}} dateTo={props.card.lastUsed}/>
|
||||||
|
</AMUICardDetail>
|
||||||
|
)}
|
||||||
|
{props.children}
|
||||||
|
</AMUICard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PhysicalICInfo(props: { card: AMCardData | null }) {
|
||||||
|
if (props.card.physicalCardData?.cardType === ICType.Transport) {
|
||||||
|
return (
|
||||||
|
<AMUICardDetail>
|
||||||
|
<RailSymbol size={18} color="gray"/>
|
||||||
|
<Text style={{color: "gray"}}>Transport IC Card</Text>
|
||||||
|
</AMUICardDetail>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.card.physicalCardData?.cardVendor) {
|
||||||
|
const [icon, color]: [LucideIcon, string] = props.card.isLimitedAimeCard ? [Gem, "crimson"] : [Joystick, "gray"];
|
||||||
|
return (
|
||||||
|
<AMUICardDetail>
|
||||||
|
{React.createElement(icon, {size: 18, color})}
|
||||||
|
<Text style={{color}}>
|
||||||
|
{props.card.physicalCardData.isBootlegAmusementCard ? "Bootleg " : null}{ICVendorName(props.card.physicalCardData.cardVendor)}
|
||||||
|
</Text>
|
||||||
|
</AMUICardDetail>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ICVendorName(vendor: AmusementICVendor): string {
|
||||||
|
switch (vendor) {
|
||||||
|
case AmusementICVendor.Sega:
|
||||||
|
return "AiMe Card";
|
||||||
|
|
||||||
|
case AmusementICVendor.Konami:
|
||||||
|
return "e-Amusement Pass";
|
||||||
|
|
||||||
|
case AmusementICVendor.BandaiNamcoGames:
|
||||||
|
case AmusementICVendor.BandaiNamcoEntertainment:
|
||||||
|
return "Banapass";
|
||||||
|
|
||||||
|
case AmusementICVendor.Nesica:
|
||||||
|
return "NESiCA Card";
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "Amusement IC Card";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import {Buffer} from "buffer";
|
||||||
|
|
||||||
|
const s_box_inv = [
|
||||||
|
"243cba36e385a4d0934373b9706ec9f1100e9b2c97e70b636c2920fe86f3e1f5f69fb616047b8fabb1392a1beb5ca8ac3811125f893e7dcaec53db6d1ed281789646fff9541c287a4fd3c0dcc16af2bccb57fd4ae4f0b2c79540625241e2ad49a6b51f02c8da92e5b0c564764821de0f45584cdfa784cfd5154e27806b7ffc44714722dd300ca13be037a03523903274bf8dc2ead650bbd9c483b43168558b5df47218b7eff7982d010361cc0a8e1300e814af095175a52f1d0d658acd66073d05d84be99d997c91d1b819c32b426988c679173a4d5ba25afb253fbdf8edcee687a326a92ebe94087e67608c9c5e6fb39e06fa82aeee5977d71a9a34d456aa33",
|
||||||
|
"f9817c00b93037d590516ef0b206fbcd39145ff81bc74a82708c921deac34ec812e6b610d32f95842542a572e58f55ef86a253aeed262047de786828e445cc35bc3ebb8ec0be34afa60964017b44f5f3b13fa4b33280c94f6f40bf214c74e1115eeb3d712e9d6275d41648771367ad6b5d0787f7a89a59768b9b1ed8eeaae9992dc597f1a783fccadcbab84be88917050ba06523d1ce3603d7e0f291df0e9c2c0d66d673585cb40a4dc23bd2b7e2ac33db6027bd56432404028d7d7ff6b5f4fddd5bec79c4221fa18854e71998947e31a329e35acb6ab0cf6d936c7a08a93c1cd06350c68538c14149ab61522a9fd9181a57462b69dafaff0f15963a8afe9e0c",
|
||||||
|
"11a29ac394a051015bf7d130ee1f06d57767d3c16ee76a1aa48e9f6566d60c2dc6e469b7565a5760fc791ee11652f007d0cdca78c98bb38827f6e0c084cf2fa304008040a7fa759d4b89462228373413ff20b4da08266c8ff25d7b735c4c9071418a9ebb12e8556d2a254aafbd9519a531baadd9c5a66b83c4b6a9ccf9a1b17adcc7db2e7e7f8d4e620f0339fdb8d418c2ec6102531c3b7dbc1d59f58c98f16f9385f82c74d85e4d97ab87820a21425f0d5833443adde9fb5047c8d7819be51b1754862beff429320e7c2315ebe63c35e296683f0b43cede9cbe4f457276b510e3df92d2a84891b9acbf49b2996470eaf33e6314aeedaa24fe3dcbb009383605",
|
||||||
|
"c4f02849554af5fd75af2069c843866bc9a8c6544cdd025be89b597734d7c0515ff37ed4f19081ce198a7833cd978cd66a4b8fa480dc1a1714c5078766ad9d85b2f88d98df3e1f3f645840ac6c5c085d53baffd2a92c25abe432389af9cbc29167d0e322292d9248b40e9905a36f0a15efd5104fd8f600e6462b31578394ae03eb8e1324a11e5ed126d9a7ca366e7137eeb1fa7c7606b823be21bc9ec1da7a3b62277bb0e56318827f0feda02a9cbf11bde0880d3a795256f7b6d309161b70b342602f1ca29f72124547bbe10b018b1ddeec0c4ee9cfcc9574f4a593c7db4dfb3565fee739b5ea96c304414484fc6d30e2f268b7897d733db95a50a63c61aa2e",
|
||||||
|
"1a599cadc8e41154ed370f3ae65f3c4bb81589b1e8da697791568bdb0624cf18f8b087df8c35cb86539da4664a7a710a4838ffdc8320ce9832f6d7af705e738a14721ec129790708e94346d02fde2a4f3d2c50b375fc0b64ae317b61a530a093d2bec255ba6cf36268fdac3b95491f6bb485f7a603ec6e9a81090c6aee9e4ef0ab2d7ea1e1bbc9bc41f2fa2e1bdd2734d36004b501d640f9d50247d9d8e08f2bfbb7c5eb5716e5788df400cd8239c696bdbfe336450e261d63e7843eb69b22a3f58e236dc3997d9097102580d44ce274ccb25c33c7ea05123f51a8fef11c42cad176286f92a767a919a2445baaef587f21c08865b95d4d0d5ac413527c9f9417",
|
||||||
|
"c72b82615bd09684d34a70a19b59339fc02014532917c50bc97b97020d3a1e7c3f6b52e8753df6e40c8a4fc35f2665733123284874aaa73609b1e291045122fc08a605a4f1121c19eb4037c2a0411dd4dc07438f47afd12e98ab01baf06668acf9e769b6cb8d78871503d5dfa31a9d6aea2f944e9e42d7b83892d8bbdedd9abcb04c79f4583ee98381ffe355fd5db2ef9c6d549960da3becfa11d6c42aed4bae13bfb9068be01f7f5aad90390ff3bd466c2cf2f8fed9e6720e89be5ec6a8cff5577e8cb7e1887ac81b18db6f35d216326463a58ef7b47695867de5a95c8500c1934dfb30a2b5253410773c67712d4445560a50b3cd6280ce214924caee276ecc",
|
||||||
|
"a5b06db251551fbf3e8ddd1992ea1bbd326529a48967b1906800da0da65954f210142f45e03b23fad750ac93e643010a5c78709846a97bf395072ad374b33fa36082394e3448c01cf6c291644d3cf89d359a94e37af0f96eb912b8042d02281385728087db2bf14f26a2e1497e9ccca7b6a0d09b3677ad8b6b4a031d058a064bafe531b5d4c60c66ba83fd090fae71b76fdc41e8178e407f6230ffa88425fb16ce3744ab991eeb183a47f75f81cfed582c6cfe9e57539720ca791a5a88f5699fe7d90ebe42df56e44c22aa730b15c5eefcc7d6cbcd8ce27621e9d1ecc87dd88f617c2ebcdeb475d2c4633da15e5d6a0824c927bbef33865bd5385211f4c396c1",
|
||||||
|
"345adb2c5957122b30c2a092bfedbc45de279b96d3e6c5ebd8244ba421cca8d6ce3cbab109e0d732664a831d19ca89670f420771e9bb445e8517c4ae9c3f6b78e76d02a7fe33e3d90a7deaf118877b6203795223003e25b9dd16683b1f90a62ac629918a9def1e8476ee4f39fb111b0b93ad49b705e14dcbe838d055f2f70d8b65cdfad49ec9814e14c826b6f9aaf580f86a98ab58f3b88eb5974372a2da64c1405c13b0e5bd089f1c7e8f06be6e501a2f37a1fc102e705304206348e2fd6f7a7561b3353acf5b888251d247a5a9746c31947c9ac0ec150e28012dc3f0b4e48ddcac3d5f86c79522f4b2a35446d1778c0cffaf565d36697f994cd56073df41f6",
|
||||||
|
"04da79631ebdeae30b652501cda992ed187557308903096adcc798a450911fb10c778566ca121c67c2e01783599dd5228256a89fc66048b311fca328fb06dd5dba297a4fe8fee910cbf3937b6c6954e744a2841d8dcefffa1a879074a1f814aabcc0cf31b4d9bfd85e262dd0e43f19d68c2fab3958726edf3be63cb66288de40b28f9b7c9543d19eac9cd423e10e4b5333462013341b97f7f1c3614a6f5a217f702e554105c5d776e52715ec425c4d78358befd2eeb5beae023ad35fc424f0f9514eeb000ffdaf3ec19a528681807ef62bccb97d68f2ad99a7072c7338b06bb78e71a0f43da60d37db0a473616966d322a5be24594f5a54cc88a4964bb08c9b8"
|
||||||
|
];
|
||||||
|
|
||||||
|
function rotate_right(data: Uint8Array, nBytes: number, nBits: number) {
|
||||||
|
let prior = data[nBytes - 1];
|
||||||
|
|
||||||
|
for (let i = 0; i < nBytes; i++) {
|
||||||
|
let current = data[i];
|
||||||
|
|
||||||
|
data[i] = (current >> nBits) | ((prior & ((1 << nBits) - 1)) << (8 - nBits));
|
||||||
|
prior = current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const S_BOX_INV = s_box_inv.map(x => new Uint8Array(Buffer.from(x, 'hex')));
|
||||||
|
const N_TABLES = S_BOX_INV.length - 1;
|
||||||
|
const ITER_ADD = 5;
|
||||||
|
|
||||||
|
export function performDecrypt(data: Uint8Array) {
|
||||||
|
const spad = data.map(x => S_BOX_INV[N_TABLES][x]);
|
||||||
|
|
||||||
|
let count = (spad[15] >> 4) + 7;
|
||||||
|
let table = spad[15] + ITER_ADD * count;
|
||||||
|
|
||||||
|
for (let z = 0; z < count; z++) {
|
||||||
|
table -= ITER_ADD;
|
||||||
|
rotate_right(spad, 15, 5);
|
||||||
|
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
spad[i] = S_BOX_INV[table % N_TABLES][spad[i]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: spad[0],
|
||||||
|
accessCodeBytes: spad.slice(6) // access code is a binary-coded decimal
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import {Buffer} from "buffer";
|
||||||
|
import NfcManager, {NfcTech} from "react-native-nfc-manager";
|
||||||
|
|
||||||
|
import {performDecrypt} from "./iccard-crypto";
|
||||||
|
|
||||||
|
export enum ICType {
|
||||||
|
Amusement = 1,
|
||||||
|
Transport = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AmusementICVendor {
|
||||||
|
Sega = 0x0078,
|
||||||
|
Konami = 0x0068,
|
||||||
|
BandaiNamcoEntertainment = 0x002A,
|
||||||
|
BandaiNamcoGames = 0x003A,
|
||||||
|
Nesica = 0x0079,
|
||||||
|
Unknown = 0x0000
|
||||||
|
}
|
||||||
|
|
||||||
|
type AmusementICCard = {
|
||||||
|
type: ICType.Amusement,
|
||||||
|
|
||||||
|
idm: string,
|
||||||
|
accessCode: string,
|
||||||
|
vendor: AmusementICVendor
|
||||||
|
}
|
||||||
|
|
||||||
|
type TransportICCard = {
|
||||||
|
type: ICType.Transport,
|
||||||
|
|
||||||
|
idm: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ICCard = AmusementICCard | TransportICCard;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an IC card IDm to the 20-digit "0008" number used by some networks
|
||||||
|
* @param idm The IDm to convert
|
||||||
|
*/
|
||||||
|
export function icCard0008Number(idm: string): string {
|
||||||
|
// 0008 is the numerical representation of the idm padded with zeros until it is 20 digits
|
||||||
|
return BigInt(`0x${idm}`).toString().padStart(20, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a FeliCa card, returning access code information.
|
||||||
|
*/
|
||||||
|
export async function felicaCardReadAsync(): Promise<ICCard | null> {
|
||||||
|
try {
|
||||||
|
await NfcManager.requestTechnology(NfcTech.FelicaIOS, {
|
||||||
|
isReaderModeEnabled: true,
|
||||||
|
invalidateAfterFirstRead: false,
|
||||||
|
alertMessage: "Place an Amusement IC or Transport card on your device."
|
||||||
|
});
|
||||||
|
|
||||||
|
const tag = await NfcManager.getTag();
|
||||||
|
const idm = tag['idm'] as string;
|
||||||
|
|
||||||
|
switch (tag['systemCode'] as string) {
|
||||||
|
case "0003":
|
||||||
|
return {
|
||||||
|
type: ICType.Transport,
|
||||||
|
idm
|
||||||
|
};
|
||||||
|
|
||||||
|
case "88B4":
|
||||||
|
return await readAmusementICCardContents(idm);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
await NfcManager.cancelTechnologyRequest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readAmusementICCardContents(idm: string): Promise<AmusementICCard | null> {
|
||||||
|
const idmBytes = Uint8Array.from(Buffer.from(idm, 'hex'));
|
||||||
|
const readCommand = [
|
||||||
|
0x06, // read
|
||||||
|
...idmBytes,
|
||||||
|
0x01, // service count (fixed)
|
||||||
|
0x0B, 0x00, // service code (000b, le)
|
||||||
|
0x02, // block count
|
||||||
|
0x80, 0x00, // S_PAD0 block
|
||||||
|
0x80, 0x82 // ID block
|
||||||
|
];
|
||||||
|
|
||||||
|
let response = await NfcManager.sendFelicaCommandIOS(readCommand);
|
||||||
|
const offset = response.indexOf(0x07); // look for 0x07 as for some reason the response may not start at response[0]
|
||||||
|
|
||||||
|
// response must have 44 bytes
|
||||||
|
if (offset === -1 || response.length - offset !== 44) {
|
||||||
|
return null;
|
||||||
|
} else if (offset !== 0) {
|
||||||
|
response = response.slice(offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// response format:
|
||||||
|
// 0 - read response (0x07)
|
||||||
|
// 1-8 - IDm
|
||||||
|
// 9 - status flag 1
|
||||||
|
// 10 - status flag 2
|
||||||
|
// 11 - number of blocks (0x02)
|
||||||
|
// 12-27 - S_PAD0 block
|
||||||
|
// 28-43 - ID block (DFC is 35-36)
|
||||||
|
|
||||||
|
if (response[9] !== 0x00 || response[11] !== 0x02) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dfcValue = response[37] | (response[36] << 8);
|
||||||
|
|
||||||
|
if (dfcValue in AmusementICVendor && dfcValue !== AmusementICVendor.Unknown) {
|
||||||
|
const accessCode = performDecrypt(new Uint8Array(response.slice(12, 28)));
|
||||||
|
return {
|
||||||
|
type: ICType.Amusement,
|
||||||
|
accessCode: [...accessCode.accessCodeBytes].map(x => x.toString(16).padStart(2, '0')).join(''),
|
||||||
|
vendor: dfcValue,
|
||||||
|
idm
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: ICType.Amusement,
|
||||||
|
accessCode: icCard0008Number(idm),
|
||||||
|
vendor: AmusementICVendor.Unknown,
|
||||||
|
idm
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,7 +6,8 @@ module.exports = (async () => {
|
|||||||
const config = await getDefaultConfig(__dirname);
|
const config = await getDefaultConfig(__dirname);
|
||||||
|
|
||||||
config.resolver.extraNodeModules = {
|
config.resolver.extraNodeModules = {
|
||||||
'amnet-shared': resolve(__dirname, '../amnet-shared')
|
'amnet-shared': resolve(__dirname, '../amnet-shared'),
|
||||||
|
'@babel/runtime': resolve(__dirname, 'node_modules/@babel/runtime')
|
||||||
};
|
};
|
||||||
|
|
||||||
config.watchFolders = [
|
config.watchFolders = [
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {Realm} from "realm";
|
import {Realm} from "realm";
|
||||||
|
import {AmusementICVendor, ICType} from "../lib/iccard";
|
||||||
|
|
||||||
export interface AMCardData {
|
export interface AMCardData {
|
||||||
code: string;
|
code: string;
|
||||||
@@ -6,6 +7,8 @@ export interface AMCardData {
|
|||||||
|
|
||||||
added: Date;
|
added: Date;
|
||||||
lastUsed?: Date | null;
|
lastUsed?: Date | null;
|
||||||
|
isLimitedAimeCard: boolean;
|
||||||
|
physicalCardData?: AMPhysicalCardData | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AMCard extends Realm.Object<AMCard> implements AMCardData {
|
export class AMCard extends Realm.Object<AMCard> implements AMCardData {
|
||||||
@@ -15,17 +18,35 @@ 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;
|
||||||
|
|
||||||
|
physicalCardData?: AMPhysicalCardData | null = null;
|
||||||
|
|
||||||
|
get isLimitedAimeCard() {
|
||||||
|
// limited cards use the company code 500
|
||||||
|
return this.physicalCardData?.cardType === ICType.Amusement && this.code.startsWith('500');
|
||||||
|
}
|
||||||
|
|
||||||
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()
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static delete(realm: Realm, card: AMCard) {
|
||||||
|
realm.write(() => {
|
||||||
|
if (card.physicalCardData) {
|
||||||
|
realm.delete(card.physicalCardData);
|
||||||
|
}
|
||||||
|
|
||||||
|
realm.delete(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
static schema: Realm.ObjectSchema = {
|
static schema: Realm.ObjectSchema = {
|
||||||
name: "card",
|
name: "card",
|
||||||
primaryKey: "_id",
|
primaryKey: "_id",
|
||||||
@@ -35,7 +56,32 @@ 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, indexed: true},
|
||||||
|
lastUsedGameId: {type: "string", optional: true, indexed: true},
|
||||||
|
physicalCardData: {type: "object", objectType: "physicalCard", optional: true}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AMPhysicalCardData extends Realm.Object<AMPhysicalCardData> {
|
||||||
|
|
||||||
|
cardIDm: string;
|
||||||
|
cardType: ICType;
|
||||||
|
cardVendor: AmusementICVendor | null;
|
||||||
|
|
||||||
|
get isBootlegAmusementCard() {
|
||||||
|
// real cards have an IDm manufacturer code of 012E (first two bytes of the IDm or in this case, the first 4 characters)
|
||||||
|
return this.cardType === ICType.Amusement && !this.cardIDm.startsWith("012E");
|
||||||
|
}
|
||||||
|
|
||||||
|
static schema: Realm.ObjectSchema = {
|
||||||
|
name: "physicalCard",
|
||||||
|
primaryKey: "cardIDm",
|
||||||
|
|
||||||
|
properties: {
|
||||||
|
cardIDm: "string",
|
||||||
|
cardType: "int",
|
||||||
|
cardVendor: {type: "int", optional: true}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,25 +1,34 @@
|
|||||||
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;
|
|
||||||
|
preferredCardIdFormat?: "idm" | "access" | null;
|
||||||
|
|
||||||
|
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,10 +40,11 @@ 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", indexed: "full-text"},
|
||||||
createdAt: {type: "date", default: new Date()},
|
createdAt: {type: "date", default: new Date()},
|
||||||
lastUsed: {type: "date", optional: true}
|
lastUsed: {type: "date", optional: true, indexed: true}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import {createRealmContext} from "@realm/react";
|
import {createRealmContext} from "@realm/react";
|
||||||
import {AMCard} from "./AMCard";
|
import {AMCard, AMPhysicalCardData} from "./AMCard";
|
||||||
import {AMServer} from "./AMServer";
|
import {AMServer} from "./AMServer";
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
export default createRealmContext({
|
export default createRealmContext({
|
||||||
schemaVersion: 1,
|
schemaVersion: 4,
|
||||||
schema: [AMCard, AMServer],
|
schema: [AMPhysicalCardData, AMCard, AMServer],
|
||||||
deleteRealmIfMigrationNeeded: false
|
deleteRealmIfMigrationNeeded: false
|
||||||
});
|
});
|
||||||
Generated
+5056
-4660
File diff suppressed because it is too large
Load Diff
+34
-27
@@ -1,44 +1,51 @@
|
|||||||
{
|
{
|
||||||
"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",
|
||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
"ios-prebuild": "expo prebuild --platform ios",
|
"ios-prebuild": "expo prebuild --platform ios",
|
||||||
"postinstall": "patch-package"
|
"android-prebuild": "expo prebuild --platform android"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.26.7",
|
||||||
"@expo-google-fonts/jetbrains-mono": "^0.2.3",
|
"@expo-google-fonts/jetbrains-mono": "^0.2.3",
|
||||||
"@react-navigation/bottom-tabs": "^6.6.1",
|
"@react-navigation/bottom-tabs": "^7.2.0",
|
||||||
"@react-navigation/native": "^6.1.18",
|
"@react-navigation/native": "^7.0.14",
|
||||||
"@react-navigation/native-stack": "^6.11.0",
|
"@react-navigation/native-stack": "^7.2.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": "^4.1.0",
|
||||||
"expo": "^51.0.0",
|
"expo": "^52.0.30",
|
||||||
"expo-blur": "~13.0.2",
|
"expo-blur": "~14.0.3",
|
||||||
"expo-clipboard": "~6.0.3",
|
"expo-build-properties": "~0.13.2",
|
||||||
"expo-constants": "~16.0.2",
|
"expo-clipboard": "~7.0.1",
|
||||||
"expo-dev-client": "~4.0.10",
|
"expo-constants": "~17.0.3",
|
||||||
"expo-linking": "~6.3.1",
|
"expo-dev-client": "~5.0.11",
|
||||||
"expo-status-bar": "~1.12.1",
|
"expo-linking": "~7.0.5",
|
||||||
"expo-system-ui": "~3.0.7",
|
"expo-splash-screen": "~0.29.21",
|
||||||
"lucide-react-native": "^0.427.0",
|
"expo-status-bar": "~2.0.1",
|
||||||
"patch-package": "^8.0.0",
|
"expo-system-ui": "~4.0.7",
|
||||||
"react": "18.2.0",
|
"lucide-react-native": "^0.474.0",
|
||||||
"react-native": "0.74.1",
|
"pusher-js": "^8.4.0",
|
||||||
|
"react": "18.3.1",
|
||||||
|
"react-native": "0.76.6",
|
||||||
"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-nfc-manager": "^3.16.1",
|
||||||
"react-native-reanimated": "^3.15.0",
|
"react-native-paper": "^5.13.1",
|
||||||
"react-native-safe-area-context": "4.10.5",
|
"react-native-reanimated": "~3.16.1",
|
||||||
"react-native-screens": "3.31.1",
|
"react-native-safe-area-context": "4.12.0",
|
||||||
"react-native-svg": "^15.5.0",
|
"react-native-screens": "~4.4.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"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@expo/ngrok": "^4.1.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
diff --git a/node_modules/expo-modules-core/ios/AppDelegates/ExpoAppDelegate.swift b/node_modules/expo-modules-core/ios/AppDelegates/ExpoAppDelegate.swift
|
|
||||||
index 8ba09a7..8d7d2b0 100644
|
|
||||||
--- a/node_modules/expo-modules-core/ios/AppDelegates/ExpoAppDelegate.swift
|
|
||||||
+++ b/node_modules/expo-modules-core/ios/AppDelegates/ExpoAppDelegate.swift
|
|
||||||
@@ -101,59 +101,6 @@ open class ExpoAppDelegate: UIResponder, UIApplicationDelegate {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- // MARK: - Handling Remote Notification Registration
|
|
||||||
-
|
|
||||||
- open func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
|
||||||
- subscribers.forEach { $0.application?(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken) }
|
|
||||||
- }
|
|
||||||
-
|
|
||||||
- open func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
|
||||||
- subscribers.forEach { $0.application?(application, didFailToRegisterForRemoteNotificationsWithError: error) }
|
|
||||||
- }
|
|
||||||
-
|
|
||||||
- open func application(
|
|
||||||
- _ application: UIApplication,
|
|
||||||
- didReceiveRemoteNotification userInfo: [AnyHashable: Any],
|
|
||||||
- fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
|
|
||||||
- ) {
|
|
||||||
- let selector = #selector(application(_:didReceiveRemoteNotification:fetchCompletionHandler:))
|
|
||||||
- let subs = subscribers.filter { $0.responds(to: selector) }
|
|
||||||
- var subscribersLeft = subs.count
|
|
||||||
- let dispatchQueue = DispatchQueue(label: "expo.application.remoteNotification", qos: .userInteractive)
|
|
||||||
- var failedCount = 0
|
|
||||||
- var newDataCount = 0
|
|
||||||
-
|
|
||||||
- let handler = { (result: UIBackgroundFetchResult) in
|
|
||||||
- dispatchQueue.sync {
|
|
||||||
- if result == .failed {
|
|
||||||
- failedCount += 1
|
|
||||||
- } else if result == .newData {
|
|
||||||
- newDataCount += 1
|
|
||||||
- }
|
|
||||||
-
|
|
||||||
- subscribersLeft -= 1
|
|
||||||
-
|
|
||||||
- if subscribersLeft == 0 {
|
|
||||||
- if newDataCount > 0 {
|
|
||||||
- completionHandler(.newData)
|
|
||||||
- } else if failedCount > 0 {
|
|
||||||
- completionHandler(.failed)
|
|
||||||
- } else {
|
|
||||||
- completionHandler(.noData)
|
|
||||||
- }
|
|
||||||
- }
|
|
||||||
- }
|
|
||||||
- }
|
|
||||||
-
|
|
||||||
- if subs.isEmpty {
|
|
||||||
- completionHandler(.noData)
|
|
||||||
- } else {
|
|
||||||
- subs.forEach { subscriber in
|
|
||||||
- subscriber.application?(application, didReceiveRemoteNotification: userInfo, fetchCompletionHandler: handler)
|
|
||||||
- }
|
|
||||||
- }
|
|
||||||
- }
|
|
||||||
-
|
|
||||||
// MARK: - Continuing User Activity and Handling Quick Actions
|
|
||||||
|
|
||||||
open func application(_ application: UIApplication, willContinueUserActivityWithType userActivityType: String) -> Bool {
|
|
||||||
@@ -13,7 +13,14 @@ const modalStyles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
formItemDivider: {
|
formItemDivider: {
|
||||||
marginVertical: 15
|
marginVertical: 15
|
||||||
}
|
},
|
||||||
|
rootLayout: {
|
||||||
|
display: "flex",
|
||||||
|
gap: 10,
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: 15,
|
||||||
|
paddingTop: 25
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function useModalStyles() {
|
export function useModalStyles() {
|
||||||
|
|||||||
Vendored
+47
-27
@@ -2,30 +2,55 @@
|
|||||||
* 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;
|
||||||
}
|
physicalCardIDm?: string;
|
||||||
interface AMServerInfoResponse {
|
|
||||||
serverAddress: string;
|
|
||||||
serverName: string;
|
|
||||||
apiVersion: number;
|
|
||||||
gameId: string;
|
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Server connection info (api version, address, etc.)
|
||||||
|
*/
|
||||||
|
interface AMServerConnectionInfo {
|
||||||
|
apiVersion: number;
|
||||||
|
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 const AMNET_COMPANY_ID = 597;
|
||||||
declare function generateCardNumber(): string;
|
declare function generateCardNumber(): string;
|
||||||
/**
|
/**
|
||||||
* Validates the provided card id by known parameters
|
* Validates the provided card id by known parameters
|
||||||
@@ -33,22 +58,17 @@ 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 };
|
type DecodedAccessCode = {
|
||||||
|
revision: number;
|
||||||
|
serial: number;
|
||||||
|
};
|
||||||
|
declare function decodeAccessCode(code: string): DecodedAccessCode | null;
|
||||||
|
declare function encodeAccessCode(company: number, serial: number, rev?: number): string;
|
||||||
|
|
||||||
|
export { type AMCardInRequest, AMNET_API_VERSION, AMNET_COMPANY_ID, type AMServerConnectionInfo, type AMServerInfo, type AMServerInstanceMetrics, type AMServerMetadata, CARD_ID_REGEX, decodeAccessCode as DecodeAMAccessCode, encodeAccessCode as EncodeAMAccessCode, MIN_AMNET_API_VERSION, fetchServerInfo, generateCardNumber, getGameName, performCardIn, validateCardId };
|
||||||
|
|||||||
Vendored
+47
-27
@@ -2,30 +2,55 @@
|
|||||||
* 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;
|
||||||
}
|
physicalCardIDm?: string;
|
||||||
interface AMServerInfoResponse {
|
|
||||||
serverAddress: string;
|
|
||||||
serverName: string;
|
|
||||||
apiVersion: number;
|
|
||||||
gameId: string;
|
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Server connection info (api version, address, etc.)
|
||||||
|
*/
|
||||||
|
interface AMServerConnectionInfo {
|
||||||
|
apiVersion: number;
|
||||||
|
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 const AMNET_COMPANY_ID = 597;
|
||||||
declare function generateCardNumber(): string;
|
declare function generateCardNumber(): string;
|
||||||
/**
|
/**
|
||||||
* Validates the provided card id by known parameters
|
* Validates the provided card id by known parameters
|
||||||
@@ -33,22 +58,17 @@ 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 };
|
type DecodedAccessCode = {
|
||||||
|
revision: number;
|
||||||
|
serial: number;
|
||||||
|
};
|
||||||
|
declare function decodeAccessCode(code: string): DecodedAccessCode | null;
|
||||||
|
declare function encodeAccessCode(company: number, serial: number, rev?: number): string;
|
||||||
|
|
||||||
|
export { type AMCardInRequest, AMNET_API_VERSION, AMNET_COMPANY_ID, type AMServerConnectionInfo, type AMServerInfo, type AMServerInstanceMetrics, type AMServerMetadata, CARD_ID_REGEX, decodeAccessCode as DecodeAMAccessCode, encodeAccessCode as EncodeAMAccessCode, MIN_AMNET_API_VERSION, fetchServerInfo, generateCardNumber, getGameName, performCardIn, validateCardId };
|
||||||
|
|||||||
Vendored
+141
-70
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+126
-69
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Generated
+100
-73
@@ -7,7 +7,12 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "amnet-shared",
|
"name": "amnet-shared",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"big-integer": "^1.6.52",
|
||||||
|
"lodash": "^4.17.21"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/lodash": "^4.17.14",
|
||||||
"tsup": "^8.2.4",
|
"tsup": "^8.2.4",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
}
|
}
|
||||||
@@ -541,9 +546,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 +560,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 +574,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 +588,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 +602,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 +616,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 +630,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 +644,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 +658,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 +672,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 +686,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 +700,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 +714,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 +728,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 +742,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 +756,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"
|
||||||
],
|
],
|
||||||
@@ -771,6 +776,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/lodash": {
|
||||||
|
"version": "4.17.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.14.tgz",
|
||||||
|
"integrity": "sha512-jsxagdikDiDBeIRaPYtArcT8my4tN1og7MtMRquFT3XNA6axxyHDRUemqDz/taRDdOUn0GnGHRCuff4q48sW9A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ansi-regex": {
|
"node_modules/ansi-regex": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
|
||||||
@@ -835,6 +847,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/big-integer": {
|
||||||
|
"version": "1.6.52",
|
||||||
|
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
|
||||||
|
"integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==",
|
||||||
|
"license": "Unlicense",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
@@ -963,9 +984,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.3",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1391,6 +1412,12 @@
|
|||||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.17.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.sortby": {
|
"node_modules/lodash.sortby": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
|
||||||
@@ -1423,9 +1450,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 +1750,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 +1766,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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,7 +16,12 @@
|
|||||||
".": "./dist/index.mjs"
|
".": "./dist/index.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/lodash": "^4.17.14",
|
||||||
"tsup": "^8.2.4",
|
"tsup": "^8.2.4",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"big-integer": "^1.6.52",
|
||||||
|
"lodash": "^4.17.21"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-12
@@ -3,26 +3,58 @@
|
|||||||
*/
|
*/
|
||||||
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;
|
||||||
}
|
physicalCardIDm?: string;
|
||||||
|
|
||||||
export interface AMServerInfoResponse {
|
|
||||||
serverAddress: string;
|
|
||||||
serverName: string;
|
|
||||||
apiVersion: number;
|
|
||||||
gameId: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server connection info (api version, address, etc.)
|
||||||
|
*/
|
||||||
|
export interface AMServerConnectionInfo {
|
||||||
|
apiVersion: number;
|
||||||
|
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 +74,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;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,23 +1,12 @@
|
|||||||
|
import {encodeAccessCode} from "./card-encoding";
|
||||||
|
|
||||||
export const CARD_ID_REGEX = /^(?:[0-9]{4} ?){5}$/i;
|
export const CARD_ID_REGEX = /^(?:[0-9]{4} ?){5}$/i;
|
||||||
|
export const AMNET_COMPANY_ID = 597;
|
||||||
|
|
||||||
export function generateCardNumber(): string {
|
export function generateCardNumber(): string {
|
||||||
let cardId = '';
|
// serial between 5000 and 99999999999
|
||||||
|
const serial = Math.floor(Math.random() * 99999994999) + 5000;
|
||||||
while (true) {
|
return encodeAccessCode(AMNET_COMPANY_ID, serial, 0);
|
||||||
// don't start with a 3
|
|
||||||
const firstDigit = Math.floor(Math.random() * 10);
|
|
||||||
|
|
||||||
if (firstDigit !== 3) {
|
|
||||||
cardId = firstDigit.toString();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < 19; i++) {
|
|
||||||
cardId += Math.floor(Math.random() * 10).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
return cardId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+20
-18
@@ -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"
|
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
export * from './api';
|
export * from './api';
|
||||||
export * from './card';
|
export * from './card';
|
||||||
export * from './gameIds';
|
export * from './gameIds';
|
||||||
|
|
||||||
|
export {decodeAccessCode as DecodeAMAccessCode, encodeAccessCode as EncodeAMAccessCode} from './card-encoding';
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2016",
|
"baseUrl": ".",
|
||||||
|
"target": "ES2017",
|
||||||
"module": "CommonJS",
|
"module": "CommonJS",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"strict": true,
|
|
||||||
"noUncheckedIndexedAccess": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"baseUrl": "."
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+671
-661
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>)
|
||||||
|
}
|
||||||
@@ -98,10 +98,9 @@ function AimeCardCreationForm(props: { callback?: () => void }) {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="0000 0000 0000 0000 0000" {...field} />
|
<Input placeholder="0000 0000 0000 0000 0000" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription className="select-none">
|
||||||
If you've got a card, enter the matrix code here. If not, you can
|
If you've got a card, enter the matrix code here. If not, you can
|
||||||
<span className="font-bold hover:cursor-pointer ml-1"
|
<span className="font-bold hover:cursor-pointer ml-1" onClick={() => form.setValue("id", generateCardNumber())}>
|
||||||
onClick={() => form.setValue("id", generateCardNumber())}>
|
|
||||||
generate a new one.
|
generate a new one.
|
||||||
</span>
|
</span>
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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:" && (<>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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],
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
"DOM",
|
"DOM",
|
||||||
"DOM.Iterable"
|
"DOM.Iterable"
|
||||||
],
|
],
|
||||||
"module": "ESNext",
|
"module": "ES2020",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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,76 @@ 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`, replacing the existing [aimeio] section with this one (read all the comments and change settings as needed):
|
||||||
```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
|
; useAimeDBForPhysicalCards - Whether physical cards should use AiMeDB to retrieve access codes. If the game is using a hosted network, set this to 1 to load the same account data/profile as you would get on a physical cab.
|
||||||
;serverAddress=http://+:6070
|
gameId=SDEZ
|
||||||
```
|
;serverName=CHUNI-PENGUIN
|
||||||
|
;serverAddress=http://+:6070
|
||||||
|
;enableKeyboardMode=1
|
||||||
|
;useAimeDBForPhysicalCards=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`, import your existing card (or create a new one) and start playing!
|
||||||
|
|
||||||
### Web Interface
|
### Physical Cards
|
||||||
The web interface can be accessed by navigating to http://card.ppc.moe/ on a device on the same network as the server.
|
AMNet 1.3 adds support for scanning physical cards on iOS devices. If you own a physical AiMe/Banapass/e-Amusement/Suica card, you can scan it using the app and use it in-game.
|
||||||
This website is also installable as a PWA, which will allow it to be registered as an app on your device.
|
By changing the `useAimeDBForPhysicalCards` in segatools.ini, you can choose how the card is read on a per-game basis:
|
||||||
|
|
||||||
> 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.
|
- `0` will send the 20-digit access code on the back of the card to the game and load the save data directly (default option).
|
||||||
|
- `1` - Send the card's IDm to the game and use AiMeDB to retrieve the 20-digit code assigned to the card.
|
||||||
|
|
||||||
[](http://card.ppc.moe)
|
Mode `1` makes the app function in the same way to a real card reader and should be used if you want the same access code to show when scanning the card on a cab.
|
||||||
|
You should also use this option if you're connecting to a hosted server (AquaNet/rinnet/etc).
|
||||||
|
|
||||||
### Native Application
|
Additionally, if `useAimeDBForPhysicalCards` is set to `1` and the game is failing to read the card, make sure an up-to-date version of segatools is being used.
|
||||||
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.
|
|
||||||
Android is technically supported but hasn't been tested; as such APKs aren't provided at this moment.
|
## Interfaces
|
||||||
|
AMNet can be used on most platforms, see below for options available for each platform:
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>Platform</th>
|
||||||
|
<th>Method</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>iOS</td>
|
||||||
|
<td><a href="https://testflight.apple.com/join/kHbeJTsQ" target="_blank">TestFlight</a></td>
|
||||||
|
<td>Aime/Banapass/e-Amusement/Suica IC cards supported</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 +96,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 +113,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
|
||||||
|
|||||||
Reference in New Issue
Block a user