32 Commits
Author SHA1 Message Date
ppc b29daecdcd output var fix 2024-09-05 11:19:04 +01:00
ppc 57d9f07a64 Merge branch 'add-server-metrics' 2024-09-05 11:09:00 +01:00
ppc a538812aee disable auto tagging in actions 2024-09-05 11:06:10 +01:00
ppc dff641e6f1 colour fix 2024-09-05 11:05:27 +01:00
ppc 00736e72dd add startup alert to web 2024-09-05 10:55:03 +01:00
ppc f4dd845ee6 add game name to page, make detail icons use same classes 2024-09-05 10:45:50 +01:00
ppc e467329db8 version bump 2024-09-05 10:45:10 +01:00
ppc e7c521cf2e update padding values 2024-09-04 12:12:58 +01:00
ppc a332562928 add server startup warning message 2024-09-04 11:21:10 +01:00
ppc 245a26aecf explicitly serialize null values 2024-09-04 11:20:56 +01:00
ppc 8a1c751448 change param type for readability 2024-09-04 10:21:52 +01:00
ppc 73b7800bf3 merge serializer contents into webserver class 2024-09-04 10:19:51 +01:00
ppc cff5989a8d add instance metrics to server 2024-09-04 10:17:14 +01:00
ppc 20577f76f0 split and rename server response interfaces 2024-09-04 10:15:29 +01:00
ppc 91dc31226d fix manifest name 2024-09-03 18:15:58 +01:00
ppc 7b5c1b9672 move connection error message to own component 2024-09-03 18:00:50 +01:00
ppc 963f2ddb48 add game name display support 2024-09-03 17:59:10 +01:00
ppc 153d3ff7ad remove enum, replace with string-only method 2024-09-03 17:06:19 +01:00
ppc 9fe0dcc35c use empty string over null 2024-09-03 16:03:40 +01:00
ppc 6acddc2f78 add server share option to context menu 2024-09-03 15:02:11 +01:00
ppc 15329d5dcd add card edit support 2024-09-03 14:52:52 +01:00
ppc ef8e03ee9b fix 2024-09-03 14:19:09 +01:00
ppc a7217c8e31 add game name resolution 2024-09-03 14:15:43 +01:00
ppc 1b3865b325 apply updates to amnet-native 2024-09-03 14:12:26 +01:00
ppc 40934f999d update web to use new method signatures 2024-09-03 14:09:36 +01:00
ppc 087f657563 rebuild local lib 2024-09-03 14:07:22 +01:00
ppc fdfbe73bb2 require api version with server connection requests 2024-09-03 14:07:04 +01:00
ppc e0a0002ca1 add server/website build script 2024-09-03 14:03:40 +01:00
ppc ba58e3af1b document keyboard mode setting switch 2024-09-02 16:28:53 +01:00
ppc e4cb903198 add hold-enter-to-login support back 2024-09-02 16:24:00 +01:00
ppc 14efbc4971 return parsed card when setting 2024-09-02 16:23:48 +01:00
ppc ec98003d2c move card storage/web server handlers to separate files 2024-09-02 14:14:02 +01:00
33 changed files with 915 additions and 477 deletions
+81
View File
@@ -0,0 +1,81 @@
name: Release
on:
# push:
# tags:
# - '^v?(?:[0-9]+.?){1,4}$'
workflow_dispatch:
inputs:
tag:
description: 'Tag Name'
required: true
jobs:
get-upload-url:
runs-on: ubuntu-latest
name: Get Asset Upload URL
outputs:
upload_url: ${{ steps.get-url.outputs.url }}
env:
TAG: ${{ github.event.inputs.tag || github.ref_name }}
steps:
- name: Get Upload URL
id: get-url
run: |
UPLOAD_URL=$(curl -s -X GET -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" "https://gitea.tendokyu.moe/api/v1/repos/ppc/amnet/releases/tags/$TAG" | jq -r '.upload_url')
echo "url=$UPLOAD_URL" >> $GITHUB_OUTPUT
build-server:
runs-on: windows-latest
needs: get-upload-url
name: Build amnet-server
env:
DOTNET_CLI_TELEMETRY_OPTOUT: 1
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.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"
+78
View File
@@ -0,0 +1,78 @@
using System;
using System.Globalization;
using System.Linq;
namespace AMNet.Server;
/// <summary>
/// Represents a storage location to load/read cards from
/// </summary>
internal class CardPresenter
{
private readonly object _cardLock = new();
private StoredCard _currentCard;
public record StoredCard(byte[] Value, string OriginalValue, long ExpiresAt)
{
public bool Expired => Environment.TickCount64 >= ExpiresAt;
public override string ToString() => string.Join(" ", Enumerable.Range(0, 5).Select(x => OriginalValue.Substring(x * 4, 4)));
}
/// <summary>
/// Returns the currently active card without clearing it
/// </summary>
public StoredCard PeekActiveCard()
{
lock (_cardLock)
{
return _currentCard;
}
}
public StoredCard TakeActiveCard()
{
lock (_cardLock)
{
var card = _currentCard;
_currentCard = null;
return card;
}
}
/// <summary>
/// Sets the card
/// </summary>
/// <param name="cardId">The card id to set</param>
/// <param name="validFor">How long the card should be presented to the game for in milliseconds</param>
/// <returns>Whether the card was set successfully</returns>
public StoredCard SetCard(string cardId, long validFor = 5000)
{
// ensure the matrix code is 20-digits long, otherwise pad with zeros
var matrixCode = cardId.Replace(" ", "").PadLeft(20, '0');
var bytes = new byte[10];
for (var i = 0; i < bytes.Length; i++)
{
var value = matrixCode.Substring(i * 2, 2);
if (byte.TryParse(value, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b))
{
bytes[i] = b;
}
else
{
return null;
}
}
var card = new StoredCard(bytes, matrixCode, Environment.TickCount64 + validFor);
lock (_cardLock)
{
_currentCard = card;
}
return card;
}
}
+37
View File
@@ -0,0 +1,37 @@
using System;
using Windows.Win32;
using Windows.Win32.Foundation;
namespace AMNet.Server;
internal static class Config
{
private const string ConfigFileName = @".\segatools.ini";
internal const string IOSection = "aimeio";
static Config()
{
GameId = ReadKey(IOSection, "gameId", 4);
ServerName = ReadKey(IOSection, "serverName", 26, Environment.MachineName);
EnableAimeTxt = PInvoke.GetPrivateProfileInt(IOSection, "enableKeyboardMode", 1, ConfigFileName) > 0;
AimeTxtPath = ReadKey("aime", "aimePath", PInvoke.MAX_PATH, @"DEVICE\aime.txt");
}
public static string GameId { get; }
public static string ServerName { get; }
public static bool EnableAimeTxt { get; }
public static string AimeTxtPath { get; }
internal static unsafe string ReadKey(string section, string key, uint maxLength, string @default = null)
{
// +1 for null terminator
var buffer = stackalloc char[(int)maxLength + 1];
var bufferStr = new PWSTR(buffer);
PInvoke.GetPrivateProfileString(section, key, @default ?? string.Empty, bufferStr, maxLength + 1, ConfigFileName);
return bufferStr.ToString();
}
}
+68 -148
View File
@@ -1,206 +1,126 @@
using System;
using System.Globalization;
using System.Linq;
using System.IO;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text;
using System.Threading;
using Windows.Win32;
using Windows.Win32.Foundation;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace AMNet.Server;
public static class DllMain
{
private const int ApiVersion = 1;
private const string WebAddress = "http://card.ppc.moe";
private static ILogger _serverLogger;
private static string[] _serverAddresses;
private static string _serverName;
private static string _gameId;
private static (byte[] IdBytes, string OriginalId, long Expires)? _currentCard;
private static readonly object _cardLock = new();
// instance metrics
internal static long LastPollTime = -1;
internal static long ServerStartedAt;
static DllMain()
{
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")]
public static ushort GetApiVersion() => 0x0100;
[UnmanagedCallersOnly(EntryPoint = "aime_io_init")]
public static int Init()
{
LoadConfiguration();
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 =>
CancellationTokenRegistration? cancellationRegistration = null;
cancellationRegistration = App.Lifetime.ApplicationStarted.Register(() =>
{
CardReadRequest request;
App.Logger.LogInformation("AMNet Server ({gameId}) started successfully.", Config.GameId);
App.Logger.LogInformation("Visit {addr} from a mobile device on the same network to get started.", WebAddress);
try
{
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;
// ReSharper disable once AccessToModifiedClosure
cancellationRegistration?.Dispose();
});
CancellationTokenRegistration cancellationRegistration = default;
cancellationRegistration = app.Lifetime.ApplicationStarted.Register(() =>
{
_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();
Volatile.Write(ref ServerStartedAt, Environment.TickCount64);
App.RunAsync();
return 0;
}
[UnmanagedCallersOnly(EntryPoint = "aime_io_nfc_poll")]
public static int NfcPoll(byte unitNo)
{
if (_currentCard.HasValue && _currentCard.Value.Expires < Environment.TickCount64)
Volatile.Write(ref LastPollTime, Environment.TickCount64);
if (!Config.EnableAimeTxt)
{
lock (_cardLock)
return 1;
}
// check keyboard input for the enter key being held down (0x0D)
var enterPressed = (PInvoke.GetAsyncKeyState(0x0D) & 0x8000) != 0;
if (!enterPressed)
{
return 0;
}
try
{
using var aimeTxtReader = new StreamReader(File.OpenRead(Config.AimeTxtPath), Encoding.UTF8);
var fileName = Path.GetFileName(Config.AimeTxtPath);
var cardResult = App.Services.GetRequiredService<CardPresenter>().SetCard(aimeTxtReader.ReadLine());
if (cardResult == null)
{
_currentCard = null;
App.Logger.LogWarning("Failed to read card from {file}: invalid format", fileName);
}
else
{
App.Logger.LogInformation("Card loaded from {fileName}", fileName);
}
}
catch (IOException e)
{
App.Logger.LogWarning("Failed to read '{path}': {message}", Path.GetFullPath(Config.AimeTxtPath), e.Message);
}
// there's no polling here (handled by the webserver)
return 0;
}
[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;
}
if (_currentCard.Value.Expires < Environment.TickCount64)
{
_serverLogger.LogWarning("Submitted card id expired before it could be read.");
_currentCard = null;
return 1;
}
Marshal.Copy(_currentCard.Value.IdBytes, 0, luid, (int)luidSize);
_serverLogger.LogInformation("Card read in: {0}", string.Join(" ", Enumerable.Range(0, 5).Select(x => _currentCard.Value.OriginalId.Substring(x * 4, 4))));
_currentCard = null;
return 1;
}
return 0;
var card = App.Services.GetRequiredService<CardPresenter>().TakeActiveCard();
if (card?.Expired != false)
{
return 1;
}
try
{
Marshal.Copy(card.Value, 0, luid, (int)luidSize);
return 0;
}
finally
{
App.Logger.LogInformation("Card read in: {cardId}", card);
}
}
[UnmanagedCallersOnly(EntryPoint = "aime_io_nfc_get_felica_id")]
public static unsafe int GetFelicaId(byte unitNo, ulong* idm)
{
// felica not supported
return 1;
}
public static unsafe int GetFelicaId(byte unitNo, ulong* idm) => 1; // felica not supported
[UnmanagedCallersOnly(EntryPoint = "aime_io_led_set_color")]
public static void SetLedColour(byte unitNo, byte r, byte g, byte b)
{
// 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(';');
}
}
+3 -1
View File
@@ -1,3 +1,5 @@
GetPrivateProfileStringW
GetPrivateProfileInt
AllocConsole
GetAsyncKeyState
AllocConsole
MAX_PATH
-16
View File
@@ -1,16 +0,0 @@
using System.Text.Json.Serialization;
namespace AMNet.Server;
public record SystemState(
[property: JsonPropertyName("apiVersion")] int ApiVersion,
[property: JsonPropertyName("gameId")] string GameId,
[property: JsonPropertyName("serverName")] string ServerName);
public record CardReadRequest(
[property: JsonPropertyName("cardId")] string MatrixCode);
[JsonSerializable(typeof(SystemState))]
[JsonSerializable(typeof(CardReadRequest))]
[JsonSourceGenerationOptions(WriteIndented = true)]
public partial class SerializerContext : JsonSerializerContext;
+106 -3
View File
@@ -1,16 +1,37 @@
using System;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace AMNet.Server;
internal static class WebServer
internal static partial class WebServer
{
public static WebApplicationBuilder BuildServer()
private const int ApiVersion = 1;
public record SystemState(
[property: JsonPropertyName("apiVersion")] int ApiVersion,
[property: JsonPropertyName("gameId")] string GameId,
[property: JsonPropertyName("serverName")] string ServerName,
[property: JsonPropertyName("sessionUptime")] long SessionUptime,
[property: JsonPropertyName("timeSinceLastPoll")] long? TimeSinceLastPoll);
public record CardReadRequest(
[property: JsonPropertyName("cardId")] string MatrixCode);
public static WebApplication BuildServer(params string[] listenAddresses)
{
var builder = WebApplication.CreateSlimBuilder([]);
builder.WebHost.UseUrls(listenAddresses);
builder.Logging.ClearProviders();
builder.Logging.AddSimpleConsole(o =>
{
@@ -39,6 +60,88 @@ internal static class WebServer
options.SerializerOptions.TypeInfoResolverChain.Insert(0, SerializerContext.Default);
});
return builder;
builder.Services.AddSingleton<CardPresenter>();
var app = builder.Build();
app.UseCors();
app.MapGet("/amnet/info", ServerInfo);
app.MapPost("/amnet/signin", ProcessCard);
return app;
}
private static IResult ServerInfo()
{
var startedAt = Volatile.Read(ref DllMain.ServerStartedAt);
var lastPollAt = Volatile.Read(ref DllMain.LastPollTime);
var state = new SystemState(
ApiVersion,
Config.GameId,
Config.ServerName,
Environment.TickCount64 - startedAt,
lastPollAt < 0 ? null : Environment.TickCount64 - lastPollAt);
return Results.Ok(state);
}
private static async Task ProcessCard(HttpContext ctx)
{
CardReadRequest request;
try
{
request = await ctx.Request.ReadFromJsonAsync<CardReadRequest>();
}
catch (JsonException e)
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsync(e.Message);
return;
}
if (string.IsNullOrEmpty(request?.MatrixCode))
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsync("Card id not provided.");
return;
}
if (request.MatrixCode.Length is 0 or < 20)
{
ctx.Response.StatusCode = 422;
await ctx.Response.WriteAsync("Invalid card id length.");
return;
}
var cardPresenter = ctx.RequestServices.GetRequiredService<CardPresenter>();
var currentCard = cardPresenter.PeekActiveCard();
if (currentCard?.Expired == false)
{
var timeLeft = Math.Ceiling(TimeSpan.FromMilliseconds(currentCard.ExpiresAt - Environment.TickCount64).TotalSeconds);
ctx.Response.StatusCode = 429;
ctx.Response.Headers.RetryAfter = timeLeft.ToString(CultureInfo.InvariantCulture);
return;
}
if (cardPresenter.SetCard(request.MatrixCode) != null)
{
ctx.Response.StatusCode = 202;
}
else
{
ctx.Response.StatusCode = 422;
await ctx.Response.WriteAsync("Invalid card id format.");
}
}
[JsonSerializable(typeof(SystemState))]
[JsonSerializable(typeof(CardReadRequest))]
[JsonSourceGenerationOptions(WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.Never)]
private partial class SerializerContext : JsonSerializerContext;
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "AMNet",
"scheme": "amnet",
"version": "1.0.0",
"version": "1.1.0",
"slug": "amnet-native",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
+35 -9
View File
@@ -9,7 +9,7 @@ import RealmContext from "../models/RealmContext";
const {useRealm} = RealmContext;
export default function CardAddModal({navigation}) {
export default function CardAddModal({navigation, route}) {
const realm = useRealm();
const theme = useTheme();
const modalStyles = useModalStyles();
@@ -17,9 +17,10 @@ export default function CardAddModal({navigation}) {
// state values
const [cardName, setCardName] = useState('');
const [cardNumber, setCardNumber] = useState('');
const [cardNumberReadOnly, setCardNumberReadOnly] = useState(false);
const cardAddCallback = useCallback((cardId: string, cardName: string) => {
const duplicateCard = realm.objects<AMCard>("card").find(x => x.code == cardNumber);
const duplicateCard = realm.objects<AMCard>("card").find(x => x.code === cardId);
if (duplicateCard) {
realm.write(() => {
@@ -55,6 +56,25 @@ export default function CardAddModal({navigation}) {
})
}, [navigation, modalStyles, cardName, cardNumber]);
// enable renaming by passing cardId to modal
useLayoutEffect(() => {
if (!route.params?.cardId) {
setCardNumberReadOnly(false);
return;
}
setCardNumber(route.params.cardId);
setCardNumberReadOnly(true);
if (route.params.cardName) {
setCardName(route.params.cardName);
}
navigation.setOptions({
title: "Edit Card"
});
}, [route]);
return (<View style={{display: "flex", gap: 10, justifyContent: "center", paddingHorizontal: 15, paddingTop: 25}}>
<Card mode="contained">
<Card.Content>
@@ -76,21 +96,27 @@ export default function CardAddModal({navigation}) {
<Paragraph style={modalStyles.formItemName}>
Matrix Code
</Paragraph>
<TextInput style={modalStyles.formItemInput}
numberOfLines={1}
<TextInput numberOfLines={1}
keyboardType="numeric"
readOnly={cardNumberReadOnly}
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) ?? '')}/>
onChangeText={val => setCardNumber(val?.replaceAll(/\D/g, '').substring(0, 20) ?? '')}
style={{...modalStyles.formItemInput, color: cardNumberReadOnly ? theme.colors.onSurfaceDisabled : undefined}}/>
</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>
{/*only show card entry info when in create mode*/}
{!cardNumberReadOnly && (
<>
<Paragraph style={modalStyles.formFootnote}>
If you have a physical card, you can enter the 20-digit code on the back.
</Paragraph>
<Button mode="text" onPress={() => setCardNumber(generateCardNumber())}>Generate Matrix Code</Button>
<Button mode="text" onPress={() => setCardNumber(generateCardNumber())}>Generate Matrix Code</Button>
</>
)}
</View>)
}
+33 -22
View File
@@ -12,33 +12,43 @@ import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder";
const {useQuery, useRealm} = RealmContext;
function CardEntry(props: { card: AMCard }) {
function CardEntry(props: { card: AMCard, navigation: any }) {
const realm = useRealm();
const callback = useCallback(async (index: number) => {
const callback = useCallback(async (indexPath: number[]) => {
if (!props.card.isValid()) {
return;
}
switch (index) {
// copy card no
case 0:
switch (indexPath.toString()) {
// copy card
case "0":
await Clipboard.setStringAsync(props.card.code);
break;
case 1:
realm.write(() => {
realm.delete(props.card);
});
break;
return;
// edit card
case "1,0":
props.navigation.navigate('add-card', {cardId: props.card.code, cardName: props.card.name});
return;
// remove card
case "1,1":
realm.write(() => realm.delete(props.card));
return;
}
}, [realm, props.card]);
}, [realm, props.card, props.navigation]);
return (
<ContextMenu
onPress={e => callback(e.nativeEvent?.index)}
onPress={e => callback(e.nativeEvent?.indexPath)}
actions={[
{title: "Copy Card Number", systemIcon: "doc.on.doc"},
{title: "Delete Card", destructive: true, systemIcon: "trash"}]}>
{
title: "",
inlineChildren: true,
actions: [
{title: "Edit Card", systemIcon: "square.and.pencil"},
{title: "Delete Card", destructive: true, systemIcon: "trash"}]
}]}>
<AimeCard card={props.card} showFullCardNumber/>
</ContextMenu>
);
@@ -47,14 +57,15 @@ function CardEntry(props: { card: AMCard }) {
export default function CardManager({navigation}) {
const query = useQuery(AMCard);
const cards = useMemo(() => query.sorted("added", true), [query]);
const theme = useTheme();
if (!cards.length) {
return (
<View style={{flex: 1, gap: 10, justifyContent: "center", alignItems: "center"}}>
<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>
)
}
@@ -64,8 +75,8 @@ export default function CardManager({navigation}) {
style={{padding: 16, paddingTop: 24}}
data={cards}
keyExtractor={i => i._id.toString()}
contentContainerStyle={{paddingBottom: 40}}
contentContainerStyle={{paddingBottom: 120}}
ItemSeparatorComponent={() => <View style={{height: 15}}/>}
renderItem={i => <CardEntry card={i.item}/>}/>
renderItem={i => <CardEntry card={i.item} navigation={navigation}/>}/>
)
}
+70 -43
View File
@@ -1,22 +1,35 @@
import {BSON} from "realm";
import {fetchServerInfo, performCardIn} from "amnet-shared";
import {useSafeAreaInsets} from "react-native-safe-area-context";
import React, {useCallback, useEffect, useLayoutEffect, useMemo, useState} from "react";
import {ActivityIndicator, Button, Card, Paragraph, Title, useTheme} from "react-native-paper";
import {ActivityIndicator, Button, Paragraph, Title, useTheme} from "react-native-paper";
import {Alert, RefreshControl, ScrollView, Share, StyleSheet, TouchableOpacity, View} from "react-native";
import {CircleCheck, CircleDot, CreditCard, Nfc, Plus, SmartphoneNfcIcon, WifiOff, Share as ShareIcon} from "lucide-react-native";
import {
CircleCheck, CircleDot, CreditCard, Nfc, Plus, SmartphoneNfcIcon,
Share as ShareIcon, WifiOff, TvMinimalPlay
} from "lucide-react-native";
import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder";
import {AMUIStatusAlert} from "../components/AMUIStatusAlert";
import {AimeCard} from "../components/AimeCard";
import RealmContext from "../models/RealmContext";
import {AMServer} from "../models/AMServer";
import {AMCard} from "../models/AMCard";
import ObjectId = BSON.ObjectId;
import {
AMServerConnectionInfo,
AMServerInstanceMetrics,
fetchServerInfo,
getGameName,
performCardIn
} from "amnet-shared";
const {useRealm, useQuery, useObject} = RealmContext;
export default function ServerCardIn({route, navigation}) {
const realm = useRealm();
const theme = useTheme();
const safeArea = useSafeAreaInsets();
// card listing state
const query = useQuery(AMCard);
@@ -50,30 +63,21 @@ export default function ServerCardIn({route, navigation}) {
const server = useObject(AMServer, ObjectId.createFromHexString(serverId));
useLayoutEffect(() => {
if (!server) {
if (server?.isValid() !== true) {
return;
}
navigation.setOptions({
title: server.serverName,
headerRight: () => (
<TouchableOpacity onPress={() => Share.share({url: `http://card.ppc.moe/#/servers/add?address=${encodeURIComponent(server.serverAddress)}`})}>
<TouchableOpacity onPress={() => Share.share({url: server.shareUrl})}>
<ShareIcon/>
</TouchableOpacity>
)
});
}, [navigation, server]);
// set selected card (once)
useEffect(() => {
if (selectedCard?.isValid() === true) {
return;
}
setSelectedCard(cards.length ? cards[0] : null);
}, [realm]);
// refresh server info (server change or reload request)
// card selection, server connection checks
useEffect(() => {
if (server?.isValid() !== true) {
navigation.goBack();
@@ -86,6 +90,10 @@ export default function ServerCardIn({route, navigation}) {
return;
}
if (selectedCard?.isValid() !== true) {
setSelectedCard(cards.length ? cards[0] : null);
}
const abortController = new AbortController();
fetchServerInfo(server.serverAddress, abortController.signal).then(r => {
if (r) {
@@ -112,7 +120,7 @@ export default function ServerCardIn({route, navigation}) {
setCardInCompleted(false);
const abortController = new AbortController();
performCardIn(server.serverAddress, {cardId: selectedCard.code}, abortController.signal).then(async r => {
performCardIn(server, {cardId: selectedCard.code}, abortController.signal).then(async r => {
const now = new Date();
setCardInCompleted(r);
@@ -132,6 +140,12 @@ export default function ServerCardIn({route, navigation}) {
return () => abortController.abort();
}, [server, selectedCard]);
const performRefresh = () => {
setServerRefreshing(true);
setServerReloadCounter(serverReloadCounter + 1);
}
const gameName = useMemo(() => getGameName(server.gameId), [server.gameId]);
const disableCardIn = useMemo(() => cardInCompleted !== null || !connectionTestResult, [cardInCompleted, connectionTestResult]);
if (connectionTestResult === null) {
@@ -146,30 +160,10 @@ export default function ServerCardIn({route, navigation}) {
return (
<ScrollView contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{...styles.container, paddingTop: connectionTestResult ? 0 : 10}}
refreshControl={<RefreshControl refreshing={serverRefreshing}
tintColor={theme.colors.onSurface}
onRefresh={() => {
setServerRefreshing(true);
setServerReloadCounter(serverReloadCounter + 1);
}}/>}>
{!connectionTestResult && (
<Card mode="outlined" style={{borderColor: theme.colors.secondary}}>
<Card.Content>
<View style={{alignItems: "center", flexDirection: "row", gap: 10}}>
<WifiOff color={theme.colors.secondary} size={24}/>
<Title style={{color: theme.colors.secondary}}>Connection Failed</Title>
</View>
<Paragraph>
There was an issue connecting to <Paragraph
style={{fontFamily: "JetBrainsMono_400Regular"}}>{server.serverAddress}</Paragraph>.
Check the network connection and try again.
</Paragraph>
</Card.Content>
</Card>
)}
refreshControl={<RefreshControl refreshing={serverRefreshing} tintColor={theme.colors.onSurface} onRefresh={performRefresh}/>}>
<StatusAlert server={server} connectionEstablished={connectionTestResult}/>
<View style={styles.section}>
<Title style={{fontWeight: "bold"}}>Selected Card</Title>
<Title style={styles.sectionTitle}>{gameName ? `Play ${gameName}` : "Selected Card"}</Title>
{selectedCard ? <>
<AimeCard actionDisabled
card={selectedCard}
@@ -196,7 +190,7 @@ export default function ServerCardIn({route, navigation}) {
{cards.length > 0 && (
<View style={styles.section}>
<Title style={{fontWeight: "bold"}}>Additional Cards</Title>
<Title style={styles.sectionTitle}>Additional Cards</Title>
<Button mode="contained"
textColor="white"
buttonColor={theme.colors.tertiary}
@@ -207,7 +201,7 @@ export default function ServerCardIn({route, navigation}) {
</Button>
{unselectedCards.length
? unselectedCards.map(card => (
? unselectedCards.map((card: AMCard) => (
<AimeCard card={card}
key={card._id.toHexString()}
actionIcon={<CircleDot color={theme.colors.tertiary} size={24}/>}
@@ -221,17 +215,50 @@ export default function ServerCardIn({route, navigation}) {
)
}
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;
}
const styles = StyleSheet.create({
section: {
gap: 10
},
sectionTitle: {
fontWeight: "bold"
},
emptyPlaceholderView: {
marginVertical: 30,
gap: 15
},
container: {
paddingHorizontal: 16,
paddingBottom: 25,
paddingBottom: 80,
gap: 20
}
})
});
+32 -14
View File
@@ -1,7 +1,7 @@
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 {FlatList, NativeSyntheticEvent, Share, View} from "react-native";
import React, {useCallback, useLayoutEffect, useMemo, useState} from "react";
import {Globe, History, Joystick, Plus, ServerOff} from "lucide-react-native";
import ContextMenu, {ContextMenuOnPressNativeEvent} from "react-native-context-menu-view";
import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder";
import {AMUICard, AMUICardDetail} from "../components/AMUICard";
@@ -9,11 +9,14 @@ import TimeAgo from "../components/TimeAgo";
import RealmContext from "../models/RealmContext";
import {AMServer} from "../models/AMServer";
import {getGameName} from "amnet-shared";
const {useRealm, useQuery} = RealmContext;
function ServerCard(props: { server: AMServer, navigation: any }) {
const realm = useRealm();
const gameName = useMemo(() => getGameName(props.server.gameId), [props.server.gameId]);
const pageCallback = useCallback(() => {
props.navigation.navigate('server', {
serverId: props.server._id.toHexString()
@@ -21,31 +24,46 @@ function ServerCard(props: { server: AMServer, navigation: any }) {
}, [props.navigation, props.server]);
const pageContextMenuCallback = useCallback((action: NativeSyntheticEvent<ContextMenuOnPressNativeEvent>) => {
switch (action.nativeEvent.index) {
case 0:
switch (action.nativeEvent.indexPath.toString()) {
case "0":
pageCallback();
break;
return;
case 1:
realm.write(() => {
realm.delete(props.server);
});
break;
case "1,0":
Share.share({url: props.server.shareUrl});
return;
case "1,1":
realm.write(() => realm.delete(props.server));
return;
}
}, [realm, props.server]);
return (
<ContextMenu onPress={pageContextMenuCallback} actions={[
{title: `Select ${props.server.serverName}`, systemIcon: "chevron.right"},
{title: "Delete Server", systemIcon: "trash", destructive: true}
{
title: "",
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}>
<AMUICardDetail>
<Globe size={18} color="gray"/>
<Text style={{color: "gray", fontFamily: "JetBrainsMono_400Regular"}}>
<Paragraph style={{color: "gray", fontFamily: "JetBrainsMono_400Regular"}}>
{props.server.serverAddress}
</Text>
</Paragraph>
</AMUICardDetail>
{gameName && (
<AMUICardDetail>
<Joystick size={18} color="gray"/>
<Paragraph style={{color: "gray"}}>{gameName}</Paragraph>
</AMUICardDetail>
)}
<AMUICardDetail>
<History size={18} color="gray"/>
{props.server.lastUsed
@@ -102,7 +120,7 @@ export default function ServerManager({navigation}) {
data={servers}
keyExtractor={i => i._id.toString()}
style={{padding: 16, paddingTop: 24}}
contentContainerStyle={{paddingBottom: 40}}
contentContainerStyle={{paddingBottom: 80}}
ItemSeparatorComponent={() => <View style={{height: 15}}/>}
renderItem={i => <ServerCard navigation={navigation} server={i.item}/>}/>
)
@@ -0,0 +1,22 @@
import {View} from "react-native";
import React, {ReactNode} from "react";
import {Card, Title} from "react-native-paper";
import {LucideIcon, LucideProps} from "lucide-react-native";
export function AMUIStatusAlert(props: { title: string, color: string, icon: LucideIcon, children?: ReactNode }) {
return (
<Card mode="outlined" style={{borderColor: props.color}}>
<Card.Content>
<View style={{alignItems: "center", flexDirection: "row", gap: 10}}>
{renderIconWithProps(props.icon, {size: 22, color: props.color})}
<Title style={{color: props.color}}>{props.title}</Title>
</View>
{props.children}
</Card.Content>
</Card>
);
}
function renderIconWithProps(icon: LucideIcon, additionalProps: Partial<LucideProps>) {
return React.cloneElement(React.createElement(icon), additionalProps);
}
+3 -3
View File
@@ -15,14 +15,14 @@ export class AMCard extends Realm.Object<AMCard> implements AMCardData {
name: string;
added: Date;
lastUsed?: Date | null;
lastUsed?: Date;
static generate(code: string, name: string) {
return {
_id: new Realm.BSON.ObjectId(),
added: new Date(),
code: code,
name: name,
added: new Date()
name: name
};
}
+13 -5
View File
@@ -1,25 +1,32 @@
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;
apiVersion: number;
gameId?: string;
serverName: string;
serverAddress: string;
lastUsed: Date;
createdAt: Date;
apiVersion: number;
get shareUrl() {
return `http://card.ppc.moe/#/servers/add?address=${encodeURIComponent(this.serverAddress)}`;
}
static generate(serverAddress: string) {
return {
_id: new Realm.BSON.ObjectId(),
apiVersion: 0,
serverAddress,
serverName: "New Server",
serverAddress: serverAddress,
createdAt: new Date(),
lastUsed: null
}
}
@@ -31,6 +38,7 @@ export class AMServer extends Realm.Object<AMServer> implements AMServerInfoResp
properties: {
_id: "objectId",
apiVersion: {type: "int", default: 0},
gameId: {type: "string", optional: true},
serverAddress: "string",
serverName: {type: "string", default: "New Server"},
createdAt: {type: "date", default: new Date()},
+1 -1
View File
@@ -4,7 +4,7 @@ import {AMServer} from "./AMServer";
// @ts-ignore
export default createRealmContext({
schemaVersion: 1,
schemaVersion: 2,
schema: [AMCard, AMServer],
deleteRealmIfMigrationNeeded: false
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "amnet-native",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"scripts": {
"android": "expo run:android",
+36 -25
View File
@@ -2,28 +2,51 @@
* The latest version of the AMNet API this library supports
*/
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 {
cardId: string;
}
interface AMServerInfoResponse {
serverAddress: string;
serverName: string;
/**
* Server connection info (api version, address, etc.)
*/
interface AMServerConnectionInfo {
apiVersion: number;
gameId: string;
serverAddress: string;
}
/**
* User-definable server metadata
*/
interface AMServerMetadata {
serverName: string;
gameId?: string | null | undefined;
}
/**
* Metrics about the current game session
*/
interface AMServerInstanceMetrics {
timeSinceLastPoll?: number;
sessionUptime?: number;
}
/**
* The response from an AMNet server's info endpoint
*/
type AMServerInfo = AMServerMetadata & AMServerConnectionInfo & AMServerInstanceMetrics;
/**
* Performs the card-in request to the specified server
* @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 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
* @param serverAddress The origin address of the server to connect to
* @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 function generateCardNumber(): string;
@@ -33,22 +56,10 @@ declare function generateCardNumber(): string;
*/
declare function validateCardId(id: string): boolean;
declare enum GameIds {
/**
* Initial D The Arcade
*/
IDAC = "SDGT",
/**
* Chunithm (Paradise Lost)
*/
Chuni = "SDBT",
/**
* Chunithm NEW onwards
*/
Chusan = "SDHD",
ONGEKI = "SDDT",
maimaiDX = "SDEZ",
DivaFT = "SBZV"
}
/**
* Get the game name from the game identifier (4-letter code)
* @param id
*/
declare function getGameName(id: string): string | null;
export { type AMCardInRequest, AMNET_API_VERSION, type AMServerInfoResponse, CARD_ID_REGEX, GameIds, fetchServerInfo, generateCardNumber, performCardIn, validateCardId };
export { type AMCardInRequest, AMNET_API_VERSION, type AMServerConnectionInfo, type AMServerInfo, type AMServerInstanceMetrics, type AMServerMetadata, CARD_ID_REGEX, MIN_AMNET_API_VERSION, fetchServerInfo, generateCardNumber, getGameName, performCardIn, validateCardId };
+36 -25
View File
@@ -2,28 +2,51 @@
* The latest version of the AMNet API this library supports
*/
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 {
cardId: string;
}
interface AMServerInfoResponse {
serverAddress: string;
serverName: string;
/**
* Server connection info (api version, address, etc.)
*/
interface AMServerConnectionInfo {
apiVersion: number;
gameId: string;
serverAddress: string;
}
/**
* User-definable server metadata
*/
interface AMServerMetadata {
serverName: string;
gameId?: string | null | undefined;
}
/**
* Metrics about the current game session
*/
interface AMServerInstanceMetrics {
timeSinceLastPoll?: number;
sessionUptime?: number;
}
/**
* The response from an AMNet server's info endpoint
*/
type AMServerInfo = AMServerMetadata & AMServerConnectionInfo & AMServerInstanceMetrics;
/**
* Performs the card-in request to the specified server
* @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 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
* @param serverAddress The origin address of the server to connect to
* @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 function generateCardNumber(): string;
@@ -33,22 +56,10 @@ declare function generateCardNumber(): string;
*/
declare function validateCardId(id: string): boolean;
declare enum GameIds {
/**
* Initial D The Arcade
*/
IDAC = "SDGT",
/**
* Chunithm (Paradise Lost)
*/
Chuni = "SDBT",
/**
* Chunithm NEW onwards
*/
Chusan = "SDHD",
ONGEKI = "SDDT",
maimaiDX = "SDEZ",
DivaFT = "SBZV"
}
/**
* Get the game name from the game identifier (4-letter code)
* @param id
*/
declare function getGameName(id: string): string | null;
export { type AMCardInRequest, AMNET_API_VERSION, type AMServerInfoResponse, CARD_ID_REGEX, GameIds, fetchServerInfo, generateCardNumber, performCardIn, validateCardId };
export { type AMCardInRequest, AMNET_API_VERSION, type AMServerConnectionInfo, type AMServerInfo, type AMServerInstanceMetrics, type AMServerMetadata, CARD_ID_REGEX, MIN_AMNET_API_VERSION, fetchServerInfo, generateCardNumber, getGameName, performCardIn, validateCardId };
+30 -13
View File
@@ -59,9 +59,10 @@ var src_exports = {};
__export(src_exports, {
AMNET_API_VERSION: () => AMNET_API_VERSION,
CARD_ID_REGEX: () => CARD_ID_REGEX,
GameIds: () => GameIds,
MIN_AMNET_API_VERSION: () => MIN_AMNET_API_VERSION,
fetchServerInfo: () => fetchServerInfo,
generateCardNumber: () => generateCardNumber,
getGameName: () => getGameName,
performCardIn: () => performCardIn,
validateCardId: () => validateCardId
});
@@ -69,10 +70,14 @@ module.exports = __toCommonJS(src_exports);
// src/api.ts
var AMNET_API_VERSION = 1;
function performCardIn(serverAddress, request, signal) {
var MIN_AMNET_API_VERSION = 1;
function performCardIn(server, request, signal) {
return __async(this, null, function* () {
if (server.apiVersion < MIN_AMNET_API_VERSION || server.apiVersion > AMNET_API_VERSION) {
throw new Error("Unsupported server API version");
}
try {
const response = yield fetch(`${new URL(serverAddress).origin}/amnet/signin`, {
const response = yield fetch(`${new URL(server.serverAddress).origin}/amnet/signin`, {
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -92,6 +97,9 @@ function fetchServerInfo(serverAddress, abort) {
const response = yield fetch(`${new URL(serverAddress).origin}/amnet/info`, {
signal: abort
});
if (!response.ok) {
return null;
}
return __spreadProps(__spreadValues({}, yield response.json()), {
serverAddress
});
@@ -122,22 +130,31 @@ function validateCardId(id) {
}
// src/gameIds.ts
var GameIds = /* @__PURE__ */ ((GameIds2) => {
GameIds2["IDAC"] = "SDGT";
GameIds2["Chuni"] = "SDBT";
GameIds2["Chusan"] = "SDHD";
GameIds2["ONGEKI"] = "SDDT";
GameIds2["maimaiDX"] = "SDEZ";
GameIds2["DivaFT"] = "SBZV";
return GameIds2;
})(GameIds || {});
function getGameName(id) {
switch (id == null ? void 0 : 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:
return null;
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
AMNET_API_VERSION,
CARD_ID_REGEX,
GameIds,
MIN_AMNET_API_VERSION,
fetchServerInfo,
generateCardNumber,
getGameName,
performCardIn,
validateCardId
});
+1 -1
View File
File diff suppressed because one or more lines are too long
+28 -12
View File
@@ -40,10 +40,14 @@ var __async = (__this, __arguments, generator) => {
// src/api.ts
var AMNET_API_VERSION = 1;
function performCardIn(serverAddress, request, signal) {
var MIN_AMNET_API_VERSION = 1;
function performCardIn(server, request, signal) {
return __async(this, null, function* () {
if (server.apiVersion < MIN_AMNET_API_VERSION || server.apiVersion > AMNET_API_VERSION) {
throw new Error("Unsupported server API version");
}
try {
const response = yield fetch(`${new URL(serverAddress).origin}/amnet/signin`, {
const response = yield fetch(`${new URL(server.serverAddress).origin}/amnet/signin`, {
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -63,6 +67,9 @@ function fetchServerInfo(serverAddress, abort) {
const response = yield fetch(`${new URL(serverAddress).origin}/amnet/info`, {
signal: abort
});
if (!response.ok) {
return null;
}
return __spreadProps(__spreadValues({}, yield response.json()), {
serverAddress
});
@@ -93,21 +100,30 @@ function validateCardId(id) {
}
// src/gameIds.ts
var GameIds = /* @__PURE__ */ ((GameIds2) => {
GameIds2["IDAC"] = "SDGT";
GameIds2["Chuni"] = "SDBT";
GameIds2["Chusan"] = "SDHD";
GameIds2["ONGEKI"] = "SDDT";
GameIds2["maimaiDX"] = "SDEZ";
GameIds2["DivaFT"] = "SBZV";
return GameIds2;
})(GameIds || {});
function getGameName(id) {
switch (id == null ? void 0 : 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:
return null;
}
}
export {
AMNET_API_VERSION,
CARD_ID_REGEX,
GameIds,
MIN_AMNET_API_VERSION,
fetchServerInfo,
generateCardNumber,
getGameName,
performCardIn,
validateCardId
};
+1 -1
View File
File diff suppressed because one or more lines are too long
+44 -9
View File
@@ -3,26 +3,57 @@
*/
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 {
cardId: string;
}
export interface AMServerInfoResponse {
serverAddress: string;
serverName: string;
/**
* Server connection info (api version, address, etc.)
*/
export interface AMServerConnectionInfo {
apiVersion: number;
gameId: string;
serverAddress: string;
}
/**
* User-definable server metadata
*/
export interface AMServerMetadata {
serverName: string;
gameId?: string | null | undefined;
}
/**
* Metrics about the current game session
*/
export interface AMServerInstanceMetrics {
timeSinceLastPoll?: number;
sessionUptime?: number;
}
/**
* The response from an AMNet server's info endpoint
*/
export type AMServerInfo = AMServerMetadata & AMServerConnectionInfo & AMServerInstanceMetrics;
/**
* Performs the card-in request to the specified server
* @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 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 {
const response = await fetch(`${new URL(serverAddress).origin}/amnet/signin`, {
const response = await fetch(`${new URL(server.serverAddress).origin}/amnet/signin`, {
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -42,16 +73,20 @@ export async function performCardIn(serverAddress: string, request: AMCardInRequ
* @param serverAddress The origin address of the server to connect to
* @param 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 {
const response = await fetch(`${new URL(serverAddress).origin}/amnet/info`, {
signal: abort
});
if (!response.ok) {
return null;
}
return {
...await response.json(),
serverAddress: serverAddress
} satisfies AMServerInfoResponse;
} satisfies AMServerMetadata;
} catch {
return null;
}
+22 -20
View File
@@ -1,20 +1,22 @@
export enum GameIds {
/**
* Initial D The Arcade
*/
IDAC = "SDGT",
/**
* Chunithm (Paradise Lost)
*/
Chuni = "SDBT",
/**
* Chunithm NEW onwards
*/
Chusan = "SDHD",
ONGEKI = "SDDT",
maimaiDX = "SDEZ",
DivaFT = "SBZV"
}
/**
* Get the game name from the game identifier (4-letter code)
* @param id
*/
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:
return null;
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"id": "?pwa-home=1",
"name": "AimeNet",
"description": "Aime Card Switcher",
"name": "AMNet",
"description": "IC Card Switcher",
"start_url": "/",
"categories": [
"utilities",
+8 -9
View File
@@ -8,20 +8,19 @@ import {AMCard, AMCardActionProps, AMCardDetail} from "@/components/am-card.tsx"
export function AimeCardEntry(props: AMCardActionProps & { card: AimeCard, children?: Readonly<React.ReactNode> }) {
const isDesktop = useMediaQuery("(min-width: 512px)");
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 && (
<AMCardDetail>
<History className="h-5 w-5"/>
<AMCardDetail icon={History}>
<TimeAgo date={props.card.lastUsed}/>
</AMCardDetail>
)}
<AMCardDetail>
<CreditCard className="h-5 w-5 cursor-pointer" onClick={() => navigator.clipboard.writeText(props.card.id)}/>
<code>{isDesktop
? props.card.id.match(/.{1,4}/g)?.join(" ")
: `•••• ${props.card.id.substring(props.card.id.length - 4)}`}
<AMCardDetail icon={CreditCard} iconClicked={() => navigator.clipboard.writeText(props.card.id)}>
<code>
{isDesktop
? props.card.id.match(/.{1,4}/g)?.join(" ")
: `•••• ${props.card.id.substring(props.card.id.length - 4)}`}
</code>
</AMCardDetail>
</AMCard>
+5 -3
View File
@@ -1,5 +1,6 @@
import React from "react";
import {ChevronRight} from "lucide-react";
import {ChevronRight, LucideIcon} from "lucide-react";
import {Card} from "@/components/ui/card.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}>
{props.title}
</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}
</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 (
<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}
</div>
)
+45
View File
@@ -0,0 +1,45 @@
import {useMemo} from "react";
import TimeAgo from "react-timeago";
import {getGameName} from "amnet-shared";
import {useNavigate} from "react-router-dom";
import {Earth, History, Joystick, Trash} from "lucide-react";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from "@/components/ui/context-menu.tsx";
import {AimeServer, db} from "@/lib/database.ts";
import {AMCard, AMCardDetail} from "@/components/am-card.tsx";
export function AMServer(props: { server: AimeServer }) {
const navigate: (p: string) => void = useNavigate();
const gameName = useMemo(() => props.server.gameId ? getGameName(props.server.gameId) : null, [props.server.gameId]);
return (
<ContextMenu key={props.server.id}>
<ContextMenuTrigger>
<AMCard title={props.server.serverName} titlePerformsAction actionClicked={() => navigate(`/servers/${props.server.id}`)}>
<AMCardDetail icon={Earth}>
<code className="text-ellipsis whitespace-nowrap overflow-hidden">{props.server.serverAddress}</code>
</AMCardDetail>
<AMCardDetail icon={History}>
{props.server.lastConnected > 0 ? <TimeAgo date={props.server.lastConnected}/> :
<span>never</span>}
</AMCardDetail>
{gameName && <AMCardDetail icon={Joystick}>{gameName}</AMCardDetail>}
</AMCard>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem inset onClick={() => navigate(`/servers/${props.server.id}`)}>
Select Server
</ContextMenuItem>
<ContextMenuSeparator/>
<ContextMenuItem className="text-red-500" onClick={() => db.servers.delete(props.server.id)}>
<Trash className="mr-2 h-4 w-4"/> Remove Server
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>)
}
+3 -3
View File
@@ -1,9 +1,9 @@
import Dexie, { type EntityTable } from 'dexie';
import {AMServerInfoResponse} from "amnet-shared";
import {AMServerConnectionInfo, AMServerMetadata} from "amnet-shared";
export const CARD_LIMIT = 15;
interface AimeServer extends AMServerInfoResponse {
interface AimeServer extends AMServerMetadata, AMServerConnectionInfo {
id: number;
lastConnected: number;
}
@@ -19,7 +19,7 @@ const db = new Dexie('amnet_db') as Dexie & {
cards: EntityTable<AimeCard, 'id'>;
};
db.version(1).stores({
db.version(2).stores({
servers: '++id, serverAddress',
cards: 'id'
});
+43 -25
View File
@@ -1,10 +1,8 @@
import {useLiveQuery} from "dexie-react-hooks";
import {useEffect, useMemo, useState} from "react";
import {useNavigate, useParams} from "react-router-dom";
import {CircleDot, CircleCheckBig, Home, Nfc, Wallet, WifiOff} from "lucide-react";
import {AimeCard, AimeServer, db} from "@/lib/database";
import {performCardIn, fetchServerInfo} from "amnet-shared";
import {performCardIn, fetchServerInfo, getGameName, AMServerInfo} from "amnet-shared";
import {CircleDot, CircleCheckBig, Home, Nfc, Wallet, WifiOff, TvMinimalPlay} from "lucide-react";
import {Button} from "@/components/ui/button";
import {useToast} from "@/components/ui/use-toast";
@@ -21,6 +19,8 @@ import {
AlertDialogTrigger
} from "@/components/ui/alert-dialog.tsx";
import {AimeCard, AimeServer, db} from "@/lib/database";
import {AMHeader} from "@/components/am-header";
import {AMFooter} from "@/components/am-footer";
import {Separator} from "@/components/ui/separator";
@@ -38,7 +38,7 @@ async function performCardInAction(card: AimeCard, server: AimeServer, setCardIn
const abort = new AbortController();
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()});
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 }) {
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() {
const {toast} = useToast();
const {serverId} = useParams<{ serverId: string }>();
@@ -98,6 +129,7 @@ function ServerCardInPage() {
const [connectionError, setConnectionError] = useState(false);
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
useEffect(() => setSelectedCard(cards?.sort((a, b) => b.lastUsed - a.lastUsed)[0] ?? null), [cards]);
@@ -152,25 +184,12 @@ function ServerCardInPage() {
</Button>
</AMHeader>
{!server && (<LoadingIndicator/>)}
{(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>
)}
<AlertPanel server={server} connectionError={connectionError} onReloadRequested={() => setServerReloadCounter(serverReloadCounter + 1)}/>
{server && (
<div className="flex flex-col gap-4 w-full flex-grow">
{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}>
<Button size="icon" variant="ghost" disabled>
@@ -198,8 +217,7 @@ function ServerCardInPage() {
</div>
)}
<AMFooter
className="flex flex-col-reverse sm:flex-row sm:flex-wrap justify-center sm:justify-between sm:gap-2 gap-4">
<AMFooter className="flex flex-col-reverse sm:flex-row sm:flex-wrap justify-center sm:justify-between sm:gap-2 gap-4">
{server?.id && (
<div className="flex items-center justify-center sm:gap-5 gap-2">
{location.protocol === "https:" && (<>
@@ -252,4 +270,4 @@ function ServerCardInPage() {
);
}
export default ServerCardInPage;
export default ServerCardInPage;
+25 -62
View File
@@ -1,21 +1,19 @@
import {z} from "zod";
import TimeAgo from "react-timeago";
import {useForm} from "react-hook-form";
import {useLiveQuery} from "dexie-react-hooks";
import {AMNET_API_VERSION} from "amnet-shared";
import React, {useEffect, useState} from "react";
import {useLocation, useNavigate} from 'react-router-dom';
import {zodResolver} from "@hookform/resolvers/zod";
import {
Earth,
History,
Plus,
ShieldAlert,
SquareArrowOutUpRight,
Trash,
WalletCards
} from "lucide-react";
import {AimeServer, db} from "@/lib/database";
import {zodResolver} from "@hookform/resolvers/zod";
import {Input} from "@/components/ui/input";
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 {Form, FormControl, FormField, FormItem, FormMessage} from "@/components/ui/form";
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 {AMHeader} from "@/components/am-header";
import {AMNET_API_VERSION} from "amnet-shared";
import {AMCard, AMCardDetail} from "@/components/am-card";
import {AMServer} from "@/components/am-server.tsx";
const serverAdditionSchema = z.object({
address: z.string().startsWith("http")
});
function ServerList(props: { servers: AimeServer[] | undefined }) {
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>
);
}
function ServerList(props: { servers: AimeServer[] | undefined }) {
if (!props.servers) {
return (<LoadingIndicator/>);
}
@@ -65,32 +75,7 @@ function ServerList(props: { servers: AimeServer[] | undefined }) {
<HttpsMixedModeRequestWarning/>
<ServerAddForm/>
{props.servers.map(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>)}
{props.servers.map(s => <AMServer key={s.id} server={s}/>)}
</>);
}
@@ -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;
+2
View File
@@ -25,9 +25,11 @@ path=amnet.dll
; 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)
; serverAddress - the address to listen on, including the protocol (defaults to http://+:6070, where + is a wildcard)
; 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).
gameId=SDEZ
;serverName=CHUNI-PENGUIN
;serverAddress=http://+:6070
;enableKeyboardMode=1
```
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!