diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fbd069c..77613fb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,9 @@ name: Release on: - push: - tags: - - '^v?(?:[0-9]+.?){1,4}$' + # push: + # tags: + # - '^v?(?:[0-9]+.?){1,4}$' workflow_dispatch: inputs: tag: diff --git a/AMNet.Server/CardPresenter.cs b/AMNet.Server/CardPresenter.cs index 4e1fc41..5221c55 100644 --- a/AMNet.Server/CardPresenter.cs +++ b/AMNet.Server/CardPresenter.cs @@ -7,7 +7,7 @@ namespace AMNet.Server; /// /// Represents a storage location to load/read cards from /// -public class CardPresenter +internal class CardPresenter { private readonly object _cardLock = new(); private StoredCard _currentCard; diff --git a/AMNet.Server/DllMain.cs b/AMNet.Server/DllMain.cs index f3ef0a0..e951608 100644 --- a/AMNet.Server/DllMain.cs +++ b/AMNet.Server/DllMain.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using System.Runtime.InteropServices; using System.Text; @@ -13,6 +14,10 @@ public static class DllMain { private const string WebAddress = "http://card.ppc.moe"; + // instance metrics + internal static long LastPollTime = -1; + internal static long ServerStartedAt; + static DllMain() { PInvoke.AllocConsole(); @@ -38,6 +43,7 @@ public static class DllMain cancellationRegistration?.Dispose(); }); + Volatile.Write(ref ServerStartedAt, Environment.TickCount64); App.RunAsync(); return 0; } @@ -45,6 +51,8 @@ public static class DllMain [UnmanagedCallersOnly(EntryPoint = "aime_io_nfc_poll")] public static int NfcPoll(byte unitNo) { + Volatile.Write(ref LastPollTime, Environment.TickCount64); + if (!Config.EnableAimeTxt) { return 1; @@ -83,7 +91,7 @@ public static class DllMain } [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) { if (unitNo != 0) { diff --git a/AMNet.Server/SerializerContext.cs b/AMNet.Server/SerializerContext.cs deleted file mode 100644 index e3f64d9..0000000 --- a/AMNet.Server/SerializerContext.cs +++ /dev/null @@ -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; \ No newline at end of file diff --git a/AMNet.Server/WebServer.cs b/AMNet.Server/WebServer.cs index b0d5427..21917cb 100644 --- a/AMNet.Server/WebServer.cs +++ b/AMNet.Server/WebServer.cs @@ -1,6 +1,8 @@ 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; @@ -10,10 +12,20 @@ using Microsoft.Extensions.Logging; namespace AMNet.Server; -internal static class WebServer +internal static partial class WebServer { 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([]); @@ -53,12 +65,28 @@ internal static class WebServer var app = builder.Build(); app.UseCors(); - app.MapGet("/amnet/info", () => Results.Ok(new SystemState(ApiVersion, Config.GameId, Config.ServerName))); + + 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; @@ -111,4 +139,9 @@ internal static class WebServer 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; } \ No newline at end of file diff --git a/amnet-native/app.json b/amnet-native/app.json index 343c611..8f38dca 100644 --- a/amnet-native/app.json +++ b/amnet-native/app.json @@ -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", diff --git a/amnet-native/app/CardAddModal.tsx b/amnet-native/app/CardAddModal.tsx index e7a3cfc..84fff16 100644 --- a/amnet-native/app/CardAddModal.tsx +++ b/amnet-native/app/CardAddModal.tsx @@ -103,7 +103,7 @@ export default function CardAddModal({navigation, route}) { 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: cardNumberReadOnly ? theme.colors.backdrop : undefined}}/> + style={{...modalStyles.formItemInput, color: cardNumberReadOnly ? theme.colors.onSurfaceDisabled : undefined}}/> diff --git a/amnet-native/app/CardManager.tsx b/amnet-native/app/CardManager.tsx index 1029579..10f648e 100644 --- a/amnet-native/app/CardManager.tsx +++ b/amnet-native/app/CardManager.tsx @@ -75,7 +75,7 @@ export default function CardManager({navigation}) { style={{padding: 16, paddingTop: 24}} data={cards} keyExtractor={i => i._id.toString()} - contentContainerStyle={{paddingBottom: 40}} + contentContainerStyle={{paddingBottom: 120}} ItemSeparatorComponent={() => } renderItem={i => }/> ) diff --git a/amnet-native/app/ServerCardIn.tsx b/amnet-native/app/ServerCardIn.tsx index e1bb5fa..0f557d0 100644 --- a/amnet-native/app/ServerCardIn.tsx +++ b/amnet-native/app/ServerCardIn.tsx @@ -1,17 +1,11 @@ import {BSON} from "realm"; -import {fetchServerInfo, getGameName, performCardIn} from "amnet-shared"; +import {useSafeAreaInsets} from "react-native-safe-area-context"; import React, {useCallback, useEffect, useLayoutEffect, useMemo, useState} from "react"; 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, - Share as ShareIcon, - WifiOff, + CircleCheck, CircleDot, CreditCard, Nfc, Plus, SmartphoneNfcIcon, + Share as ShareIcon, WifiOff, TvMinimalPlay } from "lucide-react-native"; import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder"; @@ -22,12 +16,20 @@ 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); @@ -61,7 +63,7 @@ export default function ServerCardIn({route, navigation}) { const server = useObject(AMServer, ObjectId.createFromHexString(serverId)); useLayoutEffect(() => { - if (!server) { + if (server?.isValid() !== true) { return; } @@ -75,16 +77,7 @@ export default function ServerCardIn({route, navigation}) { }); }, [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(); @@ -97,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) { @@ -143,6 +140,11 @@ 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]); @@ -158,12 +160,7 @@ export default function ServerCardIn({route, navigation}) { return ( { - setServerRefreshing(true); - setServerReloadCounter(serverReloadCounter + 1); - }}/>}> + refreshControl={}> {gameName ? `Play ${gameName}` : "Selected Card"} @@ -218,17 +215,31 @@ export default function ServerCardIn({route, navigation}) { ) } -function StatusAlert(props: {server: AMServer, connectionEstablished: boolean}) { +function StatusAlert(props: { + server: AMServerConnectionInfo & Partial, + connectionEstablished: boolean +}) { if (!props.connectionEstablished) { return ( - + There was an issue connecting to {props.server.serverAddress}. Check the network connection and try again. - ); + ) + } + + // don't show if undefined (some older server versions) + if (props.server.timeSinceLastPoll === null) { + return ( + + + Scanning a card below may not have any effect until the service has fully started up. + + + ) } return null; @@ -247,7 +258,7 @@ const styles = StyleSheet.create({ }, container: { paddingHorizontal: 16, - paddingBottom: 25, + paddingBottom: 80, gap: 20 } }); diff --git a/amnet-native/app/ServerManager.tsx b/amnet-native/app/ServerManager.tsx index b55aad8..5eba6fd 100644 --- a/amnet-native/app/ServerManager.tsx +++ b/amnet-native/app/ServerManager.tsx @@ -8,8 +8,8 @@ import {AMUICard, AMUICardDetail} from "../components/AMUICard"; import TimeAgo from "../components/TimeAgo"; import RealmContext from "../models/RealmContext"; -import {getGameName} from "../../amnet-shared"; import {AMServer} from "../models/AMServer"; +import {getGameName} from "amnet-shared"; const {useRealm, useQuery} = RealmContext; @@ -120,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={() => } renderItem={i => }/> ) diff --git a/amnet-native/models/AMServer.ts b/amnet-native/models/AMServer.ts index 7b16bce..08e5f33 100644 --- a/amnet-native/models/AMServer.ts +++ b/amnet-native/models/AMServer.ts @@ -1,7 +1,7 @@ import {Realm} from 'realm'; -import {AMServerInfoResponse} from "amnet-shared"; +import {AMServerConnectionInfo, AMServerMetadata} from "amnet-shared"; -export class AMServer extends Realm.Object implements AMServerInfoResponse { +export class AMServer extends Realm.Object implements AMServerConnectionInfo, AMServerMetadata { _id!: Realm.BSON.ObjectId; apiVersion: number; diff --git a/amnet-native/package.json b/amnet-native/package.json index 2e535ab..9c1a4ba 100644 --- a/amnet-native/package.json +++ b/amnet-native/package.json @@ -1,6 +1,6 @@ { "name": "amnet-native", - "version": "1.0.0", + "version": "1.1.0", "private": true, "scripts": { "android": "expo run:android", diff --git a/amnet-shared/dist/index.d.mts b/amnet-shared/dist/index.d.mts index 543e219..ad11302 100644 --- a/amnet-shared/dist/index.d.mts +++ b/amnet-shared/dist/index.d.mts @@ -2,23 +2,38 @@ * 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; } /** - * Information needed to perform a request against an AMNet server + * Server connection info (api version, address, etc.) */ interface AMServerConnectionInfo { apiVersion: number; serverAddress: string; } /** - * Received metadata from an AMNet server + * User-definable server metadata */ -interface AMServerInfoResponse extends AMServerConnectionInfo { +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 server The server to perform the request against @@ -31,7 +46,7 @@ declare function performCardIn(server: AMServerConnectionInfo, request: AMCardIn * @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; +declare function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise; declare const CARD_ID_REGEX: RegExp; declare function generateCardNumber(): string; @@ -47,4 +62,4 @@ declare function validateCardId(id: string): boolean; */ declare function getGameName(id: string): string | null; -export { type AMCardInRequest, AMNET_API_VERSION, type AMServerConnectionInfo, type AMServerInfoResponse, CARD_ID_REGEX, fetchServerInfo, generateCardNumber, getGameName, 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 }; diff --git a/amnet-shared/dist/index.d.ts b/amnet-shared/dist/index.d.ts index 543e219..ad11302 100644 --- a/amnet-shared/dist/index.d.ts +++ b/amnet-shared/dist/index.d.ts @@ -2,23 +2,38 @@ * 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; } /** - * Information needed to perform a request against an AMNet server + * Server connection info (api version, address, etc.) */ interface AMServerConnectionInfo { apiVersion: number; serverAddress: string; } /** - * Received metadata from an AMNet server + * User-definable server metadata */ -interface AMServerInfoResponse extends AMServerConnectionInfo { +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 server The server to perform the request against @@ -31,7 +46,7 @@ declare function performCardIn(server: AMServerConnectionInfo, request: AMCardIn * @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; +declare function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise; declare const CARD_ID_REGEX: RegExp; declare function generateCardNumber(): string; @@ -47,4 +62,4 @@ declare function validateCardId(id: string): boolean; */ declare function getGameName(id: string): string | null; -export { type AMCardInRequest, AMNET_API_VERSION, type AMServerConnectionInfo, type AMServerInfoResponse, CARD_ID_REGEX, fetchServerInfo, generateCardNumber, getGameName, 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 }; diff --git a/amnet-shared/dist/index.js b/amnet-shared/dist/index.js index 9312eac..fb2c6ec 100644 --- a/amnet-shared/dist/index.js +++ b/amnet-shared/dist/index.js @@ -59,6 +59,7 @@ var src_exports = {}; __export(src_exports, { AMNET_API_VERSION: () => AMNET_API_VERSION, CARD_ID_REGEX: () => CARD_ID_REGEX, + MIN_AMNET_API_VERSION: () => MIN_AMNET_API_VERSION, fetchServerInfo: () => fetchServerInfo, generateCardNumber: () => generateCardNumber, getGameName: () => getGameName, @@ -69,8 +70,12 @@ module.exports = __toCommonJS(src_exports); // src/api.ts var AMNET_API_VERSION = 1; +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(server.serverAddress).origin}/amnet/signin`, { method: "POST", @@ -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 }); @@ -143,6 +151,7 @@ function getGameName(id) { 0 && (module.exports = { AMNET_API_VERSION, CARD_ID_REGEX, + MIN_AMNET_API_VERSION, fetchServerInfo, generateCardNumber, getGameName, diff --git a/amnet-shared/dist/index.js.map b/amnet-shared/dist/index.js.map index f47eeb7..8db7e48 100644 --- a/amnet-shared/dist/index.js.map +++ b/amnet-shared/dist/index.js.map @@ -1 +1 @@ -{"version":3,"sources":["../src/index.ts","../src/api.ts","../src/card.ts","../src/gameIds.ts"],"sourcesContent":["export * from './api';\nexport * from './card';\nexport * from './gameIds';","/**\n * The latest version of the AMNet API this library supports\n */\nexport const AMNET_API_VERSION = 1;\n\nexport interface AMCardInRequest {\n cardId: string;\n}\n\n/**\n * Information needed to perform a request against an AMNet server\n */\nexport interface AMServerConnectionInfo {\n apiVersion: number;\n serverAddress: string;\n}\n\n/**\n * Received metadata from an AMNet server \n */\nexport interface AMServerInfoResponse extends AMServerConnectionInfo {\n serverName: string;\n gameId?: string | null | undefined;\n}\n\n/**\n * Performs the card-in request to the specified server\n * @param server The server to perform the request against\n * @param request The card-in request to send\n * @param signal The abort signal to use for the request\n */\nexport async function performCardIn(server: AMServerConnectionInfo, request: AMCardInRequest, signal: AbortSignal) {\n try {\n const response = await fetch(`${new URL(server.serverAddress).origin}/amnet/signin`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\"\n },\n body: JSON.stringify(request),\n signal: signal\n });\n\n return response.ok;\n } catch {\n return false;\n }\n}\n\n/**\n * Attempts to connect to the specified server and fetch its info\n * @param serverAddress The origin address of the server to connect to\n * @param abort The abort signal to use for the request (for cancellation)\n */\nexport async function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise {\n try {\n const response = await fetch(`${new URL(serverAddress).origin}/amnet/info`, {\n signal: abort\n });\n\n return {\n ...await response.json(),\n serverAddress: serverAddress\n } satisfies AMServerInfoResponse;\n } catch {\n return null;\n }\n}\n","export const CARD_ID_REGEX = /^(?:[0-9]{4} ?){5}$/i;\n\nexport function generateCardNumber(): string {\n let cardId = '';\n\n while (true) {\n // don't start with a 3\n const firstDigit = Math.floor(Math.random() * 10);\n\n if (firstDigit !== 3) {\n cardId = firstDigit.toString();\n break;\n }\n }\n\n for (let i = 0; i < 19; i++) {\n cardId += Math.floor(Math.random() * 10).toString();\n }\n\n return cardId;\n}\n\n/**\n * Validates the provided card id by known parameters\n * @param id The card id to validate\n */\nexport function validateCardId(id: string): boolean {\n return id[0] !== '3' && CARD_ID_REGEX.test(id);\n}\n","/**\n * Get the game name from the game identifier (4-letter code)\n * @param id\n */\nexport function getGameName(id: string): string | null {\n switch (id?.toUpperCase()) {\n case \"SDGT\":\n return \"Initial D The Arcade\";\n case \"SDHD\":\n case \"SDBT\":\n return \"CHUNITHM\";\n case \"SDDT\":\n return \"O.N.G.E.K.I.\";\n case \"SDEZ\":\n return \"maimai DX\";\n case \"SBZV\":\n return \"Project DIVA Future Tone\";\n \n default:\n return null;\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACGO,IAAM,oBAAoB;AA4BjC,SAAsB,cAAc,QAAgC,SAA0B,QAAqB;AAAA;AAC/G,QAAI;AACA,YAAM,WAAW,MAAM,MAAM,GAAG,IAAI,IAAI,OAAO,aAAa,EAAE,MAAM,iBAAiB;AAAA,QACjF,QAAQ;AAAA,QACR,SAAS;AAAA,UACL,gBAAgB;AAAA,QACpB;AAAA,QACA,MAAM,KAAK,UAAU,OAAO;AAAA,QAC5B;AAAA,MACJ,CAAC;AAED,aAAO,SAAS;AAAA,IACpB,SAAQ;AACJ,aAAO;AAAA,IACX;AAAA,EACJ;AAAA;AAOA,SAAsB,gBAAgB,eAAuB,OAA0D;AAAA;AACnH,QAAI;AACA,YAAM,WAAW,MAAM,MAAM,GAAG,IAAI,IAAI,aAAa,EAAE,MAAM,eAAe;AAAA,QACxE,QAAQ;AAAA,MACZ,CAAC;AAED,aAAO,iCACA,MAAM,SAAS,KAAK,IADpB;AAAA,QAEH;AAAA,MACJ;AAAA,IACJ,SAAQ;AACJ,aAAO;AAAA,IACX;AAAA,EACJ;AAAA;;;AClEO,IAAM,gBAAgB;AAEtB,SAAS,qBAA6B;AACzC,MAAI,SAAS;AAEb,SAAO,MAAM;AAET,UAAM,aAAa,KAAK,MAAM,KAAK,OAAO,IAAI,EAAE;AAEhD,QAAI,eAAe,GAAG;AAClB,eAAS,WAAW,SAAS;AAC7B;AAAA,IACJ;AAAA,EACJ;AAEA,WAAS,IAAI,GAAG,IAAI,IAAI,KAAK;AACzB,cAAU,KAAK,MAAM,KAAK,OAAO,IAAI,EAAE,EAAE,SAAS;AAAA,EACtD;AAEA,SAAO;AACX;AAMO,SAAS,eAAe,IAAqB;AAChD,SAAO,GAAG,CAAC,MAAM,OAAO,cAAc,KAAK,EAAE;AACjD;;;ACxBO,SAAS,YAAY,IAA2B;AACnD,UAAQ,yBAAI,eAAe;AAAA,IACvB,KAAK;AACD,aAAO;AAAA,IACX,KAAK;AAAA,IACL,KAAK;AACD,aAAO;AAAA,IACX,KAAK;AACD,aAAO;AAAA,IACX,KAAK;AACD,aAAO;AAAA,IACX,KAAK;AACD,aAAO;AAAA,IAEX;AACI,aAAO;AAAA,EACf;AACJ;","names":[]} \ No newline at end of file +{"version":3,"sources":["../src/index.ts","../src/api.ts","../src/card.ts","../src/gameIds.ts"],"sourcesContent":["export * from './api';\nexport * from './card';\nexport * from './gameIds';","/**\n * The latest version of the AMNet API this library supports\n */\nexport const AMNET_API_VERSION = 1;\n\n/**\n * The minimum version of the AMNet API this library supports\n */\nexport const MIN_AMNET_API_VERSION = 1;\n\nexport interface AMCardInRequest {\n cardId: string;\n}\n\n/**\n * Server connection info (api version, address, etc.)\n */\nexport interface AMServerConnectionInfo {\n apiVersion: number;\n serverAddress: string;\n}\n\n/**\n * User-definable server metadata\n */\nexport interface AMServerMetadata {\n serverName: string;\n gameId?: string | null | undefined;\n}\n\n/**\n * Metrics about the current game session\n */\nexport interface AMServerInstanceMetrics {\n timeSinceLastPoll?: number;\n sessionUptime?: number;\n}\n\n/**\n * The response from an AMNet server's info endpoint\n */\nexport type AMServerInfo = AMServerMetadata & AMServerConnectionInfo & AMServerInstanceMetrics;\n\n/**\n * Performs the card-in request to the specified server\n * @param server The server to perform the request against\n * @param request The card-in request to send\n * @param signal The abort signal to use for the request\n */\nexport async function performCardIn(server: AMServerConnectionInfo, request: AMCardInRequest, signal: AbortSignal): Promise {\n if (server.apiVersion < MIN_AMNET_API_VERSION || server.apiVersion > AMNET_API_VERSION) {\n throw new Error(\"Unsupported server API version\");\n }\n \n try {\n const response = await fetch(`${new URL(server.serverAddress).origin}/amnet/signin`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\"\n },\n body: JSON.stringify(request),\n signal: signal\n });\n\n return response.ok;\n } catch {\n return false;\n }\n}\n\n/**\n * Attempts to connect to the specified server and fetch its info\n * @param serverAddress The origin address of the server to connect to\n * @param abort The abort signal to use for the request (for cancellation)\n */\nexport async function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise {\n try {\n const response = await fetch(`${new URL(serverAddress).origin}/amnet/info`, {\n signal: abort\n });\n \n if (!response.ok) {\n return null;\n }\n\n return {\n ...await response.json(),\n serverAddress: serverAddress\n } satisfies AMServerMetadata;\n } catch {\n return null;\n }\n}\n","export const CARD_ID_REGEX = /^(?:[0-9]{4} ?){5}$/i;\n\nexport function generateCardNumber(): string {\n let cardId = '';\n\n while (true) {\n // don't start with a 3\n const firstDigit = Math.floor(Math.random() * 10);\n\n if (firstDigit !== 3) {\n cardId = firstDigit.toString();\n break;\n }\n }\n\n for (let i = 0; i < 19; i++) {\n cardId += Math.floor(Math.random() * 10).toString();\n }\n\n return cardId;\n}\n\n/**\n * Validates the provided card id by known parameters\n * @param id The card id to validate\n */\nexport function validateCardId(id: string): boolean {\n return id[0] !== '3' && CARD_ID_REGEX.test(id);\n}\n","/**\n * Get the game name from the game identifier (4-letter code)\n * @param id\n */\nexport function getGameName(id: string): string | null {\n switch (id?.toUpperCase()) {\n case \"SDGT\":\n return \"Initial D The Arcade\";\n case \"SDHD\":\n case \"SDBT\":\n return \"CHUNITHM\";\n case \"SDDT\":\n return \"O.N.G.E.K.I.\";\n case \"SDEZ\":\n return \"maimai DX\";\n case \"SBZV\":\n return \"Project DIVA Future Tone\";\n \n default:\n return null;\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACGO,IAAM,oBAAoB;AAK1B,IAAM,wBAAwB;AAyCrC,SAAsB,cAAc,QAAgC,SAA0B,QAAuC;AAAA;AACjI,QAAI,OAAO,aAAa,yBAAyB,OAAO,aAAa,mBAAmB;AACpF,YAAM,IAAI,MAAM,gCAAgC;AAAA,IACpD;AAEA,QAAI;AACA,YAAM,WAAW,MAAM,MAAM,GAAG,IAAI,IAAI,OAAO,aAAa,EAAE,MAAM,iBAAiB;AAAA,QACjF,QAAQ;AAAA,QACR,SAAS;AAAA,UACL,gBAAgB;AAAA,QACpB;AAAA,QACA,MAAM,KAAK,UAAU,OAAO;AAAA,QAC5B;AAAA,MACJ,CAAC;AAED,aAAO,SAAS;AAAA,IACpB,SAAQ;AACJ,aAAO;AAAA,IACX;AAAA,EACJ;AAAA;AAOA,SAAsB,gBAAgB,eAAuB,OAAkD;AAAA;AAC3G,QAAI;AACA,YAAM,WAAW,MAAM,MAAM,GAAG,IAAI,IAAI,aAAa,EAAE,MAAM,eAAe;AAAA,QACxE,QAAQ;AAAA,MACZ,CAAC;AAED,UAAI,CAAC,SAAS,IAAI;AACd,eAAO;AAAA,MACX;AAEA,aAAO,iCACA,MAAM,SAAS,KAAK,IADpB;AAAA,QAEH;AAAA,MACJ;AAAA,IACJ,SAAQ;AACJ,aAAO;AAAA,IACX;AAAA,EACJ;AAAA;;;AC5FO,IAAM,gBAAgB;AAEtB,SAAS,qBAA6B;AACzC,MAAI,SAAS;AAEb,SAAO,MAAM;AAET,UAAM,aAAa,KAAK,MAAM,KAAK,OAAO,IAAI,EAAE;AAEhD,QAAI,eAAe,GAAG;AAClB,eAAS,WAAW,SAAS;AAC7B;AAAA,IACJ;AAAA,EACJ;AAEA,WAAS,IAAI,GAAG,IAAI,IAAI,KAAK;AACzB,cAAU,KAAK,MAAM,KAAK,OAAO,IAAI,EAAE,EAAE,SAAS;AAAA,EACtD;AAEA,SAAO;AACX;AAMO,SAAS,eAAe,IAAqB;AAChD,SAAO,GAAG,CAAC,MAAM,OAAO,cAAc,KAAK,EAAE;AACjD;;;ACxBO,SAAS,YAAY,IAA2B;AACnD,UAAQ,yBAAI,eAAe;AAAA,IACvB,KAAK;AACD,aAAO;AAAA,IACX,KAAK;AAAA,IACL,KAAK;AACD,aAAO;AAAA,IACX,KAAK;AACD,aAAO;AAAA,IACX,KAAK;AACD,aAAO;AAAA,IACX,KAAK;AACD,aAAO;AAAA,IAEX;AACI,aAAO;AAAA,EACf;AACJ;","names":[]} \ No newline at end of file diff --git a/amnet-shared/dist/index.mjs b/amnet-shared/dist/index.mjs index adf405a..a88051e 100644 --- a/amnet-shared/dist/index.mjs +++ b/amnet-shared/dist/index.mjs @@ -40,8 +40,12 @@ var __async = (__this, __arguments, generator) => { // src/api.ts var AMNET_API_VERSION = 1; +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(server.serverAddress).origin}/amnet/signin`, { method: "POST", @@ -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 }); @@ -113,6 +120,7 @@ function getGameName(id) { export { AMNET_API_VERSION, CARD_ID_REGEX, + MIN_AMNET_API_VERSION, fetchServerInfo, generateCardNumber, getGameName, diff --git a/amnet-shared/dist/index.mjs.map b/amnet-shared/dist/index.mjs.map index 862e4f0..be11df6 100644 --- a/amnet-shared/dist/index.mjs.map +++ b/amnet-shared/dist/index.mjs.map @@ -1 +1 @@ -{"version":3,"sources":["../src/api.ts","../src/card.ts","../src/gameIds.ts"],"sourcesContent":["/**\n * The latest version of the AMNet API this library supports\n */\nexport const AMNET_API_VERSION = 1;\n\nexport interface AMCardInRequest {\n cardId: string;\n}\n\n/**\n * Information needed to perform a request against an AMNet server\n */\nexport interface AMServerConnectionInfo {\n apiVersion: number;\n serverAddress: string;\n}\n\n/**\n * Received metadata from an AMNet server \n */\nexport interface AMServerInfoResponse extends AMServerConnectionInfo {\n serverName: string;\n gameId?: string | null | undefined;\n}\n\n/**\n * Performs the card-in request to the specified server\n * @param server The server to perform the request against\n * @param request The card-in request to send\n * @param signal The abort signal to use for the request\n */\nexport async function performCardIn(server: AMServerConnectionInfo, request: AMCardInRequest, signal: AbortSignal) {\n try {\n const response = await fetch(`${new URL(server.serverAddress).origin}/amnet/signin`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\"\n },\n body: JSON.stringify(request),\n signal: signal\n });\n\n return response.ok;\n } catch {\n return false;\n }\n}\n\n/**\n * Attempts to connect to the specified server and fetch its info\n * @param serverAddress The origin address of the server to connect to\n * @param abort The abort signal to use for the request (for cancellation)\n */\nexport async function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise {\n try {\n const response = await fetch(`${new URL(serverAddress).origin}/amnet/info`, {\n signal: abort\n });\n\n return {\n ...await response.json(),\n serverAddress: serverAddress\n } satisfies AMServerInfoResponse;\n } catch {\n return null;\n }\n}\n","export const CARD_ID_REGEX = /^(?:[0-9]{4} ?){5}$/i;\n\nexport function generateCardNumber(): string {\n let cardId = '';\n\n while (true) {\n // don't start with a 3\n const firstDigit = Math.floor(Math.random() * 10);\n\n if (firstDigit !== 3) {\n cardId = firstDigit.toString();\n break;\n }\n }\n\n for (let i = 0; i < 19; i++) {\n cardId += Math.floor(Math.random() * 10).toString();\n }\n\n return cardId;\n}\n\n/**\n * Validates the provided card id by known parameters\n * @param id The card id to validate\n */\nexport function validateCardId(id: string): boolean {\n return id[0] !== '3' && CARD_ID_REGEX.test(id);\n}\n","/**\n * Get the game name from the game identifier (4-letter code)\n * @param id\n */\nexport function getGameName(id: string): string | null {\n switch (id?.toUpperCase()) {\n case \"SDGT\":\n return \"Initial D The Arcade\";\n case \"SDHD\":\n case \"SDBT\":\n return \"CHUNITHM\";\n case \"SDDT\":\n return \"O.N.G.E.K.I.\";\n case \"SDEZ\":\n return \"maimai DX\";\n case \"SBZV\":\n return \"Project DIVA Future Tone\";\n \n default:\n return null;\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAGO,IAAM,oBAAoB;AA4BjC,SAAsB,cAAc,QAAgC,SAA0B,QAAqB;AAAA;AAC/G,QAAI;AACA,YAAM,WAAW,MAAM,MAAM,GAAG,IAAI,IAAI,OAAO,aAAa,EAAE,MAAM,iBAAiB;AAAA,QACjF,QAAQ;AAAA,QACR,SAAS;AAAA,UACL,gBAAgB;AAAA,QACpB;AAAA,QACA,MAAM,KAAK,UAAU,OAAO;AAAA,QAC5B;AAAA,MACJ,CAAC;AAED,aAAO,SAAS;AAAA,IACpB,SAAQ;AACJ,aAAO;AAAA,IACX;AAAA,EACJ;AAAA;AAOA,SAAsB,gBAAgB,eAAuB,OAA0D;AAAA;AACnH,QAAI;AACA,YAAM,WAAW,MAAM,MAAM,GAAG,IAAI,IAAI,aAAa,EAAE,MAAM,eAAe;AAAA,QACxE,QAAQ;AAAA,MACZ,CAAC;AAED,aAAO,iCACA,MAAM,SAAS,KAAK,IADpB;AAAA,QAEH;AAAA,MACJ;AAAA,IACJ,SAAQ;AACJ,aAAO;AAAA,IACX;AAAA,EACJ;AAAA;;;AClEO,IAAM,gBAAgB;AAEtB,SAAS,qBAA6B;AACzC,MAAI,SAAS;AAEb,SAAO,MAAM;AAET,UAAM,aAAa,KAAK,MAAM,KAAK,OAAO,IAAI,EAAE;AAEhD,QAAI,eAAe,GAAG;AAClB,eAAS,WAAW,SAAS;AAC7B;AAAA,IACJ;AAAA,EACJ;AAEA,WAAS,IAAI,GAAG,IAAI,IAAI,KAAK;AACzB,cAAU,KAAK,MAAM,KAAK,OAAO,IAAI,EAAE,EAAE,SAAS;AAAA,EACtD;AAEA,SAAO;AACX;AAMO,SAAS,eAAe,IAAqB;AAChD,SAAO,GAAG,CAAC,MAAM,OAAO,cAAc,KAAK,EAAE;AACjD;;;ACxBO,SAAS,YAAY,IAA2B;AACnD,UAAQ,yBAAI,eAAe;AAAA,IACvB,KAAK;AACD,aAAO;AAAA,IACX,KAAK;AAAA,IACL,KAAK;AACD,aAAO;AAAA,IACX,KAAK;AACD,aAAO;AAAA,IACX,KAAK;AACD,aAAO;AAAA,IACX,KAAK;AACD,aAAO;AAAA,IAEX;AACI,aAAO;AAAA,EACf;AACJ;","names":[]} \ No newline at end of file +{"version":3,"sources":["../src/api.ts","../src/card.ts","../src/gameIds.ts"],"sourcesContent":["/**\n * The latest version of the AMNet API this library supports\n */\nexport const AMNET_API_VERSION = 1;\n\n/**\n * The minimum version of the AMNet API this library supports\n */\nexport const MIN_AMNET_API_VERSION = 1;\n\nexport interface AMCardInRequest {\n cardId: string;\n}\n\n/**\n * Server connection info (api version, address, etc.)\n */\nexport interface AMServerConnectionInfo {\n apiVersion: number;\n serverAddress: string;\n}\n\n/**\n * User-definable server metadata\n */\nexport interface AMServerMetadata {\n serverName: string;\n gameId?: string | null | undefined;\n}\n\n/**\n * Metrics about the current game session\n */\nexport interface AMServerInstanceMetrics {\n timeSinceLastPoll?: number;\n sessionUptime?: number;\n}\n\n/**\n * The response from an AMNet server's info endpoint\n */\nexport type AMServerInfo = AMServerMetadata & AMServerConnectionInfo & AMServerInstanceMetrics;\n\n/**\n * Performs the card-in request to the specified server\n * @param server The server to perform the request against\n * @param request The card-in request to send\n * @param signal The abort signal to use for the request\n */\nexport async function performCardIn(server: AMServerConnectionInfo, request: AMCardInRequest, signal: AbortSignal): Promise {\n if (server.apiVersion < MIN_AMNET_API_VERSION || server.apiVersion > AMNET_API_VERSION) {\n throw new Error(\"Unsupported server API version\");\n }\n \n try {\n const response = await fetch(`${new URL(server.serverAddress).origin}/amnet/signin`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\"\n },\n body: JSON.stringify(request),\n signal: signal\n });\n\n return response.ok;\n } catch {\n return false;\n }\n}\n\n/**\n * Attempts to connect to the specified server and fetch its info\n * @param serverAddress The origin address of the server to connect to\n * @param abort The abort signal to use for the request (for cancellation)\n */\nexport async function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise {\n try {\n const response = await fetch(`${new URL(serverAddress).origin}/amnet/info`, {\n signal: abort\n });\n \n if (!response.ok) {\n return null;\n }\n\n return {\n ...await response.json(),\n serverAddress: serverAddress\n } satisfies AMServerMetadata;\n } catch {\n return null;\n }\n}\n","export const CARD_ID_REGEX = /^(?:[0-9]{4} ?){5}$/i;\n\nexport function generateCardNumber(): string {\n let cardId = '';\n\n while (true) {\n // don't start with a 3\n const firstDigit = Math.floor(Math.random() * 10);\n\n if (firstDigit !== 3) {\n cardId = firstDigit.toString();\n break;\n }\n }\n\n for (let i = 0; i < 19; i++) {\n cardId += Math.floor(Math.random() * 10).toString();\n }\n\n return cardId;\n}\n\n/**\n * Validates the provided card id by known parameters\n * @param id The card id to validate\n */\nexport function validateCardId(id: string): boolean {\n return id[0] !== '3' && CARD_ID_REGEX.test(id);\n}\n","/**\n * Get the game name from the game identifier (4-letter code)\n * @param id\n */\nexport function getGameName(id: string): string | null {\n switch (id?.toUpperCase()) {\n case \"SDGT\":\n return \"Initial D The Arcade\";\n case \"SDHD\":\n case \"SDBT\":\n return \"CHUNITHM\";\n case \"SDDT\":\n return \"O.N.G.E.K.I.\";\n case \"SDEZ\":\n return \"maimai DX\";\n case \"SBZV\":\n return \"Project DIVA Future Tone\";\n \n default:\n return null;\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAGO,IAAM,oBAAoB;AAK1B,IAAM,wBAAwB;AAyCrC,SAAsB,cAAc,QAAgC,SAA0B,QAAuC;AAAA;AACjI,QAAI,OAAO,aAAa,yBAAyB,OAAO,aAAa,mBAAmB;AACpF,YAAM,IAAI,MAAM,gCAAgC;AAAA,IACpD;AAEA,QAAI;AACA,YAAM,WAAW,MAAM,MAAM,GAAG,IAAI,IAAI,OAAO,aAAa,EAAE,MAAM,iBAAiB;AAAA,QACjF,QAAQ;AAAA,QACR,SAAS;AAAA,UACL,gBAAgB;AAAA,QACpB;AAAA,QACA,MAAM,KAAK,UAAU,OAAO;AAAA,QAC5B;AAAA,MACJ,CAAC;AAED,aAAO,SAAS;AAAA,IACpB,SAAQ;AACJ,aAAO;AAAA,IACX;AAAA,EACJ;AAAA;AAOA,SAAsB,gBAAgB,eAAuB,OAAkD;AAAA;AAC3G,QAAI;AACA,YAAM,WAAW,MAAM,MAAM,GAAG,IAAI,IAAI,aAAa,EAAE,MAAM,eAAe;AAAA,QACxE,QAAQ;AAAA,MACZ,CAAC;AAED,UAAI,CAAC,SAAS,IAAI;AACd,eAAO;AAAA,MACX;AAEA,aAAO,iCACA,MAAM,SAAS,KAAK,IADpB;AAAA,QAEH;AAAA,MACJ;AAAA,IACJ,SAAQ;AACJ,aAAO;AAAA,IACX;AAAA,EACJ;AAAA;;;AC5FO,IAAM,gBAAgB;AAEtB,SAAS,qBAA6B;AACzC,MAAI,SAAS;AAEb,SAAO,MAAM;AAET,UAAM,aAAa,KAAK,MAAM,KAAK,OAAO,IAAI,EAAE;AAEhD,QAAI,eAAe,GAAG;AAClB,eAAS,WAAW,SAAS;AAC7B;AAAA,IACJ;AAAA,EACJ;AAEA,WAAS,IAAI,GAAG,IAAI,IAAI,KAAK;AACzB,cAAU,KAAK,MAAM,KAAK,OAAO,IAAI,EAAE,EAAE,SAAS;AAAA,EACtD;AAEA,SAAO;AACX;AAMO,SAAS,eAAe,IAAqB;AAChD,SAAO,GAAG,CAAC,MAAM,OAAO,cAAc,KAAK,EAAE;AACjD;;;ACxBO,SAAS,YAAY,IAA2B;AACnD,UAAQ,yBAAI,eAAe;AAAA,IACvB,KAAK;AACD,aAAO;AAAA,IACX,KAAK;AAAA,IACL,KAAK;AACD,aAAO;AAAA,IACX,KAAK;AACD,aAAO;AAAA,IACX,KAAK;AACD,aAAO;AAAA,IACX,KAAK;AACD,aAAO;AAAA,IAEX;AACI,aAAO;AAAA,EACf;AACJ;","names":[]} \ No newline at end of file diff --git a/amnet-shared/src/api.ts b/amnet-shared/src/api.ts index 12c19da..1b90f75 100644 --- a/amnet-shared/src/api.ts +++ b/amnet-shared/src/api.ts @@ -3,12 +3,17 @@ */ 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; } /** - * Information needed to perform a request against an AMNet server + * Server connection info (api version, address, etc.) */ export interface AMServerConnectionInfo { apiVersion: number; @@ -16,20 +21,37 @@ export interface AMServerConnectionInfo { } /** - * Received metadata from an AMNet server + * User-definable server metadata */ -export interface AMServerInfoResponse extends AMServerConnectionInfo { +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 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(server: AMServerConnectionInfo, request: AMCardInRequest, signal: AbortSignal) { +export async function performCardIn(server: AMServerConnectionInfo, request: AMCardInRequest, signal: AbortSignal): Promise { + 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(server.serverAddress).origin}/amnet/signin`, { method: "POST", @@ -51,16 +73,20 @@ export async function performCardIn(server: AMServerConnectionInfo, request: AMC * @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 { +export async function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise { 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; } diff --git a/amnet-web/src/components/aime-card.tsx b/amnet-web/src/components/aime-card.tsx index 06d09ec..a55784e 100644 --- a/amnet-web/src/components/aime-card.tsx +++ b/amnet-web/src/components/aime-card.tsx @@ -8,20 +8,19 @@ import {AMCard, AMCardActionProps, AMCardDetail} from "@/components/am-card.tsx" export function AimeCardEntry(props: AMCardActionProps & { card: AimeCard, children?: Readonly }) { const isDesktop = useMediaQuery("(min-width: 512px)"); - return ( - + {props.card.lastUsed > 0 && ( - - + )} - - navigator.clipboard.writeText(props.card.id)}/> - {isDesktop - ? props.card.id.match(/.{1,4}/g)?.join(" ") - : `•••• ${props.card.id.substring(props.card.id.length - 4)}`} + navigator.clipboard.writeText(props.card.id)}> + + {isDesktop + ? props.card.id.match(/.{1,4}/g)?.join(" ") + : `•••• ${props.card.id.substring(props.card.id.length - 4)}`} diff --git a/amnet-web/src/components/am-card.tsx b/amnet-web/src/components/am-card.tsx index f22540f..045f3f7 100644 --- a/amnet-web/src/components/am-card.tsx +++ b/amnet-web/src/components/am-card.tsx @@ -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} -
+
{props.children}
@@ -32,9 +33,10 @@ export function AMCard(props: AMCardActionProps & { title: string, titlePerforms ) } -export function AMCardDetail(props: { children?: Readonly }) { +export function AMCardDetail(props: { icon: LucideIcon, iconClicked?: () => void | Promise, children?: Readonly }) { return (
+ {React.createElement(props.icon, {className: "h-5 w-5", onClick: props.iconClicked})} {props.children}
) diff --git a/amnet-web/src/components/am-server.tsx b/amnet-web/src/components/am-server.tsx new file mode 100644 index 0000000..2f19844 --- /dev/null +++ b/amnet-web/src/components/am-server.tsx @@ -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 ( + + + navigate(`/servers/${props.server.id}`)}> + + {props.server.serverAddress} + + + {props.server.lastConnected > 0 ? : + never} + + {gameName && {gameName}} + + + + navigate(`/servers/${props.server.id}`)}> + Select Server + + + db.servers.delete(props.server.id)}> + Remove Server + + + ) +} \ No newline at end of file diff --git a/amnet-web/src/lib/database.ts b/amnet-web/src/lib/database.ts index 77e171e..9acdd83 100644 --- a/amnet-web/src/lib/database.ts +++ b/amnet-web/src/lib/database.ts @@ -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; }; -db.version(1).stores({ +db.version(2).stores({ servers: '++id, serverAddress', cards: 'id' }); diff --git a/amnet-web/src/pages/card-in-screen.tsx b/amnet-web/src/pages/card-in-screen.tsx index e564cc9..50e9e53 100644 --- a/amnet-web/src/pages/card-in-screen.tsx +++ b/amnet-web/src/pages/card-in-screen.tsx @@ -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"; @@ -60,8 +60,6 @@ async function performCardInAction(card: AimeCard, server: AimeServer, setCardIn } } -/* eslint-enable @typescript-eslint/no-explicit-any */ - function CardList(props: { cards: ReadonlyArray | null, cardSelected: (card: AimeCard) => void }) { const navigate = useNavigate(); @@ -82,6 +80,39 @@ function CardList(props: { cards: ReadonlyArray | null, cardSelected: ); } +function AlertPanel(props: { server: AMServerInfo | null, connectionError: boolean, onReloadRequested: () => void}) { + if (!props.server) { + return + } + + if (props.connectionError) { + return + + Connection Issue + + There was an issue connecting to {props.server.serverAddress}. + Please check the game is running and try again. + + + } + + if (props.server.timeSinceLastPoll === null) { + return + + Server Startup + + Scanning a card below may not have any effect until the service has fully started up. reload this page to perform another check. + + + } + + return +} + 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() { - {!server && ()} - {(server && !connectionError) && } - {(server && connectionError) && ( - - - Connection Issue - - There was an issue connecting to {server.serverAddress}. - Please check the game is running and setServerReloadCounter(serverReloadCounter + 1)}>try again. - - - )} + setServerReloadCounter(serverReloadCounter + 1)}/> {server && (
{selectedCard && (<> -

Selected Card

+

{gameName ? `Play ${gameName}` : "Selected Card"}

)} - + {server?.id && (
{location.protocol === "https:" && (<> @@ -252,4 +270,4 @@ function ServerCardInPage() { ); } -export default ServerCardInPage; \ No newline at end of file +export default ServerCardInPage; diff --git a/amnet-web/src/pages/server-listing.tsx b/amnet-web/src/pages/server-listing.tsx index 39919a9..2e050f0 100644 --- a/amnet-web/src/pages/server-listing.tsx +++ b/amnet-web/src/pages/server-listing.tsx @@ -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 ( +
+ + + + +
+ +
+ + +
+ ); +} + +function ServerList(props: { servers: AimeServer[] | undefined }) { if (!props.servers) { return (); } @@ -65,32 +75,7 @@ function ServerList(props: { servers: AimeServer[] | undefined }) { - {props.servers.map(s => - - - navigate(`/servers/${s.id}`)}> - {s.lastConnected > 0 && ( - - - - - )} - - - {s.serverAddress} - - - - - navigate(`/servers/${s.id}`)}> - Select Server - - - db.servers.delete(s.id)}> - Remove Server - - - )} + {props.servers.map(s => )} ); } @@ -224,26 +209,4 @@ function ServerAddForm() { ); } -function ServerListing() { - const navigate = useNavigate(); - const servers = useLiveQuery(() => db.servers.toArray()); - - return ( -
- - - - -
- -
- - -
- ); -} - export default ServerListing;